fix: isolate wall union failures and guard geometry writes

Issue: #278
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 08:48:37 +03:00
parent 930a911a36
commit 8156f80140
31 changed files with 2101 additions and 1119 deletions
+56
View File
@@ -0,0 +1,56 @@
{
"provenance": "Minimized and anonymized from the private #278 wall-union regression export; user names, ids, markers, layout and unrelated geometry were removed.",
"config": {
"model_version": 7,
"spaces": [
{
"id": "wall-union-regression",
"title": "Wall union regression",
"cell_cm": 1,
"view_box": [0, 0, 1, 1],
"rooms": [
{
"id": "r1",
"poly": [
[-1.670833333, 1.266666667],
[-1.670833333, -0.208333333],
[0.2875, -0.208333333],
[0.2875, 1.266666667]
]
},
{
"id": "r2",
"poly": [
[2.404166667, -0.208333333],
[2.404166667, 1.266666667],
[0.2875, 1.266666667],
[0.2875, -0.208333333]
]
}
],
"walls": [
{
"key": "-0.691667,-0.208333@0.0000",
"cm": 30,
"a": [-1.670833333, -0.208333333],
"b": [0.2875, -0.208333333]
},
{
"key": "1.345833,-0.208333@0.0000",
"cm": 30,
"a": [0.2875, -0.208333333],
"b": [2.404166667, -0.208333333]
},
{
"key": "0.287500,0.529167@1.5706",
"cm": 20,
"a": [0.2875, -0.208333333],
"b": [0.2875, 1.266666667]
}
]
}
],
"markers": [],
"settings": {}
}
}
+13 -1
View File
@@ -352,7 +352,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 43);
assert.equal(GOLDEN_MATRIX_VERSION, 44);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -430,6 +430,18 @@ test('issue #197 golden keeps the complete junction fixture in Plan and View', (
}
});
test('issue #278 golden keeps degraded wall components in both themes', () => {
const scenarios = GOLDEN_SCENARIOS.filter((item) => item.wallUnionIsolation);
assert.deepEqual(scenarios.map((item) => item.theme).sort(), ['dark', 'light']);
for (const scenario of scenarios) {
assert.equal(scenario.mode, 'view');
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.ok(space);
assert.deepEqual([space.rooms.length, space.walls.length], [2, 3]);
}
});
test('issue #249 golden isolates a bounded physical three-ray bevel', () => {
const scenario = GOLDEN_SCENARIOS.find(
(item) => item.id === 'multiwall-junction-bevel-view-dark',
+30
View File
@@ -2,8 +2,10 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
checkSpacePhysicalGeometry,
checkOptimizeGeometry,
prepareSpacePhysicalGeometryInputs,
spacePhysicalGeometryFingerprint,
} from '../test-build/plan-geometry-preflight.js';
import { GRID_STEP_N, spaceModels } from '../test-build/space-geometry.js';
import { contentFingerprint } from '../test-build/visual-continuity.js';
@@ -139,6 +141,34 @@ test('null, exceptions and floor failure are bounded while successful empty geom
assert.doesNotMatch(JSON.stringify(prepareThrows), /private preparation detail/);
});
test('#278 strict one-space barrier rejects degraded render-safe geometry', () => {
const config = base([{
id: 'strict', title: 'Strict', view_box: [0, 0, 1, 1],
rooms: [room()], walls: [wall()],
}]);
const degraded = checkSpacePhysicalGeometry(config, 'strict', {
wallPass: () => ({
status: 'degraded-extra', geom: [], components: [], roomGeom: [], paperGeom: [],
depthUnits: 0, openingIndex: null, degradedExtraCount: 1,
}),
});
assert.equal(degraded.ok, false);
assert.equal(degraded.reason, 'wall-degraded-extra');
assert.equal(degraded.fingerprint, spacePhysicalGeometryFingerprint(config.spaces[0]));
const failedCore = checkSpacePhysicalGeometry(config, 'strict', {
wallPass: () => ({
status: 'failed-core', geom: [], components: [], roomGeom: [], paperGeom: [],
depthUnits: 0, openingIndex: null, degradedExtraCount: 0,
}),
});
assert.equal(failedCore.reason, 'wall-failed-core');
const missing = checkSpacePhysicalGeometry(config, 'missing');
assert.equal(missing.ok, false);
assert.equal(missing.reason, 'prepare-exception');
});
test('one failed space blocks the ordered whole-plan result and uses safe display fallbacks', () => {
const config = base([
{ id: 'good', title: ' Good floor ', view_box: [0, 0, 1, 1], rooms: [] },
+53
View File
@@ -80,6 +80,59 @@ const assertProbeInside = (geom, point, message) =>
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');
});
const enclosedLocalHoleRings = (geometry, node) => {
const radius = MITRE_LIMIT * node.halfDepth + 1e-6;
return (geometry || []).flatMap((polygon) => (polygon || []).slice(1)).filter((ring) =>
+118
View File
@@ -0,0 +1,118 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
checkOptimizeGeometry,
prepareSpacePhysicalGeometryInputs,
spacePhysicalGeometryFingerprint,
} from '../test-build/plan-geometry-preflight.js';
import { spaceModels } from '../test-build/space-geometry.js';
import { wallBodiesGeometry, wallBodiesUnionPath } from '../test-build/wall-thickness.js';
import { checkPhysicalGeometry } from '../scripts/model-invariants.mjs';
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
));
const prepare = (config) => {
const raw = config.spaces[0];
const model = spaceModels(config)[0];
return prepareSpacePhysicalGeometryInputs(raw, model);
};
const build = (input) => wallBodiesGeometry(
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
input.physicalBodies,
);
test('#278 anonymized regression degrades one boolean merge instead of losing all masonry', () => {
assert.match(fixture.provenance, /Minimized and anonymized/);
assert.doesNotMatch(JSON.stringify(fixture), /Дет|Кабин|Холл|этаж/i);
const input = prepare(fixture.config);
const geometry = build(input);
assert.equal(geometry.status, 'degraded-extra');
assert.equal(geometry.degradedExtraCount, 1);
assert.equal(geometry.components.length, 2);
assert.ok(geometry.components.every((component) => component.geom.length > 0));
const projected = wallBodiesUnionPath(
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
input.physicalBodies,
);
assert.ok(projected, 'render-safe projection remains drawable');
assert.equal(projected.status, 'degraded-extra');
assert.equal(projected.paths.length, 2);
assert.ok(projected.paths.every((component) => component.d.length > 20));
});
test('#278 component set is deterministic under room and wall permutations', () => {
const variants = [
fixture.config,
{ ...fixture.config, spaces: [{
...fixture.config.spaces[0],
rooms: [...fixture.config.spaces[0].rooms].reverse(),
walls: [...fixture.config.spaces[0].walls].reverse(),
}] },
];
const projections = variants.map((config) => {
const input = prepare(config);
return wallBodiesUnionPath(
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
input.physicalBodies,
);
});
assert.ok(projections.every((projection) => projection?.status === 'degraded-extra'));
assert.deepEqual(
projections[0].paths.map((component) => component.d).sort(),
projections[1].paths.map((component) => component.d).sort(),
);
});
test('#278 Optimize and model-invariants use the same strict structural result', () => {
const preflight = checkOptimizeGeometry(fixture.config);
assert.equal(preflight.ok, false);
assert.equal(preflight.failures[0].reason, 'wall-degraded-extra');
const violations = checkPhysicalGeometry(fixture.config);
assert.deepEqual(violations, [{
invariant: 'physical_geometry', kind: 'physical_geometry', owner: 'space[1]',
reference: 'wall-degraded-extra',
detail: 'canonical wall geometry is not safe for a write',
}]);
assert.doesNotMatch(JSON.stringify(violations), /Wall union regression|r1|r2/);
});
test('#278 physical fingerprint ignores decor but covers every strict writer field', () => {
const raw = fixture.config.spaces[0];
const baseline = spacePhysicalGeometryFingerprint(raw);
assert.equal(spacePhysicalGeometryFingerprint({ ...raw, title: 'Else', decor: [{ id: 'd' }] }), baseline);
for (const field of [
'rooms', 'walls', 'open_spans', 'openings', 'partitions', 'room_drafts', 'wall_columns',
]) {
const changed = { ...raw, [field]: [...(raw[field] || []), { id: `changed-${field}` }] };
assert.notEqual(spacePhysicalGeometryFingerprint(changed), baseline, field);
}
});
test('#278 production source routes physical writers through one barrier and decor around it', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
for (const historyKey of [
'draft_segment', 'wall_chain_finish', 'column_add', 'physical_edit', 'physical_delete',
'physical_move', 'resize_room', 'close_boundary', 'open_boundary', 'wall_thickness',
'move_opening', 'delete_opening', 'merge_rooms', 'contour_to_partitions',
]) {
assert.match(source, new RegExp(`_commitPhysicalGeometry\\(this\\._t\\('history\\.${historyKey}'`), historyKey);
}
assert.match(source, /_commitPhysicalGeometry\([\s\S]{0,160}history\.edit_opening/);
assert.match(source, /_commitPhysicalGeometry\([\s\S]{0,160}history\.split_room/);
assert.match(source, /_recordGeometry\(this\._t\('history\.decor_edit'/);
assert.doesNotMatch(source, /_commitPhysicalGeometry\(this\._t\('history\.decor_/);
assert.match(source, /this\._checkSpacePhysicalGeometry\(candidate, preview\.space\)/);
assert.match(source, /if \(physicalChanged\)[\s\S]{0,900}_pendingPhysicalWrites\.set\(state\.spaceId/,
'physical Undo/Redo must retain the deferred-write barrier');
assert.match(source, /if \(configChanged\)[\s\S]{0,300}_pendingPhysicalWrites\.clear\(\)/,
'an external baseline must invalidate pending local approvals');
});