mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
3488 lines
150 KiB
JavaScript
3488 lines
150 KiB
JavaScript
// 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}]`);
|
||
}
|
||
}
|
||
});
|