fix(walls): bound multi-wall repairs to finite rays

Issue: #271
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-23 21:06:20 +03:00
parent ffbbb217b5
commit e3b635aba2
15 changed files with 278 additions and 45 deletions
+11 -1
View File
@@ -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;
+2
View File
@@ -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',
+36 -4
View File
@@ -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;
+6 -3
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -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
+10
View File
@@ -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
+3 -1
View File
@@ -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.
+3 -1
View File
@@ -391,7 +391,9 @@ T-соединение входит в проходящую стену без в
карточке. Толщина каждого ранее поставленного отрезка остаётся своей; свободный
конец незамкнутой стены остаётся плоским. У сохранённого T-стыка обе половины
физической стены остаются сплошными: фаска убирает только чрезмерно выступающий
угол и не оставляет белого треугольного клина.
угол и не оставляет белого треугольного клина. Короткое плечо заканчивается в
своей реальной точке: исправление стыка не рисует торец, тень или световой
барьер там, где сохранённой стены нет.
Каждый законченный отрезок цепочки сохраняется сразу. Esc/Ctrl+Z удаляет
последнюю точку. Pan, pinch и `pointercancel` ничего не завершают и не добавляют.
+7 -4
View File
@@ -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
+13
View File
@@ -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 '
+6 -2
View File
@@ -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
+82 -28
View File
@@ -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,
+4
View File
@@ -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);
+85 -1
View File
@@ -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,