fix: restore the full pair apex and trim the poking butt end (#310)

Узел ровно двух лучей снова закрывается полным mitre — стены сходятся в
точку, фаска #309 остаётся только веерам узлов ≥3 лучей. Настоящий зубец
убран: pairButtEndTrimWedges возвращает адресный клин — часть тела стены
снаружи наружной грани соседа и не дальше 2·halfDepth от узла — который
physicalBodyParts и превью вычитают из тела до разрезов проёмов. Это второе
адресное вычитание конвейера узлов рядом с латеральным тримом #271.

Узлы-двойки невидимы детектору #302 (карта требует ≥3 лучей): контракт «без
дыр» для них закрыт парным сеточным юнитом (кладка = полосы ∪ патч − клинья)
на spike-узле фикстуры владельца и синтетике. 3 новых мутанта, краснота
каждого проверена исполнением; парный юнит #309 переписан под полное остриё.

Issue: #310
User-Visible: yes
This commit is contained in:
Codex
2026-08-25 23:16:19 +03:00
parent 9ec3a77ffa
commit de0867b001
10 changed files with 651 additions and 262 deletions
File diff suppressed because one or more lines are too long
+112 -112
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- Two walls meeting at an acute angle keep their full drawing point again —
the #309 chamfer now applies only to nodes of three and more walls. The
real tooth is gone instead: the deeper wall's butt end no longer pokes
sideways past the face of its thinner partner
([#310](https://github.com/Matysh/houseplan-card/issues/310)).
- Wall junctions no longer sprout teeth: a mitre apex is limited to 1.5
half-depths and anything longer is closed with a flat drafting chamfer, so
acute pairs lose their tails, thick multi-wall nodes lose their peaks, and
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- Две стены, сходящиеся под острым углом, снова сходятся в полное остриё —
фаска #309 действует только в узлах из трёх и более стен. Вместо этого
убран настоящий зубец: торец толстой стены больше не торчит вбок из грани
тонкой ([#310](https://github.com/Matysh/houseplan-card/issues/310)).
- Стыковочные узлы больше не выпускают зубцы: вылет mitre ограничен
1.5 полутолщины, всё длиннее закрывается плоской чертёжной фаской — у
острых пар исчезают хвосты, у толстых многолучевых узлов пики, а кресты
+16 -3
View File
@@ -208,9 +208,8 @@ protrude at most `VISUAL_MITRE_LIMIT = 1.5` maximal half-depths from the node
(a square corner of equal depths peaks at ~1.41·h, so right and obtuse
corners are byte-identical); a longer apex is closed with a flat chamfer
perpendicular to the apex direction at the limit (`chamferApex`). The rule
applies to both the junction fans and the pair patches of
`linearWallJoinPatches`; `MITRE_LIMIT = 4` survives only as the sanity bound
for candidate construction. At a node of three or more canonical rays the
applies to the junction fans; `MITRE_LIMIT = 4` survives only as the sanity
bound for candidate construction. At a node of three or more canonical rays the
pair patches are not built at all: a pair patch lives in the sector OPPOSITE
its pair and painted a step over the thinner strips owning that sector (the
15/15/30/30 cross of the owner report) — such nodes are closed with the same
@@ -218,6 +217,20 @@ sector fans via a local multi-wall node map inside `linearWallJoinPatches`.
The #249 machinery (`MULTI_WALL_JOIN_LIMIT = 1.25`, `multiWallBevelCutsAt`,
room-contour mitres) is untouched.
**Pair apex and butt-end trim (#310, owner decision 2026-08-25).** A node of
exactly two rays keeps the FULL mitre at any length: two walls meet in a
drawing point, the #309 chamfer does not apply there. What does get removed is
the butt-end tooth: the deeper wall's rectangular end may poke sideways past
the outer face of its thinner partner right at the node —
`pairButtEndTrimWedges` returns, per wall, the addressed wedge (outside the
partner's apex-side outer face, within 2·halfDepth of the node along the
axis) which consumers subtract from the owning body before opening cuts. This
is the SECOND addressed subtraction of the junction pipeline, next to the
lateral trim of #271; both are strictly local to their node. Two-ray nodes are
invisible to `junctionContractHoles` (the node map requires 3+ canonical
rays), so their no-holes contract is a grid probe in the unit suite: masonry
inside the node neighbourhood equals (strips ∪ patch) − wedges.
**Hatch density is physical (#230).** The pattern step is a distance on the
plan, not a count of coordinate units: `wallHatchStepUnits(cellCm)` returns
`8 × (5 / cell_cm)`, which is 9.6 cm at every grid scale and exactly the
+11 -11
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"sourceFingerprint": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"imageSha256": "0ca982495a7749907c48ebbddfd089c54385a4347f79f64e94af2c5a8d5c03eb"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"imageSha256": "1c8e28ff34736b936ba7c9ec047d1da27aaf6fbb01411155f2d5c05282c02c41"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"imageSha256": "329750c81a7a958c4fd105b2c80f85dce55ed87df9ddf0ea08084e4428e85f04"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"imageSha256": "ff2e903e548423aa0aa52680e20b8ad951c704e01130cb4b180222015a241276"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"imageSha256": "ff55925f079411023442e7e7dd5ed528c331aaa3fd3fc2fbbf3b1aaf34a85718"
},
"device-editor": {
@@ -69,7 +69,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"imageSha256": "6b9cd281450e7782cff292f11efc58d227bedd0df86afc3be987feb2edec9dc3"
},
"device-display-preview": {
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
}
}
+38
View File
@@ -2170,6 +2170,44 @@ export const MUTANTS = [
replace: ' .dev:not(.unavail):hover {',
}],
},
{
id: 'pair-chamfer-returns',
// #310: фаска возвращается в узел-двойку — остриё пары снова срезано
// вопреки решению владельца.
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 310" test/wall-thickness.test.mjs',
because: 'узел ровно двух лучей обязан закрываться полным mitre — стены сходятся в точку',
patches: [{
file: 'src/wall-thickness.ts',
find: ' const patch = hit\n ? [node.slice(), pA, hit, pB]\n : [node.slice(), pA, pB];',
replace: ' const visual = VISUAL_MITRE_LIMIT * Math.max(a.halfDepth, b.halfDepth);\n const patch = hit && Math.hypot(hit[0] - node[0], hit[1] - node[1]) <= visual\n ? [node.slice(), pA, hit, pB]\n : (hit && chamferApex(node, pA, hit, pB, visual)) || [node.slice(), pA, pB];',
}],
},
{
id: 'butt-end-trim-disabled',
// #310: торцевой трим отключён — зубец торца толстой стены снова торчит
// из тонкой.
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 310" test/wall-thickness.test.mjs',
because: 'выступ прямоугольного торца за грань тонкой стены — это и есть зубец из отчёта владельца',
patches: [{
file: 'src/physical-geometry.ts',
find: ' for (const { segmentIndex, wedge } of pairButtEndTrimWedges(allSegments, epsilon)) {',
replace: ' for (const { segmentIndex, wedge } of pairButtEndTrimWedges([], epsilon)) {',
}],
},
{
id: 'butt-end-trim-unbounded',
// #310: трим без ограничения окрестностью узла режет всё тело стены.
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 310" test/wall-thickness.test.mjs',
because: 'торцевой трим обязан быть адресным — не дальше 2·halfDepth от узла вдоль оси',
patches: [{
file: 'src/wall-thickness.ts',
find: ' const reach = Math.min(2 * self.halfDepth, self.length);',
replace: ' const reach = self.length;',
}],
},
{
id: 'visual-mitre-limit-back-to-4',
// #309: порог визуального среза возвращается к классическим 4·h — шип на
+51 -2
View File
@@ -2,7 +2,7 @@
import { difference, intersection, union } from 'polyclip-ts';
import { polygonArea } from './logic';
import {
linearWallBody, linearWallJoinPatches, wallCmToUnits,
linearWallBody, linearWallJoinPatches, pairButtEndTrimWedges, wallCmToUnits,
type LinearWallSegment,
} from './wall-thickness';
import type {
@@ -189,6 +189,35 @@ export function cutPartitionBody(
* Most runtime consumers need these polygons directly and must not pay for an
* additional polygon union which they never read.
*/
/**
* Subtract one #310 butt-end wedge from a simple wall body. The wedge sits at
* a body corner, so the difference is expected to stay one simple ring; on
* any degenerate polygon-clipping outcome the body is left untouched.
*/
function subtractWedgeFromBody(
body: number[][], wedge: number[][],
): number[][] | null {
try {
const result: any = difference(
[[...body.map((point) => [point[0], point[1]]), [body[0][0], body[0][1]]]] as any,
[[...wedge.map((point) => [point[0], point[1]]), [wedge[0][0], wedge[0][1]]]] as any,
);
let best: number[][] | null = null;
let bestArea = 0;
for (const polygon of result || []) {
const ring = (polygon?.[0] || []) as number[][];
const area = Math.abs(polygonArea(ring));
if (ring.length >= 4 && area > bestArea) {
bestArea = area;
best = ring.slice(0, -1).map((point) => [point[0], point[1]]);
}
}
return best;
} catch {
return null;
}
}
export function physicalBodyParts(
space: Pick<SpaceModel, 'partitions' | 'room_drafts' | 'wall_columns'>,
cellCm: number,
@@ -219,6 +248,7 @@ export function physicalBodyParts(
drafts.push(body);
}
}
const partitionMeta: { id: string; body: number[][] }[] = [];
for (const partition of space.partitions || []) {
const segment = {
a: partition.a,
@@ -229,8 +259,27 @@ export function physicalBodyParts(
if (!body) continue;
partitionSegments.push(segment);
partitions.push(body);
partitionMeta.push({ id: partition.id, body });
}
// #310: at a two-ray node the deeper wall's rectangular butt end may poke
// past its thin partner's outer face; subtract the addressed wedge from the
// owning body BEFORE opening cuts, so jambs inherit the clean silhouette.
const allSegments = [...draftSegments, ...partitionSegments];
for (const { segmentIndex, wedge } of pairButtEndTrimWedges(allSegments, epsilon)) {
const target = segmentIndex < draftSegments.length
? { list: drafts, at: segmentIndex }
: { list: partitions, at: segmentIndex - draftSegments.length };
const trimmed = subtractWedgeFromBody(target.list[target.at], wedge);
if (trimmed) {
target.list[target.at] = trimmed;
if (segmentIndex >= draftSegments.length) {
partitionMeta[target.at].body = trimmed;
}
}
}
for (const meta of partitionMeta) {
presentedPartitions.push(...cutPartitionBody(
body, cutsByPartition.get(partition.id) || [], epsilon,
meta.body, cutsByPartition.get(meta.id) || [], epsilon,
));
}
const columns = (space.wall_columns || []).map((column) =>
+137 -5
View File
@@ -1114,6 +1114,123 @@ function addJunctionRay(rays: JunctionRay[], dx: number, dy: number, halfDepth:
* same bounded mitre/bevel used by room contours. Unioning these patches with
* the raw bodies removes the tooth without changing caps at degree-one nodes.
*/
/** #310: subtract one butt-end wedge from a simple body (largest ring wins). */
function clipBodyByWedge(body: number[][], wedge: number[][]): number[][] | null {
try {
const result: any = difference(
closedRing(body) as any, closedRing(wedge) as any,
);
let best: number[][] | null = null;
let bestArea = 0;
for (const polygon of result || []) {
const ring = (polygon?.[0] || []) as number[][];
const area = Math.abs(signedArea(ring));
if (ring.length >= 4 && area > bestArea) {
bestArea = area;
best = ring.slice(0, -1).map((point) => [point[0], point[1]]);
}
}
return best;
} catch {
return null;
}
}
/**
* Butt-end trim of a two-ray node (#310, owner decision). With the full pair
* mitre restored, the rectangular butt end of the deeper wall can still poke
* sideways past the outer face of its thinner partner right at the node — the
* «tooth sticking out of the thin wall» of the owner report. For every
* two-ray node with an accepted mitre this returns, per input segment, the
* wedges to subtract: the part of that segment's body OUTSIDE the partner's
* outer face and within 2·halfDepth of the node along the segment's axis.
* The rule is symmetric; for the thinner wall the wedge is empty. This is the
* SECOND addressed subtraction of the junction pipeline, next to the lateral
* trim of #271 — both strictly local to their node.
*/
export function pairButtEndTrimWedges(
input: LinearWallSegment[], epsilon = 1e-6,
): { segmentIndex: number; wedge: number[][] }[] {
const segments = (input || []).map((segment, index) => ({ segment, index }))
.filter(({ segment }) =>
segment && Array.isArray(segment.a) && Array.isArray(segment.b)
&& segment.a.length >= 2 && segment.b.length >= 2
&& segment.a.every(Number.isFinite) && segment.b.every(Number.isFinite)
&& Number.isFinite(segment.halfDepth) && segment.halfDepth > 0
&& Math.hypot(segment.b[0] - segment.a[0], segment.b[1] - segment.a[1]) > 1e-9);
if (segments.length < 2) return [];
const eps = Math.max(Number.isFinite(epsilon) ? epsilon : 0, 1e-9);
const endpoints = segments.flatMap(({ segment }) => [segment.a, segment.b])
.map((point) => [point[0], point[1]])
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const nodes: number[][] = [];
for (const point of endpoints) {
if (!nodes.some((node) => closePoint(node, point, eps))) nodes.push(point);
}
const out: { segmentIndex: number; wedge: number[][] }[] = [];
for (const node of nodes) {
// Endpoint rays only: an interior (T) hit makes the node degree-3+ and
// the fans of the multi-wall machinery own it, not the pair mitre.
const rays: { u: number[]; halfDepth: number; length: number; index: number }[] = [];
let interior = false;
for (const { segment, index } of segments) {
const length = Math.hypot(segment.b[0] - segment.a[0], segment.b[1] - segment.a[1]);
if (closePoint(node, segment.a, eps)) {
rays.push({ u: [(segment.b[0] - segment.a[0]) / length,
(segment.b[1] - segment.a[1]) / length], halfDepth: segment.halfDepth, length, index });
} else if (closePoint(node, segment.b, eps)) {
rays.push({ u: [(segment.a[0] - segment.b[0]) / length,
(segment.a[1] - segment.b[1]) / length], halfDepth: segment.halfDepth, length, index });
} else if (pointOnSegmentInterior(node, segment, eps)) {
interior = true;
}
}
if (interior || rays.length !== 2) continue;
const [a, b] = rays;
const cross = a.u[0] * b.u[1] - a.u[1] * b.u[0];
if (Math.abs(cross) < 1e-9) continue;
const sign = cross < 0 ? 1 : -1;
const nA = [-a.u[1], a.u[0]];
const nB = [-b.u[1], b.u[0]];
const pA = [node[0] + nA[0] * a.halfDepth * sign, node[1] + nA[1] * a.halfDepth * sign];
const pB = [node[0] - nB[0] * b.halfDepth * sign, node[1] - nB[1] * b.halfDepth * sign];
if (!lineIntersect(pA, a.u, pB, b.u)) continue; // no mitre — nothing pokes
// For each wall: clip its near-node body rectangle by the OUTSIDE
// half-plane of the partner's outer face (the face owning the apex side).
const pairs: [typeof a, typeof b, number[], number[]][] = [
[a, b, pB, [nB[0] * -sign, nB[1] * -sign]],
[b, a, pA, [nA[0] * sign, nA[1] * sign]],
];
for (const [self, , faceP, faceOut] of pairs) {
const reach = Math.min(2 * self.halfDepth, self.length);
const ex = [-self.u[1] * self.halfDepth, self.u[0] * self.halfDepth];
const rect = [
[node[0] + ex[0], node[1] + ex[1]],
[node[0] + self.u[0] * reach + ex[0], node[1] + self.u[1] * reach + ex[1]],
[node[0] + self.u[0] * reach - ex[0], node[1] + self.u[1] * reach - ex[1]],
[node[0] - ex[0], node[1] - ex[1]],
];
// Sutherland–Hodgman clip of the rectangle by dot(x - faceP, faceOut) >= 0.
const side = (point: number[]): number =>
(point[0] - faceP[0]) * faceOut[0] + (point[1] - faceP[1]) * faceOut[1];
const clipped: number[][] = [];
for (let i = 0; i < rect.length; i++) {
const cur = rect[i], nxt = rect[(i + 1) % rect.length];
const sc = side(cur), sn = side(nxt);
if (sc >= -1e-12) clipped.push(cur);
if ((sc > 1e-12 && sn < -1e-12) || (sc < -1e-12 && sn > 1e-12)) {
const t = sc / (sc - sn);
clipped.push([cur[0] + (nxt[0] - cur[0]) * t, cur[1] + (nxt[1] - cur[1]) * t]);
}
}
if (clipped.length >= 3 && Math.abs(signedArea(clipped)) > eps * eps) {
out.push({ segmentIndex: self.index, wedge: clipped });
}
}
}
return out;
}
export function linearWallJoinPatches(
input: LinearWallSegment[], epsilon = 1e-6,
): number[][][] {
@@ -1191,10 +1308,12 @@ export function linearWallJoinPatches(
node[1] - nB[1] * b.halfDepth * sign,
];
const hit = lineIntersect(pA, a.u, pB, b.u);
const visual = VISUAL_MITRE_LIMIT * Math.max(a.halfDepth, b.halfDepth);
const patch = hit && Math.hypot(hit[0] - node[0], hit[1] - node[1]) <= visual
// #310 (owner decision): a node of exactly two rays keeps the FULL
// mitre — two walls meet in a point like on a drawing. The #309
// chamfer applies only to the fans of >=3-ray nodes above.
const patch = hit
? [node.slice(), pA, hit, pB]
: (hit && chamferApex(node, pA, hit, pB, visual)) || [node.slice(), pA, pB];
: [node.slice(), pA, pB];
if (Math.abs(signedArea(patch)) > eps * eps) patches.push(patch);
}
}
@@ -1257,8 +1376,21 @@ export function drawWallPreviewD(
if (Math.hypot(b[0] - a[0], b[1] - a[1]) >= 1e-9 && h > 0)
segments.push({ a, b, halfDepth: h });
}
const bodies = segments.map(linearWallBody).filter((body): body is number[][] => !!body);
const joined = [...bodies, ...linearWallJoinPatches(segments)];
// Index-aligned with `segments`: the #310 wedge below addresses its owner
// body by segment index, so the null filter happens only at the join.
const bodies = segments.map(linearWallBody);
// #310: the preview shares the butt-end trim with persisted masonry, so the
// rubber-band silhouette matches what the click will save.
for (const { segmentIndex, wedge } of pairButtEndTrimWedges(segments)) {
const body = bodies[segmentIndex];
if (!body) continue;
const trimmed = clipBodyByWedge(body, wedge);
if (trimmed) bodies[segmentIndex] = trimmed;
}
const joined = [
...bodies.filter((body): body is number[][] => !!body),
...linearWallJoinPatches(segments),
];
const geom = unionSimpleBodies(joined);
if (geom) return polyclipToPathD(geom);
return joined.map((body) => polyToPath(body)).join(' ');
+163 -17
View File
@@ -14,6 +14,7 @@ import {
virtualJunctionPatches, stableJunctionPatch, unionJunctionPatches,
innerContourForRoom, innerEdgeSpan, ownEdgeOffsets,
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT, VISUAL_MITRE_LIMIT,
pairButtEndTrimWedges,
MULTI_WALL_JOIN_LIMIT, buildMultiWallNodeMap, multiWallBevelTriangles,
MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES,
MULTI_WALL_ORTHOGONAL_DOT_EPSILON, multiWallProtectedRayIndexes,
@@ -2754,12 +2755,9 @@ test('linear wall joins bevel an excessive mitre and ignore malformed or near-mi
{ a: [0, 0], b: [10, 0.1], halfDepth: 1 },
], 1e-6);
assert.equal(acute.length, 1);
assert.equal(acute[0].length, 5, 'a mitre beyond the visual limit becomes a flat chamfer (#309)');
// The chamfer bounds the APEX projection at 1.5·h; a chamfer corner can sit
// at most hypot(limit, h) from the node (limit along the axis, h across).
assert.ok(acute[0].every((point) =>
Math.hypot(point[0], point[1]) <= Math.hypot(VISUAL_MITRE_LIMIT, 1) + 1e-6),
'chamfer keeps every vertex near the node instead of the far apex');
// #310 (owner decision): a node of exactly two rays keeps the FULL mitre —
// the drawing point of two converging walls is legitimate at any length.
assert.equal(acute[0].length, 4, 'a two-ray node keeps its full mitre apex (#310)');
const separate = linearWallJoinPatches([
{ a: [-2, 0], b: [0, 0], halfDepth: 1 },
@@ -3103,23 +3101,31 @@ const pointInPoly = (point, body) => {
return inside;
};
test('issue 309 the acute 10/20 pair keeps its tail within the visual limit', () => {
test('issue 310 the acute 10/20 pair keeps its full apex and loses the butt-end tooth', () => {
const node = [2220.833333333333, 1350];
const segments = teethSegments().filter((segment) =>
[segment.a, segment.b].some((end) =>
Math.hypot(end[0] - node[0], end[1] - node[1]) < 0.5));
assert.equal(segments.length, 2);
const hMax = Math.max(...segments.map((segment) => segment.halfDepth));
const patches = linearWallJoinPatches(segments, 0.2);
assert.ok(patches.length >= 1, 'the acute pair still closes its outer sector');
for (const patch of patches) {
assert.equal(patch.length, 5, 'the over-long apex is a flat chamfer, not a spike');
for (const point of patch) {
assert.ok(Math.hypot(point[0] - node[0], point[1] - node[1])
<= Math.hypot(VISUAL_MITRE_LIMIT * hMax, hMax) + 0.5,
`spike vertex ${point} survived past the visual limit`);
}
}
assert.equal(patches.length, 1, 'the pair closes its outer sector with one patch');
// Full apex (#310): four points, the apex at the intersection of the outer
// faces — farther than the #309 visual limit, and that is the point.
assert.equal(patches[0].length, 4, 'the pair keeps its full mitre, not a chamfer');
const hMax = Math.max(...segments.map((segment) => segment.halfDepth));
const apex = patches[0][2];
assert.ok(Math.hypot(apex[0] - node[0], apex[1] - node[1]) > 1.5 * hMax,
'the restored apex must reach past the retired pair chamfer limit');
// Butt-end tooth (#310): the deeper wall's wedge exists and removes the
// probe that used to sit inside the poking corner of its butt end.
const wedges = pairButtEndTrimWedges(segments, 0.2);
assert.equal(wedges.length, 1, 'exactly one non-empty butt-end wedge (the deeper wall)');
const deeper = segments.reduce((a, b) => (a.halfDepth >= b.halfDepth ? a : b));
assert.equal(segments.indexOf(deeper), wedges[0].segmentIndex,
'the wedge belongs to the deeper wall');
const probe = [node[0] - deeper.halfDepth * 0.9, node[1] - 2];
assert.equal(pointInPoly(probe, wedges[0].wedge), true,
'the wedge covers the old tooth corner probe');
});
test('issue 309 the 3×50 node fans stay within the visual limit', () => {
@@ -3198,3 +3204,143 @@ test('issue 309 the full teeth fixture leaves no junction holes', () => {
'the teeth fixture has junction holes',
);
});
test('issue 310 the butt-end trim is addressed and the node body is a clean wedge', () => {
const node = [2220.833333333333, 1350];
const segments = teethSegments().filter((segment) =>
[segment.a, segment.b].some((end) =>
Math.hypot(end[0] - node[0], end[1] - node[1]) < 0.5));
const fixture = teethFixture();
const space = {
partitions: fixture.partitions
.filter((partition) => ['partition-mt8liuxi-0', 'partition-mt8liuxi-1']
.includes(partition.id))
.map((partition) => ({
...partition,
a: [partition.a[0] * NORM_W, partition.a[1] * NORM_W],
b: [partition.b[0] * NORM_W, partition.b[1] * NORM_W],
})),
room_drafts: [], wall_columns: [],
};
const frame = physicalBodySet(space, 1, GRID_PITCH, 0.2);
const inGeometry = (point) => (frame.geometry || []).some((polygon) =>
(polygon || []).some((ring, index) => index === 0 && pointInPoly(point, ring)));
const deeper = segments.reduce((a, b) => (a.halfDepth >= b.halfDepth ? a : b));
// The old tooth: just outside the thin wall's outer face, inside the deep
// wall's rectangular butt end. Must be empty now.
assert.equal(inGeometry([node[0] - deeper.halfDepth * 0.9, node[1] - 2]), false,
'the butt-end tooth survived the trim');
// The apex direction stays filled: probe halfway from the node to the
// actual mitre apex of the restored full pair patch.
const apex = linearWallJoinPatches(segments, 0.2)[0][2];
assert.equal(inGeometry([(node[0] + apex[0]) / 2, (node[1] + apex[1]) / 2]), true,
'the full apex region must stay masonry');
// Addressed: far from the node the deep wall body is intact at full width.
assert.equal(inGeometry([node[0] - deeper.halfDepth * 0.9, node[1] - 200]), true,
'the trim may not eat the wall far from the node');
});
test('issue 310 a square pair of equal depths has no butt-end wedge', () => {
const wedges = pairButtEndTrimWedges([
{ a: [0, 0], b: [100, 0], halfDepth: 10 },
{ a: [0, 0], b: [0, 100], halfDepth: 10 },
], 1e-6);
assert.deepEqual(wedges, [], 'equal square corners have nothing poking out');
});
test('issue 310 the butt-end trim reach is bounded by the node neighbourhood', () => {
// A near-parallel co-directed pair: the thin wall's outer face undercuts the
// deep wall's strip for ~100 units, but the ADDRESSED wedge may only reach
// 2·halfDepth from the node along the axis — the rest of the wall is not
// this node's business.
const segments = [
{ a: [0, 0], b: [300, 0], halfDepth: 10 },
{ a: [0, 0], b: [300, 30], halfDepth: 20 },
];
const wedges = pairButtEndTrimWedges(segments, 1e-6);
assert.ok(wedges.length >= 1, 'the near-parallel pair must produce a wedge');
for (const { segmentIndex, wedge } of wedges) {
const segment = segments[segmentIndex];
const length = Math.hypot(segment.b[0] - segment.a[0], segment.b[1] - segment.a[1]);
const u = [(segment.b[0] - segment.a[0]) / length, (segment.b[1] - segment.a[1]) / length];
for (const point of wedge) {
const along = point[0] * u[0] + point[1] * u[1];
assert.ok(along <= 2 * segment.halfDepth + 1e-6,
`wedge vertex ${point} reaches past 2·halfDepth along the wall`);
}
}
});
test('issue 310 pair grid contract: masonry equals strips plus patch minus wedges', () => {
// AC5 for two-ray nodes: buildMultiWallNodeMap drops nodes of <3 rays, so
// the #302 detector never sees a pair. The pair contract is checked on a
// grid instead: inside the node neighbourhood a point is masonry IFF it
// lies in (strip A ∪ strip B ∪ mitre patch) − (butt-end wedges).
const cases = [
{ name: 'owner spike 10/20', segments: (() => {
const node = [2220.833333333333, 1350];
return teethSegments().filter((segment) =>
[segment.a, segment.b].some((end) =>
Math.hypot(end[0] - node[0], end[1] - node[1]) < 0.5));
})(), node: [2220.833333333333, 1350] },
{ name: 'square 90', segments: [
{ a: [0, 0], b: [300, 0], halfDepth: 10 },
{ a: [0, 0], b: [0, 300], halfDepth: 10 },
], node: [0, 0] },
{ name: 'near-parallel', segments: [
{ a: [0, 0], b: [300, 0], halfDepth: 10 },
{ a: [0, 0], b: [300, 30], halfDepth: 20 },
], node: [0, 0] },
];
for (const { name, segments, node } of cases) {
const bodies = segments.map((segment) => linearWallBody(segment));
const patches = linearWallJoinPatches(segments, 1e-6);
const wedges = pairButtEndTrimWedges(segments, 1e-6);
const space = {
partitions: segments.map((segment, index) => ({
id: `pair-${index}`, a: segment.a, b: segment.b, cm: 15,
})),
room_drafts: [], wall_columns: [],
};
// physicalBodyParts converts cm itself; drive it with exact halfDepths by
// reusing wall bodies via the pure pipeline instead: geometry from parts.
const parts = [
...bodies.filter(Boolean).map((body, index) => {
const wedge = wedges.filter((item) => item.segmentIndex === index);
return { body, wedge };
}),
];
const h = Math.max(...segments.map((segment) => segment.halfDepth));
const radius = 3 * h;
const step = Math.max(h / 4, 2);
const inside = (point, poly) => pointInPoly(point, poly);
for (let dx = -radius; dx <= radius; dx += step) {
for (let dy = -radius; dy <= radius; dy += step) {
const point = [node[0] + dx, node[1] + dy];
const inStrip = bodies.some((body) => body && inside(point, body));
const inPatch = patches.some((patch) => inside(point, patch));
const inWedge = wedges.some(({ wedge }) => inside(point, wedge));
const expected = (inStrip || inPatch) && !inWedge;
// actual masonry: trimmed strips ∪ patches
const actual = parts.some(({ body, wedge }) =>
inside(point, body) && !wedge.some(({ wedge: w }) => inside(point, w)))
|| inPatch;
// Skip probes within one epsilon band of any edge: point-in-polygon
// on shared borders is not a stable oracle.
const nearEdge = [...bodies.filter(Boolean), ...patches,
...wedges.map(({ wedge }) => wedge)].some((poly) =>
poly.some((a, i) => {
const b = poly[(i + 1) % poly.length];
const t = Math.max(0, Math.min(1,
((point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1]))
/ (((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2) || 1e-12)));
return Math.hypot(point[0] - (a[0] + (b[0] - a[0]) * t),
point[1] - (a[1] + (b[1] - a[1]) * t)) < step / 4;
}));
if (nearEdge) continue;
assert.equal(actual, expected,
`${name}: contract mismatch at [${dx.toFixed(1)}, ${dy.toFixed(1)}]`);
}
}
}
});