From e3b635aba26f7d84f3f504d1a02eacf3725a1502 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 23 Aug 2026 19:56:08 +0300 Subject: [PATCH] fix(walls): bound multi-wall repairs to finite rays Issue: #271 User-Visible: yes --- demo/golden/harness.mjs | 12 ++- demo/golden/matrix.mjs | 2 + demo/smoke_junction_patch_resilience.mjs | 40 ++++++++- docs/ARCHITECTURE.md | 9 +- docs/CHANGELOG.md | 5 ++ docs/CHANGELOG.ru.md | 5 ++ docs/TESTING.md | 10 +++ docs/USER-GUIDE.md | 4 +- docs/USER-GUIDE.ru.md | 4 +- docs/WALL-THICKNESS.md | 11 ++- scripts/mutation-gate.mjs | 13 +++ scripts/smoke-links.mjs | 8 +- src/wall-thickness.ts | 110 +++++++++++++++++------ test/golden-matrix.test.mjs | 4 + test/wall-thickness.test.mjs | 86 +++++++++++++++++- 15 files changed, 278 insertions(+), 45 deletions(-) diff --git a/demo/golden/harness.mjs b/demo/golden/harness.mjs index 43fc1de0..7a021302 100644 --- a/demo/golden/harness.mjs +++ b/demo/golden/harness.mjs @@ -153,7 +153,11 @@ export function prepareGoldenFixture(scenario) { if (scenario.junctionPatchResilience) { if (!Array.isArray(scenario.retainedWedgeProbe) || scenario.retainedWedgeProbe.length !== 2 - || !scenario.retainedWedgeProbe.every(Number.isFinite)) { + || !scenario.retainedWedgeProbe.every(Number.isFinite) + || !Array.isArray(scenario.absentWallProbes) + || scenario.absentWallProbes.length < 1 + || scenario.absentWallProbes.some((point) => !Array.isArray(point) + || point.length !== 2 || !point.every(Number.isFinite))) { throw new Error(`invalid golden retainedWedgeProbe: ${scenario.id}`); } fixture.config.spaces.push({ @@ -578,6 +582,12 @@ export async function prepareGoldenScenario(page, scenario) { || !papers.some((paper) => paper.isPointInFill?.(point))) { throw new Error(`golden retained T-junction wedge contract failed: ${scenario.id}`); } + for (const absent of scenario.absentWallProbes || []) { + const absentPoint = new DOMPoint(absent[0] * 1000, absent[1] * card._spaceH); + if (wall?.isPointInFill?.(absentPoint)) { + throw new Error(`golden finite multi-wall ray contract failed: ${scenario.id}`); + } + } } if (scenario.wallKeyRoundtrip) { const { node, incidentArm } = scenario.wallKeyRoundtrip; diff --git a/demo/golden/matrix.mjs b/demo/golden/matrix.mjs index 970cae1e..62d2f9b3 100644 --- a/demo/golden/matrix.mjs +++ b/demo/golden/matrix.mjs @@ -263,10 +263,12 @@ export const GOLDEN_SCENARIOS = Object.freeze([ { id: 'junction-patch-resilience-plan-dark', fixture: 'visual', space: 'golden-junction-patch-resilience', junctionPatchResilience: true, mode: 'plan', retainedWedgeProbe: [0.8955, 0.556], + absentWallProbes: [[0.420833333, 0.37625], [0.936524285, 0.345833333]], theme: 'dark', viewport: { width: 1180, height: 900 }, ...page }, { id: 'junction-patch-resilience-view-dark', fixture: 'visual', space: 'golden-junction-patch-resilience', junctionPatchResilience: true, mode: 'view', retainedWedgeProbe: [0.8955, 0.556], + absentWallProbes: [[0.420833333, 0.37625], [0.936524285, 0.345833333]], theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage }, { id: 'multiwall-junction-bevel-view-dark', fixture: 'visual', space: 'golden-multiwall-junction', mode: 'view', diff --git a/demo/smoke_junction_patch_resilience.mjs b/demo/smoke_junction_patch_resilience.mjs index c9740f58..f4d80725 100644 --- a/demo/smoke_junction_patch_resilience.mjs +++ b/demo/smoke_junction_patch_resilience.mjs @@ -12,8 +12,21 @@ const result = await page.evaluate(async (spaceFixture) => { const card = window.__card; const root = () => card.shadowRoot || card.renderRoot; const retainedWedgeProbe = [895.5, 556]; + const finiteRayOutsideProbe = [0.420833333 * 1000, 0.37625 * card._spaceH]; const svgContains = (element, point = retainedWedgeProbe) => !!element?.isPointInFill?.(new DOMPoint(point[0], point[1])); + const pathDataContains = (d, point) => { + const svgRoot = root().querySelector('svg'); + if (!svgRoot || !d) return false; + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', d); + path.setAttribute('fill-rule', 'evenodd'); + path.setAttribute('visibility', 'hidden'); + svgRoot.append(path); + const contains = svgContains(path, point); + path.remove(); + return contains; + }; const ringContains = (ring, point) => { let inside = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { @@ -79,6 +92,7 @@ const result = await page.evaluate(async (spaceFixture) => { out.planKeepsMasonry = !!planD && !!planPath && canonical?.d === planD; out.paperKeepsFootprint = !!canonical?.paperD && !!root().querySelector('.hp-paper'); out.planRetainsMeasuredWedge = svgContains(planPath); + out.planStopsAtFiniteRayEndpoint = !svgContains(planPath, finiteRayOutsideProbe); out.paperRetainsMeasuredWedge = [...root().querySelectorAll('.hp-paper')] .some((paper) => svgContains(paper)); @@ -89,6 +103,9 @@ const result = await page.evaluate(async (spaceFixture) => { && barriers.occluders.length > 0 && !!barriers.fingerprint; out.lightAndSunRetainMeasuredWedge = geometryContains(barriers.masonryGeometry); + out.lightAndSunStopAtFiniteRayEndpoint = !geometryContains( + barriers.masonryGeometry, finiteRayOutsideProbe, + ); const barrierFingerprint = barriers.fingerprint; const cacheBeforeState = card._wallUnionCache; @@ -113,6 +130,9 @@ const result = await page.evaluate(async (spaceFixture) => { await update(false); out.viewMatchesPlan = root().querySelector('[data-hp="wall"]')?.getAttribute('d') === planD; out.viewRetainsMeasuredWedge = svgContains(root().querySelector('[data-hp="wall"]')); + out.viewStopsAtFiniteRayEndpoint = !svgContains( + root().querySelector('[data-hp="wall"]'), finiteRayOutsideProbe, + ); card._hoverRoom = { space: spaceFixture.id, room: model.rooms[0] }; const hoverFloor = card._roomHoverPaths(model); out.cleanFloorConsumerStaysNonEmpty = !!hoverFloor?.fillD && !!hoverFloor.outlineD; @@ -120,10 +140,13 @@ const result = await page.evaluate(async (spaceFixture) => { card._hoverRoom = { space: spaceFixture.id, room }; const floor = card._roomHoverPaths(model); if (!floor?.fillD) return true; - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', floor.fillD); - path.setAttribute('fill-rule', 'evenodd'); - return !svgContains(path); + return !pathDataContains(floor.fillD, retainedWedgeProbe); + }); + out.cleanFloorOwnsAreaAfterFiniteEndpoint = model.rooms.some((room) => { + card._hoverRoom = { space: spaceFixture.id, room }; + const floor = card._roomHoverPaths(model); + if (!floor?.fillD) return false; + return pathDataContains(floor.fillD, finiteRayOutsideProbe); }); card._hoverRoom = null; @@ -132,6 +155,9 @@ const result = await page.evaluate(async (spaceFixture) => { await update(false); out.kioskMatchesPlan = root().querySelector('[data-hp="wall"]')?.getAttribute('d') === planD; out.kioskRetainsMeasuredWedge = svgContains(root().querySelector('[data-hp="wall"]')); + out.kioskStopsAtFiniteRayEndpoint = !svgContains( + root().querySelector('[data-hp="wall"]'), finiteRayOutsideProbe, + ); card._config.kiosk = kioskBefore; await update(false); @@ -158,6 +184,9 @@ const result = await page.evaluate(async (spaceFixture) => { out.staticRetainsMeasuredWedge = svgContains( staticCard.renderRoot?.querySelector('[data-hp="wall"]'), ); + out.staticStopsAtFiniteRayEndpoint = !svgContains( + staticCard.renderRoot?.querySelector('[data-hp="wall"]'), finiteRayOutsideProbe, + ); staticCard.remove(); const labs = Object.freeze(['iso']); @@ -169,6 +198,9 @@ const result = await page.evaluate(async (spaceFixture) => { out.hiddenIsoKeepsMasonry = !!root().querySelector('[data-hp="iso-walls"]') && isoWalls.flat(2).length > 0; out.hiddenIsoRetainsMeasuredWedge = geometryContains(isoWalls); + out.hiddenIsoStopsAtFiniteRayEndpoint = !geometryContains( + isoWalls, finiteRayOutsideProbe, + ); out.renderNeverWritesConfig = JSON.stringify(card._serverCfg.spaces[0]) === sourceBefore; return out; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f482b6d2..0fe003f6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -429,9 +429,12 @@ prevents one cosmetic junction repair from blanking a whole plan without masking a real structural failure (#197). The same structural pass builds one scale-relative physical endpoint map for room profiles, exterior intervals and junction patches (#249). Co-directional -duplicates collapse while opposite rays remain distinct. At degree 3+ nodes it -uses `H = max(incident half-depth)` and clips excessive overlap to a straight -bevel bounded by `1.25 × H`; degree-2 joins keep the legacy `MITRE_LIMIT = 4`. +duplicates collapse while opposite rays remain distinct. Each canonical +direction retains the non-dominated finite `(half-depth, length)` supports of +its source intervals, so local reconstruction cannot invent masonry, paper or +an occluder after a real endpoint (#271). At degree 3+ nodes it uses +`H = max(incident half-depth)` and clips excessive overlap to a straight bevel +bounded by `1.25 × H`; degree-2 joins keep the legacy `MITRE_LIMIT = 4`. The final bevel is applied to canonical masonry after its room/atomic/exterior union, preventing later boolean inputs from recreating the discarded spike. Canonical masonry replaces each affected local mask with complete physical ray diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 992f3dfb..8df2d3b0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,11 @@ place; fragments between two topology nodes or touching an opening boundary remain protected. Preview, Apply, reload and maintenance Undo use the same canonical result ([#273](https://github.com/Matysh/houseplan-card/issues/273)). +- Degree-3+ wall junctions no longer extend a short wall arm up to `8×` the + largest half-thickness. Plan, View, Static, hidden Iso, floor, shadows and + light barriers now stop at the real saved endpoint, including when a short + thick arm shares its direction with a longer thin wall + ([#271](https://github.com/Matysh/houseplan-card/issues/271)). ## v1.67.0-beta.5 — 2026-08-23 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 3c285500..958980d1 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -15,6 +15,11 @@ по-прежнему защищены. Preview, Apply, перезагрузка и служебная отмена используют один канонический результат ([#273](https://github.com/Matysh/houseplan-card/issues/273)). +- Стыки трёх и более стен больше не продолжают короткое плечо на расстояние до + `8×` максимальной полутолщины. Plan, View, Static, скрытая изометрия, пол, + тени и световые барьеры теперь заканчиваются в реальной сохранённой точке — + в том числе когда короткое толстое плечо сонаправлено более длинной тонкой + стене ([#271](https://github.com/Matysh/houseplan-card/issues/271)). ## v1.67.0-beta.5 — 2026-08-23 diff --git a/docs/TESTING.md b/docs/TESTING.md index 76e852a8..70182d37 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -2648,6 +2648,16 @@ require hands on real hardware — they remain for the human pass. junction-patch-resilience-plan-dark + junction-patch-resilience-view-dark; mutation: multi-wall-paper-full-origin-cut]. +- [ ] **Degree-3 repair stops at every finite ray endpoint (#271)**: canonical + co-directional rays retain separate short-thick and long-thin supports; + the rebuilt masonry, paper and light barrier contain the real short arm + but no area after its endpoint. Plan, View, kiosk, Static, hidden Iso and + clean floor agree, independent of owner order, winding and scale + [unit: test/wall-thickness.test.mjs; auto: + smoke_junction_patch_resilience; golden: + junction-patch-resilience-plan-dark + + junction-patch-resilience-view-dark; mutation: + multi-wall-finite-ray-disabled]. - [ ] **Openings cut the slab**: a door/window/gate on a thick wall leaves a gap in the body; the door swing is offset toward the inner face and gate leaves toward the exterior face; with diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index d4d47e2b..ed2f6ffd 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -327,7 +327,9 @@ Wall thickness is stored in real units. A room may have different thicknesses on different spans. Open wall branches and T-junctions are allowed; shared geometry remains joined without painted end caps. At a saved T-junction both physical half-walls stay solid: the bounded bevel removes only an excessive -projecting corner and never leaves a white triangular gap. +projecting corner and never leaves a white triangular gap. A short arm stops at +its real endpoint; the join repair never draws a cap, shadow or light barrier +where no wall was saved. If Resize moves only part of a longer thick wall, the moved part keeps its thickness and follows the room while the remaining continuation stays in place. Openings on the moved part follow it in the same Undo/Redo operation. diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 2415c779..d4fc6610 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -391,7 +391,9 @@ T-соединение входит в проходящую стену без в карточке. Толщина каждого ранее поставленного отрезка остаётся своей; свободный конец незамкнутой стены остаётся плоским. У сохранённого T-стыка обе половины физической стены остаются сплошными: фаска убирает только чрезмерно выступающий -угол и не оставляет белого треугольного клина. +угол и не оставляет белого треугольного клина. Короткое плечо заканчивается в +своей реальной точке: исправление стыка не рисует торец, тень или световой +барьер там, где сохранённой стены нет. Каждый законченный отрезок цепочки сохраняется сразу. Esc/Ctrl+Z удаляет последнюю точку. Pan, pinch и `pointercancel` ничего не завершают и не добавляют. diff --git a/docs/WALL-THICKNESS.md b/docs/WALL-THICKNESS.md index fb3c57fa..2e307cb3 100644 --- a/docs/WALL-THICKNESS.md +++ b/docs/WALL-THICKNESS.md @@ -107,10 +107,13 @@ joins; bevel when the mitre spike exceeds `MITRE_LIMIT × thickness`. At a physical node with **three or more distinct incident rays**, the stricter multi-wall rule applies (#249). Shared room ownership and reversed interval direction do not create extra rays. One structural node map records the largest -incident half-depth `H`; every excessive join is cut back with a straight local -bevel and may not extend beyond `R = 1.25 × H`. Inside the room union, a bounded -mask replaces the legacy ring with the complete finite ray strips, retains their -overlap through `R`, and removes only the remaining excessive pairwise wedge. +incident half-depth `H` and the finite `(half-depth, endpoint distance)` supports +of every co-directional ray. A longer thin support never extends a shorter thick +support, and no repair may continue either one beyond its saved endpoint (#271). +Every excessive join is cut back with a straight local bevel and may not extend +beyond `R = 1.25 × H`. Inside the room union, a bounded mask replaces the legacy +ring with the complete finite ray strips, retains their overlap through `R`, and +removes only the remaining excessive pairwise wedge. The same bounded rule applies to the exterior half-wall and paper envelope: local ray strips are clipped to that physical envelope rather than the room centre, so a valid T-junction cannot become a white wedge. This keeps the node diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 29c4623d..2b13dbd9 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -292,6 +292,19 @@ export const MUTANTS = [ + ' else if (envelope) localInside = intersection(localInside, envelope);', }], }, + { + id: 'multi-wall-finite-ray-disabled', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="issue #271 keeps finite" ' + + 'test/wall-thickness.test.mjs', + because: 'a degree-3 repair must stop at every real interval endpoint; restoring the ' + + 'node-wide 8H rectangle recreates the phantom wall and light barrier from #271', + patches: [{ + file: 'src/wall-thickness.ts', + find: ' const supportExtent = Math.min(extent, support.length);', + replace: ' const supportExtent = extent;', + }], + }, { id: 'wall-exact-span-fallback-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' diff --git a/scripts/smoke-links.mjs b/scripts/smoke-links.mjs index 6531efd9..edf57f79 100644 --- a/scripts/smoke-links.mjs +++ b/scripts/smoke-links.mjs @@ -43,11 +43,15 @@ export const SMOKE_LINKS = [ { // #261: the browser observes the canonical wall/paper paths and downstream // geometries, while the pure bevel helpers remain internal to the bundle. - symbols: ['multiWallBevelTriangles', 'multiWallBevelTrianglesAt'], + symbols: [ + 'multiWallBevelTriangles', 'multiWallBevelTrianglesAt', + 'buildMultiWallNodeMap', 'MultiWallNodeRay', 'MultiWallNodeRaySupport', + ], smokes: ['smoke_junction_patch_resilience.mjs', 'smoke_multiwall_junction.mjs'], because: 'the #197 smoke probes the repaired exterior wedge across Plan, View, kiosk, ' + 'Static, hidden Iso and light barriers, while the #249 smoke proves the old excessive ' - + 'wedge stays empty; neither browser scenario calls the pure bevel helpers by name', + + 'wedge stays empty, while #271 also proves finite ray endpoints across Plan, Static, ' + + 'hidden Iso and light barriers; the browser scenarios call none of the pure helpers by name', }, { // #258: the browser sees only the resulting path/caches; it cannot call diff --git a/src/wall-thickness.ts b/src/wall-thickness.ts index 5b7cd1df..02f4339e 100644 --- a/src/wall-thickness.ts +++ b/src/wall-thickness.ts @@ -48,10 +48,22 @@ export const MITRE_LIMIT = 4; /** Multi-ray joins stay inside this × the largest incident half-depth (#249). */ export const MULTI_WALL_JOIN_LIMIT = 1.25; +export interface MultiWallNodeRaySupport { + /** Physical half-depth owned by this finite co-directional interval. */ + halfDepth: number; + /** Distance from the canonical node to the interval's real endpoint. */ + length: number; +} + export interface MultiWallNodeRay { /** Unit direction from the canonical node toward the interval's other end. */ u: [number, number]; + /** Largest incident half-depth at the node; used by the join formula. */ halfDepth: number; + /** Furthest real endpoint in this direction. */ + length: number; + /** Non-dominated finite strips whose union is the physical ray support. */ + supports: MultiWallNodeRaySupport[]; } export interface MultiWallNode { @@ -1463,7 +1475,9 @@ export interface RoomWallProfile extends AtomicPoly { interface PendingMultiWallNode { point: [number, number]; - rays: Array<{ u: [number, number]; halfDepth: number; angle: number }>; + rays: Array<{ + u: [number, number]; halfDepth: number; length: number; angle: number; + }>; } function spatialBucket(point: number[], epsilon: number): [number, number] { @@ -1565,39 +1579,72 @@ export function buildMultiWallNodeMap( const u: [number, number] = [dx / length, dy / length]; let angle = Math.atan2(u[1], u[0]); if (angle < 0) angle += Math.PI * 2; - node.rays.push({ u, halfDepth: endpoint.halfDepth, angle }); + node.rays.push({ u, halfDepth: endpoint.halfDepth, length, angle }); } const nodes: MultiWallNode[] = []; const angleEps = 1e-9; + const canonicalSupports = ( + input: MultiWallNodeRaySupport[], + ): MultiWallNodeRaySupport[] => { + // Endpoint clustering uses a deliberately visible plan-space tolerance; + // dominance between already matched physical strips must not. Otherwise a + // long thin strip can erase a shorter thick strip merely because their + // half-depth difference is below the node lookup epsilon. + const supportEps = 1e-9 * Math.max(1, scale); + const validSupports = input.filter((support) => Number.isFinite(support.halfDepth) + && support.halfDepth > 0 && Number.isFinite(support.length) && support.length > eps); + return validSupports + .filter((support, index) => !validSupports.some((other, otherIndex) => ( + otherIndex !== index + && other.halfDepth >= support.halfDepth - supportEps + && other.length >= support.length - supportEps + && (other.halfDepth > support.halfDepth + supportEps + || other.length > support.length + supportEps + || otherIndex < index) + ))) + .sort((a, b) => a.length - b.length || a.halfDepth - b.halfDepth) + .map((support) => ({ ...support })); + }; for (const node of pending) { - const sorted = node.rays.sort((a, b) => a.angle - b.angle || a.halfDepth - b.halfDepth); - const rays: Array<{ u: [number, number]; halfDepth: number; angle: number }> = []; + const sorted = node.rays.sort((a, b) => a.angle - b.angle + || a.length - b.length || a.halfDepth - b.halfDepth); + const rays: Array<{ + u: [number, number]; angle: number; supports: MultiWallNodeRaySupport[]; + }> = []; for (const ray of sorted) { const previous = rays[rays.length - 1]; if (previous && Math.abs(ray.angle - previous.angle) <= angleEps) { - if (ray.halfDepth > previous.halfDepth) { - previous.halfDepth = ray.halfDepth; - previous.u = ray.u; - } + previous.supports.push({ halfDepth: ray.halfDepth, length: ray.length }); } else { - rays.push({ ...ray, u: [...ray.u] }); + rays.push({ + u: [...ray.u], angle: ray.angle, + supports: [{ halfDepth: ray.halfDepth, length: ray.length }], + }); } } if (rays.length > 1 && Math.PI * 2 - rays[rays.length - 1].angle + rays[0].angle <= angleEps) { const last = rays.pop()!; - if (last.halfDepth > rays[0].halfDepth) { - rays[0].halfDepth = last.halfDepth; - rays[0].u = last.u; - } + rays[0].supports.push(...last.supports); } if (rays.length < 3) continue; - const halfDepth = Math.max(...rays.map((ray) => ray.halfDepth)); + const canonicalRays = rays.map((ray) => { + const supports = canonicalSupports(ray.supports); + return { + u: [...ray.u] as [number, number], + halfDepth: Math.max(...supports.map((support) => support.halfDepth)), + length: Math.max(...supports.map((support) => support.length)), + supports, + }; + }).filter((ray) => Number.isFinite(ray.halfDepth) && ray.halfDepth > 0 + && Number.isFinite(ray.length) && ray.length > eps); + if (canonicalRays.length < 3) continue; + const halfDepth = Math.max(...canonicalRays.map((ray) => ray.halfDepth)); if (!(halfDepth > 0) || !Number.isFinite(halfDepth)) continue; nodes.push({ point: [...node.point], - rays: rays.map(({ u, halfDepth: half }) => ({ u: [...u], halfDepth: half })), + rays: canonicalRays, halfDepth, limit: MULTI_WALL_JOIN_LIMIT * halfDepth, }); @@ -2048,19 +2095,26 @@ function bevelMultiWallBody( let local: any = null; for (const ray of node.rays) { const n = [-ray.u[1], ray.u[0]]; - const rectangle = stableJunctionPatch([ - [node.point[0] + n[0] * ray.halfDepth, - node.point[1] + n[1] * ray.halfDepth], - [node.point[0] + ray.u[0] * extent + n[0] * ray.halfDepth, - node.point[1] + ray.u[1] * extent + n[1] * ray.halfDepth], - [node.point[0] + ray.u[0] * extent - n[0] * ray.halfDepth, - node.point[1] + ray.u[1] * extent - n[1] * ray.halfDepth], - [node.point[0] - n[0] * ray.halfDepth, - node.point[1] - n[1] * ray.halfDepth], - ], map.coordinateScale); - if (!rectangle) continue; - const piece: any = closedRing(rectangle) as any; - local = local ? union(local, piece) : piece; + // A canonical direction may be owned by overlapping room intervals + // with different depth/length pairs. Rebuild their finite union; using + // the node-wide 8H extent here invents a wall after a short endpoint. + for (const support of ray.supports) { + const supportExtent = Math.min(extent, support.length); + if (!(supportExtent > map.epsilon)) continue; + const rectangle = stableJunctionPatch([ + [node.point[0] + n[0] * support.halfDepth, + node.point[1] + n[1] * support.halfDepth], + [node.point[0] + ray.u[0] * supportExtent + n[0] * support.halfDepth, + node.point[1] + ray.u[1] * supportExtent + n[1] * support.halfDepth], + [node.point[0] + ray.u[0] * supportExtent - n[0] * support.halfDepth, + node.point[1] + ray.u[1] * supportExtent - n[1] * support.halfDepth], + [node.point[0] - n[0] * support.halfDepth, + node.point[1] - n[1] * support.halfDepth], + ], map.coordinateScale); + if (!rectangle) continue; + const piece: any = closedRing(rectangle) as any; + local = local ? union(local, piece) : piece; + } } for (const triangle of multiWallBevelTriangles({ ...map, diff --git a/test/golden-matrix.test.mjs b/test/golden-matrix.test.mjs index e12be96e..4ac717ba 100644 --- a/test/golden-matrix.test.mjs +++ b/test/golden-matrix.test.mjs @@ -372,6 +372,10 @@ test('issue #197 golden keeps the complete junction fixture in Plan and View', ( assert.equal(scenario.theme, 'dark'); assert.equal(scenario.junctionPatchResilience, true); assert.deepEqual(scenario.retainedWedgeProbe, [0.8955, 0.556]); + assert.deepEqual( + scenario.absentWallProbes, + [[0.420833333, 0.37625], [0.936524285, 0.345833333]], + ); const fixture = prepareGoldenFixture(scenario); const space = fixture.config.spaces.find((item) => item.id === scenario.space); assert.ok(space); diff --git a/test/wall-thickness.test.mjs b/test/wall-thickness.test.mjs index bd9e6b18..1f647b92 100644 --- a/test/wall-thickness.test.mjs +++ b/test/wall-thickness.test.mjs @@ -837,6 +837,83 @@ test('issue #249 bounds the exported three-wall junction with straight bevels', assert.equal(JSON.stringify({ rooms, walls }), before, 'geometry mutated saved data'); }); +test('issue #271 keeps finite co-directional ray supports and never rebuilds past an endpoint', () => { + const interval = (key, b, half) => ({ + roomId: key, a: [0, 0], b, key, kind: 'outer', cm: half * 2, + open: false, half, + }); + const source = [ + interval('east-thick-short', [10, 0], 8), + interval('east-thin-long', [100, 0], 4), + interval('east-owner-duplicate', [100, 0], 4), + interval('north', [0, -100], 5), + interval('west', [-100, 0], 5), + ]; + const map = buildMultiWallNodeMap(source, 5); + assert.equal(map.nodes.length, 1); + const east = map.nodes[0].rays.find((ray) => ray.u[0] > 0.99); + assert.ok(east); + assert.equal(east.halfDepth, 8); + assert.equal(east.length, 100); + assert.deepEqual(east.supports, [ + { halfDepth: 8, length: 10 }, + { halfDepth: 4, length: 100 }, + ], 'the shorter thick strip and longer thin strip need separate finite support'); + const permuted = buildMultiWallNodeMap( + [...source].reverse().map((item) => ({ ...item, a: item.b, b: item.a })), + 5, + ); + const raySignature = (nodeMap) => nodeMap.nodes[0].rays.map((ray) => ({ + u: ray.u.map((value) => Math.round(value * 1e9) / 1e9), + halfDepth: ray.halfDepth, + length: ray.length, + supports: ray.supports, + })); + assert.deepEqual(raySignature(permuted), raySignature(map)); + + const rooms = [ + { id: 'lower', poly: [[-1000, 0], [0, 0], [0, 20], [500, 20], [500, 1000], [-1000, 1000]] }, + { id: 'upper', poly: [[-1000, -1000], [0, -1000], [0, 0], [-1000, 0]] }, + ]; + let walls = []; + for (const [a, b] of [ + [[-1000, 0], [0, 0]], + [[0, -1000], [0, 0]], + [[0, 0], [0, 20]], + ]) walls = setWallThickness(walls, a, b, 15, pitch, 1); + const nodeMap = buildMultiWallNodeMap( + wallIntervals(rooms, walls, [], pitch, 1, GRID_PITCH, 1), + pitch * 0.04 * 4, + ); + const node = nodeMap.nodes.find((candidate) => + Math.hypot(candidate.point[0], candidate.point[1]) < 1e-7); + assert.ok(node); + const short = node.rays.find((ray) => ray.u[1] > 0.99); + assert.ok(short); + closeTo(short.length, 20, 1e-7); + assert.ok(8 * node.halfDepth > short.length * 5, + 'fixture no longer distinguishes the old 8H rebuild from the finite interval'); + + const geometry = wallBodiesGeometry( + rooms, walls, [], [], pitch, 1, GRID_PITCH, 1, + ); + assert.ok(geometry); + assertProbeInside(geometry.geom, [0, 10], 'the finite short arm disappeared'); + assertProbeOutside( + geometry.geom, [0, 100], + 'the degree-3 repair rebuilt masonry after the short ray endpoint', + ); + assertProbeOutside( + geometry.roomGeom, [0, 100], + 'the pre-opening canonical masonry still contains the phantom ray', + ); + const cleanFloor = difference(closedGeometry(rooms[0].poly), geometry.roomGeom); + assertProbeInside( + cleanFloor, [1, 100], + 'the phantom ray still removes usable clean-floor area after its endpoint', + ); +}); + test('issue #249 node classification is order, direction and scale independent', () => { const cases = [ { angles: [0, 30, 200], halves: [5, 5, 5], bevel: true }, @@ -866,6 +943,11 @@ test('issue #249 node classification is order, direction and scale independent', Math.round(ray.u[0] * 1e9) / 1e9, Math.round(ray.u[1] * 1e9) / 1e9, ray.halfDepth / scale, + Math.round((ray.length / scale) * 1e9) / 1e9, + ray.supports.map((support) => [ + support.halfDepth / scale, + Math.round((support.length / scale) * 1e9) / 1e9, + ]), ]), })); const makeFanGeometry = (fixture, permuted = false) => { @@ -1412,7 +1494,9 @@ test('issue #197 keeps the full masonry when one virtual-junction patch has ULP // #249 intentionally bevels degree-3+ nodes in this older fixture too. // #249 retains the physical multi-wall overlap up to R instead of reducing // right-angle arms to point contacts. - closeTo(geometryArea(geometry.geom), 124568.27047237023, 1e-6); + // #271 removes only the area that the old node-wide 8H rectangles invented + // after finite ray endpoints; all semantic #197/#249/#261 probes above stay. + closeTo(geometryArea(geometry.geom), 124244.26848307278, 1e-6); closeTo(geometryArea(geometry.paperGeom), 727303.8153386558, 1e-6); assert.equal( JSON.stringify({ rooms, walls, cuts, openings, extraBodies }), before,