feat: join independent wall junctions

Issue: #141
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-14 12:11:08 +00:00
committed by claude[bot]
parent b860ef4c43
commit 087f7cf381
22 changed files with 1131 additions and 433 deletions
+24 -1
View File
@@ -66,6 +66,12 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
assert.equal(scenario.mode, 'plan', scenario.id);
assert.equal(scenario.capture, 'page', scenario.id);
}
if (scenario.wallJunctionPreview) {
assert.equal(scenario.wallJunctions, true, scenario.id);
assert.equal(scenario.mode, 'plan', scenario.id);
assert.equal(scenario.capture, 'page', scenario.id);
assert.ok(scenario.wallJunctionPreview.path.length >= 1, scenario.id);
}
}
});
@@ -78,7 +84,7 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
'backup-full', 'backup-space', 'value-badge-positions', 'isometric-geometry',
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
'isometric-large-warm-remount', 'split-corner-wall', 'plan-snap-endpoint',
'plan-snap-line-gaps'])
'plan-snap-line-gaps', 'wall-junctions', 'isometric-wall-junctions'])
assert.equal(ids.includes(token), true, token);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('plan'), true);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('devices'), true);
@@ -87,6 +93,23 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.theme)).has('dark'), true);
});
test('wall junction goldens cover live L/T previews plus saved flat and isometric bodies', () => {
const scenarios = GOLDEN_SCENARIOS.filter((scenario) => scenario.wallJunctions);
assert.deepEqual(scenarios.map((scenario) => scenario.id), [
'wall-junctions-plan-preview-light',
'wall-junctions-plan-t-dark',
'wall-junctions-view-dark',
'isometric-wall-junctions-dark',
]);
for (const scenario of scenarios) {
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(space.partitions.length, 7);
assert.equal(space.room_drafts[0].segments.length, 2);
assert.ok(space.partitions.some((item) => item.b[1] === 0.94), 'room-wall T fixture');
}
});
test('corner Split golden captures before, thin and thick facade states', () => {
const scenarios = GOLDEN_SCENARIOS.filter((scenario) => scenario.cornerSplitWall);
assert.deepEqual(scenarios.map((scenario) => scenario.cornerSplitWall), ['before', 'thin', 'thick']);
+46 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import {
canonicalColumnAngle, columnBody, floorMinusBodies, geometryArea,
directionalOccluders, intersectionPaths, partitionBody, pointInPhysicalBody,
pointInOpaquePlanBody, pointInPhysicalGeometry,
physicalBodySet, pointInOpaquePlanBody, pointInPhysicalGeometry,
sameColumnPlacement,
} from '../test-build/physical-geometry.js';
import {
@@ -21,6 +21,51 @@ test('partition body keeps the centreline and requested physical width', () => {
closeTo(geometryArea([[[...body, body[0]]]]), 0.5);
});
test('joined partitions fill straight and oblique endpoint teeth without changing flat free caps', () => {
const base = {
room_drafts: [], wall_columns: [],
partitions: [
{ id: 'horizontal', a: [-2, 0], b: [0, 0], cm: 10 },
{ id: 'vertical', a: [0, 0], b: [0, 2], cm: 10 },
{ id: 'oblique', a: [4, 2], b: [3, 0], cm: 20 },
{ id: 'oblique-arm', a: [3, 0], b: [5, -1], cm: 10 },
],
};
const frame = physicalBodySet(base, 5, 0.25);
assert.ok(frame.patches.length >= 2, 'each non-collinear endpoint node gains a bounded patch');
assert.equal(pointInPhysicalGeometry([0.2, -0.2], frame.geometry), true,
'the missing outer quadrant at the right angle is solid');
assert.equal(pointInPhysicalGeometry([5.3, -1.15], frame.geometry), false,
'an unrelated flat free cap is not extended');
const reversed = physicalBodySet({
...base,
partitions: [...base.partitions].reverse().map((segment) => ({
...segment, a: segment.b, b: segment.a,
})),
}, 5, 0.25);
closeTo(geometryArea(frame.geometry), geometryArea(reversed.geometry), 1e-8);
});
test('endpoint-on-line T join is computed without splitting or mutating source records', () => {
const space = {
room_drafts: [{
id: 'draft-branch', points: [[1, -2], [1, 0]], segments: [{ cm: 15 }],
}],
wall_columns: [],
partitions: [
{ id: 'through', a: [-2, 0], b: [2, 0], cm: 20 },
{ id: 'branch', a: [0, -2], b: [0, 0], cm: 10 },
],
};
const before = JSON.stringify(space);
const frame = physicalBodySet(space, 5, 0.25);
assert.ok(frame.patches.length >= 2, 'partition and saved-draft branches share the T primitive');
assert.equal(pointInPhysicalGeometry([0.2, -0.1], frame.geometry), true);
assert.equal(pointInPhysicalGeometry([1.2, -0.1], frame.geometry), true);
assert.equal(JSON.stringify(space), before, 'computed node topology is render-only');
});
test('column size means square side or circle diameter', () => {
const square = columnBody(
{ id: 'sq', shape: 'square', center: [1, 1], cm: 20, angle: 45 }, 5, 0.25,
+30 -1
View File
@@ -4,7 +4,8 @@ import assert from 'node:assert/strict';
import {
wallKey, lookupWall, thicknessCmAt, degradeWalls, rekeyWallsAfterMove,
setWallThickness, setWallThicknessForRoom, applyWallThicknessToNewRoom,
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, clampWallCm, cmToField, fieldToCm,
drawWallPreviewD, linearWallBody, linearWallJoinPatches,
DRAW_WALL_DEFAULT_CM, clampWallCm, cmToField, fieldToCm,
wallCmToUnits, insetContour, inwardNormal, edgeKinds, wallEdgeBodies,
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, floorFootprintGeometry,
innerContourForRoom,
@@ -970,7 +971,35 @@ test('split materialisation cuts a partial shared interval at the new divider',
test('drawWallPreviewD returns a path for open and closed outlines', () => {
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);
assert.equal(acute[0].length, 3, 'a mitre beyond the limit becomes a bevel triangle');
assert.ok(acute[0].every((point) => Math.hypot(point[0], point[1]) <= MITRE_LIMIT));
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');
});