fix: cap junction mitres at the visual limit and drop foreign-sector pair patches (#309)

Вылет mitre ограничен VISUAL_MITRE_LIMIT = 1.5·max(h): длиннее — плоская
фаска перпендикулярно направлению вершины (chamferApex), в парных патчах и
в веерах узлов. Узлы ≥3 канонических лучей закрываются веерами
junctionNodeGeometry прямо в linearWallJoinPatches: парный патч живёт в
секторе, противоположном своей паре, и красил ступень поверх тонких полос
(крест 15/15/30/30 из отчёта владельца). Прямые углы (вылет 1.41h)
байтово прежние. Механизм #249 (MULTI_WALL_JOIN_LIMIT,
multiWallBevelCutsAt, mitre контуров комнат) не тронут.

Юниты формы на фикстуре трёх узлов владельца + контрактный детектор дыр;
4 новых мутанта, краснота каждого проверена исполнением. Матрица golden 46:
три новые сцены junction-309-{step,spike,hump}-dark.

Issue: #309
User-Visible: yes
This commit is contained in:
Codex
2026-08-25 21:50:28 +03:00
parent 787891c4e7
commit 101cf70952
12 changed files with 668 additions and 271 deletions
File diff suppressed because one or more lines are too long
+14
View File
@@ -5,6 +5,9 @@ import { readFileSync } from 'node:fs';
const junctionArtifactsFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8',
));
const junctionTeethFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/309-junction-teeth.json', import.meta.url), 'utf8',
));
const junctionPatchFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/197-junction-patch.json', import.meta.url), 'utf8',
));
@@ -296,6 +299,17 @@ export function prepareGoldenFixture(scenario) {
settings: { fill_mode: 'none', show_borders: true, show_names: false },
});
}
if (scenario.junctionTeeth) {
fixture.config.spaces.push({
id: scenario.space,
title: 'Junction teeth repro',
cell_cm: junctionTeethFixture.cell_cm,
partitions: structuredClone(junctionTeethFixture.partitions),
rooms: [],
view_box: [0.1, 0.65, 3.05, 1.75],
settings: { fill_mode: 'none', show_borders: true, show_names: false },
});
}
if (scenario.junctionPatchResilience) {
if (!Array.isArray(scenario.retainedWedgeProbe)
|| scenario.retainedWedgeProbe.length !== 2
+9 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 45;
export const GOLDEN_MATRIX_VERSION = 46;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -617,6 +617,14 @@ export const GOLDEN_SCENARIOS = Object.freeze([
id, fixture: 'visual', space: `golden-${id}`, mode: 'view', junctionNode,
zoom: 4, theme: 'dark', viewport: { width: 900, height: 900 }, ...stage,
})),
...[
['junction-309-step-dark', 8, [808.33, 1233.33]],
['junction-309-spike-dark', 8, [2220.83, 1350]],
['junction-309-hump-dark', 6, [1758.33, 1612.5]],
].map(([id, zoom, zoomCenter]) => ({
id, fixture: 'visual', space: `golden-${id}`, mode: 'view', junctionTeeth: true,
zoom, zoomCenter, theme: 'dark', viewport: { width: 900, height: 900 }, ...stage,
})),
{ id: 'junction-owner-repro-dark', fixture: 'visual',
space: 'golden-junction-owner-repro', mode: 'view', junctionArtifacts: true,
zoom: 6, zoomCenter: [329.17, 141.67], theme: 'dark',
+121 -121
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- 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
mixed-thickness crosses no longer show a step in a foreign quadrant. Square
corners are untouched
([#309](https://github.com/Matysh/houseplan-card/issues/309)).
- The active wall chain keeps its axis and node markers visible on already
placed segments while drawing. The chain ink now paints above the wall
masonry (and below the snap overlay), so each click no longer buries the
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Стыковочные узлы больше не выпускают зубцы: вылет mitre ограничен
1.5 полутолщины, всё длиннее закрывается плоской чертёжной фаской — у
острых пар исчезают хвосты, у толстых многолучевых узлов пики, а кресты
смешанных толщин теряют ступеньку в чужом квадранте. Прямые углы не
изменились ([#309](https://github.com/Matysh/houseplan-card/issues/309)).
- Во время рисования цепочки стен осевая линия и узлы остаются видимыми на уже
поставленных сегментах. Разметка цепочки теперь рисуется поверх кладки стен
(и под снап-оверлеем), поэтому каждый клик больше не прячет жёлтую ось под
+15
View File
@@ -203,6 +203,21 @@ bound» is machine-checked by `junctionContractHoles` in tests and the
`smoke_junction_holes` wiring probe. Degenerate zero-area rings left by
coincident chords are dropped.
**Visual mitre limit (#309, owner decision 2026-08-25).** A mitre apex may
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
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
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.
**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": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"sourceFingerprint": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"imageSha256": "0ca982495a7749907c48ebbddfd089c54385a4347f79f64e94af2c5a8d5c03eb"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"imageSha256": "1c8e28ff34736b936ba7c9ec047d1da27aaf6fbb01411155f2d5c05282c02c41"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"imageSha256": "329750c81a7a958c4fd105b2c80f85dce55ed87df9ddf0ea08084e4428e85f04"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"imageSha256": "ff2e903e548423aa0aa52680e20b8ad951c704e01130cb4b180222015a241276"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"imageSha256": "ff55925f079411023442e7e7dd5ed528c331aaa3fd3fc2fbbf3b1aaf34a85718"
},
"device-editor": {
@@ -69,7 +69,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"imageSha256": "6b9cd281450e7782cff292f11efc58d227bedd0df86afc3be987feb2edec9dc3"
},
"device-display-preview": {
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
}
}
+53 -2
View File
@@ -2170,6 +2170,57 @@ export const MUTANTS = [
replace: ' .dev:not(.unavail):hover {',
}],
},
{
id: 'visual-mitre-limit-back-to-4',
// #309: порог визуального среза возвращается к классическим 4·h — шип на
// острой паре и горб над узлом 3×50 отрастают обратно.
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 309" test/wall-thickness.test.mjs',
because: 'без визуального лимита mitre снова торчит на 2–4 толщины за габарит узла',
patches: [{
file: 'src/wall-thickness.ts',
find: 'export const VISUAL_MITRE_LIMIT = 1.5;',
replace: 'export const VISUAL_MITRE_LIMIT = 4;',
}],
},
{
id: 'chamfer-disabled-full-mitre',
// #309: фаска отключена — сверх лимита рисуется сырой mitre.
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 309" test/wall-thickness.test.mjs',
because: 'сектор сверх визуального лимита обязан закрываться фаской, а не вершиной',
patches: [{
file: 'src/wall-thickness.ts',
find: ' const ux = apex[0] - node[0], uy = apex[1] - node[1];\n const d = Math.hypot(ux, uy);\n if (!(d > 0) || d <= limit + 1e-9) return null;',
replace: ' const ux = apex[0] - node[0], uy = apex[1] - node[1];\n const d = Math.hypot(ux, uy);\n if (d >= 0) return null;',
}],
},
{
id: 'pair-patches-at-multiwall-nodes',
// #309: скип узлов ≥3 лучей отключён — парный патч через чужой сектор
// снова красит ступень на кресте смешанных толщин.
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 309" test/wall-thickness.test.mjs',
because: 'парный патч в узле ≥3 лучей живёт в чужом секторе и красит ступень поверх тонких полос',
patches: [{
file: 'src/wall-thickness.ts',
find: ' for (const node of nodes) {\n if (coveredByFans(node)) continue;',
replace: ' for (const node of nodes) {',
}],
},
{
id: 'chamfer-chord-instead-of-perpendicular',
// #309: срез хордой pA–pB вместо перпендикуляра на пороге — это возврат
// формы #249 (плоский бевел у самого узла), сектор худеет.
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 309" test/wall-thickness.test.mjs',
because: 'фаска обязана резать перпендикулярно направлению вершины на пороге, а не хордой между гранями',
patches: [{
file: 'src/wall-thickness.ts',
find: ' const cA = cut(pA), cB = cut(pB);\n if (!cA || !cB) return null;\n return [[node[0], node[1]], pA, cA, cB, pB];',
replace: ' return null;',
}],
},
{
id: 'junction-fans-disabled',
// Юниты формы, не детектор: контрактные пробы детектора строятся из той
@@ -2181,8 +2232,8 @@ export const MUTANTS = [
+ 'это и есть класс артефактов #302',
patches: [{
file: 'src/wall-thickness.ts',
find: ' if (mitre) {\n push([[P[0], P[1]], EA, mitre, EB]);\n continue;\n }',
replace: ' if (mitre) {\n continue;\n }',
find: ' push(chamferApex([P[0], P[1]], EA, mitre, EB, visual)\n ?? [[P[0], P[1]], EA, mitre, EB]);\n continue;',
replace: ' continue;',
}, {
file: 'src/wall-thickness.ts',
find: ' push([[P[0], P[1]], EA, A2, B2, EB]);',
+59 -4
View File
@@ -79,6 +79,41 @@ export const HATCH_MIN_STEP_PX = 2;
/** Mitre spikes longer than this × thickness fall back to a bevel. */
export const MITRE_LIMIT = 4;
/**
* Visual mitre limit (#309, owner decision 2026-08-25). A mitre apex may
* protrude at most this many maximal half-depths from the node; anything
* longer is closed with a flat chamfer perpendicular to the apex direction.
* A square corner of equal depths peaks at ~1.41·h, so 1.5 keeps every
* right and obtuse corner byte-identical and only trims acute spikes.
* MITRE_LIMIT above stays as the sanity bound for candidate construction.
*/
export const VISUAL_MITRE_LIMIT = 1.5;
/**
* Flat chamfer of an over-long mitre apex (#309). Returns the clipped
* polygon [node, pA, cA, cB, pB] where cA/cB sit on the fan edges at the
* visual limit along the apex direction, or null when the apex is within
* the limit (keep the mitre) or the cut degenerates (fall back to the
* caller's bevel/chord).
*/
function chamferApex(
node: number[], pA: number[], apex: number[], pB: number[], limit: number,
): number[][] | null {
const ux = apex[0] - node[0], uy = apex[1] - node[1];
const d = Math.hypot(ux, uy);
if (!(d > 0) || d <= limit + 1e-9) return null;
const nx = ux / d, ny = uy / d;
const cut = (from: number[]): number[] | null => {
const f = (from[0] - node[0]) * nx + (from[1] - node[1]) * ny;
const t = (limit - f) / (d - f);
if (!Number.isFinite(t) || t < -1e-9 || t > 1 + 1e-9) return null;
return [from[0] + (apex[0] - from[0]) * t, from[1] + (apex[1] - from[1]) * t];
};
const cA = cut(pA), cB = cut(pB);
if (!cA || !cB) return null;
return [[node[0], node[1]], pA, cA, cB, pB];
}
/** Multi-ray joins stay inside this × the largest incident half-depth (#249). */
export const MULTI_WALL_JOIN_LIMIT = 1.25;
@@ -1099,7 +1134,22 @@ export function linearWallJoinPatches(
}
const patches: number[][][] = [];
// #309: a node of three or more canonical rays is closed with the sector
// fans of the multi-wall junction machinery (visual mitre limit included)
// instead of pair patches. A pair patch lives in the sector OPPOSITE its
// pair and, at such a node, paints a step over the thinner strips that own
// that sector (owner report: the 15/15/30/30 cross).
const intervals: WallInterval[] = segments.map((segment, i) => ({
roomId: '', a: [segment.a[0], segment.a[1]], b: [segment.b[0], segment.b[1]],
key: `join-${i}`, kind: 'outer', cm: 0, open: false,
half: segment.halfDepth,
}));
const multiWallNodes = buildMultiWallNodeMap(intervals, eps);
for (const fan of junctionNodeGeometry(multiWallNodes).fans) patches.push(fan);
const coveredByFans = (point: number[]): boolean =>
!!multiWallNodeAt(multiWallNodes, point);
for (const node of nodes) {
if (coveredByFans(node)) continue;
const rays: JunctionRay[] = [];
for (const segment of segments) {
if (closePoint(node, segment.a, eps)) {
@@ -1141,10 +1191,10 @@ export function linearWallJoinPatches(
node[1] - nB[1] * b.halfDepth * sign,
];
const hit = lineIntersect(pA, a.u, pB, b.u);
const limit = MITRE_LIMIT * Math.max(a.halfDepth, b.halfDepth);
const patch = hit && Math.hypot(hit[0] - node[0], hit[1] - node[1]) <= limit
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
? [node.slice(), pA, hit, pB]
: [node.slice(), pA, pB];
: (hit && chamferApex(node, pA, hit, pB, visual)) || [node.slice(), pA, pB];
if (Math.abs(signedArea(patch)) > eps * eps) patches.push(patch);
}
}
@@ -2116,7 +2166,12 @@ export function junctionNodeGeometry(
if (Math.abs(signedArea(poly)) > areaEps) out.fans.push(poly);
};
if (mitre) {
push([[P[0], P[1]], EA, mitre, EB]);
// #309: the accepted apex may still be visually too long (the classic
// bound admits 4·h). Past the visual limit the fan is closed with a
// flat chamfer perpendicular to the apex direction.
const visual = VISUAL_MITRE_LIMIT * Math.max(A.halfDepth, B.halfDepth);
push(chamferApex([P[0], P[1]], EA, mitre, EB, visual)
?? [[P[0], P[1]], EA, mitre, EB]);
continue;
}
if (reflex) {
+113
View File
@@ -0,0 +1,113 @@
{
"partitions": [
{
"id": "partition-mt8lgb4w-0",
"a": [
0.1625,
1.2333333333333334
],
"b": [
0.8083333333333333,
1.2333333333333334
],
"cm": 15
},
{
"id": "partition-mt8lgb4w-1",
"a": [
0.8083333333333333,
1.2333333333333334
],
"b": [
0.8083333333333333,
1.6291666666666667
],
"cm": 15
},
{
"id": "partition-mt8lgho0-0",
"a": [
0.8083333333333333,
1.2333333333333334
],
"b": [
1.3666666666666667,
1.2333333333333334
],
"cm": 30
},
{
"id": "partition-mt8lglu1-0",
"a": [
0.8083333333333333,
1.2333333333333334
],
"b": [
0.8083333333333333,
0.7291666666666666
],
"cm": 30
},
{
"id": "partition-mt8liuxi-0",
"a": [
1.9,
1.0291666666666666
],
"b": [
2.220833333333333,
1.35
],
"cm": 10
},
{
"id": "partition-mt8liuxi-1",
"a": [
2.220833333333333,
1.35
],
"b": [
2.220833333333333,
0.8708333333333333
],
"cm": 20
},
{
"id": "partition-mt8ljoqh-0",
"a": [
1.1291666666666667,
2.066666666666667
],
"b": [
1.7583333333333333,
1.6125
],
"cm": 50
},
{
"id": "partition-mt8ljoqh-1",
"a": [
1.7583333333333333,
1.6125
],
"b": [
3.0625,
1.7
],
"cm": 50
},
{
"id": "partition-mt8lju31-0",
"a": [
1.7583333333333333,
1.6125
],
"b": [
2.0833333333333335,
2.3291666666666666
],
"cm": 50
}
],
"cell_cm": 1
}
+139 -11
View File
@@ -13,7 +13,7 @@ import {
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, floorFootprintGeometry,
virtualJunctionPatches, stableJunctionPatch, unionJunctionPatches,
innerContourForRoom, innerEdgeSpan, ownEdgeOffsets,
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT, VISUAL_MITRE_LIMIT,
MULTI_WALL_JOIN_LIMIT, buildMultiWallNodeMap, multiWallBevelTriangles,
MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES,
MULTI_WALL_ORTHOGONAL_DOT_EPSILON, multiWallProtectedRayIndexes,
@@ -30,7 +30,7 @@ import {
import { polygonArea, paperRoomShapes, splitRoomPath, sharedBoundary } from '../test-build/logic.js';
import { resolveOpenCuts } from '../test-build/open-spans.js';
import { GRID_PITCH, NORM_W } from '../test-build/space-geometry.js';
import { geometryArea } from '../test-build/physical-geometry.js';
import { geometryArea, physicalBodySet } from '../test-build/physical-geometry.js';
import { checkMixedRoleRecords, checkWallRecordsPreserved } from '../scripts/model-invariants.mjs';
import { difference, intersection, union } from 'polyclip-ts';
@@ -2754,8 +2754,12 @@ 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, 3, 'a mitre beyond the limit becomes a bevel triangle');
assert.ok(acute[0].every((point) => Math.hypot(point[0], point[1]) <= MITRE_LIMIT));
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');
const separate = linearWallJoinPatches([
{ a: [-2, 0], b: [0, 0], halfDepth: 1 },
@@ -3053,20 +3057,144 @@ test('issue 302 the owner repro is hole-free end to end', () => {
);
});
test('issue 302 the 57° mixed-thickness pair takes the full mitre (decision #5)', () => {
// The owner's repro corner: mitre at ~8.7 units — beyond the retired 1.25×h
// join limit (6.1) but well inside the classic MITRE_LIMIT bound. It must
// be a four-point mitre fan, not a bevel: this is the visible difference
// between decision #5 and the #249 look.
test('issue 309 the 57° mixed-thickness pair chamfers past the visual limit', () => {
// The #302 owner's repro corner: the raw mitre lands at ~8.7 units — beyond
// the visual limit 1.5·4.861 ≈ 7.29 (#309), so the sector closes with a
// five-point flat chamfer. It must still reach past the retired 1.25×h join
// limit (6.1): the chamfer is a trimmed mitre, not the #249 bevel.
const map = nodeMapOf(starIntervals([
{ deg: 45, half: 4.861 }, { deg: 102.3, half: 3.472 }, { deg: 332.2, half: 3.472 },
]));
assert.equal(map.nodes.length, 1);
const { fans } = junctionNodeGeometry(map);
const sectorFan = fans.find((poly) => poly.length === 4 && poly.some((point) => {
const limit = VISUAL_MITRE_LIMIT * 4.861;
const sectorFan = fans.find((poly) => poly.length === 5 && poly.some((point) => {
const dx = point[0] - 500, dy = point[1] - 500;
const angle = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
return Math.hypot(dx, dy) > 6.2 && angle > 45 && angle < 103;
}));
assert.ok(sectorFan, 'the 57° sector fell back to a bevel — the 1.25×h limit is back');
assert.ok(sectorFan, 'the 57° sector lost its chamfered mitre — #249 bevel or raw apex is back');
for (const poly of fans) {
for (const point of poly) {
assert.ok(Math.hypot(point[0] - 500, point[1] - 500) <= limit + map.epsilon + 0.1,
`vertex ${point} escapes the visual limit`);
}
}
});
// --- issue #309: visual mitre limit and node teeth ---------------------------
const teethFixture = () => JSON.parse(readFileSync(
new URL('./fixtures/309-junction-teeth.json', import.meta.url), 'utf8',
));
const teethSegments = () => teethFixture().partitions.map((p) => ({
a: [p.a[0] * NORM_W, p.a[1] * NORM_W],
b: [p.b[0] * NORM_W, p.b[1] * NORM_W],
halfDepth: wallCmToUnits(p.cm, 1, GRID_PITCH) / 2,
}));
const pointInPoly = (point, body) => {
let inside = false;
for (let i = 0, j = body.length - 1; i < body.length; j = i++) {
const a = body[i], b = body[j];
if (((a[1] > point[1]) !== (b[1] > point[1]))
&& point[0] < ((b[0] - a[0]) * (point[1] - a[1]))
/ ((b[1] - a[1]) || 1e-12) + a[0]) inside = !inside;
}
return inside;
};
test('issue 309 the acute 10/20 pair keeps its tail within the visual limit', () => {
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`);
}
}
});
test('issue 309 the 3×50 node fans stay within the visual limit', () => {
const node = [1758.3333333333333, 1612.5];
const segments = teethSegments();
const intervals = segments.map((segment, index) => ({
roomId: '', a: segment.a, b: segment.b, key: `iv-${index}`,
kind: 'outer', cm: 0, open: false, half: segment.halfDepth,
}));
const map = buildMultiWallNodeMap(intervals, 0.2);
const target = map.nodes.find((candidate) =>
Math.hypot(candidate.point[0] - node[0], candidate.point[1] - node[1]) < 0.5);
assert.ok(target, 'the 3×50 node is a multi-wall node');
const h = Math.max(...target.rays.map((ray) => ray.halfDepth));
const { fans } = junctionNodeGeometry(map);
let sawChamfer = false;
for (const fan of fans) {
if (!fan.some((p) => Math.hypot(p[0] - node[0], p[1] - node[1]) < 0.5)) continue;
if (fan.length === 5) sawChamfer = true;
for (const point of fan) {
const along = Math.hypot(point[0] - node[0], point[1] - node[1]);
assert.ok(along <= Math.hypot(VISUAL_MITRE_LIMIT * h, h) + 0.5,
`hump vertex ${point} escapes the node envelope`);
}
}
assert.ok(sawChamfer, 'the 1.95·h apex must be chamfered into a five-point fan');
});
test('issue 309 the mixed-thickness cross has no step in a foreign quadrant', () => {
const node = [808.3333333333334, 1233.3333333333333];
const segments = teethSegments();
const patches = linearWallJoinPatches(segments, 0.2);
// The parasite pair patch used to cover this probe: thick-half deep into the
// quadrant owned by the two thin rays (owner report, the 15/15/30/30 cross).
const probe = [node[0] - 47, node[1] + 47];
assert.equal(patches.some((patch) => pointInPoly(probe, patch)), false,
'a pair patch across a foreign sector paints the step again');
// The thin-owned corner itself stays closed by its sector fan.
const corner = [node[0] - 25, node[1] + 25];
assert.equal(patches.some((patch) => pointInPoly(corner, patch)), true,
'the thin corner lost its fan coverage');
});
test('issue 309 a square corner of equal depths keeps its byte-identical mitre', () => {
const patches = linearWallJoinPatches([
{ a: [0, 0], b: [100, 0], halfDepth: 10 },
{ a: [0, 0], b: [0, 100], halfDepth: 10 },
], 1e-6);
assert.equal(patches.length, 1);
assert.equal(patches[0].length, 4, 'a 1.41·h apex stays a full mitre');
const apex = patches[0][2];
assert.ok(Math.abs(Math.hypot(apex[0], apex[1]) - 10 * Math.SQRT2) < 1e-9,
'the square mitre apex is exactly √2·h from the node');
});
test('issue 309 the full teeth fixture leaves no junction holes', () => {
const segments = teethSegments();
const intervals = segments.map((segment, index) => ({
roomId: '', a: segment.a, b: segment.b, key: `iv-${index}`,
kind: 'outer', cm: 0, open: false, half: segment.halfDepth,
}));
const map = buildMultiWallNodeMap(intervals, 0.2);
assert.ok(map.nodes.length >= 2, 'fixture keeps its multi-wall nodes');
const space = {
partitions: teethFixture().partitions.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 reports = junctionContractHoles(frame.geometry, map, { step: 2 });
assert.deepEqual(
reports.map((report) => ({ node: report.node, holes: report.holes.length })), [],
'the teeth fixture has junction holes',
);
});