Files
houseplan-card/test/wall-thickness.test.mjs
Matysh 5ff97858f8 Repair escaped mutation witnesses
Issue: #466
Issue: #467
Issue: #468
Issue: #469
User-Visible: no
2026-09-05 22:13:25 +03:00

3488 lines
150 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Wall thickness pure geometry (docs/WALL-THICKNESS.md §10).
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
wallKey, lookupWall, thicknessCmAt, degradeWalls, rekeyWallsAfterMove,
rekeyWallsAfterMoveChecked,
wallRecordsHaveCarrierCoverage,
setWallThickness, setWallThicknessForRoom, applyWallThicknessToNewRoom,
drawWallPreviewD, linearWallBody, linearWallJoinPatches,
DRAW_WALL_DEFAULT_CM, clampWallCm, cmToField, fieldToCm,
wallCmToUnits, insetContour, outsetContour, inwardNormal, edgeKinds, wallEdgeBodies,
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, recutWallBodiesGeometry,
floorFootprintGeometry,
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,
multiWallProtectedStripGeometry,
atomicPolyForRoom, insetOffsetsForRoom, wallIntervals, materializeWallIntervals,
normalizeWallIntervals,
intervalCmAt, wallBodyNeedsSolid, openingInnerFaceOffset, openingTunnelGeometry,
openingTunnelGeometries, tunnelFacePath,
WALL_HATCH_MIN_PX,
wallHatchStepUnits, wallHatchNeedsSolid,
HATCH_BASE_STEP_UNITS, HATCH_MIN_STEP_UNITS, HATCH_MAX_STEP_UNITS,
junctionNodeBound, junctionNodeGeometry, junctionContractHoles,
} from '../test-build/wall-thickness.js';
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, physicalBodySet } from '../test-build/physical-geometry.js';
import { checkMixedRoleRecords, checkWallRecordsPreserved } from '../scripts/model-invariants.mjs';
import { difference, intersection, union } from 'polyclip-ts';
const closeTo = (got, want, tol = 1e-6) =>
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
const pitch = 1 / 240; // normalised grid step
const cellCm = 5;
const closedGeometry = (poly) => {
const ring = [...poly, poly[0]].map((point) => [...point]);
return [[ring]];
};
const geometryBounds = (geom) => {
const points = geom.flat(2);
return [
Math.min(...points.map((point) => point[0])),
Math.min(...points.map((point) => point[1])),
Math.max(...points.map((point) => point[0])),
Math.max(...points.map((point) => point[1])),
];
};
test('Stage floor footprint excludes detached independent physical bodies', () => {
const rooms = [{ id: 'room', poly: [[0, 0], [100, 0], [100, 100], [0, 100]] }];
const detached = [[[200, 20], [220, 20], [220, 80], [200, 80]]];
const footprint = floorFootprintGeometry(rooms, [], [], 20, 250, 40, 1);
const withBody = wallBodiesGeometry(rooms, [], [], [], 20, 250, 40, 1, detached);
assert.ok(footprint && withBody);
assert.deepEqual(geometryBounds(footprint), [0, 0, 100, 100]);
assert.deepEqual(geometryBounds(withBody.paperGeom), [0, 0, 100, 100]);
assert.deepEqual(geometryBounds(withBody.geom), [200, 20, 220, 80]);
});
const geometryDifferenceArea = (a, b) => geometryArea(difference(a, b));
const geometryProbeCoverage = (geom, [x, y], radius = 0.05) => {
const probe = closedGeometry([
[x - radius, y - radius],
[x + radius, y - radius],
[x + radius, y + radius],
[x - radius, y + radius],
]);
return geometryArea(intersection(geom, probe)) / ((radius * 2) ** 2);
};
const assertProbeInside = (geom, point, message) =>
assert.ok(geometryProbeCoverage(geom, point) > 0.99, message || `missing body at ${point}`);
const assertProbeOutside = (geom, point, message) =>
assert.ok(geometryProbeCoverage(geom, point) < 1e-7, message || `unexpected body at ${point}`);
test('#278 isolates one failed independent-body union without losing core or later bodies', () => {
const roomPoly = [[0, 0], [100, 0], [100, 100], [0, 100]];
const rooms = [{ id: 'core', poly: roomPoly }];
const walls = roomPoly.map((a, index) => {
const b = roomPoly[(index + 1) % roomPoly.length];
return { key: wallKey(a, b, 20), a, b, cm: 20 };
});
const extras = [
[[120, 10], [130, 10], [130, 30], [120, 30]],
[[140, 10], [150, 10], [150, 30], [140, 30]],
[[160, 10], [170, 10], [170, 30], [160, 30]],
];
const operations = {
mergeExtra(primary, extra, index) {
if (index === 1) throw new Error('local clipping failure');
return primary ? union(primary, extra) : extra;
},
};
const geometry = wallBodiesGeometry(
rooms, walls, [], [], 20, 5, 40, 1, extras, operations,
);
assert.equal(geometry.status, 'degraded-extra');
assert.equal(geometry.degradedExtraCount, 1);
assert.equal(geometry.components.length, 2, 'primary plus isolated body');
assertProbeInside(geometry.components[0].geom, [5, 5], 'core masonry survives');
assertProbeInside(geometry.components[0].geom, [165, 20], 'later extra still merges');
assertProbeInside(geometry.components[1].geom, [145, 20], 'failed extra stays isolated');
const projected = wallBodiesUnionPath(
rooms, walls, [], [], 20, 5, 40, 1, extras, operations,
);
assert.equal(projected?.status, 'degraded-extra');
assert.equal(projected?.paths.length, 2, 'isolated geometry has a separate SVG path');
});
test('#278 rejects degraded extras in strict preflight while render geometry remains usable', () => {
const result = wallBodiesGeometry(
[], [], [], [], 20, 5, 40, 1,
[
[[0, 0], [10, 0], [10, 10], [0, 10]],
[[20, 0], [30, 0], [30, 10], [20, 10]],
],
{ mergeExtra: (primary, extra, index) => {
if (index) throw new Error('one extra');
return primary ? union(primary, extra) : extra;
} },
);
assert.equal(result.status, 'degraded-extra');
assert.equal(result.components.length, 2);
assert.equal(result.roomGeom.length, 0, 'independent bodies never enter room area masonry');
});
test('alternate opening policy recuts the retained room masonry exactly', () => {
const rooms = [
{ id: 'left', poly: [[0, 0], [100, 0], [100, 100], [0, 100]] },
{ id: 'right', poly: [[100, 0], [200, 0], [200, 100], [100, 100]] },
];
const unique = new Map();
for (const room of rooms) for (let index = 0; index < room.poly.length; index++) {
const a = room.poly[index], b = room.poly[(index + 1) % room.poly.length];
const key = wallKey(a, b, 1);
if (!unique.has(key)) unique.set(key, { key, a, b, cm: 20 });
}
const walls = [...unique.values()];
const interiorDoor = { x: 100, y: 50, angle: 90, length: 30 };
const exteriorWindow = { x: 50, y: 0, angle: 0, length: 24 };
const extras = [[[130, 35], [150, 35], [150, 45], [130, 45]]];
const visual = wallBodiesGeometry(
rooms, walls, [], [interiorDoor, exteriorWindow], 1, 5, 5, 1, extras,
);
const directLight = wallBodiesGeometry(
rooms, walls, [], [interiorDoor], 1, 5, 5, 1, extras,
);
const recutLight = recutWallBodiesGeometry(visual, [interiorDoor], extras);
assert.equal(visual.status, 'ok');
assert.ok(visual.roomComponents?.length, 'uncut room masonry is retained');
assert.equal(directLight.status, 'ok');
assert.equal(recutLight?.status, 'ok');
closeTo(geometryDifferenceArea(directLight.geom, recutLight.geom), 0, 1e-6);
closeTo(geometryDifferenceArea(recutLight.geom, directLight.geom), 0, 1e-6);
});
const enclosedLocalHoleRings = (geometry, node) => {
const radius = MITRE_LIMIT * node.halfDepth + 1e-6;
return (geometry || []).flatMap((polygon) => (polygon || []).slice(1)).filter((ring) =>
ring.length >= 4 && ring.every((point) => Math.hypot(
point[0] - node.point[0], point[1] - node.point[1],
) <= radius));
};
const assertNoEnclosedLocalHoles = (geometry, node, consumer) => assert.equal(
enclosedLocalHoleRings(geometry, node).length,
0,
`${consumer} retained an enclosed background hole at ${node.point}`,
);
const protectedOrthogonalStripGeometry = (node, map) => {
const protectedRays = new Set();
for (let i = 0; i < node.rays.length; i++) {
for (let j = i + 1; j < node.rays.length; j++) {
const a = node.rays[i].u, b = node.rays[j].u;
if (Math.abs(a[0] * b[0] + a[1] * b[1]) <= 1e-9) {
protectedRays.add(i);
protectedRays.add(j);
}
}
}
const radius = MITRE_LIMIT * node.halfDepth + map.epsilon * 2;
const extent = radius * 2;
let geometry = null;
for (const index of protectedRays) {
const ray = node.rays[index];
const n = [-ray.u[1], ray.u[0]];
for (const support of ray.supports) {
const length = Math.min(extent, support.length);
if (!(length > map.epsilon)) continue;
const rectangle = closedGeometry([
[node.point[0] + n[0] * support.halfDepth,
node.point[1] + n[1] * support.halfDepth],
[node.point[0] + ray.u[0] * length + n[0] * support.halfDepth,
node.point[1] + ray.u[1] * length + n[1] * support.halfDepth],
[node.point[0] + ray.u[0] * length - n[0] * support.halfDepth,
node.point[1] + ray.u[1] * length - n[1] * support.halfDepth],
[node.point[0] - n[0] * support.halfDepth,
node.point[1] - n[1] * support.halfDepth],
]);
geometry = geometry ? union(geometry, rectangle) : rectangle;
}
}
return { geometry, protectedRays: [...protectedRays].sort((a, b) => a - b) };
};
const assertBoundedMultiWallBevels = (
rooms, walls, geometry, cell = cellCm, scale = 1,
) => {
const map = buildMultiWallNodeMap(
wallIntervals(rooms, walls, [], pitch, cell, GRID_PITCH, scale),
pitch * scale * 0.04 * 4,
scale,
);
const triangles = multiWallBevelTriangles(map);
assert.ok(triangles.length > 0, 'fixture no longer exercises an oversized multi-wall join');
// Strips live only inside the approved facade bound: a giant shared wall
// poking past the building outline is clipped there, exactly as before.
const facadeBound = junctionNodeBound(
rooms, walls, [], pitch, cell, GRID_PITCH, scale, map,
);
for (const triangle of triangles) {
const node = map.nodes.find((candidate) => triangle.slice(0, 2).every((point) =>
Math.hypot(
point[0] - candidate.point[0],
point[1] - candidate.point[1],
) <= candidate.limit + 1e-7));
assert.ok(node, 'bevel endpoints escaped every multi-wall node limit');
const base = [
(triangle[0][0] + triangle[1][0]) / 2,
(triangle[0][1] + triangle[1][1]) / 2,
];
const probe = [
(base[0] + triangle[2][0]) / 2,
(base[1] + triangle[2][1]) / 2,
];
// #302 contract (owner decision #5): the node is purely additive. A
// probe inside the node's support strips or mitre/bevel fans — within the
// plain facade bound — is FILLED; outside all of them the wedge is empty.
const nodeCorners = junctionNodeGeometry({
epsilon: map.epsilon, coordinateScale: map.coordinateScale,
nodes: [node], index: new Map(),
});
const inPolygonProbe = (points) => {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const [xi, yi] = points[i];
const [xj, yj] = points[j];
if ((yi > probe[1]) !== (yj > probe[1])
&& probe[0] < ((xj - xi) * (probe[1] - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
};
const inStrips = node.rays.some((ray) => ray.supports.some((support) => {
const rx = probe[0] - node.point[0];
const ry = probe[1] - node.point[1];
const along = rx * ray.u[0] + ry * ray.u[1];
if (along < 0 || along > support.length) return false;
return Math.abs(rx * ray.u[1] - ry * ray.u[0]) <= support.halfDepth - 1e-7;
}));
const inFans = nodeCorners.fans.some((fan) => inPolygonProbe(fan));
const inContract = (inStrips || inFans)
&& (!facadeBound || geometryProbeCoverage(facadeBound, probe) > 1e-7);
const actualCoverage = geometryProbeCoverage(geometry.geom, probe);
if (inContract) {
assert.ok(
actualCoverage > 1e-7,
`the additive node lost contract material at ${probe}`,
);
} else {
assert.ok(
actualCoverage < 1e-7,
`a wedge outside every strip and fan remains filled at ${probe}`,
);
}
}
return map;
};
function cornerSplitFixture({
poly = [[100, 100], [900, 100], [900, 700], [100, 700]],
path = [[100, 100], [900, 500]],
outerCm = 15,
dividerCm = 15,
outerOverrides = [],
} = {}) {
const original = { id: 'source', poly: poly.map((point) => [...point]) };
const split = splitRoomPath(original.poly, path);
assert.ok(split, 'fixture must be a valid corner split');
let walls = outerCm > 0
? applyWallThicknessToNewRoom([], [original], original.id, outerCm, pitch)
: [];
for (const [a, b, cm] of outerOverrides)
walls = setWallThickness(walls, a, b, cm, pitch);
const before = walls.length
? wallBodiesGeometry([original], walls, [], [], pitch, cellCm, GRID_PITCH)
: null;
walls = materializeWallIntervals([original], walls, [], pitch, cellCm, GRID_PITCH);
const rooms = [
{ id: 'source', poly: split[0] },
{ id: 'fresh', poly: split[1] },
];
const divider = sharedBoundary(rooms[0].poly, rooms[1].poly);
assert.equal(divider.length, 1);
walls = setWallThickness(
walls, divider[0].slice(0, 2), divider[0].slice(2), dividerCm, pitch,
);
walls = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
const after = wallBodiesGeometry(rooms, walls, [], [], pitch, cellCm, GRID_PITCH);
assert.ok(after, `wall geometry missing for outer=${outerCm}, divider=${dividerCm}`);
return { original, rooms, walls, divider, before, after };
}
function splitThicknessTransitionFixture() {
const scale = 1000;
const rooms = [
{ id: 'left', poly: [[100, 100], [500, 100], [500, 900], [100, 900]] },
{ id: 'right', poly: [[500, 100], [900, 100], [900, 900], [500, 900]] },
];
const walls = setWallThicknessForRoom([], rooms, 'left', 10, pitch, [], scale);
const geometry = wallBodiesGeometry(
rooms, walls, [], [], pitch, cellCm, GRID_PITCH, scale,
);
assert.ok(geometry, 'production-scale split fixture must produce wall geometry');
return { scale, rooms, walls, geometry };
}
// ------------------------------- key ----------------------------------------
test('wallKey is the same from either end of the wall', () => {
const a = [0.1, 0.2], b = [0.4, 0.2];
assert.equal(wallKey(a, b, pitch), wallKey(b, a, pitch));
});
test('wallKey changes when the wall moves by one grid step', () => {
const a = [0.1, 0.2], b = [0.4, 0.2];
const a2 = [0.1, 0.2 + pitch], b2 = [0.4, 0.2 + pitch];
assert.notEqual(wallKey(a, b, pitch), wallKey(a2, b2, pitch));
});
test('issue 258 wallKey survives the nine-decimal storage round-trip', () => {
const cases = [
[[0.8875, 0.05], [0.8875, 83 / 240], [0.8875, 0.05], [0.8875, 0.345833333]],
[[235 / 240, 83 / 240], [235 / 240, 0.55], [0.979166667, 0.345833333], [0.979166667, 0.55]],
[[-83 / 240, -12 / 240], [-83 / 240, -47 / 240], [-0.345833333, -0.05], [-0.345833333, -0.195833333]],
[[12 / 240, 7 / 240], [48 / 240, 7 / 240], [0.05, 0.029166667], [0.2, 0.029166667]],
];
for (const [exactA, exactB, storedA, storedB] of cases) {
const exact = wallKey(exactA, exactB, pitch);
assert.equal(wallKey(storedA, storedB, pitch), exact);
assert.equal(wallKey(storedB, storedA, pitch), exact);
}
assert.notEqual(wallKey([0, 0], [pitch - pitch * 2e-6, 0], pitch),
wallKey([0, 0], [pitch, 0], pitch), 'coordinates beyond key epsilon are not snapped');
});
test('issue 258 exact-span lookup repairs either persisted key without leaking', () => {
const a = [0.8875, 0.05], b = [0.8875, 0.345833333];
const exactA = [213 / 240, 12 / 240], exactB = [213 / 240, 83 / 240];
const canonical = wallKey(exactA, exactB, pitch);
const affected = canonical.replace(',0.200000@', ',0.195833@');
for (const key of [canonical, affected]) {
const walls = [{ key, cm: 29, a, b }];
assert.equal(lookupWall(walls, exactA, exactB, pitch)?.cm, 29);
assert.equal(lookupWall(walls, exactB, exactA, pitch)?.cm, 29);
assert.equal(thicknessCmAt(walls, exactA, exactB, pitch), 29);
}
const unrelated = [
{ key: 'broken-parent', cm: 31, a: [0.8875, 0], b: [0.8875, 0.4] },
{ key: 'broken-child', cm: 32, a: [0.8875, 0.05], b: [0.8875, 0.2] },
{ key: 'broken-neighbour', cm: 33, a: [0.8875 + pitch, 0.05], b: [0.8875 + pitch, 0.345833333] },
{ key: 'broken-parallel', cm: 34, a: [0.05, 0.8875], b: [0.345833333, 0.8875] },
];
assert.equal(lookupWall(unrelated, exactA, exactB, pitch), null);
const scale = 1000;
const renderA = exactA.map((v) => v * scale), renderB = exactB.map((v) => v * scale);
const renderWalls = [{ key: affected, cm: 29, a, b }];
assert.equal(lookupWall(renderWalls, renderA, renderB, pitch, scale)?.cm, 29);
assert.equal(thicknessCmAt(renderWalls, renderA, renderB, pitch, scale), 29);
});
test('issue 258 repaired span reaches intervals, junction nodes and masonry', () => {
const scale = NORM_W;
const node = [213 / 240 * scale, 83 / 240 * scale];
const rooms = [
{ id: 'lower', poly: [
[0.75 * scale, 12 / 240 * scale],
[213 / 240 * scale, 12 / 240 * scale],
node,
[0.75 * scale, 83 / 240 * scale],
] },
{ id: 'upper', poly: [
[0.75 * scale, 83 / 240 * scale],
[0.95 * scale, 83 / 240 * scale],
[0.95 * scale, 0.5 * scale],
[0.75 * scale, 0.5 * scale],
] },
];
let walls = setWallThicknessForRoom([], rooms, 'lower', 20, pitch, [], scale);
walls = setWallThicknessForRoom(walls, rooms, 'upper', 20, pitch, [], scale);
const a = [0.8875, 0.05], b = [0.8875, 0.345833333];
const target = walls.findIndex((wall) => (
wall.a && wall.b
&& Math.abs(wall.a[0] - a[0]) < 1e-9 && Math.abs(wall.b[0] - b[0]) < 1e-9
));
assert.ok(target >= 0, 'fixture must contain the affected vertical wall');
walls[target] = { key: '0.887500,0.195833@1.5706', cm: 29, a, b };
const intervals = wallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH, scale);
const affected = intervals.find((interval) => (
Math.abs(interval.a[0] - node[0]) < 1e-6
&& Math.abs(interval.b[0] - node[0]) < 1e-6
&& Math.min(interval.a[1], interval.b[1]) < node[1] - 1
&& Math.max(interval.a[1], interval.b[1]) >= node[1] - 1e-6
));
assert.equal(affected?.cm, 29);
const nodes = buildMultiWallNodeMap(intervals, pitch * scale * 0.04 * 4, scale);
const junction = nodes.nodes.find((candidate) => (
Math.hypot(candidate.point[0] - node[0], candidate.point[1] - node[1]) < 1e-6
));
assert.ok(junction && junction.rays.length >= 3);
assert.ok(junction.rays.some((ray) => (
Math.abs(ray.halfDepth - wallCmToUnits(29, cellCm, GRID_PITCH) / 2) < 1e-9
&& Math.abs(ray.u[0]) < 1e-9 && ray.u[1] < -0.999
)), `multi-wall node lost the affected incident wall: ${JSON.stringify(junction.rays)}`);
const geometry = wallBodiesGeometry(
rooms, walls, [], [], pitch, cellCm, GRID_PITCH, scale,
);
assert.ok(geometry);
assertProbeInside(geometry.geom, node, 'the affected T-node contains a white wedge');
});
test('lookupWall finds an entry and thicknessCmAt reads it', () => {
const a = [0.1, 0.2], b = [0.4, 0.2];
const walls = [{ key: wallKey(a, b, pitch), cm: 20 }];
assert.equal(lookupWall(walls, a, b, pitch)?.cm, 20);
assert.equal(lookupWall(walls, b, a, pitch)?.cm, 20);
assert.equal(thicknessCmAt(walls, a, b, pitch), 20);
assert.equal(thicknessCmAt(walls, [0, 0], [1, 1], pitch), 0);
});
test('thicknessCmAt inherits the narrowest exact parent that covers an atomic child', () => {
const parent = setWallThickness([], [0, 0], [10, 0], 20, pitch);
assert.equal(thicknessCmAt(parent, [0, 0], [4, 0], pitch), 20);
assert.equal(thicknessCmAt(parent, [10, 0], [4, 0], pitch), 20);
const production = setWallThickness([], [0, 0], [10000, 0], 20, pitch, 1000);
assert.equal(thicknessCmAt(production, [0, 0], [4000, 0], pitch, 1000), 20);
assert.equal(thicknessCmAt(production, [10000, 0], [4000, 0], pitch, 1000), 20);
const nested = [
...parent,
...setWallThickness([], [4, 0], [6, 0], 30, pitch),
];
for (const walls of [nested, [...nested].reverse()]) {
assert.equal(thicknessCmAt(walls, [4, 0], [5, 0], pitch), 30);
}
});
test('thicknessCmAt exact-parent fallback does not leak from partial or unrelated spans', () => {
const partial = setWallThickness([], [0, 0], [4, 0], 20, pitch);
assert.equal(thicknessCmAt(partial, [0, 0], [10, 0], pitch), 0);
assert.equal(thicknessCmAt(partial, [0, 1], [4, 1], pitch), 0);
assert.equal(thicknessCmAt(partial, [0, 0], [0, 4], pitch), 0);
assert.equal(thicknessCmAt([
{ key: wallKey([0, 0], [4, 0], pitch), cm: 20, a: ['bad', 0], b: [4, 0] },
{ key: 'broken', cm: 20, a: [0, 0], b: [0, 0] },
], [0, 0], [2, 0], pitch), 0);
});
// ------------------------------- units --------------------------------------
test('cm ↔ field: metric stays cm, imperial is inches', () => {
assert.equal(cmToField(25.4, false), '25.4');
assert.equal(cmToField(25.4, true), '10');
assert.equal(fieldToCm('10', true), 25.4);
assert.equal(fieldToCm('20', false), 20);
assert.equal(fieldToCm('', false), null);
assert.equal(fieldToCm('0', true), null);
assert.equal(clampWallCm(0.5), WALL_MIN_CM);
assert.equal(clampWallCm(999), WALL_MAX_CM);
});
test('wallCmToUnits goes through cell_cm like every other length', () => {
// 5 cm at 5 cm/cell and pitch P → 1 cell = P units
closeTo(wallCmToUnits(5, 5, GRID_PITCH), GRID_PITCH);
closeTo(wallCmToUnits(10, 5, GRID_PITCH), 2 * GRID_PITCH);
});
test('thin-on-screen fallback policy is shared by both renderers', () => {
assert.equal(wallBodyNeedsSolid(2, 1), true);
assert.equal(wallBodyNeedsSolid(WALL_HATCH_MIN_PX, 1), false);
assert.equal(wallBodyNeedsSolid(2, 2), false);
assert.equal(wallBodyNeedsSolid(Number.NaN, 1), false);
assert.equal(wallBodyNeedsSolid(2, 0), false);
});
// ------------------------------- degrade / rekey ----------------------------
test('degradeWalls drops a key with no matching room edge', () => {
const rooms = [{ id: 'r1', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] }];
const live = wallKey([0, 0], [1, 0], pitch);
const walls = [
{ key: live, cm: 15 },
{ key: '0.00,0.00@9.9999', cm: 10 },
];
const kept = degradeWalls(walls, rooms, pitch);
assert.equal(kept.length, 1);
assert.equal(kept[0].key, live);
});
test('degradeWalls keeps an exact maximal run even when another breakpoint subdivides it', () => {
const rooms = [{ id: 'r1', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] }];
const walls = setWallThickness([], [0, 0], [0.4, 0], 18, pitch);
const kept = degradeWalls(walls, rooms, pitch);
assert.equal(kept.length, 1);
assert.deepEqual(kept[0].a, [0, 0]);
assert.deepEqual(kept[0].b, [0.4, 0]);
});
test('rekeyWallsAfterMove rewrites the key when a span shifts by one cell', () => {
const oldA = [0.1, 0.2], oldB = [0.4, 0.2];
const newA = [0.1, 0.2 + pitch], newB = [0.4, 0.2 + pitch];
const walls = [{ key: wallKey(oldA, oldB, pitch), cm: 18 }];
const next = rekeyWallsAfterMove(walls, [[oldA, oldB]], [[newA, newB]], pitch);
assert.equal(next.length, 1);
assert.equal(next[0].key, wallKey(newA, newB, pitch));
assert.equal(next[0].cm, 18);
});
test('rekeyWallsAfterMove carries atomic remainders of a partially virtual wall', () => {
const oldA = [0.5, 0.1], oldB = [0.5, 0.7];
const newA = [0.6, 0.1], newB = [0.6, 0.7];
const walls = [
{ key: wallKey([0.5, 0.1], [0.5, 0.3], pitch), cm: 20 },
{ key: wallKey([0.5, 0.5], [0.5, 0.7], pitch), cm: 25 },
];
const next = rekeyWallsAfterMove(walls, [[oldA, oldB]], [[newA, newB]], pitch);
assert.deepEqual(next, [
{ key: wallKey([0.6, 0.1], [0.6, 0.3], pitch), cm: 20 },
{ key: wallKey([0.6, 0.5], [0.6, 0.7], pitch), cm: 25 },
]);
});
test('rekeyWallsAfterMove carries exact interval endpoints with the wall', () => {
const oldA = [0.2, 0.1], oldB = [0.2, 0.4];
const newA = [0.3, 0.1], newB = [0.3, 0.4];
const walls = setWallThickness([], oldA, oldB, 22, pitch);
const next = rekeyWallsAfterMove(walls, [[oldA, oldB]], [[newA, newB]], pitch);
assert.equal(next[0].key, wallKey(newA, newB, pitch));
assert.deepEqual(next[0].a, newA);
assert.deepEqual(next[0].b, newB);
});
const issue253SpanSet = (spans) => spans
.map(([a, b]) => [a, b].map((point) => point.map((value) => Number(value.toFixed(9)))))
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
test('issue 253 splits a longer exact wall and moves only the covered interval', () => {
const wallA = [0.070833333, 0.4375], wallB = [0.420833333, 0.4375];
const oldA = [0.070833333, 0.4375], oldB = [0.204166667, 0.4375];
const newA = [0.070833333, 0.4625], newB = [0.204166667, 0.4625];
const walls = setWallThickness([], wallA, wallB, 33, pitch);
const next = rekeyWallsAfterMove(walls, [[oldA, oldB]], [[newA, newB]], pitch);
assert.deepEqual(next.map((wall) => wall.cm), [33, 33]);
assert.deepEqual(issue253SpanSet(next.map((wall) => [wall.a, wall.b])), issue253SpanSet([
[newA, newB],
[[0.204166667, 0.4375], wallB],
]));
assert.deepEqual(checkWallRecordsPreserved(walls, next), []);
});
test('issue 253 interval partition is orientation and axis independent', () => {
const cases = [
{
wall: [[1, 0], [0, 0]], old: [[0.2, 0], [0.6, 0]], next: [[0.2, 1], [0.6, 1]],
want: [[[0, 0], [0.2, 0]], [[0.2, 1], [0.6, 1]], [[0.6, 0], [1, 0]]],
},
{
wall: [[0, 0], [0, 1]], old: [[0, 0.25], [0, 0.75]], next: [[1, 0.25], [1, 0.75]],
want: [[[0, 0], [0, 0.25]], [[1, 0.25], [1, 0.75]], [[0, 0.75], [0, 1]]],
},
{
wall: [[0, 0], [1, 1]], old: [[0.25, 0.25], [0.75, 0.75]],
next: [[0.25, 0.5], [0.75, 1]],
want: [[[0, 0], [0.25, 0.25]], [[0.25, 0.5], [0.75, 1]], [[0.75, 0.75], [1, 1]]],
},
];
for (const entry of cases) {
const walls = setWallThickness([], entry.wall[0], entry.wall[1], 18, pitch);
const next = rekeyWallsAfterMove(walls, [entry.old], [entry.next], pitch);
assert.deepEqual(issue253SpanSet(next.map((wall) => [wall.a, wall.b])), issue253SpanSet(entry.want));
}
});
test('issue 253 maps a covered interval through scale and rotation', () => {
const walls = setWallThickness([], [0.25, 0], [0.75, 0], 24, pitch);
const next = rekeyWallsAfterMove(
walls,
[[[0, 0], [1, 0]]],
[[[0, 0], [2, 1]]],
pitch,
);
assert.deepEqual(issue253SpanSet(next.map((wall) => [wall.a, wall.b])), issue253SpanSet([
[[0.5, 0.25], [1.5, 0.75]],
]));
assert.equal(next[0].cm, 24);
});
test('issue 253 unchanged context edges preserve the wall array semantically', () => {
const walls = [{ key: 'legacy', cm: 19 }, ...setWallThickness([], [1, 0], [0, 0], 21, pitch)];
const next = rekeyWallsAfterMove(walls, [[[0, 0], [1, 0]]], [[[0, 0], [1, 0]]], pitch);
assert.deepEqual(next, walls);
assert.notEqual(next, walls);
});
test('issue 253 equivalent shared-room transforms apply once and conflicts fail closed', () => {
const wall = setWallThickness([], [0, 0], [1, 0], 20, pitch);
const old = [[0, 0], [1, 0]];
const moved = [[0, 1], [1, 1]];
const equivalent = rekeyWallsAfterMove(wall, [old, old], [moved, moved], pitch);
assert.equal(equivalent.length, 1);
assert.deepEqual([equivalent[0].a, equivalent[0].b], moved);
const conflictA = rekeyWallsAfterMove(wall, [old, old], [moved, [[0, 2], [1, 2]]], pitch);
const conflictB = rekeyWallsAfterMove(wall, [old, old], [[[0, 2], [1, 2]], moved], pitch);
assert.deepEqual(conflictA, conflictB, 'array order cannot choose a room transform');
assert.deepEqual([conflictA[0].a, conflictA[0].b], old);
});
test('issue 293 moving a shared seam keeps one continuous side-wall record', () => {
const wall = setWallThickness([], [0, 0], [2, 0], 20, pitch);
const next = rekeyWallsAfterMove(
wall,
[[[0, 0], [1, 0]], [[2, 0], [1, 0]]],
[[[0, 0], [1.2, 0]], [[2, 0], [1.2, 0]]],
pitch,
);
assert.equal(next.length, 1, 'a moving internal seam must not atomise one physical wall');
assert.deepEqual([next[0].a, next[0].b], [[0, 0], [2, 0]]);
assert.equal(next[0].cm, 20);
const bent = rekeyWallsAfterMove(
wall,
[[[0, 0], [1, 0]], [[2, 0], [1, 0]]],
[[[0, 0], [1, 0.005]], [[2, 0], [1, 0.005]]],
pitch,
);
assert.equal(bent.length, 2, 'meeting atoms with different directions must remain losslessly split');
});
test('issue 298 fixed-topology rekey never scales an interior side-wall endpoint', () => {
const wall = setWallThickness([], [-85, 304], [577, 304], 20, pitch, 1000);
const next = rekeyWallsAfterMove(
wall,
[[[-100, 304], [100, 304]]],
[[[-96, 304], [100, 304]]],
pitch,
1000,
'fixed-topology',
);
assert.equal(next.length, 1);
assert.deepEqual([next[0].a, next[0].b], [wall[0].a, wall[0].b]);
assert.equal(next[0].key, wall[0].key);
const affine = rekeyWallsAfterMove(
wall,
[[[-100, 304], [100, 304]]],
[[[-96, 304], [100, 304]]],
pitch,
1000,
);
assert.notDeepEqual(affine[0].a, wall[0].a,
'regression fixture must kill the historical proportional mapping');
});
test('issue 298 fixed-topology rekey translates every breakpoint of the moving wall', () => {
const wall = setWallThickness([], [20, 0], [80, 0], 25, pitch, 1000);
const next = rekeyWallsAfterMove(
wall,
[[[0, 0], [100, 0]]],
[[[0, 5], [100, 5]]],
pitch,
1000,
'fixed-topology',
);
assert.deepEqual([next[0].a, next[0].b], [[0.02, 0.005], [0.08, 0.005]]);
assert.equal(next[0].cm, 25);
});
test('issue 298 fixed-topology rekey preserves an unrelated exact record byte-semantically', () => {
const wall = {
key: 'compatibility-key-that-must-not-change', cm: 21,
a: [0, 0], b: [0.1, 0],
};
const next = rekeyWallsAfterMove(
[wall],
[[[0, 200], [100, 200]]],
[[[0, 205], [100, 205]]],
pitch,
1000,
'fixed-topology',
);
assert.deepEqual(next, [wall]);
});
test('issue 298 carrier proof covers collinear chains and rejects gaps or off-grid endpoints', () => {
const exact = setWallThickness([], [0, 0], [100, 0], 20, pitch, 1000);
assert.equal(wallRecordsHaveCarrierCoverage(
exact, [[[0, 0], [40, 0]], [[40, 0], [100, 0]]], pitch, 1000,
), true);
assert.equal(wallRecordsHaveCarrierCoverage(
exact, [[[0, 0], [40, 0]], [[41, 0], [100, 0]]], pitch, 1000,
), false, 'endpoint-only validation must not accept a carrier gap');
const offGrid = [{ ...exact[0], a: [0.00001, 0] }];
assert.equal(wallRecordsHaveCarrierCoverage(
offGrid, [[[0.01, 0], [100, 0]]], pitch, 1000,
), false, 'a true off-grid coordinate is not storage noise');
assert.equal(wallRecordsHaveCarrierCoverage(
offGrid, [[[0.01, 0], [100, 0]]], pitch, 1000, offGrid,
), true, 'an identical historical endpoint is debt, not a new Resize coordinate');
assert.equal(wallRecordsHaveCarrierCoverage(
[{ ...offGrid[0], a: [0.00002, 0] }],
[[[0.02, 0], [100, 0]]], pitch, 1000, offGrid,
), false, 'Resize may not replace old debt with a different off-grid endpoint');
});
test('issue 298 fixed-topology legacy records move only by unambiguous whole-edge identity', () => {
const oldEdge = [[0, 0], [80, 0]];
const newEdge = [[0, 0], [92, 0]];
const whole = { key: wallKey(...oldEdge, pitch), cm: 22 };
const moved = rekeyWallsAfterMoveChecked(
[whole], [oldEdge], [newEdge], pitch, 1, 'fixed-topology',
);
assert.equal(moved.rejected, false);
assert.deepEqual(moved.walls, [{ ...whole, key: wallKey(...newEdge, pitch) }]);
const renderSpaceWhole = {
key: wallKey([1 / 480, 0], [80 - 1 / 480, 0], pitch), cm: 22,
};
const renderMoved = rekeyWallsAfterMoveChecked(
[renderSpaceWhole], [oldEdge], [newEdge], pitch, 1000, 'fixed-topology',
);
assert.equal(renderMoved.rejected, false, 'historical render-space whole-edge key is unambiguous');
assert.equal(renderMoved.walls[0].key, wallKey(...newEdge, pitch));
const untouched = { key: wallKey([100, 10], [120, 10], pitch), cm: 19, future: 'kept' };
const untouchedResult = rekeyWallsAfterMoveChecked(
[untouched], [oldEdge], [newEdge], pitch, 1, 'fixed-topology',
);
assert.equal(untouchedResult.rejected, false);
assert.deepEqual(untouchedResult.walls, [untouched]);
const partial = { key: wallKey([10, 0], [30, 0], pitch), cm: 21 };
const ambiguous = rekeyWallsAfterMoveChecked(
[partial], [oldEdge], [newEdge], pitch, 1, 'fixed-topology',
);
assert.equal(ambiguous.rejected, true);
assert.deepEqual(ambiguous.walls, [partial], 'a rejected candidate cannot partially rekey storage');
const conflictingWhole = rekeyWallsAfterMoveChecked(
[whole], [oldEdge, oldEdge], [newEdge, [[0, 0], [96, 0]]],
pitch, 1, 'fixed-topology',
);
assert.equal(conflictingWhole.rejected, true,
'one whole-edge key with conflicting destinations must reject atomically');
assert.deepEqual(conflictingWhole.walls, [whole]);
const exactWithBadCompatibilityKey = {
key: 'stale-compatibility-key', cm: 24, a: [0, 0], b: [0.08, 0],
};
const exact = rekeyWallsAfterMoveChecked(
[exactWithBadCompatibilityKey], [oldEdge], [newEdge], pitch, 1000, 'fixed-topology',
);
assert.equal(exact.rejected, false);
assert.deepEqual(exact.walls[0].b, [0.092, 0]);
assert.equal(exact.walls[0].key, wallKey([0, 0], [0.092, 0], pitch));
});
test('issue 298 first-floor fixture keeps the room-b seam on 17/52/57/101 boundaries', () => {
const fixture = JSON.parse(readFileSync(new URL(
'./fixtures/real-plan-first-floor.json', import.meta.url,
), 'utf8')).space;
const changedIds = new Set(['room-b', 'room-g']);
const nextRooms = fixture.rooms.map((room) => {
const copy = structuredClone(room);
if (copy.id === 'room-b') copy.poly[0][0] = copy.poly[1][0] = 52 / 240;
if (copy.id === 'room-g') copy.poly[2][0] = copy.poly[3][0] = 52 / 240;
return copy;
});
const oldSpans = [];
const newSpans = [];
for (const oldRoom of fixture.rooms.filter((room) => changedIds.has(room.id))) {
const nextRoom = nextRooms.find((room) => room.id === oldRoom.id);
const oldPoly = oldRoom.poly.map((point) => point.map((value) => value * 1000));
const newPoly = nextRoom.poly.map((point) => point.map((value) => value * 1000));
for (let index = 0; index < oldPoly.length; index++) {
oldSpans.push([oldPoly[index], oldPoly[(index + 1) % oldPoly.length]]);
newSpans.push([newPoly[index], newPoly[(index + 1) % newPoly.length]]);
}
}
const result = rekeyWallsAfterMoveChecked(
fixture.walls, oldSpans, newSpans, pitch, 1000, 'fixed-topology',
);
assert.equal(result.rejected, false);
const next = result.walls;
const step = (value) => Number((value * 240).toFixed(6));
const y155 = next.filter((wall) => wall.a && wall.b
&& Math.abs(step(wall.a[1]) - 155) < 0.001
&& Math.abs(step(wall.b[1]) - 155) < 0.001);
const spans = y155.map((wall) => [step(wall.a[0]), step(wall.b[0])].sort((a, b) => a - b))
.sort((a, b) => a[0] - b[0]);
assert.deepEqual(spans, [[17, 57], [52, 101]]);
assert.equal(spans.flat().some((value) => Math.abs(value - 59.538461) < 0.001), false);
const untouched = fixture.walls.find((wall) => wall.key === '0.479167,0.050000@0.0000');
assert.ok(next.some((wall) => JSON.stringify(wall) === JSON.stringify(untouched)),
'an unrelated first-floor record stays byte-semantic');
const carriers = nextRooms.flatMap((room) => room.poly.map((point, index) => [
point.map((value) => value * 1000),
room.poly[(index + 1) % room.poly.length].map((value) => value * 1000),
]));
assert.equal(wallRecordsHaveCarrierCoverage(
y155, carriers, pitch, 1000, fixture.walls,
), true, 'all surviving first-floor endpoints remain lattice/carrier safe');
const affine = rekeyWallsAfterMove(
fixture.walls, oldSpans, newSpans, pitch, 1000, 'affine',
);
assert.equal(affine.some((wall) => [wall.a, wall.b].flat()
.some((value) => Math.abs(step(value) - 59.538461) < 0.001)), true,
'fixture must kill the historical proportional endpoint mapping');
});
test('issue 253 key collisions never erase different exact or legacy records', () => {
const exact = [
{ key: wallKey([-1, 0], [1, 0], pitch), cm: 20, a: [-1, 0], b: [1, 0] },
{ key: wallKey([-0.5, 0], [0.5, 0], pitch), cm: 30, a: [-0.5, 0], b: [0.5, 0] },
];
assert.equal(exact[0].key, exact[1].key, 'fixture must exercise one compatibility key');
const exactNext = rekeyWallsAfterMove(exact, [[[2, 0], [3, 0]]], [[[2, 1], [3, 1]]], pitch);
assert.equal(exactNext.length, 2);
assert.deepEqual(exactNext.map((wall) => wall.cm), [20, 30]);
const legacy = exact.map(({ key, cm }) => ({ key, cm }));
const legacyNext = rekeyWallsAfterMove(legacy, [[[2, 0], [3, 0]]], [[[2, 1], [3, 1]]], pitch);
assert.equal(legacyNext.length, 2);
assert.deepEqual(legacyNext.map((wall) => wall.cm), [20, 30]);
});
test('issue 253 deduplicates only identical exact geometry with identical thickness', () => {
const a = [0, 0], b = [1, 0];
const same = [
{ key: wallKey(a, b, pitch), cm: 20, a, b },
{ key: wallKey(b, a, pitch), cm: 20, a: b, b: a },
{ key: wallKey(a, b, pitch), cm: 30, a, b },
];
const next = rekeyWallsAfterMove(same, [[[2, 0], [3, 0]]], [[[2, 1], [3, 1]]], pitch);
assert.equal(next.length, 2);
assert.deepEqual(next.map((wall) => wall.cm), [20, 30]);
});
test('issue 253 does not truncate a lossless split at the backend 500-record boundary', () => {
const walls = Array.from({ length: 500 }, (_, index) => {
const y = index * 2;
return { key: wallKey([0, y], [1, y], pitch), cm: 20, a: [0, y], b: [1, y] };
});
const next = rekeyWallsAfterMove(
walls,
[[[0.25, 0], [0.75, 0]]],
[[[0.25, 1], [0.75, 1]]],
pitch,
);
assert.equal(next.length, 502, 'frontend must preserve every atom; backend may reject atomically');
assert.equal(next.filter((wall) => wall.cm === 20).length, 502);
});
test('setWallThickness upserts and removes', () => {
const a = [0, 0], b = [1, 0];
let walls = setWallThickness([], a, b, 12, pitch);
assert.equal(walls.length, 1);
walls = setWallThickness(walls, a, b, 30, pitch);
assert.equal(walls[0].cm, 30);
walls = setWallThickness(walls, a, b, null, pitch);
assert.equal(walls.length, 0);
});
test('setWallThicknessForRoom skips open cuts', () => {
const room = { id: 'r', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] };
const open = [[0, 0, 1, 0]];
const walls = setWallThicknessForRoom([], [room], 'r', 20, pitch, open);
// three edges get thickness; the open bottom does not
assert.equal(walls.length, 3);
assert.equal(thicknessCmAt(walls, [0, 0], [1, 0], pitch), 0);
assert.equal(thicknessCmAt(walls, [1, 0], [1, 1], pitch), 20);
});
// ---------------------- atomic intervals (AUD-159B6-01) ---------------------
// A's right edge runs y=0..10, B only touches y=0..4: thickness set on that
// shared stretch used to be reported for the whole 10-long edge, so the outer
// remainder silently grew a wall the user never asked for.
const partialRooms = () => ([
{ id: 'a', poly: [[0, 0], [5, 0], [5, 10], [0, 10]] },
{ id: 'b', poly: [[5, 0], [10, 0], [10, 4], [5, 4]] },
]);
test('partial shared wall: an edge is split at the shared boundary end', () => {
const at = atomicPolyForRoom(partialRooms(), 'a', [], pitch);
assert.ok(at);
assert.equal(at.poly.length, 5, JSON.stringify(at.poly));
assert.ok(at.poly.some((p) => Math.abs(p[0] - 5) < 1e-9 && Math.abs(p[1] - 4) < 1e-9));
});
test('partial shared wall: thickness stays on its own interval', () => {
const rooms = partialRooms();
const walls = [{ key: wallKey([5, 0], [5, 4], pitch), cm: 30 }];
const kinds = edgeKinds(rooms, 'a', [], pitch);
const offs = insetOffsetsForRoom(rooms, 'a', walls, [], pitch, cellCm, pitch);
const ivs = wallIntervals(rooms, walls, [], pitch, cellCm, pitch)
.filter((iv) => iv.roomId === 'a' && Math.abs(iv.a[0] - 5) < 1e-9 && Math.abs(iv.b[0] - 5) < 1e-9);
const shared = ivs.find((iv) => iv.kind === 'shared');
const outer = ivs.find((iv) => iv.kind === 'outer');
assert.equal(shared?.cm, 30);
assert.equal(outer?.cm, 0, 'thickness must not leak past the shared stretch');
assert.equal(kinds.filter((k) => k === 'shared').length, 1);
assert.equal(offs.filter((o) => o > 0).length, 1);
});
test('partial shared wall: a pre-atomic whole-edge key still covers both pieces', () => {
const rooms = partialRooms();
// written before the split: the key names the WHOLE right edge (mid y=5)
const walls = [{ key: wallKey([5, 0], [5, 10], pitch), cm: 30 }];
const ivs = wallIntervals(rooms, walls, [], pitch, cellCm, pitch)
.filter((iv) => iv.roomId === 'a' && Math.abs(iv.a[0] - 5) < 1e-9 && Math.abs(iv.b[0] - 5) < 1e-9);
assert.equal(ivs.length, 2);
assert.ok(ivs.every((iv) => iv.cm === 30), 'an existing plan must not lose thickness');
});
test('a compacted exact wall covers a shorter collinear side in another room', () => {
const rooms = [
{ id: 'guest', poly: [[2, 2], [2, 6], [4, 6], [4, 2]] },
{ id: 'hall', poly: [[4, 2], [8, 2], [8, 11], [4, 11]] },
];
// Production T-junction: a long vertical real wall crosses the guest-room
// corner while a horizontal virtual wall starts at that same node. The
// compacted wall midpoint (4, 6.5) lies outside the shorter guest side, but
// its exact endpoints cover that side completely.
const walls = setWallThickness([], [4, 2], [4, 11], 15, pitch);
const open = [[4, 2, 8, 2]];
const right = wallIntervals(rooms, walls, open, pitch, cellCm, GRID_PITCH)
.find((iv) => iv.roomId === 'guest'
&& Math.abs(iv.a[0] - 4) < 1e-9 && Math.abs(iv.b[0] - 4) < 1e-9);
assert.equal(right?.cm, 15);
assert.ok(right && right.half > 0, 'hover/body profile must use the real inner face');
});
test('#299 equal thickness does not compact across a shared-to-outer role boundary', () => {
const rooms = partialRooms();
const walls = [
{ key: wallKey([5, 0], [5, 4], pitch), cm: 30 },
{ key: wallKey([5, 4], [5, 10], pitch), cm: 30 },
];
const next = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
assert.equal(next.length, 2);
assert.ok(next.some((wall) => wall.key === wallKey([5, 0], [5, 4], pitch)));
assert.ok(next.some((wall) => wall.key === wallKey([5, 4], [5, 10], pitch)));
assert.ok(next.every((wall) => wall.cm === 30));
assert.deepEqual(checkMixedRoleRecords({ spaces: [{ id: 'roles', rooms, walls: next }] }), []);
const reversed = normalizeWallIntervals(
[...rooms].reverse(), [...walls].reverse(), [], pitch, cellCm, GRID_PITCH,
);
assert.deepEqual(
reversed.map((wall) => wall.key).sort(),
next.map((wall) => wall.key).sort(),
'room/input order must not change the physical role breakpoint',
);
});
test('#299 the shared owner pair is part of the compaction role', () => {
const rooms = [
{ id: 'a', poly: [[0, 0], [5, 0], [5, 10], [0, 10]] },
{ id: 'b', poly: [[5, 0], [10, 0], [10, 5], [5, 5]] },
{ id: 'c', poly: [[5, 5], [10, 5], [10, 10], [5, 10]] },
];
const walls = [
{ key: wallKey([5, 0], [5, 5], pitch), cm: 22 },
{ key: wallKey([5, 5], [5, 10], pitch), cm: 22 },
];
const next = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
const vertical = next.filter((wall) => wall.a?.[0] === 5 && wall.b?.[0] === 5);
assert.equal(vertical.length, 2);
assert.ok(vertical.some((wall) => wall.key === wallKey([5, 0], [5, 5], pitch)));
assert.ok(vertical.some((wall) => wall.key === wallKey([5, 5], [5, 10], pitch)));
assert.deepEqual(checkMixedRoleRecords({ spaces: [{ id: 'pairs', rooms, walls: next }] }), []);
});
test('#299 equal neighbouring outer atoms of one room still compact', () => {
const rooms = [{ id: 'a', poly: [[0, 0], [5, 0], [5, 10], [0, 10]] }];
const walls = [
{ key: wallKey([5, 0], [5, 4], pitch), a: [5, 0], b: [5, 4], cm: 30 },
{ key: wallKey([5, 4], [5, 10], pitch), a: [5, 4], b: [5, 10], cm: 30 },
];
const next = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
assert.equal(next.length, 1);
assert.equal(next[0].key, wallKey([5, 0], [5, 10], pitch));
assert.equal(next[0].cm, 30);
});
test('different solid thicknesses remain separate atomic keys', () => {
const rooms = partialRooms();
const walls = [
{ key: wallKey([5, 0], [5, 4], pitch), cm: 30 },
{ key: wallKey([5, 4], [5, 10], pitch), cm: 20 },
];
const next = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
assert.equal(next.length, 2);
assert.deepEqual(new Set(next.map((w) => w.cm)), new Set([20, 30]));
assert.ok(!next.some((w) => w.key === wallKey([5, 0], [5, 10], pitch)));
});
test('lossless wall helpers preserve an isolated sub-half-step thickness island outside Optimize', () => {
const y = 0.2, split = 0.5, length = pitch / 3;
const rooms = [{ id: 'r1', poly: [[0.2, y], [0.8, y], [0.8, 0.8], [0.2, 0.8]] }];
const walls = [
{ key: wallKey([0.2, y], [split, y], pitch), a: [0.2, y], b: [split, y], cm: 22 },
{ key: wallKey([split, y], [split + length, y], pitch),
a: [split, y], b: [split + length, y], cm: 15 },
{ key: wallKey([split + length, y], [0.8, y], pitch),
a: [split + length, y], b: [0.8, y], cm: 22 },
];
const before = structuredClone(walls);
const byKey = (entries) => structuredClone(entries)
.sort((left, right) => left.key.localeCompare(right.key));
const normalized = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
const degraded = degradeWalls(walls, rooms, pitch);
assert.deepEqual(byKey(normalized), byKey(before), 'runtime/editor normalization remains lossless');
assert.deepEqual(byKey(degraded), byKey(before), 'runtime/editor degradation must not infer island removal');
assert.deepEqual(walls, before, 'direct lossless helpers must not mutate persisted input');
});
test('closing the sole geometric split preserves different thicknesses', () => {
const rooms = [
{ id: 'a', poly: [[0, 0], [5, 0], [5, 10], [0, 10]] },
{ id: 'b', poly: [[5, 0], [10, 0], [10, 10], [5, 10]] },
];
// No neighbour endpoint and no open cut remains at y=4. Exact endpoints in
// new wall entries are therefore the only record of this intentional break.
let walls = setWallThickness([], [5, 0], [5, 4], 20, pitch);
walls = setWallThickness(walls, [5, 4], [5, 10], 30, pitch);
const next = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
assert.equal(next.length, 2);
assert.deepEqual(new Set(next.map((w) => w.cm)), new Set([20, 30]));
assert.ok(next.some((w) => w.a?.[1] === 4 || w.b?.[1] === 4));
});
test('exact endpoints do not prevent equal closed pieces from compacting', () => {
const rooms = [
{ id: 'a', poly: [[0, 0], [5, 0], [5, 10], [0, 10]] },
{ id: 'b', poly: [[5, 0], [10, 0], [10, 10], [5, 10]] },
];
let walls = setWallThickness([], [5, 0], [5, 4], 20, pitch);
walls = setWallThickness(walls, [5, 4], [5, 10], 20, pitch);
const next = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
assert.equal(next.length, 1);
assert.equal(next[0].key, wallKey([5, 0], [5, 10], pitch));
});
// An open span that does NOT contain the parent edge's midpoint used to leave
// the key in place, so the wall body stayed solid straight across the passage.
test('open span away from the edge midpoint clears only its own interval', () => {
const scale = 1000;
const p = 1 / 240;
const rooms = [
{ id: 'a', poly: [[100, 140], [300, 140], [300, 460], [100, 460]] },
{ id: 'b', poly: [[300, 140], [500, 140], [500, 460], [300, 460]] },
];
const walls = [{ key: wallKey([300 / scale, 140 / scale], [300 / scale, 460 / scale], p), cm: 30 }];
const cut = [[300, 150, 300, 220]];
const full = wallBodiesUnionPath(rooms, walls, [], [], p, cellCm, GRID_PITCH, scale);
const opened = wallBodiesUnionPath(rooms, walls, cut, [], p, cellCm, GRID_PITCH, scale);
assert.ok(full && opened);
assert.notEqual(full.d, opened.d, 'the wall body must open under the span');
const next = normalizeWallIntervals(rooms, walls, cut, p, cellCm, GRID_PITCH, scale);
assert.equal(intervalCmAt(rooms, next, cut, [300, 150, 300, 220], p, cellCm, GRID_PITCH, scale), 0);
assert.equal(intervalCmAt(rooms, next, cut, [300, 220, 300, 460], p, cellCm, GRID_PITCH, scale), 30);
assert.equal(intervalCmAt(rooms, next, cut, [300, 140, 300, 150], p, cellCm, GRID_PITCH, scale), 30);
});
// ------------------------------- inset --------------------------------------
test('insetContour: rectangle inset by half-thickness on every side', () => {
const poly = [[0, 0], [10, 0], [10, 6], [0, 6]];
const inset = insetContour(poly, [1, 1, 1, 1]);
assert.ok(inset && inset.length >= 4);
// area of a 10×6 rect inset by 1 → 8×4 = 32
closeTo(polygonArea(inset), 32, 0.05);
});
test('insetContour: one thick edge among thin ones', () => {
const poly = [[0, 0], [10, 0], [10, 6], [0, 6]];
// only bottom edge (i=0) has offset 2
const inset = insetContour(poly, [2, 0, 0, 0]);
assert.ok(inset);
// bottom moves up; area shrinks by roughly 2×10 = 20
assert.ok(polygonArea(inset) < polygonArea(poly) - 15);
});
test('insetContour: L-shape stays a simple polygon', () => {
const poly = [[0, 0], [6, 0], [6, 2], [2, 2], [2, 6], [0, 6]];
const inset = insetContour(poly, [0.5, 0.5, 0.5, 0.5, 0.5, 0.5]);
assert.ok(inset && inset.length >= 6);
assert.ok(polygonArea(inset) < polygonArea(poly));
assert.ok(polygonArea(inset) > 0);
});
test('insetContour: acute corner falls back to a bevel (no infinite spike)', () => {
// very sharp tip at the origin
const poly = [[0, 0], [10, 0.3], [10, 0], [0, 0]];
// degenerate — use a proper acute triangle
const sharp = [[0, 0], [10, 1], [10, -1]];
const offsets = [1, 1, 1];
const inset = insetContour(sharp, offsets);
assert.ok(inset);
for (const p of inset) {
const dist = Math.hypot(p[0], p[1]);
// no vertex may fly farther than MITRE_LIMIT × thickness from origin-ish
assert.ok(dist < 10 + MITRE_LIMIT * 1 + 1, `spike at ${p}`);
}
});
test('variable-offset contours keep a local cap at angled positive-to-zero joins', () => {
const poly = [[0, 0], [10, 0], [20, 0.1], [20, 10], [0, 10]];
const vertex = poly[1];
const hasPoint = (contour, point) => contour.some((candidate) => (
Math.hypot(candidate[0] - point[0], candidate[1] - point[1]) <= 1e-9
));
for (const offsets of [[2, 0, 0, 0, 0], [0, 2, 0, 0, 0]]) {
const inset = insetContour(poly, offsets);
const outset = outsetContour(poly, offsets);
assert.ok(inset && outset);
assert.ok(hasPoint(inset, vertex), `inset lost the zero-edge vertex: ${JSON.stringify(offsets)}`);
assert.ok(hasPoint(outset, vertex), `outset lost the zero-edge vertex: ${JSON.stringify(offsets)}`);
assert.ok(
inset.some((point) => {
const distance = Math.hypot(point[0] - vertex[0], point[1] - vertex[1]);
return distance > 1 && distance < 3;
}),
'inset must also retain the physical edge offset point',
);
assert.ok(
outset.some((point) => {
const distance = Math.hypot(point[0] - vertex[0], point[1] - vertex[1]);
return distance > 1 && distance < 3;
}),
'outset must also retain the physical edge offset point',
);
}
});
test('issue #249 bounds the exported three-wall junction with straight bevels', () => {
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/249-multiwall-junction.json', import.meta.url), 'utf8',
));
const rooms = fixture.rooms.map((room) => ({
...room,
poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
}));
const walls = structuredClone(fixture.walls);
const nodePoint = fixture.node.map((value) => value * NORM_W);
const before = JSON.stringify({ rooms, walls });
const intervals = wallIntervals(
rooms, walls, [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
);
const nodes = buildMultiWallNodeMap(
intervals, pitch * NORM_W * 0.04 * 4,
NORM_W,
);
const node = nodes.nodes.find((candidate) =>
Math.hypot(
candidate.point[0] - nodePoint[0],
candidate.point[1] - nodePoint[1],
) < 1e-6);
assert.ok(node);
assert.equal(node.rays.length, 3);
assert.deepEqual(
multiWallProtectedRayIndexes(node),
[],
'the approved non-orthogonal #249 join must not gain protected strips',
);
closeTo(node.halfDepth, 4.861111111111112, 1e-9);
closeTo(node.limit, MULTI_WALL_JOIN_LIMIT * node.halfDepth, 1e-9);
const localTriangles = multiWallBevelTriangles(nodes).filter((triangle) =>
Math.hypot(
triangle[0][0] - nodePoint[0],
triangle[0][1] - nodePoint[1],
) < node.limit + 1e-6);
assert.equal(localTriangles.length, 2, 'fixture must exercise both oversized wedges');
for (const triangle of localTriangles) {
for (const point of triangle.slice(0, 2)) {
assert.ok(
Math.hypot(point[0] - nodePoint[0], point[1] - nodePoint[1])
<= node.limit + 1e-7,
`bevel endpoint escaped the approved radius: ${point}`,
);
}
assert.ok(
Math.hypot(
triangle[2][0] - nodePoint[0],
triangle[2][1] - nodePoint[1],
) > node.limit,
'fixture no longer contains the excessive mitre being removed',
);
}
const geometry = wallBodiesGeometry(
rooms, walls, [], [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
);
assert.ok(geometry);
assert.equal(geometry.geom.length, 1, 'the three wall arms became disconnected');
assertNoEnclosedLocalHoles(geometry.roomGeom, node, 'room masonry');
assertNoEnclosedLocalHoles(geometry.geom, node, 'final masonry');
assertNoEnclosedLocalHoles(geometry.paperGeom, node, 'exterior paper');
assertProbeInside(geometry.geom, nodePoint, 'the bevel punched a hole at the node');
for (const ray of node.rays) {
assertProbeInside(geometry.geom, [
nodePoint[0] + ray.u[0] * node.halfDepth * 2,
nodePoint[1] + ray.u[1] * node.halfDepth * 2,
], 'an incident wall arm no longer touches the junction');
}
// #302: the chamfer is strip-safe now. A wedge probe inside the strips the
// node actually owns stays FILLED (an acute junction is solid masonry);
// outside every strip the wedge is still discarded, as approved in #249.
const bound = junctionNodeBound(
rooms, walls, [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W, nodes,
);
for (const triangle of localTriangles) {
const base = [
(triangle[0][0] + triangle[1][0]) / 2,
(triangle[0][1] + triangle[1][1]) / 2,
];
const probe = [
(base[0] + triangle[2][0]) / 2,
(base[1] + triangle[2][1]) / 2,
];
const inStrip = node.rays.some((ray) => ray.supports.some((support) => {
const rx = probe[0] - node.point[0];
const ry = probe[1] - node.point[1];
const along = rx * ray.u[0] + ry * ray.u[1];
if (along < 0 || along > support.length) return false;
return Math.abs(rx * ray.u[1] - ry * ray.u[0]) <= support.halfDepth - 1e-7;
})) && (!bound || geometryProbeCoverage(bound, probe) > 1e-7);
if (inStrip) {
assertProbeInside(
geometry.geom, probe, 'the strip-safe chamfer removed strip material',
);
} else {
assertProbeOutside(geometry.geom, probe, 'the discarded mitre wedge is still filled');
}
}
const repeated = wallBodiesGeometry(
rooms, walls, [], [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
);
assert.ok(repeated);
closeTo(geometryDifferenceArea(geometry.geom, repeated.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(repeated.geom, geometry.geom), 0, 1e-7);
assert.equal(JSON.stringify({ rooms, walls }), before, 'geometry mutated saved data');
});
test('issue #275 preserves every finite strip participating in an orthogonal T join', () => {
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/275-orthogonal-strip-containment.json', import.meta.url), 'utf8',
));
for (const item of fixture.cases) {
const rooms = item.rooms.map((room) => ({
...room,
poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
}));
let walls = [];
for (const wall of item.walls) {
walls = setWallThickness(
walls,
wall.a.map((value) => value * NORM_W),
wall.b.map((value) => value * NORM_W),
wall.cm,
pitch,
NORM_W,
);
}
const map = buildMultiWallNodeMap(
wallIntervals(rooms, walls, [], pitch, item.cell_cm, GRID_PITCH, NORM_W),
pitch * NORM_W * 0.04 * 4,
NORM_W,
);
const geometry = wallBodiesGeometry(
rooms, walls, [], [], pitch, item.cell_cm, GRID_PITCH, NORM_W,
);
assert.ok(geometry, `${item.id}: production geometry failed`);
for (const storedNode of item.nodes) {
const expectedNode = storedNode.map((value) => value * NORM_W);
const node = map.nodes.find((candidate) => Math.hypot(
candidate.point[0] - expectedNode[0],
candidate.point[1] - expectedNode[1],
) <= map.epsilon);
assert.ok(node, `${item.id}: exact backup node ${storedNode} disappeared`);
const required = protectedOrthogonalStripGeometry(node, map);
assert.equal(required.protectedRays.length, node.rays.length,
`${item.id}: fixture no longer describes a pure orthogonal T join`);
assert.deepEqual(
multiWallProtectedRayIndexes(node),
required.protectedRays,
`${item.id}: product pair classification disagrees with the independent oracle`,
);
assert.ok(required.geometry, `${item.id}: no protected strip geometry`);
const productProtected = multiWallProtectedStripGeometry(node, map);
assert.ok(productProtected, `${item.id}: product protected geometry is empty`);
closeTo(geometryDifferenceArea(required.geometry, productProtected), 0, 1e-6);
closeTo(geometryDifferenceArea(productProtected, required.geometry), 0, 1e-6);
closeTo(
geometryDifferenceArea(required.geometry, geometry.roomGeom),
0,
1e-6,
);
closeTo(
geometryDifferenceArea(required.geometry, geometry.geom),
0,
1e-6,
);
}
}
});
test('issue #275 classifies orthogonal rays by pair, including mixed-node and epsilon bounds', () => {
const ray = (degrees) => {
const radians = degrees * Math.PI / 180;
return {
u: [Math.cos(radians), Math.sin(radians)],
halfDepth: 5,
length: 100,
supports: [{ halfDepth: 5, length: 100 }],
};
};
const node = (degrees) => ({
point: [0, 0],
rays: degrees.map(ray),
halfDepth: 5,
limit: 5 * MULTI_WALL_JOIN_LIMIT,
});
assert.deepEqual(multiWallProtectedRayIndexes(node([0, 90, 180])), [0, 1, 2]);
assert.deepEqual(
multiWallProtectedRayIndexes(node([0, 45, 90, 180])),
[0, 2, 3],
'the diagonal ray must not disable protection of the orthogonal rays',
);
assert.deepEqual(multiWallProtectedRayIndexes(node([0, 30, 200])), []);
const epsilonNode = (dot) => ({
point: [0, 0],
rays: [
{ ...ray(0), u: [1, 0] },
{ ...ray(90), u: [dot, Math.sqrt(1 - dot * dot)] },
ray(210),
],
halfDepth: 5,
limit: 5 * MULTI_WALL_JOIN_LIMIT,
});
assert.deepEqual(
multiWallProtectedRayIndexes(
epsilonNode(MULTI_WALL_ORTHOGONAL_DOT_EPSILON * 0.5),
),
[0, 1],
);
assert.deepEqual(
multiWallProtectedRayIndexes(
epsilonNode(MULTI_WALL_ORTHOGONAL_DOT_EPSILON * 2),
),
[],
'a physically diagonal ray beyond normalization noise must stay unprotected',
);
});
test('issue #279 protects the exact near-orthogonal T without changing #249', () => {
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/279-near-orthogonal-junction.json', import.meta.url), 'utf8',
));
const rooms = fixture.rooms.map((room) => ({
...room,
poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
}));
let walls = [];
for (const wall of fixture.walls) {
walls = setWallThickness(
walls,
wall.a.map((value) => value * NORM_W),
wall.b.map((value) => value * NORM_W),
wall.cm,
pitch,
NORM_W,
);
}
const map = buildMultiWallNodeMap(
wallIntervals(rooms, walls, [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W),
pitch * NORM_W * 0.04 * 4,
NORM_W,
);
const expectedNode = fixture.node.map((value) => value * NORM_W);
const node = map.nodes.find((candidate) => Math.hypot(
candidate.point[0] - expectedNode[0], candidate.point[1] - expectedNode[1],
) <= map.epsilon);
assert.ok(node, 'the exact beta.8 junction disappeared');
assert.equal(node.rays.length, 3);
assert.deepEqual(
multiWallProtectedRayIndexes(node, 1e-9),
[],
'the fixture must still reproduce the strict-dot regression',
);
assert.deepEqual(multiWallProtectedRayIndexes(node), [0, 1, 2]);
const protectedStrips = multiWallProtectedStripGeometry(node, map);
const geometry = wallBodiesGeometry(
rooms, walls, [], [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
);
assert.ok(protectedStrips && geometry);
closeTo(geometryDifferenceArea(protectedStrips, geometry.roomGeom), 0, 1e-6);
closeTo(geometryDifferenceArea(protectedStrips, geometry.geom), 0, 1e-6);
for (const ray of node.rays) {
assertProbeInside(geometry.geom, [
node.point[0] + ray.u[0] * node.halfDepth * 2,
node.point[1] + ray.u[1] * node.halfDepth * 2,
], 'a near-orthogonal arm detached from the junction');
}
});
test('issue #279 near-orthogonal boundary is explicit, mirrored and bounded', () => {
const ray = (degrees) => {
const radians = degrees * Math.PI / 180;
return {
u: [Math.cos(radians), Math.sin(radians)],
halfDepth: 5,
length: 100,
supports: [{ halfDepth: 5, length: 100 }],
};
};
const node = (deviation) => ({
point: [0, 0],
rays: [ray(0), ray(90 + deviation), ray(180)],
halfDepth: 5,
limit: 5 * MULTI_WALL_JOIN_LIMIT,
});
const inside = MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES - 1e-6;
const outside = MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES + 1e-6;
for (const deviation of [0, 0.181315, -0.181315, inside, -inside]) {
assert.deepEqual(multiWallProtectedRayIndexes(node(deviation)), [0, 1, 2]);
}
for (const deviation of [outside, -outside, 1, -1]) {
assert.deepEqual(
multiWallProtectedRayIndexes(node(deviation)),
[],
`${deviation} degrees must retain the bounded non-orthogonal bevel`,
);
}
});
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);
const protectedStrips = multiWallProtectedStripGeometry(node, nodeMap);
assert.ok(protectedStrips, 'the short orthogonal node lost its protected strips');
assert.equal(
multiWallProtectedRayIndexes(node).length,
node.rays.length,
'the fixture must keep every ray orthogonally protected',
);
closeTo(geometryDifferenceArea(protectedStrips, geometry.roomGeom), 0, 1e-6);
closeTo(geometryDifferenceArea(protectedStrips, geometry.geom), 0, 1e-6);
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 #272 keeps a short non-orthogonal trim open to exterior', () => {
const scale = NORM_W;
const nodePoint = [0.5 * scale, 0.5 * scale];
const angles = [0, 60, 210];
const lengths = [300, 300, 30];
const points = angles.map((degrees, index) => {
const radians = degrees * Math.PI / 180;
return [
nodePoint[0] + Math.cos(radians) * lengths[index],
nodePoint[1] + Math.sin(radians) * lengths[index],
];
});
const rooms = points.map((point, index) => ({
id: `short-fan-${index}`,
poly: [nodePoint, point, points[(index + 1) % points.length]]
.map((value) => [...value]),
}));
let walls = [];
for (const point of points) {
walls = setWallThickness(walls, nodePoint, point, 50, pitch, scale);
}
const map = buildMultiWallNodeMap(
wallIntervals(rooms, walls, [], pitch, 5, GRID_PITCH, scale),
pitch * scale * 0.04 * 4,
scale,
);
assert.equal(map.nodes.length, 1);
const [node] = map.nodes;
assert.ok(node.rays.some((ray) =>
ray.supports.some((support) => support.length < support.halfDepth * 2)));
assert.deepEqual(multiWallProtectedRayIndexes(node), []);
assert.ok(multiWallBevelTriangles(map).length > 0);
const geometry = wallBodiesGeometry(
rooms, walls, [], [], pitch, 5, GRID_PITCH, scale,
);
assert.ok(geometry);
assert.equal(geometry.status, 'ok');
assertNoEnclosedLocalHoles(
geometry.geom,
node,
'short non-orthogonal trim',
);
});
test('issue #288 keeps a shared wall attached beyond a short node ray finite', () => {
for (const scale of [1, 5, 30]) {
const interval = (key, a, b, kind, half) => ({
roomId: key,
a: a.map((value) => value * scale),
b: b.map((value) => value * scale),
key,
kind,
cm: half * 2,
open: false,
half: half * scale,
});
const source = [
interval('east', [0, 0], [120, 0], 'outer', 15),
interval('north', [0, 0], [0, -349], 'outer', 15),
interval('short', [0, 0], [0, 5], 'shared', 15),
interval('foreign-shared', [0, 5], [-200, 5], 'shared', 10),
];
const variants = [
source,
[...source].reverse().map((item) => ({ ...item, a: item.b, b: item.a })),
];
for (const input of variants) {
const map = buildMultiWallNodeMap(input, 1e-6 * scale, scale);
const node = map.nodes.find((candidate) => Math.hypot(...candidate.point) < 1e-7 * scale);
assert.ok(node, `scale ${scale}: degree-3 node disappeared`);
assert.deepEqual(
node.rays.map((ray) => Math.round(ray.length / scale)).sort((a, b) => a - b),
[5, 120, 349],
);
const short = node.rays.find((ray) => ray.u[1] > 0.99);
assert.ok(short, `scale ${scale}: short ray disappeared`);
assert.equal(short.continuations.length, 1);
const [continuation] = short.continuations;
assert.deepEqual(continuation.start.map((value) => value / scale), [0, 5]);
assert.deepEqual(continuation.u, [-1, 0]);
closeTo(continuation.length / scale, 200, 1e-9);
closeTo(continuation.halfDepth / scale, 10, 1e-9);
}
const outerContinuation = source.map((item) => item.key === 'foreign-shared'
? { ...item, kind: 'outer' }
: item);
const outerMap = buildMultiWallNodeMap(outerContinuation, 1e-6 * scale, scale);
const outerNode = outerMap.nodes.find((candidate) => Math.hypot(...candidate.point) < 1e-7 * scale);
assert.ok(outerNode);
assert.equal(outerNode.rays.find((ray) => ray.u[1] > 0.99).continuations.length, 0,
'an outer continuation bypassed the established finite-ray/exterior bevel contract');
}
});
test('issue #271 keeps a nearby door slot and its light-side approach free of a phantom ray', () => {
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/197-junction-patch.json', import.meta.url), 'utf8',
));
const rooms = fixture.rooms.map((room) => ({
...room,
poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
}));
const cuts = resolveOpenCuts(
rooms, fixture.open_spans, NORM_W, GRID_PITCH * 0.02,
);
const opening = {
x: 950, y: 345.8333333333333, angle: 0, length: 25,
};
const geometry = wallBodiesGeometry(
rooms, fixture.walls, cuts, [opening], pitch,
fixture.cell_cm, GRID_PITCH, NORM_W,
);
assert.ok(geometry);
assertProbeInside(
geometry.roomGeom, [950, 345.8333333333333],
'fixture no longer puts the door on physical masonry before the cut',
);
assertProbeOutside(
geometry.geom, [950, 345.8333333333333],
'the final door slot is not empty through the complete masonry',
);
assertProbeOutside(
geometry.roomGeom, [920, 348],
'the short 15 cm ray still paints a lateral phantom before the nearby door',
);
assertProbeOutside(
geometry.geom, [920, 348],
'opening subtraction or final union revived the lateral phantom',
);
assert.ok(
openingTunnelGeometry(
rooms, opening, fixture.walls, cuts, pitch,
fixture.cell_cm, GRID_PITCH, NORM_W,
),
'the associated opening tunnel contract disappeared',
);
});
test('issue #249 node classification is order, direction and scale independent', () => {
const cases = [
{ angles: [0, 30, 200], halves: [5, 5, 5], bevel: true },
{ angles: [45, 102, 230], halves: [7, 5, 5], bevel: true },
{ angles: [45, 102, 230], halves: [1.5, 5, 7], bevel: true },
{ angles: [0, 90, 180, 270], halves: [5, 5, 5, 5], bevel: true },
{ angles: [0, 90, 180, 270], halves: [2, 5, 3, 7], bevel: false },
];
const make = ({ angles, halves }, scale = 1) => angles.map((degrees, index) => {
const radians = degrees * Math.PI / 180;
return {
roomId: `r${index}`,
a: [0, 0],
b: [Math.cos(radians) * 100 * scale, Math.sin(radians) * 100 * scale],
key: `ray-${index}`,
kind: 'outer',
cm: halves[index] * 2,
open: false,
half: halves[index] * scale,
};
});
const signature = (map, scale) => map.nodes.map((node) => ({
point: node.point.map((value) => value / scale),
halfDepth: node.halfDepth / scale,
limit: node.limit / scale,
rays: node.rays.map((ray) => [
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, gridCellCm = cellCm) => {
const scale = NORM_W;
const node = [0.5 * scale, 0.5 * scale];
const points = fixture.angles.map((degrees) => {
const radians = degrees * Math.PI / 180;
return [
node[0] + Math.cos(radians) * 0.3 * scale,
node[1] + Math.sin(radians) * 0.3 * scale,
];
});
let rooms = points.map((point, index) => ({
id: `fan-${index}`,
poly: [node, point, points[(index + 1) % points.length]].map((p) => [...p]),
}));
let walls = [];
for (let index = 0; index < points.length; index++) {
walls = setWallThickness(
walls, node, points[index], fixture.halves[index] * 2 * gridCellCm, pitch, scale,
);
}
if (permuted) {
rooms = rooms.reverse().map((room) => ({
...room, poly: [...room.poly].reverse(),
}));
walls = walls.reverse();
}
const geometry = wallBodiesGeometry(
rooms, walls, [], [], pitch, gridCellCm, GRID_PITCH, scale,
);
assert.ok(geometry, 'multi-wall fan geometry failed');
return { rooms, walls, geometry, node, scale, gridCellCm };
};
for (const fixture of cases) {
const source = make(fixture);
const baseline = buildMultiWallNodeMap(source, 1e-6);
assert.equal(baseline.nodes.length, 1);
assert.equal(baseline.nodes[0].rays.length, fixture.angles.length);
closeTo(
baseline.nodes[0].limit,
MULTI_WALL_JOIN_LIMIT * Math.max(...fixture.halves),
1e-9,
);
const permuted = buildMultiWallNodeMap(
[...source].reverse().map((interval) => ({
...interval,
a: [...interval.b],
b: [...interval.a],
})),
1e-6,
);
assert.deepEqual(signature(permuted, 1), signature(baseline, 1));
const production = buildMultiWallNodeMap(make(fixture, 1000), 1e-3, 1000);
assert.deepEqual(signature(production, 1000), signature(baseline, 1));
assert.equal(multiWallBevelTriangles(baseline).length > 0, fixture.bevel);
const fan = makeFanGeometry(fixture);
const fanMap = buildMultiWallNodeMap(
wallIntervals(
fan.rooms, fan.walls, [], pitch, cellCm, GRID_PITCH, fan.scale,
),
pitch * fan.scale * 0.04 * 4,
fan.scale,
);
assert.equal(fanMap.nodes.length, 1);
assert.equal(fanMap.nodes[0].rays.length, fixture.angles.length);
assert.equal(
fan.geometry.geom.length,
1,
`fan ${fixture.angles.join('/')} halves ${fixture.halves.join('/')} wall arms are disconnected`,
);
assertProbeInside(fan.geometry.geom, fan.node, 'fan bevel punched a node hole');
assertNoEnclosedLocalHoles(fan.geometry.roomGeom, fanMap.nodes[0], 'fan room masonry');
assertNoEnclosedLocalHoles(fan.geometry.geom, fanMap.nodes[0], 'fan final masonry');
assertNoEnclosedLocalHoles(fan.geometry.paperGeom, fanMap.nodes[0], 'fan exterior paper');
for (const [rayIndex, ray] of fanMap.nodes[0].rays.entries()) {
const armPoint = [
fan.node[0] + ray.u[0] * fanMap.nodes[0].halfDepth * 2,
fan.node[1] + ray.u[1] * fanMap.nodes[0].halfDepth * 2,
];
const coverage = geometryProbeCoverage(fan.geometry.geom, armPoint);
// A straight bevel may legitimately remove the sector-side half of an
// acute arm close to the node; positive masonry on the centreline plus
// the single-component assertion is the required connectivity contract.
assert.ok(coverage > 0.1,
`fan ${fixture.angles.join('/')} ray ${rayIndex} at ${armPoint} coverage=${coverage}`);
}
if (multiWallBevelTriangles(fanMap).length) {
assertBoundedMultiWallBevels(
fan.rooms, fan.walls, fan.geometry, cellCm, fan.scale,
);
}
const permutedFan = makeFanGeometry(fixture, true);
closeTo(geometryDifferenceArea(fan.geometry.geom, permutedFan.geometry.geom), 0, 1e-6);
closeTo(geometryDifferenceArea(permutedFan.geometry.geom, fan.geometry.geom), 0, 1e-6);
const fineGridFan = makeFanGeometry(fixture, false, 1);
const fineGridMap = buildMultiWallNodeMap(
wallIntervals(
fineGridFan.rooms, fineGridFan.walls, [], pitch, 1, GRID_PITCH,
fineGridFan.scale,
),
pitch * fineGridFan.scale * 0.04 * 4,
fineGridFan.scale,
);
assert.equal(fineGridMap.nodes.length, 1);
assertNoEnclosedLocalHoles(
fineGridFan.geometry.roomGeom, fineGridMap.nodes[0], 'cell_cm=1 room masonry',
);
assertNoEnclosedLocalHoles(
fineGridFan.geometry.geom, fineGridMap.nodes[0], 'cell_cm=1 final masonry',
);
assertNoEnclosedLocalHoles(
fineGridFan.geometry.paperGeom, fineGridMap.nodes[0], 'cell_cm=1 exterior paper',
);
closeTo(geometryDifferenceArea(fan.geometry.geom, fineGridFan.geometry.geom), 0, 1e-6);
closeTo(geometryDifferenceArea(fineGridFan.geometry.geom, fan.geometry.geom), 0, 1e-6);
}
const twoRay = make({ angles: [0, 55], halves: [5, 7] });
const twoRayMap = buildMultiWallNodeMap(twoRay, 1e-6);
assert.equal(twoRayMap.nodes.length, 0);
const poly = [[0, 0], [10, 0], [8, 9], [0, 8]];
const offsets = [2, 2, 2, 2];
assert.deepEqual(insetContour(poly, offsets, twoRayMap), insetContour(poly, offsets));
assert.deepEqual(outsetContour(poly, offsets, twoRayMap), outsetContour(poly, offsets));
});
test('inwardNormal points into the rectangle', () => {
const poly = [[0, 0], [10, 0], [10, 6], [0, 6]];
const [nx, ny] = inwardNormal(poly, 0); // bottom edge → should point +y
assert.ok(ny > 0.5, `expected +y inward, got ${nx},${ny}`);
});
test('opening face side is known without wall thickness and can be inverted for an outward gate', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [10, 0], [10, 6], [0, 6]] }];
const topInner = openingInnerFaceOffset(
rooms, { x: 5, y: 0, angle: 0, length: 3 }, [], 1, cellCm, pitch,
);
assert.equal(topInner.cm, 0);
assert.equal(topInner.side, 1, 'the top wall room side is +Y');
const topOuter = openingInnerFaceOffset(
rooms, { x: 5, y: 0, angle: 0, length: 3, flip_v: true }, [], 1, cellCm, pitch,
);
assert.equal(topOuter.side, -1, 'inverting the selected face points outside the room');
const bottomInner = openingInnerFaceOffset(
rooms, { x: 5, y: 6, angle: 0, length: 3 }, [], 1, cellCm, pitch,
);
assert.equal(bottomInner.side, -1, 'the bottom wall room side is -Y');
});
test('opening face is independent of room order on an ambiguous shared wall', () => {
const rooms = [
{ id: 'large-first', poly: [[0, 0], [10, 0], [10, 8], [0, 8]] },
{ id: 'small-second', poly: [[3, 8], [7, 8], [7, 11], [3, 11]] },
];
const opening = { x: 5, y: 8, angle: 0, length: 2 };
const natural = openingInnerFaceOffset(rooms, opening, [], pitch, cellCm, pitch);
const flipped = openingInnerFaceOffset(rooms, { ...opening, flip_v: true }, [], pitch, cellCm, pitch);
const reversed = openingInnerFaceOffset([...rooms].reverse(), opening, [], pitch, cellCm, pitch);
const reversedFlip = openingInnerFaceOffset(
[...rooms].reverse(), { ...opening, flip_v: true }, [], pitch, cellCm, pitch,
);
assert.equal(natural.side, -1, 'shared walls use the canonical local negative side');
assert.equal(flipped.side, 1, 'flip_v selects the opposite canonical side');
assert.deepEqual(reversed, natural);
assert.deepEqual(reversedFlip, flipped);
});
test('openingTunnelGeometry: an outer thick wall gives the one room both tunnel halves', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [10, 0], [10, 6], [0, 6]] }];
const walls = [{ key: wallKey([0, 0], [10, 0], pitch), cm: 20 }];
const g = openingTunnelGeometry(
rooms, { x: 5, y: 0, angle: 0, length: 2 }, walls, [], pitch, 5, 1,
);
assert.ok(g);
assert.deepEqual(g.faces.map((f) => [f.side, f.roomId]), [[-1, 'r'], [1, 'r']]);
closeTo(g.minY, -2);
closeTo(g.maxY, 2);
assert.match(g.faces[0].d, / 0\.5\b/, 'the negative face overlaps the axis by a raster-safe amount');
assert.match(g.faces[1].d, / -0\.5\b/, 'the positive face overlaps the axis symmetrically');
});
test('openingTunnelGeometry: a 45° wall keeps the opening-local width and physical depth', () => {
const rooms = [{ id: 'diagonal', poly: [[0, 0], [10, 10], [0, 20]] }];
const walls = [{ key: wallKey([0, 0], [10, 10], pitch), cm: 20 }];
const g = openingTunnelGeometry(
rooms, { x: 5, y: 5, angle: 45, length: 4 }, walls, [], pitch, 5, 1,
);
assert.ok(g);
closeTo(g.minY, -2);
closeTo(g.maxY, 2);
const positive = g.faces.find((face) => face.side === 1);
assert.match(positive.d, /M -2(?:\.\d+)? /);
assert.match(positive.d, /L 2(?:\.\d+)? /);
});
test('openingTunnelGeometry: a shared wall is owned by the room on each local side', () => {
const rooms = [
{ id: 'south', poly: [[0, 0], [10, 0], [10, 5], [0, 5]] },
{ id: 'north', poly: [[0, -5], [10, -5], [10, 0], [0, 0]] },
];
const walls = [{ key: wallKey([0, 0], [10, 0], pitch), cm: 15 }];
const g = openingTunnelGeometry(
rooms, { x: 5, y: 0, angle: 0, length: 2 }, walls, [], pitch, 5, 1,
);
assert.ok(g);
assert.equal(g.faces.find((f) => f.side === -1).roomId, 'north');
assert.equal(g.faces.find((f) => f.side === 1).roomId, 'south');
const reversed = openingTunnelGeometry(
[...rooms].reverse(), { x: 5, y: 0, angle: 0, length: 2 }, walls, [], pitch, 5, 1,
);
assert.deepEqual(reversed, g, 'config order must not change the selected rooms or paths');
});
test('openingTunnelGeometry: mixed atomic thickness clips each piece to its real depth', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [10, 0], [10, 6], [0, 6]] }];
const walls = [
{ key: wallKey([0, 0], [5, 0], pitch), a: [0, 0], b: [5, 0], cm: 10 },
{ key: wallKey([5, 0], [10, 0], pitch), a: [5, 0], b: [10, 0], cm: 20 },
];
const g = openingTunnelGeometry(
rooms, { x: 5, y: 0, angle: 0, length: 4 }, walls, [], pitch, 5, 1,
);
assert.ok(g);
assert.match(g.faces[0].d, /-1(?:\.0+)?\b/, '10 cm half-depth is present');
assert.match(g.faces[0].d, /-2(?:\.0+)?\b/, '20 cm half-depth is present');
for (const face of g.faces) {
assert.equal((face.d.match(/\bM /g) || []).length, 1,
'a thickness step is part of one outer contour, not two touching rectangles');
assert.doesNotMatch(face.d, /-2\.02|2\.02/,
'the contour does not overpaint past either physical jamb');
}
closeTo(g.maxY, 2);
});
test('openingTunnelGeometry: overlapping wall pieces use their physical union depth', () => {
const path = tunnelFacePath(1, [
{ x0: -3, x1: 3, half: 1, cm: 10, key: 'shallow', axis: [1, 0] },
{ x0: -1, x1: 1, half: 2, cm: 20, key: 'deep', axis: [1, 0] },
]);
assert.match(path, /L 1 2 L -1 2/,
'the overlap must reach the deeper body instead of taking the minimum depth');
assert.match(path, /L 3 1 L 1 1/,
'the shallow shoulders remain part of the same non-overlapping contour');
});
test('openingTunnelGeometry: three stepped atomic strips form one non-overlapping contour', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [10, 0], [10, 6], [0, 6]] }];
const walls = [
{ key: wallKey([0, 0], [3, 0], pitch), a: [0, 0], b: [3, 0], cm: 10 },
{ key: wallKey([3, 0], [5, 0], pitch), a: [3, 0], b: [5, 0], cm: 20 },
{ key: wallKey([5, 0], [7, 0], pitch), a: [5, 0], b: [7, 0], cm: 15 },
{ key: wallKey([7, 0], [10, 0], pitch), a: [7, 0], b: [10, 0], cm: 15 },
];
const g = openingTunnelGeometry(
rooms, { x: 5, y: 0, angle: 0, length: 6 }, walls, [], pitch, 5, 1,
);
assert.ok(g);
const negative = g.faces.find((face) => face.side === -1);
const positive = g.faces.find((face) => face.side === 1);
assert.equal((negative.d.match(/\bM /g) || []).length, 1);
assert.equal((positive.d.match(/\bM /g) || []).length, 1);
assert.match(negative.d, /^M 3 0\.25 L -3 0\.25 /,
'negative and positive faces use matching nonzero winding around the wall axis');
assert.match(positive.d, /^M -3 -0\.25 L 3 -0\.25 /);
assert.match(positive.d, /L -2 1 L -3 1 Z$/,
'the one contour follows every real thickness step back to the first jamb');
});
test('openingTunnelGeometry: equal atomic strips collapse into one path without hairlines', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [10, 0], [10, 6], [0, 6]] }];
const walls = [[0, 3], [3, 5], [5, 7], [7, 10]].map(([x0, x1]) => ({
key: wallKey([x0, 0], [x1, 0], pitch), a: [x0, 0], b: [x1, 0], cm: 15,
}));
const g = openingTunnelGeometry(
rooms, { x: 5, y: 0, angle: 0, length: 6 }, walls, [], pitch, 5, 1,
);
assert.ok(g);
for (const face of g.faces) {
assert.equal((face.d.match(/\bM /g) || []).length, 1,
'one continuous wall face must not expose the three internal SVG strip edges');
}
});
test('openingTunnelGeometry: virtual, zero-thickness, orphan and draft-only walls do not paint', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [10, 0], [10, 6], [0, 6]] }];
const opening = { x: 5, y: 0, angle: 0, length: 2 };
const walls = [{ key: wallKey([0, 0], [10, 0], pitch), cm: 20 }];
assert.equal(openingTunnelGeometry(rooms, opening, [], [], pitch, 5, 1), null);
assert.equal(openingTunnelGeometry(rooms, opening, walls, [[0, 0, 10, 0]], pitch, 5, 1), null);
assert.equal(openingTunnelGeometry(rooms, { ...opening, y: 3 }, walls, [], pitch, 5, 1), null);
assert.equal(openingTunnelGeometry([], opening, walls, [], pitch, 5, 1), null,
'a physical room_draft body is not a room fill owner');
});
test('openingTunnelGeometry: angle match beats a perpendicular T-junction receiver', () => {
const rooms = [
{ id: 'horizontal', poly: [[0, 0], [10, 0], [10, 5], [0, 5]] },
{ id: 'vertical', poly: [[4, -5], [6, -5], [6, 5], [4, 5]] },
];
const walls = [
{ key: wallKey([0, 0], [10, 0], pitch), cm: 20 },
{ key: wallKey([4, -5], [4, 0], pitch), cm: 30 },
];
const g = openingTunnelGeometry(
rooms, { x: 5, y: 0, angle: 0, length: 2 }, walls, [], pitch, 5, 1,
);
assert.ok(g);
assert.ok(g.faces.every((f) => f.roomId === 'horizontal'));
closeTo(g.maxY, 2);
});
test('openingTunnelGeometry: a detached parallel room inside one cell cannot own a tunnel side', () => {
const rooms = [
{ id: 'real', poly: [[0, 0], [10, 0], [10, 5], [0, 5]] },
{ id: 'air-gap', poly: [[0, -5.5], [10, -5.5], [10, -0.5], [0, -0.5]] },
];
const walls = [
{ key: wallKey([0, 0], [10, 0], 1), cm: 20 },
{ key: wallKey([0, -0.5], [10, -0.5], 1), cm: 20 },
];
const g = openingTunnelGeometry(
rooms, { x: 5, y: 0, angle: 0, length: 2 }, walls, [], 1, 5, 1,
);
assert.ok(g);
assert.ok(g.faces.every((face) => face.roomId === 'real'));
});
test('openingTunnelGeometry: the smaller coincident nested room wins after equal full/face distance', () => {
const rooms = [
{ id: 'large', poly: [[0, 0], [10, 0], [10, 8], [0, 8]] },
{ id: 'small', poly: [[3, 0], [7, 0], [7, 3], [3, 3]] },
];
const walls = [{ key: wallKey([0, 0], [10, 0], pitch), cm: 20 }];
const g = openingTunnelGeometry(
rooms, { x: 5, y: 0, angle: 0, length: 2 }, walls, [], pitch, 5, 1,
);
assert.ok(g);
assert.ok(g.faces.every((face) => face.roomId === 'small'));
const reversed = openingTunnelGeometry(
[...rooms].reverse(), { x: 5, y: 0, angle: 0, length: 2 }, walls, [], pitch, 5, 1,
);
assert.deepEqual(reversed, g);
});
test('opening association rejects angle drift consistently for face, cut and tunnel', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [10, 0], [10, 6], [0, 6]] }];
const walls = [{ key: wallKey([0, 0], [10, 0], pitch), cm: 20 }];
const opening = { x: 5, y: 0, angle: 12, length: 2 };
assert.equal(openingInnerFaceOffset(rooms, opening, walls, pitch, 5, 1).cm, 0);
assert.equal(openingTunnelGeometry(rooms, opening, walls, [], pitch, 5, 1), null);
const uncut = wallBodiesUnionPath(rooms, walls, [], [], pitch, 5, 1);
const invalidCut = wallBodiesUnionPath(rooms, walls, [], [opening], pitch, 5, 1);
assert.deepEqual(invalidCut, uncut);
});
test('openingTunnelGeometry: a legacy opening outside the span is clipped to the real wall body', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [10, 0], [10, 6], [0, 6]] }];
const walls = [{ key: wallKey([0, 0], [10, 0], pitch), cm: 20 }];
const g = openingTunnelGeometry(
rooms, { x: 10.5, y: 0, angle: 0, length: 4 }, walls, [], pitch, 5, 1,
);
assert.ok(g);
const positive = g.faces.find((face) => face.side === 1);
assert.match(positive.d, /M -2(?:\.0+)? [^L]+L -0\.5(?:0+)? /);
assert.doesNotMatch(positive.d, /L 2(?:\.0+)? /, 'the missing wall extension is not painted');
});
test('openingTunnelGeometries removes overlap so translucent fills never composite twice', () => {
const rooms = [{ id: 'r', poly: [[0, 0], [10, 0], [10, 6], [0, 6]] }];
const walls = [{ key: wallKey([0, 0], [10, 0], pitch), cm: 20 }];
const exact = openingTunnelGeometries(
rooms,
[{ x: 5, y: 0, angle: 0, length: 4 }, { x: 5, y: 0, angle: 0, length: 4 }],
walls, [], pitch, 5, 1,
);
assert.ok(exact[0]);
assert.equal(exact[1], null, 'an exact duplicate contributes no second alpha layer');
const partial = openingTunnelGeometries(
rooms,
[{ x: 4, y: 0, angle: 0, length: 4 }, { x: 6, y: 0, angle: 0, length: 4 }],
walls, [], pitch, 5, 1,
);
assert.ok(partial[0] && partial[1]);
const positive = partial[1].faces.find((face) => face.side === 1);
assert.match(positive.d, /M 0(?:\.0+)? /, 'only the non-overlapping extension remains');
});
// ------------------------------- bodies / paper -----------------------------
test('wallEdgeBodies: shared and outer both grow ±½ from the centreline', () => {
const rooms = [
{ id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] },
{ id: 'b', poly: [[5, 0], [10, 0], [10, 4], [5, 4]] },
];
// shared vertical at x=5
const sharedKey = wallKey([5, 0], [5, 4], pitch);
const outerKey = wallKey([0, 0], [5, 0], pitch);
const walls = [
{ key: sharedKey, cm: 20 },
{ key: outerKey, cm: 30 },
];
const kindsA = edgeKinds(rooms, 'a', [], pitch);
assert.ok(kindsA.includes('shared'));
assert.ok(kindsA.includes('outer'));
const bodies = wallEdgeBodies(rooms, walls, [], pitch, cellCm, pitch);
const shared = bodies.find((b) => b.key === sharedKey);
const outer = bodies.find((b) => b.key === outerKey);
assert.ok(shared, 'shared body missing');
assert.ok(outer, 'outer body missing');
assert.equal(shared.kind, 'shared');
assert.equal(outer.kind, 'outer');
// only one body per key even though two rooms see the shared wall
assert.equal(bodies.filter((b) => b.key === sharedKey).length, 1);
// outer grows half outward: min y of quad < 0
const ys = outer.quad.map((p) => p[1]);
assert.ok(Math.min(...ys) < -1e-9, 'outer must grow outward by half');
});
test('wallBodyRings / union: outset − inset forms a closed ring', () => {
const rooms = [
{ id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] },
{ id: 'b', poly: [[5, 0], [10, 0], [10, 4], [5, 4]] },
];
const walls = [
{ key: wallKey([5, 0], [5, 4], pitch), cm: 20 },
{ key: wallKey([0, 0], [5, 0], pitch), cm: 20 },
];
const rings = wallBodyRings(rooms, walls, [], pitch, cellCm, pitch);
assert.ok(rings.length >= 1);
assert.ok(rings[0].d.includes('M'));
const united = wallBodiesUnionPath(rooms, walls, [], [], pitch, cellCm, pitch);
assert.ok(united && united.d.includes('M'));
// Partial-thickness walls may produce a simple strip (one subpath); a fully
// thick room must keep a floor hole — see the next test.
const inner = innerContourForRoom(rooms, 'a', walls, [], pitch, cellCm, pitch);
assert.ok(inner);
assert.ok(polygonArea(inner) < polygonArea(rooms[0].poly));
});
test('wallBodiesUnionPath mitres real arms owned by different rooms at a virtual T', () => {
const scale = 1000;
const rooms = [
{ id: 'a', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] },
{ id: 'b', poly: [[500, 500], [900, 500], [900, 900], [500, 900]] },
{ id: 'c', poly: [[500, 100], [900, 100], [900, 500], [500, 500]] },
];
const open = [[500, 500, 900, 500]];
const walls = [
{ key: wallKey([0.1, 0.5], [0.5, 0.5], pitch), cm: 20 },
{ key: wallKey([0.5, 0.5], [0.5, 0.9], pitch), cm: 20 },
];
const united = wallBodiesUnionPath(rooms, walls, open, [], pitch, cellCm, GRID_PITCH, scale);
assert.ok(united);
const nums = (united.d.match(/-?\d+(?:\.\d+)?/g) || []).map(Number);
const pts = [];
for (let i = 0; i + 1 < nums.length; i += 2) pts.push([nums[i], nums[i + 1]]);
const half = wallCmToUnits(20, cellCm, GRID_PITCH) / 2;
assert.ok(
pts.some((p) => Math.abs(p[0] - (500 + half)) < 1e-6
&& Math.abs(p[1] - (500 - half)) < 1e-6),
`missing outer mitre corner in ${united.d}`,
);
});
test('issue #197 keeps the full masonry when one virtual-junction patch has ULP noise', () => {
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/197-junction-patch.json', import.meta.url), 'utf8',
));
const rooms = fixture.rooms.map((room) => ({
...room,
poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
}));
const walls = structuredClone(fixture.walls);
const cuts = resolveOpenCuts(rooms, fixture.open_spans, NORM_W, GRID_PITCH * 0.02);
const openings = [];
const extraBodies = [];
const before = JSON.stringify({ rooms, walls, cuts, openings, extraBodies });
assert.deepEqual([rooms.length, walls.length, cuts.length], [8, 25, 3]);
const intervals = wallIntervals(
rooms, walls, cuts, pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
);
const multiWallMap = buildMultiWallNodeMap(
intervals, pitch * NORM_W * 0.04 * 4, NORM_W,
);
const affectedNode = multiWallMap.nodes.find((node) =>
Math.hypot(node.point[0] - 887.5, node.point[1] - 550) < 1e-6);
assert.equal(affectedNode?.rays.length, 3, 'the affected T-junction lost an incident ray');
assert.equal(multiWallMap.nodes.some((node) =>
Math.hypot(node.point[0] - 620.8333333333334, node.point[1] - 550) < 1e-6), false,
'the zero-depth shared edge became a physical multi-wall node');
const nodeCms = intervals
.filter((iv) => Math.abs(iv.a[1] - 550) < 1e-6
&& Math.abs(iv.b[1] - 550) < 1e-6)
.map((iv) => iv.cm);
assert.ok(nodeCms.includes(20), `junction lost its 20 cm arm: ${nodeCms}`);
const patches = virtualJunctionPatches(
rooms, walls, cuts, pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
);
assert.deepEqual(patches, [[
[620.8333333333334, 550],
[612.5, 550],
[612.5000000000001, 541.6666666666665],
[620.8333333333334, 541.6666666666666],
]]);
const stable = stableJunctionPatch(patches[0], NORM_W);
assert.ok(stable);
assert.equal(stable[1][0], stable[2][0], 'equivalent mitre x coordinates stay forked');
assert.equal(stable[2][1], stable[3][1], 'equivalent mitre y coordinates stay forked');
const normalizedStable = stableJunctionPatch(
patches[0].map(([x, y]) => [x / NORM_W, y / NORM_W]), 1,
);
assert.ok(normalizedStable);
assert.equal(normalizedStable[1][0], normalizedStable[2][0]);
assert.equal(normalizedStable[2][1], normalizedStable[3][1]);
const bounds = (poly) => [
Math.min(...poly.map((point) => point[0])), Math.min(...poly.map((point) => point[1])),
Math.max(...poly.map((point) => point[0])), Math.max(...poly.map((point) => point[1])),
];
bounds(stable).forEach((value, index) => closeTo(
value, bounds(patches[0])[index], 1e-9,
));
const geometry = wallBodiesGeometry(
rooms, walls, cuts, openings, pitch, fixture.cell_cm, GRID_PITCH, NORM_W, extraBodies,
);
assert.ok(geometry, 'one rejected junction patch must not erase the whole plan');
assert.ok(geometry.geom.length > 0);
assert.ok(geometry.paperGeom.length > 0);
const retainedWedgeProbe = [895.5, 556];
assertProbeInside(
geometry.roomGeom, retainedWedgeProbe,
'the room masonry lost the bounded exterior half-wall at the T-junction',
);
assertProbeInside(
geometry.geom, retainedWedgeProbe,
'the canonical masonry lost the bounded exterior half-wall at the T-junction',
);
assertProbeInside(
geometry.paperGeom, retainedWedgeProbe,
'the paper exposed the scene background through the bounded T-junction',
);
for (const ray of affectedNode.rays) assertProbeInside(
geometry.roomGeom,
[
affectedNode.point[0] + ray.u[0] * affectedNode.limit * 0.5,
affectedNode.point[1] + ray.u[1] * affectedNode.limit * 0.5,
],
'the repaired T-junction disconnected an incident physical ray',
);
for (const room of rooms) {
const cleanFloor = innerContourForRoom(
rooms, room.id, walls, cuts, pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
geometry.roomGeom,
);
if (cleanFloor) assertProbeOutside(
closedGeometry(cleanFloor), retainedWedgeProbe,
`room ${room.id} clean floor leaked into the retained T-junction wedge`,
);
}
// #249 intentionally bevels unprotected degree-3+ sectors in this older
// fixture too. #275 additionally retains every finite strip that has an
// orthogonal partner instead of letting the same bevel create an open notch.
// #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.
// #272 additionally opens any point-contact bevel cut to the exterior.
// #302: the node's support quads and mitre fans (reflex outer corners
// included) add a sliver of masonry (+0.6 units²) at this fixture's
// junctions.
closeTo(geometryArea(geometry.geom), 124535.20808099362, 1e-6);
closeTo(geometryArea(geometry.paperGeom), 727303.8194444444, 1e-6);
assert.equal(
JSON.stringify({ rooms, walls, cuts, openings, extraBodies }), before,
'rendering mutated persisted input',
);
const permuted = wallBodiesGeometry(
[...rooms].reverse(), [...walls].reverse(), cuts, openings, pitch,
fixture.cell_cm, GRID_PITCH, NORM_W, extraBodies,
);
assert.ok(permuted);
closeTo(geometryDifferenceArea(geometry.geom, permuted.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(permuted.geom, geometry.geom), 0, 1e-7);
const reversedAndRepeated = wallBodiesGeometry(
rooms, walls.map((wall) => ({ ...wall, a: [...wall.b], b: [...wall.a] })),
cuts, openings, pitch, fixture.cell_cm, GRID_PITCH, NORM_W, extraBodies,
);
assert.ok(reversedAndRepeated);
closeTo(geometryDifferenceArea(geometry.geom, reversedAndRepeated.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(reversedAndRepeated.geom, geometry.geom), 0, 1e-7);
const repeated = wallBodiesGeometry(
rooms, walls, cuts, openings, pitch, fixture.cell_cm, GRID_PITCH, NORM_W, extraBodies,
);
assert.ok(repeated);
closeTo(geometryDifferenceArea(geometry.geom, repeated.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(repeated.geom, geometry.geom), 0, 1e-7);
});
test('junction patch union isolates one failure and continues with later patches', () => {
const patches = [
[[0, 0], [2, 0], [2, 2], [0, 2]],
[[3, 0], [5, 0], [5, 2], [3, 2]],
];
const calls = [];
const result = unionJunctionPatches('initial-body', patches, 1, (body, piece) => {
calls.push({ body, piece });
if (calls.length === 1) throw new Error('controlled first-patch failure');
return 'body-with-second-patch';
});
assert.equal(result, 'body-with-second-patch');
assert.equal(calls.length, 2, 'a failed patch suppressed the following patch');
assert.equal(calls[1].body, 'initial-body', 'failure replaced the last valid body');
let invalidCalls = 0;
assert.equal(unionJunctionPatches('opaque', [
[[0, 0], [Infinity, 0], [0, 1]],
[[0, 0], [1, 0], [2, 0]],
], 1, () => { invalidCalls++; }), 'opaque');
assert.equal(invalidCalls, 0, 'invalid or zero-area patches reached the boolean engine');
});
test('wallBodiesUnionPath: single fully-thick room keeps a floor hole', () => {
const room = { id: 'n', poly: [[100, 100], [300, 100], [300, 300], [100, 300]] };
const walls = applyWallThicknessToNewRoom([], [room], 'n', 15, 0.01, [], 1000);
assert.equal(walls.length, 4);
const united = wallBodiesUnionPath([room], walls, [], [], 0.01, cellCm, 4.166666666666667, 1000);
assert.ok(united);
assert.ok((united.d.match(/M/g) || []).length >= 2, united.d);
});
test('wallBodiesUnionPath: a parent floor never erases a nested room wall', () => {
const scale = 1000;
const rooms = [
{ id: 'parent', poly: [[100, 100], [900, 100], [900, 900], [100, 900]] },
{ id: 'nested', poly: [[300, 300], [700, 300], [700, 700], [300, 700]] },
];
let walls = applyWallThicknessToNewRoom([], rooms, 'parent', 15, pitch, [], scale);
walls = applyWallThicknessToNewRoom(walls, rooms, 'nested', 15, pitch, [], scale);
const united = wallBodiesUnionPath(
rooms, walls, [], [], pitch, cellCm, GRID_PITCH, scale,
);
assert.ok(united);
// Parent ring (outer + floor hole) and nested ring (outer + floor hole).
// The old `(union outsets) - (union insets)` formula returned only two
// subpaths here because the parent floor swallowed the nested wall entirely.
assert.ok((united.d.match(/M/g) || []).length >= 4, united.d);
});
test('production-scale Split keeps the 10 → 0 facade transition at the divider', () => {
const { scale, rooms, walls, geometry } = splitThicknessTransitionFixture();
const intervals = wallIntervals(
rooms, walls, [], pitch, cellCm, GRID_PITCH, scale,
);
const top = intervals.filter((iv) =>
iv.kind === 'outer' && Math.abs(iv.a[1] - 100) < 1e-7
&& Math.abs(iv.b[1] - 100) < 1e-7);
assert.deepEqual(top.map((iv) => [iv.roomId, iv.cm]), [['left', 10], ['right', 0]]);
const divider = intervals.filter((iv) =>
iv.kind === 'shared' && Math.abs(iv.a[0] - 500) < 1e-7
&& Math.abs(iv.b[0] - 500) < 1e-7);
assert.equal(divider.length, 2);
assert.ok(divider.every((iv) => iv.cm === 10), 'one physical divider keeps 10 cm');
const half = wallCmToUnits(10, cellCm, GRID_PITCH) / 2;
assertProbeInside(geometry.geom, [300, 100 - half * 0.75], 'outer half is missing');
assertProbeInside(geometry.geom, [300, 100 + half * 0.75], 'inner half is missing');
assertProbeOutside(geometry.geom, [300, 100 - half - 0.2], 'wall exceeds 10 cm');
assertProbeOutside(geometry.geom, [300, 100 + half + 0.2], 'wall exceeds 10 cm');
assertProbeOutside(geometry.geom, [700, 96], '10 cm leaked along the zero facade');
assertProbeOutside(geometry.geom, [700, 104], 'zero facade gained an inward half-wall');
assertProbeOutside(geometry.paperGeom, [700, 96], 'paper leaked past the zero facade');
assertProbeInside(geometry.geom, [500 - half * 0.5, 300], 'left divider half is missing');
assertProbeInside(geometry.geom, [500 + half * 0.5, 300], 'right divider half is missing');
assertProbeOutside(geometry.geom, [500 + half * 0.5, 96], 'divider protrudes outside');
const points = geometry.geom.flat(2);
assert.ok(points.some(([x, y]) =>
Math.abs(x - 500) < 1e-7 && Math.abs(y - (100 - half)) < 1e-7),
'the outer transition face must start at the exact divider endpoint');
const leftFloor = innerContourForRoom(
rooms, 'left', walls, [], pitch, cellCm, GRID_PITCH, scale,
);
const rightFloor = innerContourForRoom(
rooms, 'right', walls, [], pitch, cellCm, GRID_PITCH, scale,
);
assert.ok(leftFloor && rightFloor);
assertProbeOutside(closedGeometry(leftFloor), [300, 102], 'left floor covers its wall');
assertProbeInside(closedGeometry(leftFloor), [300, 106], 'left clean floor starts too late');
assertProbeInside(closedGeometry(rightFloor), [700, 102], 'zero side lost clean floor');
const before = JSON.stringify({ rooms, walls });
assert.ok(wallBodiesGeometry(rooms, walls, [], [], pitch, cellCm, GRID_PITCH, scale));
assert.equal(JSON.stringify({ rooms, walls }), before, 'rendering must not migrate saved config');
});
test('production-scale collinear transitions keep both local depths in either direction', () => {
const scale = 1000;
const room = { id: 'room', poly: [[100, 100], [900, 100], [900, 900], [100, 900]] };
const make = (firstCm, secondCm, poly = room.poly) => {
let walls = [];
if (firstCm > 0)
walls = setWallThickness(walls, [100, 100], [500, 100], firstCm, pitch, scale);
if (secondCm > 0)
walls = setWallThickness(walls, [500, 100], [900, 100], secondCm, pitch, scale);
const geometry = wallBodiesGeometry(
[{ id: 'room', poly }], walls, [], [], pitch, cellCm, GRID_PITCH, scale,
);
assert.ok(geometry, `missing geometry for ${firstCm} → ${secondCm}`);
return { geometry, walls };
};
const assertLocalDepth = (geometry, x, cm, label) => {
if (cm === 0) {
assertProbeOutside(geometry.geom, [x, 99], `${label}: zero outer side is solid`);
assertProbeOutside(geometry.geom, [x, 101], `${label}: zero inner side is solid`);
return;
}
const half = wallCmToUnits(cm, cellCm, GRID_PITCH) / 2;
assertProbeInside(geometry.geom, [x, 100 - half * 0.75], `${label}: outer half missing`);
assertProbeInside(geometry.geom, [x, 100 + half * 0.75], `${label}: inner half missing`);
assertProbeOutside(geometry.geom, [x, 100 - half - 0.2], `${label}: outer depth too large`);
assertProbeOutside(geometry.geom, [x, 100 + half + 0.2], `${label}: inner depth too large`);
};
for (const [firstCm, secondCm] of [
[0, 10], [10, 0], [10, 20], [20, 10], [1, 100], [100, 1], [10, 10],
]) {
const { geometry } = make(firstCm, secondCm);
assertLocalDepth(geometry, 300, firstCm, `${firstCm} → ${secondCm}, first`);
assertLocalDepth(geometry, 700, secondCm, `${firstCm} → ${secondCm}, second`);
if (firstCm !== secondCm) {
const points = geometry.geom.flat(2);
for (const cm of new Set([firstCm, secondCm])) {
if (!(cm > 0)) continue;
const half = wallCmToUnits(cm, cellCm, GRID_PITCH) / 2;
for (const y of [100 - half, 100 + half])
assert.ok(points.some(([x0, y0]) =>
Math.abs(x0 - 500) < 1e-7 && Math.abs(y0 - y) < 1e-7),
`${firstCm} → ${secondCm}: missing exact transition vertex at 500,${y}`);
}
}
}
const splitEqual = make(10, 10).geometry;
let wholeWalls = setWallThickness([], [100, 100], [900, 100], 10, pitch, scale);
const whole = wallBodiesGeometry(
[room], wholeWalls, [], [], pitch, cellCm, GRID_PITCH, scale,
);
assert.ok(whole);
closeTo(geometryDifferenceArea(splitEqual.geom, whole.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(whole.geom, splitEqual.geom), 0, 1e-7);
const ordered = make(10, 20).geometry;
const reversed = make(10, 20, [...room.poly].reverse()).geometry;
closeTo(geometryDifferenceArea(ordered.geom, reversed.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(reversed.geom, ordered.geom), 0, 1e-7);
});
test('production-scale 45° facade keeps an exact unequal-thickness breakpoint', () => {
const scale = 1000;
const room = {
id: 'diagonal',
poly: [[200, 100], [800, 700], [600, 900], [0, 300]],
};
const transition = [500, 400];
let walls = setWallThickness([], room.poly[0], transition, 10, pitch, scale);
walls = setWallThickness(walls, transition, room.poly[1], 20, pitch, scale);
const geometry = wallBodiesGeometry(
[room], walls, [], [], pitch, cellCm, GRID_PITCH, scale,
);
assert.ok(geometry);
const normal = inwardNormal(room.poly, 0);
const points = geometry.geom.flat(2);
for (const cm of [10, 20]) {
const half = wallCmToUnits(cm, cellCm, GRID_PITCH) / 2;
for (const side of [-1, 1]) {
const expected = [
transition[0] + normal[0] * half * side,
transition[1] + normal[1] * half * side,
];
assert.ok(points.some(([x, y]) =>
Math.hypot(x - expected[0], y - expected[1]) < 1e-7),
`missing 45° transition vertex ${expected}`);
}
}
});
test('corner Split bounds the exterior join created by its third ray', () => {
const { rooms, walls, before, after } = cornerSplitFixture();
assert.ok(before);
assert.deepEqual(geometryBounds(after.geom), geometryBounds(before.geom));
assertBoundedMultiWallBevels(rooms, walls, after);
const paper = paperRoomShapesWithWalls(
rooms, walls, [], pitch, cellCm, GRID_PITCH,
);
assert.equal(paper.length, 1);
assert.ok('path' in paper[0]);
const nums = paper[0].path.match(/-?\d+(?:\.\d+)?/g).map(Number);
const paperPoints = [];
for (let i = 0; i < nums.length; i += 2) paperPoints.push([nums[i], nums[i + 1]]);
assert.deepEqual(geometryBounds([[paperPoints]]), geometryBounds(after.paperGeom));
const canonical = wallBodiesUnionPath(
rooms, walls, [], [], pitch, cellCm, GRID_PITCH,
);
assert.ok(canonical?.paperD, 'canonical render pass must include its paper path');
assert.equal(canonical.paperD, paper[0].path);
});
test('corner Split clips every divider thickness when exterior walls are absent', () => {
for (const dividerCm of [1, 15, 100]) {
const { original, after } = cornerSplitFixture({ outerCm: 0, dividerCm });
closeTo(geometryArea(difference(after.geom, closedGeometry(original.poly))), 0, 1e-7);
}
});
test('corner Split keeps facade bounds and bevels every positive-thickness 3-ray matrix', () => {
for (const outerCm of [1, 15, 100]) {
for (const dividerCm of [0, 1, 15, 100]) {
const { original, rooms, walls, before, after } = cornerSplitFixture({
outerCm, dividerCm,
});
assert.ok(before);
assert.deepEqual(geometryBounds(after.paperGeom), geometryBounds(before.paperGeom));
if (dividerCm > 0) {
assertBoundedMultiWallBevels(rooms, walls, after);
} else {
const centre = closedGeometry(original.poly);
closeTo(geometryDifferenceArea(
difference(before.geom, centre), difference(after.geom, centre),
), 0, 1e-7);
}
}
}
});
test('near-collinear zero-depth Split divider never grows a masonry taper', () => {
const poly = [[100, 100], [900, 100], [900, 800], [600, 800], [600, 400], [100, 400]];
const dividerStrip = (segment, halfWidth) => {
const [x0, y0, x1, y1] = segment;
const dx = x1 - x0, dy = y1 - y0;
const length = Math.hypot(dx, dy);
const nx = -dy / length, ny = dx / length;
const at = (t, side) => [
x0 + dx * t + nx * halfWidth * side,
y0 + dy * t + ny * halfWidth * side,
];
// Endpoint caps are physical. Inspect only the divider interior, far past
// the maximum 100 cm half-depth used by this matrix.
return [at(0.2, -1), at(0.8, -1), at(0.8, 1), at(0.2, 1)];
};
let reference = null;
for (const outerCm of [1, 15, 100]) {
for (const deltaY of [-5, -2.5, 2.5, 5]) {
const fixture = cornerSplitFixture({
poly,
path: [[600, 400], [900, 400 + deltaY]],
outerCm,
dividerCm: 0,
});
const shared = wallIntervals(
fixture.rooms, fixture.walls, [], pitch, cellCm, GRID_PITCH,
).filter((interval) => interval.kind === 'shared');
assert.equal(shared.length, 2, `shared interval count at ${outerCm} cm / ${deltaY}`);
assert.ok(shared.every((interval) => interval.cm === 0));
const segment = fixture.divider[0];
const halfDepth = wallCmToUnits(outerCm, cellCm, GRID_PITCH) / 2;
const strip = dividerStrip(segment, Math.max(0.25, halfDepth * 0.75));
const overlap = geometryArea(intersection(fixture.after.geom, closedGeometry(strip)));
closeTo(overlap, 0, 1e-7);
if (outerCm === 15 && deltaY === 2.5) reference = fixture;
}
}
assert.ok(reference);
const permutedRooms = reference.rooms
.map((room, index) => ({ id: `zero-divider-${index}`, poly: [...room.poly].reverse() }))
.reverse();
const permuted = wallBodiesGeometry(
permutedRooms, reference.walls, [], [], pitch, cellCm, GRID_PITCH,
);
assert.ok(permuted);
closeTo(geometryDifferenceArea(reference.after.geom, permuted.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(permuted.geom, reference.after.geom), 0, 1e-7);
});
test('corner Split keeps unequal exterior arms and is order/id/winding independent', () => {
const fixture = cornerSplitFixture({
outerOverrides: [
[[100, 100], [900, 100], 5],
[[100, 700], [100, 100], 40],
],
dividerCm: 100,
});
const shuffled = fixture.rooms
.map((room, at) => ({ id: `renamed-${at}`, poly: [...room.poly].reverse() }))
.reverse();
const permuted = wallBodiesGeometry(
shuffled, fixture.walls, [], [], pitch, cellCm, GRID_PITCH,
);
assert.ok(permuted);
closeTo(geometryDifferenceArea(fixture.after.geom, permuted.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(permuted.geom, fixture.after.geom), 0, 1e-7);
assertBoundedMultiWallBevels(
fixture.rooms, fixture.walls, fixture.after,
);
});
test('Split from a concave vertex does not turn the child mitre into facade', () => {
const poly = [[100, 100], [900, 100], [900, 800], [600, 800], [600, 400], [100, 400]];
const fixture = cornerSplitFixture({ poly, path: [[600, 400], [900, 250]], dividerCm: 100 });
const centre = closedGeometry(poly);
const beforeExterior = difference(fixture.before.geom, centre);
const afterExterior = difference(fixture.after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
});
test('Split with both endpoints at exterior vertices bevels both new 3-ray corners', () => {
const fixture = cornerSplitFixture({ path: [[100, 100], [900, 700]], dividerCm: 100 });
const map = assertBoundedMultiWallBevels(
fixture.rooms, fixture.walls, fixture.after,
);
assert.equal(map.nodes.length, 2);
});
test('corner Split clean floors equal room union minus canonical bounded walls', () => {
const fixture = cornerSplitFixture({ dividerCm: 100 });
const floors = fixture.rooms.map((room) => innerContourForRoom(
fixture.rooms, room.id, fixture.walls, [], pitch, cellCm, GRID_PITCH,
));
assert.ok(floors.every(Boolean));
const actual = union(...floors.map((floor) => closedGeometry(floor)));
const expected = difference(closedGeometry(fixture.original.poly), fixture.after.geom);
const extra = geometryDifferenceArea(actual, expected);
const missing = geometryDifferenceArea(expected, actual);
assert.ok(extra <= 1e-7 && missing <= 1e-7,
`floor mismatch extra=${extra} missing=${missing} floors=${JSON.stringify(floors)}`);
for (const floor of floors) {
for (const point of floor) {
assert.ok(point[0] >= 100 - 1e-7 && point[0] <= 900 + 1e-7
&& point[1] >= 100 - 1e-7 && point[1] <= 700 + 1e-7,
`clean floor escaped the source building: ${point}`);
}
}
});
test('corner Split rendering does not materialize or mutate saved geometry', () => {
const fixture = cornerSplitFixture({ dividerCm: 100 });
const rooms = structuredClone(fixture.rooms);
const walls = structuredClone(fixture.walls);
const before = JSON.stringify({ rooms, walls });
assert.ok(wallBodiesGeometry(rooms, walls, [], [], pitch, cellCm, GRID_PITCH));
assert.ok(paperRoomShapesWithWalls(rooms, walls, [], pitch, cellCm, GRID_PITCH).length);
assert.equal(JSON.stringify({ rooms, walls }), before);
});
test('paper with walls covers shared centreline; without walls matches paperRoomShapes', () => {
const rooms = [
{ id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] },
{ id: 'b', poly: [[5, 0], [10, 0], [10, 4], [5, 4]] },
];
const plain = paperRoomShapes(rooms);
const same = paperRoomShapesWithWalls(rooms, [], [], pitch, cellCm, pitch);
assert.deepEqual(same, plain);
const walls = [{ key: wallKey([5, 0], [5, 4], pitch), cm: 20 }];
const grown = paperRoomShapesWithWalls(rooms, walls, [], pitch, cellCm, pitch);
assert.equal(grown.length, 1);
assert.ok('path' in grown[0], 'wall-aware paper is one canonical union path');
});
test('area of the room polygon is unchanged by thickness helpers', () => {
const poly = [[0, 0], [8, 0], [8, 5], [0, 5]];
const before = polygonArea(poly);
insetContour(poly, [0.5, 0.5, 0.5, 0.5]);
assert.equal(polygonArea(poly), before);
});
test('applyWallThicknessToNewRoom skips edges that already have thickness', () => {
const sharedKey = wallKey([5, 0], [5, 4], pitch);
const existing = [{ key: sharedKey, cm: 40 }];
const older = { id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] };
const newRoom = { id: 'b', poly: [[5, 0], [10, 0], [10, 4], [5, 4]] };
const next = applyWallThicknessToNewRoom(
existing, [older, newRoom], 'b', DRAW_WALL_DEFAULT_CM, pitch,
);
const shared = next.find((w) => w.key === sharedKey);
assert.equal(shared?.cm, 40, 'neighbour thickness must be kept');
// other three edges of b get the draw default
assert.equal(next.filter((w) => w.cm === DRAW_WALL_DEFAULT_CM).length, 3);
assert.equal(thicknessCmAt(next, [5, 0], [10, 0], pitch), DRAW_WALL_DEFAULT_CM);
});
test('applyWallThicknessToNewRoom with null cm is a no-op', () => {
const room = { id: 'r', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] };
assert.deepEqual(applyWallThicknessToNewRoom([], [room], 'r', null, pitch), []);
});
test('split materialisation preserves legacy source walls around a new divider', () => {
const original = [
{ id: 'source', poly: [[0, 0], [10, 0], [10, 10], [0, 10]] },
];
// Valid profiles saved by older House Plan versions have no exact a/b span.
const legacy = [
{ key: wallKey([0, 0], [10, 0], pitch), cm: 15 },
{ key: wallKey([10, 0], [10, 10], pitch), cm: 15 },
{ key: wallKey([10, 10], [0, 10], pitch), cm: 15 },
{ key: wallKey([0, 10], [0, 0], pitch), cm: 15 },
];
const preserved = materializeWallIntervals(
original, legacy, [], pitch, cellCm, GRID_PITCH,
);
const split = [
{ id: 'source', poly: [[4, 0], [10, 0], [10, 10], [4, 10]] },
{ id: 'fresh', poly: [[0, 0], [4, 0], [4, 10], [0, 10]] },
];
const changed = setWallThickness(preserved, [4, 0], [4, 10], 22, pitch);
const next = normalizeWallIntervals(split, changed, [], pitch, cellCm, GRID_PITCH);
const cmAt = (seg) => intervalCmAt(
split, next, [], seg, pitch, cellCm, GRID_PITCH,
);
assert.equal(cmAt([4, 0, 10, 0]), 15);
assert.equal(cmAt([0, 0, 4, 0]), 15);
assert.equal(cmAt([4, 10, 10, 10]), 15);
assert.equal(cmAt([0, 10, 4, 10]), 15);
assert.equal(cmAt([4, 0, 4, 10]), 22);
});
test('split materialisation cuts a partial shared interval at the new divider', () => {
const original = [
{ id: 'source', poly: [[0, 0], [10, 0], [10, 10], [0, 10]] },
{ id: 'neighbour', poly: [[0, -6], [6, -6], [6, 0], [0, 0]] },
];
const walls = [{
key: wallKey([0, 0], [6, 0], pitch), a: [0, 0], b: [6, 0], cm: 15,
}];
const preserved = materializeWallIntervals(
original, walls, [], pitch, cellCm, GRID_PITCH,
);
const split = [
{ id: 'source', poly: [[4, 0], [10, 0], [10, 10], [4, 10]] },
original[1],
{ id: 'fresh', poly: [[0, 0], [4, 0], [4, 10], [0, 10]] },
];
const next = normalizeWallIntervals(split, preserved, [], pitch, cellCm, GRID_PITCH);
const cmAt = (seg) => intervalCmAt(split, next, [], seg, pitch, cellCm, GRID_PITCH);
assert.equal(cmAt([0, 0, 4, 0]), 15);
assert.equal(cmAt([4, 0, 6, 0]), 15);
assert.equal(cmAt([6, 0, 10, 0]), 0);
});
test('drawWallPreviewD returns a path for open and closed outlines', () => {
const single = drawWallPreviewD([[0, 0], [10, 0]], 1, false);
assert.ok(single.includes('M'), 'one flat-capped segment remains a visible preview');
const open = drawWallPreviewD([[0, 0], [10, 0], [10, 6]], 1, false);
assert.ok(open.includes('M'));
assert.match(open, /11 -1(?:\D|$)/, 'open preview already contains the 90-degree mitre');
const stepped = drawWallPreviewD(
[[0, 0], [10, 0], [10, 6]], 1, false, [1, 2],
);
assert.match(stepped, /12 -1(?:\D|$)/,
'the joined preview respects the second segment own half-depth');
const closed = drawWallPreviewD([[0, 0], [10, 0], [10, 6], [0, 6]], 1, true);
assert.ok(closed.includes('M'));
assert.equal(drawWallPreviewD([[0, 0]], 1, false), '');
});
test('linear wall joins bevel an excessive mitre and ignore malformed or near-miss inputs', () => {
const acute = linearWallJoinPatches([
{ a: [0, 0], b: [10, 0], halfDepth: 1 },
{ a: [0, 0], b: [10, 0.1], halfDepth: 1 },
], 1e-6);
assert.equal(acute.length, 1);
// #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 },
{ a: [0.001, 0], b: [0.001, 2], halfDepth: 1 },
], 1e-6);
assert.deepEqual(separate, [], 'a point outside geometry epsilon remains disconnected');
assert.equal(linearWallBody({ a: [0, 0], b: [Infinity, 1], halfDepth: 1 }), null);
assert.deepEqual(linearWallJoinPatches([
{ a: [-2, 0], b: [0, 0], halfDepth: 1 },
{ a: [0, 0], b: [0, 0], halfDepth: 1 },
{ a: [0, 0], b: [Infinity, 1], halfDepth: 1 },
]), [], 'invalid neighbours do not alter a valid flat-capped segment');
});
// --- issue #230: hatch density follows the plan's centimetres -----------------
test('issue 230 the reference scale is untouched', () => {
assert.equal(wallHatchStepUnits(5), 8, 'exactly, not approximately');
assert.equal(wallHatchStepUnits(5), HATCH_BASE_STEP_UNITS);
});
test('issue 230 one wall carries the same stripes at every grid scale', () => {
const stripes = (cell) => wallCmToUnits(15, cell, GRID_PITCH) / wallHatchStepUnits(cell);
const reference = stripes(5);
for (const cell of [1, 2, 5, 10, 25, 50]) {
assert.ok(
Math.abs(stripes(cell) - reference) < 1e-9,
`cell_cm ${cell}: ${stripes(cell)} stripes vs ${reference}`,
);
}
});
test('issue 230 density is physical, so a thicker wall gets more stripes', () => {
const stripes = (cm, cell) => wallCmToUnits(cm, cell, GRID_PITCH) / wallHatchStepUnits(cell);
for (const cell of [1, 5, 25]) {
assert.ok(
Math.abs(stripes(30, cell) / stripes(15, cell) - 2) < 1e-9,
`cell_cm ${cell}: ratio ${stripes(30, cell) / stripes(15, cell)}`,
);
}
});
test('issue 230 a missing or broken cell_cm falls back to the reference', () => {
for (const bad of [0, -5, NaN, undefined, null, 'wide', {}]) {
assert.equal(wallHatchStepUnits(bad), 8, `input ${String(bad)}`);
}
});
test('issue 230 the step stays inside its limits', () => {
assert.ok(wallHatchStepUnits(0.1) <= HATCH_MAX_STEP_UNITS, 'a hair-fine grid');
assert.ok(wallHatchStepUnits(1000) >= HATCH_MIN_STEP_UNITS, 'a hectare-wide grid');
assert.equal(wallHatchStepUnits(0.5), HATCH_MAX_STEP_UNITS, 'the upper limit is reachable');
assert.equal(wallHatchStepUnits(80), HATCH_MIN_STEP_UNITS, 'the lower limit is reachable');
});
test('issue 230 stripes too close on screen ask for a solid body', () => {
assert.equal(wallHatchNeedsSolid(1, 1), true, '1 px step is noise');
assert.equal(wallHatchNeedsSolid(1, 2), false, 'exactly the threshold is fine');
assert.equal(wallHatchNeedsSolid(8, 10), false, 'a comfortable step');
for (const [step, px] of [[0, 5], [-1, 5], [8, 0], [8, -1], [NaN, 5], [8, NaN]]) {
assert.equal(wallHatchNeedsSolid(step, px), false, `garbage in: ${step}, ${px}`);
}
});
test('issue 230 a thin wall is not turned into a blot by the new rule', () => {
// 3 cm on the reference grid is 2.5 units: fewer stripes than one. Whether it
// is filled or hatched stays the business of the thin-BODY guard, exactly as
// before — the new step guard must have no opinion about it.
const thin = wallCmToUnits(3, 5, GRID_PITCH);
const step = wallHatchStepUnits(5);
for (const px of [1, 1.2, 2, 5]) {
assert.equal(wallHatchNeedsSolid(step, px), false, `step guard fired at px=${px}`);
}
assert.equal(wallBodyNeedsSolid(thin, 1), true, 'body guard still owns the thin case');
assert.equal(wallBodyNeedsSolid(thin, 1.2), false, 'and lets it hatch once it is wide enough');
});
// --------------------- #233: внутренние размеры при ресайзе -----------------
const rect = (w, h) => [[0, 0], [w, 0], [w, h], [0, h]];
test('innerEdgeSpan measures between wall faces, not centrelines (#233)', () => {
// AC1: осевой пролёт 300 и стены 15 см дают 285 — то, что человек измерит
// рулеткой. В единицах: половинная глубина 7.5 при пролёте 300.
const poly = rect(300, 400);
const o = poly.map(() => 7.5);
assert.equal(innerEdgeSpan(poly, 0, o), 285);
assert.equal(innerEdgeSpan(poly, 1, o), 385);
// AC2: разные толщины на концах сокращают по-своему.
const mixed = [7.5, 15, 7.5, 15];
assert.equal(innerEdgeSpan(poly, 0, mixed), 300 - 15 - 15);
assert.equal(innerEdgeSpan(poly, 2, mixed), 300 - 15 - 15);
});
test('innerEdgeSpan keeps the centreline where there is no wall (#233)', () => {
const poly = rect(300, 400);
// AC3: нулевые толщины — внутренний размер равен осевому.
assert.equal(innerEdgeSpan(poly, 0, poly.map(() => 0)), 300);
// AC4: сосед без толщины не сокращает конец.
assert.equal(innerEdgeSpan(poly, 0, [7.5, 0, 7.5, 7.5]), 300 - 7.5);
// AC6a: сама сторона — проём, соседи-стены: полная осевая длина.
assert.equal(innerEdgeSpan(poly, 0, [0, 7.5, 7.5, 7.5]), 300);
});
test('innerEdgeSpan handles a diagonal edge and degenerate input (#233)', () => {
// AC5: на диагонали сокращение не равно простому o + o.
const tri = [[0, 0], [100, 0], [0, 100]];
const o = [5, 5, 5];
const diagonal = innerEdgeSpan(tri, 1, o);
const naive = Math.hypot(100, 100) - 10;
assert.ok(diagonal > 0 && Math.abs(diagonal - naive) > 1,
`диагональ ${diagonal} не должна совпадать с наивным ${naive}`);
// AC6: стены толще комнаты — ноль, а не отрицательное число.
assert.equal(innerEdgeSpan(rect(20, 20), 0, [30, 30, 30, 30]), 0);
// Мусор на входе не роняет функцию.
assert.equal(innerEdgeSpan([[0, 0], [1, 0]], 0, [1, 1]), 0);
assert.equal(innerEdgeSpan(rect(10, 10), 0, [1, 1, 1]), 0);
});
test('ownEdgeOffsets reads the atomic profile, not a whole-edge lookup (#233)', () => {
// AC6b, находка H2: толщина записана только на ЧАСТЬ ребра. Именно на таком
// ребре `thicknessCmAt` по целому ребру возвращает 0 — наивный источник
// толщин молча перестал бы сокращать подпись, и тест обязан это различать
// (M1 код-ревью r1: прежняя редакция задавала толщину на всё ребро, где оба
// источника отвечают одинаково, и наивную реализацию пропускала).
const rooms = [{ id: 'r', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] }];
const split = setWallThickness([], [0, 0], [0.6, 0], 20, pitch);
assert.equal(thicknessCmAt(split, [0, 0], [1, 0], pitch), 0,
'запрос по целому ребру против частичной толщины даёт 0 — это и есть H2');
assert.equal(thicknessCmAt(split, [0, 0], [0.6, 0], pitch), 20,
'а по самому участку толщина находится');
// Полигон здесь в нормализованных координатах, поэтому coordScale = 1:
// с NORM_W допуск поиска ключа стал бы больше самой комнаты и толщина
// «протекла» бы на противоположное ребро — проверено прогоном.
const halfDepth = wallCmToUnits(20, 5, GRID_PITCH) / 2;
const viaProfile = ownEdgeOffsets(rooms, 'r', split, [], pitch, 5, GRID_PITCH, 1);
assert.equal(viaProfile.length, 4, 'по одному числу на ребро своего полигона');
assert.equal(viaProfile[0], halfDepth,
'участок под серединой ребра отдаёт половинную глубину, а не 0');
assert.deepEqual(viaProfile.slice(1), [0, 0, 0], 'рёбра без записи остаются нулевыми');
// Разница с наивным источником именно здесь: 0 против половинной глубины.
// Дальше эту разницу видит `innerEdgeSpan` — с нулём сокращения не будет
// вовсе (AC3), с профилем соседнее ребро сократится (AC1, отдельные тесты
// выше; смешивать здесь нельзя — полигон нормализован, а профиль в единицах
// рендера).
assert.notEqual(viaProfile[0], thicknessCmAt(split, [0, 0], [1, 0], pitch));
// Неразрезанное ребро: тот же ответ, что у канонического источника профиля.
const whole = setWallThickness([], [0, 0], [1, 0], 20, pitch);
assert.deepEqual(
ownEdgeOffsets(rooms, 'r', whole, [], pitch, 5, GRID_PITCH, 1),
insetOffsetsForRoom(rooms, 'r', whole, [], pitch, 5, GRID_PITCH, 1),
);
// Комната без стен: все нули — вызывающий покажет осевую длину.
assert.deepEqual(ownEdgeOffsets(rooms, 'r', [], [], pitch, 5, GRID_PITCH, 1), [0, 0, 0, 0]);
assert.equal(ownEdgeOffsets(rooms, 'missing', whole, [], pitch, 5, GRID_PITCH, 1), null);
});
// --- issue #302: junction node corners --------------------------------------
const nodeMapOf = (intervals, eps = 1e-6) => buildMultiWallNodeMap(intervals, eps, 1);
const starIntervals = (arms) => arms.flatMap(({ deg, half, len = 300 }, index) => {
const rad = (deg * Math.PI) / 180;
const b = [500 + Math.cos(rad) * len, 500 + Math.sin(rad) * len];
return [{
key: `arm-${index}`, a: [500, 500], b, half, cm: half * 2,
kind: 'outer', open: false, roomId: `r${index}`,
}];
});
test('issue 302 a T node covers every sector with a fan or a mitre', () => {
const map = nodeMapOf(starIntervals([
{ deg: 0, half: 5 }, { deg: 90, half: 5 }, { deg: 180, half: 5 },
]));
assert.equal(map.nodes.length, 1);
const { fans, supports } = junctionNodeGeometry(map);
assert.equal(supports.length, 3, 'one quad per support');
// Sectors: [0..90], [90..180] (two quarter fans) and [180..360] reflex —
// skipped: the outside of the bar corner is legitimately empty.
assert.equal(fans.length, 2);
});
test('issue 302 a reflex sector closes with the outer mitre', () => {
const map = nodeMapOf(starIntervals([
{ deg: 0, half: 5 }, { deg: 30, half: 5 }, { deg: 100, half: 5 },
]));
const { fans } = junctionNodeGeometry(map);
// [0..30], [30..100] and the reflex [100..360]: the outer corner between
// the extreme rays closes with a BACKWARD mitre — the Y-60 notch of the
// owner's report lived exactly in a skipped reflex sector.
assert.equal(fans.length, 3);
// The reflex fan must be the four-point mitre, not the flat chord fallback:
// its apex sits deeper than either strip edge.
const node0 = map.nodes[0];
const reflexFan = fans.find((poly) => poly.some((point) => {
const dx = point[0] - node0.point[0], dy = point[1] - node0.point[1];
const deg = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
// The apex of the backward mitre: strictly inside the reflex sector and
// deeper than a strip edge (a chord fallback never leaves the edges).
return deg > 110 && deg < 350 && Math.hypot(dx, dy) > node0.halfDepth * 1.05;
}));
assert.ok(reflexFan, 'the reflex sector fell back to the chord — no outer mitre');
const node = map.nodes[0];
for (const fan of fans) {
for (const point of fan) {
assert.ok(
Math.hypot(point[0] - node.point[0], point[1] - node.point[1])
<= MITRE_LIMIT * node.halfDepth + 1e-7,
`fan escaped the classic corner bound: ${point}`,
);
}
}
});
test('issue 302 a mitre past the short thick support degrades to a bevel', () => {
// The mitre point lies inside the node limit but PAST the 2-unit thick
// support of the 0° ray: accepting it would paint a lateral phantom beside
// the thin continuation (#271), so the pair must fall back to a bevel.
const map = nodeMapOf([
...starIntervals([{ deg: 90, half: 2.5 }, { deg: 210, half: 2.5 }]),
{ key: 'short-thick', a: [500, 500], b: [502, 500], half: 10, cm: 20,
kind: 'outer', open: false, roomId: 'rt' },
]);
assert.equal(map.nodes.length, 1);
const { fans } = junctionNodeGeometry(map);
// The [0°..90°] fan: mitre would sit at (502.5, 510) — inside the limit
// (10.3 < 12.5) but past the thick support (t = 2.5 > 2) → five-point bevel.
const fan = fans.find((poly) => poly.length === 5
&& poly.some((point) => point[1] > 505));
assert.ok(fan, 'the mitre past the thick support was accepted instead of a bevel');
const bound = MITRE_LIMIT * Math.max(10, 2.5);
for (const point of fan) {
assert.ok(
Math.hypot(point[0] - 500, point[1] - 500) <= bound + 1e-7,
`fan escaped the classic corner bound: ${point}`,
);
}
});
test('issue 302 the hole detector is not blind: a deliberately holed body is red', () => {
const map = nodeMapOf(starIntervals([
{ deg: 0, half: 5 }, { deg: 120, half: 5 }, { deg: 240, half: 5 },
]));
// A body that covers only two of the three strips: the third is a hole.
const quad = (deg, half, len) => {
const rad = (deg * Math.PI) / 180;
const u = [Math.cos(rad), Math.sin(rad)];
const e = [-u[1] * half, u[0] * half];
return [[
[500 + e[0], 500 + e[1]],
[500 + u[0] * len + e[0], 500 + u[1] * len + e[1]],
[500 + u[0] * len - e[0], 500 + u[1] * len - e[1]],
[500 - e[0], 500 - e[1]],
[500 + e[0], 500 + e[1]],
]];
};
const holed = [quad(0, 5, 300), quad(120, 5, 300)];
const reports = junctionContractHoles(holed, map, { step: 2 });
assert.equal(reports.length, 1, 'the detector missed the missing strip');
assert.ok(reports[0].holes.length > 10);
const full = [quad(0, 5, 300), quad(120, 5, 300), quad(240, 5, 300),
...junctionNodeGeometry(map).fans.map((fan) => [[...fan, fan[0]]])];
assert.equal(junctionContractHoles(full, map, { step: 2 }).length, 0,
'the detector flags a body that covers the whole contract');
});
test('issue 302 the owner repro is hole-free end to end', () => {
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/302-junction-artifacts.json', import.meta.url), 'utf8',
));
const rooms = fixture.rooms.map((room) => ({
...room, poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
}));
const geometry = wallBodiesGeometry(
rooms, fixture.walls, [], [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
);
assert.equal(geometry.status, 'ok');
const map = buildMultiWallNodeMap(
wallIntervals(rooms, fixture.walls, [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W)
.filter((iv) => !iv.open && iv.half > 0),
pitch * NORM_W * 0.04 * 4, NORM_W,
);
assert.ok(map.nodes.length >= 2, 'the repro lost its multi-wall nodes');
const bound = junctionNodeBound(
rooms, fixture.walls, [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W, map,
);
const reports = junctionContractHoles(geometry.geom, map, {
step: GRID_PITCH * 0.2, bound,
});
assert.deepEqual(
reports.map((report) => ({ node: report.node, holes: report.holes.length })),
[],
'the владелец repro still has junction holes',
);
});
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 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 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 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 patches = linearWallJoinPatches(segments, 0.2);
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', () => {
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',
);
});
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)}]`);
}
}
}
});
test('issue 310 a short deep support pair bounds the wedge by its own length and stays hole-free', () => {
// ТЗ §5 risk 3: interference of the two addressed trims. Structurally the
// #271 lateral trim lives in the multi-wall node map (3+ canonical rays)
// and can never fire on a pair node; what CAN go wrong on a short deep
// support is the #310 wedge reaching past the wall's own far end. Both are
// pinned here: the wedge is bounded by min(2·halfDepth, length), and the
// grid contract stays clean on the short-support pair.
const segments = [
{ a: [0, 0], b: [300, 0], halfDepth: 10 },
// deep wall SHORTER than 2·halfDepth: length 30 < 2·20
{ a: [0, 0], b: [30, 3], halfDepth: 20 },
];
const wedges = pairButtEndTrimWedges(segments, 1e-6);
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 <= Math.min(2 * segment.halfDepth, length) + 1e-6,
`wedge vertex ${point} reaches past the wall's own end`);
}
}
// grid contract on the short-support pair (same oracle as the pair contract test)
const bodies = segments.map((segment) => linearWallBody(segment));
const patches = linearWallJoinPatches(segments, 1e-6);
const h = Math.max(...segments.map((segment) => segment.halfDepth));
const step = Math.max(h / 4, 2);
for (let dx = -3 * h; dx <= 3 * h; dx += step) {
for (let dy = -3 * h; dy <= 3 * h; dy += step) {
const point = [dx, dy];
const inStrip = bodies.some((body) => body && pointInPoly(point, body));
const inPatch = patches.some((patch) => pointInPoly(point, patch));
const inWedge = wedges.some(({ wedge }) => pointInPoly(point, wedge));
const expected = (inStrip || inPatch) && !inWedge;
const actual = bodies.some((body, index) => body && pointInPoly(point, body)
&& !wedges.some(({ segmentIndex, wedge }) =>
segmentIndex === index && pointInPoly(point, wedge)))
|| inPatch;
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, `short-support contract mismatch at [${dx}, ${dy}]`);
}
}
});