mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
fix: isolate wall union failures and guard geometry writes
Issue: #278 User-Visible: yes
This commit is contained in:
+56
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user