fix(editor): preserve residual wall identity when creating room

Issue: #804
User-Visible: yes
This commit is contained in:
Matysh
2026-10-06 12:52:59 +03:00
parent de2029b7f9
commit b3f5aefa20
17 changed files with 845 additions and 26 deletions
+339
View File
@@ -0,0 +1,339 @@
/** #804: partial partition promotion through the real room dialog and config/set. */
import { launch, check, finish } from './serve.mjs';
const point = (x, y) => [x / 240, y / 240];
const partition = (id, x1, y1, x2, y2, cm) => ({
id, a: point(x1, y1), b: point(x2, y2), cm,
});
const room = (id, x1, y1, x2, y2) => ({
id, name: id, area: null,
poly: [point(x1, y1), point(x2, y1), point(x2, y2), point(x1, y2)],
});
// Only the reported lattice geometry survives here; no private export or HA data.
// In particular there is NO short overlay carrier on x=74, y=112..133.
function garderoba({ opening = null, duplicate = false } = {}) {
const rooms = [room('neighbour', 102, 112, 130, 133)];
rooms[0].wall_ids = ['neighbour-top', 'neighbour-right', 'neighbour-bottom', 'neighbour-left'];
const wallSegments = [
partition('neighbour-left', 102, 112, 102, 133, 15),
partition('neighbour-top', 102, 112, 130, 112, 15),
partition('neighbour-bottom', 102, 133, 130, 133, 15),
partition('neighbour-right', 130, 112, 130, 133, 15),
];
// Explicit current-model seed: standalone browser/mutation runs do not need
// an unrelated test-build compilation just to construct this rectangle.
const keys = ['0.425000,0.512500@1.5706', '0.483333,0.466667@0.0000',
'0.483333,0.554167@0.0000', '0.541667,0.512500@1.5706'];
const config = {
model_version: 10, markers: [], settings: {},
spaces: [{
id: 'lineage', title: 'Lineage', cell_cm: 8, view_box: [0.24, 0.34, 0.36, 0.52],
rooms, wall_segments: wallSegments,
walls: wallSegments.map(({ a, b, cm }, index) => ({ key: keys[index], cm, a, b })),
openings: [],
partitions: [
partition('long-source', 74, 90, 74, 200, 25),
partition('upper-source', 74, 112, 102, 112, 15),
partition('lower-source', 102, 133, 74, 133, 15),
],
}, {
id: 'other-floor', title: 'Other floor', cell_cm: 8,
view_box: [0, 0, 1, 1], rooms: [], wall_segments: [],
partitions: [partition('untouched-wall', 10, 10, 30, 10, 15)],
}],
};
const space = config.spaces[0];
if (opening) {
const y = opening === 'same-residual' ? 101 : opening === 'absorbed' ? 122 : 160;
space.openings = [{
id: 'retained-opening', type: 'window', x: 74 / 240, y: y / 240,
angle: 90, length: 6 / 240, cm: 48,
host: { kind: 'partition', id: 'long-source', t: (y - 90) / 110 },
contact: 'binary_sensor.synthetic_window', future_field: { keep: true },
}];
}
if (duplicate) space.wall_segments.push(structuredClone(space.wall_segments[0]));
return config;
}
function batchConfig() {
return {
model_version: 10, markers: [], settings: {},
spaces: [{
id: 'lineage', title: 'Lineage', cell_cm: 8, view_box: [0.24, 0.34, 0.30, 0.36],
rooms: [], wall_segments: [], openings: [],
partitions: [
partition('batch-source', 74, 90, 74, 160, 25),
partition('batch-top', 74, 112, 102, 112, 15),
partition('batch-divider', 74, 126, 102, 126, 15),
partition('batch-bottom', 74, 140, 102, 140, 15),
],
}],
};
}
const results = {};
let lastBrowser;
async function scenario(name, config, body) {
if (process.env.HP_LINEAGE_SCENARIO && !name.includes(process.env.HP_LINEAGE_SCENARIO)) return;
console.log(`SCENARIO ${name}`);
const { page, browser } = await launch({ width: 1280, height: 960 });
page.setDefaultTimeout(10000);
lastBrowser = browser;
try {
await page.evaluate(async (config) => {
await window.__hpTest.setServerConfig(config);
await window.__hpTest.setLayout({});
await window.__hpTest.switchSpace('lineage');
await window.__hpTest.setMode('plan');
await window.__hpTest.setTool('draw');
// Mode transition owns the stage briefly after the toolbar settles.
await new Promise((resolve) => setTimeout(resolve, 500));
await window.__hpTest.settled();
const card = window.__card;
const fallback = card.hass.callWS.bind(card.hass);
const state = {
saved: structuredClone(card._serverCfg), revision: card._cfgRev,
attempts: [], accepted: [], rejected: [], reads: 0,
};
// A public HA boundary, not a private editor override. The mock actually
// persists accepted writes and enforces the existing host-kind/id rule.
card.hass = { ...card.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') {
state.reads++;
return { config: structuredClone(state.saved), rev: state.revision, can_write: true };
}
if (message.type !== 'houseplan/config/set') return fallback(message);
const candidate = structuredClone(message.config);
state.attempts.push(candidate);
for (const oldSpace of state.saved.spaces || []) {
const nextSpace = candidate.spaces.find((item) => item.id === oldSpace.id);
for (const oldOpening of oldSpace.openings || []) {
if (oldOpening.host?.kind !== 'partition') continue;
const next = nextSpace?.openings?.find((item) => item.id === oldOpening.id);
if (next && (next.host?.kind !== 'partition' || next.host.id !== oldOpening.host.id)) {
state.rejected.push({ opening: oldOpening.id, code: 'invalid_format' });
throw Object.assign(new Error('surviving partition opening cannot change its host'), {
code: 'invalid_format',
});
}
}
}
state.saved = candidate;
state.accepted.push(candidate);
return { ok: true, rev: ++state.revision };
} };
window.__lineageSmoke = state;
}, config);
const output = await body(page);
results[name] = output;
for (const [key, value] of Object.entries(output)) check(`${name}: ${key}`, value);
} catch (error) {
console.error(`${name}: ${error.stack || error.message}`);
console.error('UI state', await page.evaluate(() => ({
mode: window.__card._mode, tool: window.__card._tool, space: window.__card._space,
path: window.__card._path, toast: window.__card._toast,
roomDialog: window.__card._roomDialog, view: window.__card._view,
dialogs: [...window.__card.renderRoot.querySelectorAll('[data-hp="dialog"]')].map((item) => item.outerHTML.slice(0, 400)),
})));
results[name] = { exception: error.message };
check(`${name}: scenario completed`, false);
} finally {
await browser.close();
}
}
async function clickPoint(page, x, y) {
const screen = await page.evaluate(([x, y]) => {
const rect = window.__card.renderRoot.querySelector('.stage').getBoundingClientRect();
const view = window.__card._view;
return {
x: rect.left + (x / 240 * 1000 - view.x) / view.w * rect.width,
y: rect.top + (y / 240 * 1000 - view.y) / view.h * rect.height,
};
}, [x, y]);
await page.mouse.click(screen.x, screen.y);
await page.evaluate(() => window.__hpTest.settled());
}
async function decide(page, name, keep = false) {
const dialog = page.locator('[data-hp="dialog"][data-kind="room"]');
await dialog.waitFor({ state: 'visible' });
if (keep) {
await dialog.locator('[data-hp="dialog-confirm"]').filter({ hasText: 'Keep as walls' }).click();
} else {
await dialog.locator('#room-name').fill(name);
await dialog.locator('.dialog-action-commit [data-hp="dialog-confirm"]').click();
}
await page.evaluate(() => window.__hpTest.settled());
}
async function settleWrites(page) {
// The public save path is debounced. Waiting also proves a rejected gesture
// did not merely postpone a write until after an immediate assertion.
await page.waitForTimeout(800);
await page.waitForFunction(() => window.__card._writesPending === 0);
await page.evaluate(() => window.__hpTest.settled());
}
async function snapshot(page) {
return page.evaluate(() => ({
config: structuredClone(window.__card._serverCfg),
history: window.__card._geometryHistory.size,
toast: window.__card._toast,
server: structuredClone(window.__lineageSmoke),
pendingRooms: window.__card._wallFaceBatch?.candidates.length || 0,
}));
}
const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const geometry = (config) => config.spaces.map((space) => ({
id: space.id, rooms: space.rooms || [], wall_segments: space.wall_segments || [],
walls: space.walls || [], partitions: space.partitions || [], openings: space.openings || [],
}));
const uniqueIds = (space) => {
const segments = [...space.wall_segments || [], ...space.partitions || []];
const ids = segments.map((item) => item.id);
const catalog = new Set((space.wall_segments || []).map((item) => item.id));
return new Set(ids).size === ids.length
&& (space.rooms || []).every((item) => item.wall_ids?.length === item.poly.length
&& item.wall_ids.every((id) => catalog.has(id)));
};
await scenario('Garderoba without workaround', garderoba(), async (page) => {
const before = await snapshot(page);
await clickPoint(page, 88, 122);
await decide(page, 'Garderoba');
await settleWrites(page);
const after = await snapshot(page);
const space = after.config.spaces[0];
const created = space.rooms.find((item) => item.name === 'Garderoba');
const residuals = (space.partitions || []).filter((item) => (
Math.abs(item.a[0] * 240 - 74) < 1e-6 && Math.abs(item.b[0] * 240 - 74) < 1e-6
));
const out = {
roomCreated: !!created && space.rooms.length === before.config.spaces[0].rooms.length + 1,
actualConfigSaved: after.server.accepted.length === before.server.accepted.length + 1
&& !!created && equal(geometry(after.server.saved), geometry(after.config)),
twoOriginalContinuations: residuals.length === 2
&& residuals.every((item) => item.cm === 25)
&& equal(residuals.map((item) => [item.a[1] * 240, item.b[1] * 240].map(Math.round)).sort((a, b) => a[0] - b[0]), [[90, 112], [133, 200]]),
roomThicknessPreserved: !!created && equal(created.wall_ids.map((id) => space.wall_segments.find((item) => item.id === id).cm).sort((a, b) => a - b), [15, 15, 15, 25]),
neighboursAndOtherFloorUnchanged: equal(space.rooms.find((item) => item.id === 'neighbour'), before.config.spaces[0].rooms[0])
&& equal(after.config.spaces[1], before.config.spaces[1]),
identitiesValid: uniqueIds(space),
oneHistoryCommand: after.history === before.history + 1,
noMigrationError: !String(after.toast).includes('wall identifiers'),
};
if (!created) return out;
await page.keyboard.press('Escape');
await page.keyboard.press('Control+z');
await settleWrites(page);
const undone = await snapshot(page);
out.undoRestoresSource = equal(geometry(undone.config), geometry(before.config));
await page.keyboard.press('Control+Shift+z');
await settleWrites(page);
const redone = await snapshot(page);
out.redoRestoresExactIds = equal(geometry(redone.config), geometry(after.config));
// A distinct card reads the mock server via the real config/get lifecycle.
await page.evaluate(async () => {
const old = window.__card;
const next = document.createElement('houseplan-card');
next.setConfig({ type: 'custom:houseplan-card' });
next.hass = old.hass;
old.replaceWith(next);
window.__card = next;
});
await page.waitForFunction(() => window.__card._serverCfg?.spaces?.[0]?.rooms?.length === 2);
const reloaded = await snapshot(page);
out.reloadReadsDurableIdentity = reloaded.server.reads > 0
&& equal(geometry(reloaded.config), geometry(after.config));
return out;
});
for (const ending of ['save', 'keep', 'cancel', 'escape']) {
await scenario(`batch ${ending}`, batchConfig(), async (page) => {
await clickPoint(page, 102, 112);
await clickPoint(page, 102, 140);
await settleWrites(page);
const before = await snapshot(page);
await decide(page, 'First');
const pending = await snapshot(page);
const out = {
twoFacesOffered: before.pendingRooms === 2,
firstDecisionDoesNotWritePartialRoom: pending.config.spaces[0].rooms.length === 0
&& pending.server.accepted.length === before.server.accepted.length,
};
if (ending === 'save') await decide(page, 'Second');
else if (ending === 'keep') await decide(page, '', true);
else {
await page.evaluate(async (via) => {
const dialog = window.__card.renderRoot.querySelector('[data-hp="dialog"][data-kind="room"]');
const outcome = await window.__hpTest.close(dialog, { via });
if (outcome.confirm) outcome.confirm.querySelector('[data-hp="dialog-confirm"]').click();
}, ending === 'escape' ? 'escape' : 'cancel');
}
await settleWrites(page);
const after = await snapshot(page);
const space = after.config.spaces[0];
if (ending === 'save' || ending === 'keep') {
const expected = ending === 'save' ? 2 : 1;
out.onlyAcceptedRoomsCreated = space.rooms.length === expected;
out.oneAtomicHistoryAndSave = after.history === before.history + 1
&& after.server.accepted.length === before.server.accepted.length + 1;
out.identitiesValid = uniqueIds(space);
if (ending === 'save' && space.rooms.length === 2) {
const shared = space.rooms[0].wall_ids.filter((id) => space.rooms[1].wall_ids.includes(id));
out.oneSharedPhysicalAtom = shared.length === 1
&& space.wall_segments.filter((item) => item.id === shared[0]).length === 1;
}
} else {
out.cancelDiscardsEarlierDecision = equal(geometry(after.config), geometry(before.config))
&& after.history === before.history
&& after.server.accepted.length === before.server.accepted.length;
}
return out;
});
}
await scenario('true duplicate rollback', garderoba({ duplicate: true }), async (page) => {
const before = await snapshot(page);
await clickPoint(page, 88, 122);
await decide(page, 'Must not exist');
await settleWrites(page);
const after = await snapshot(page);
return {
duplicateStillRejected: String(after.toast).includes('wall identifiers'),
noPartialMutation: equal(geometry(after.config), geometry(before.config)),
noHistoryOrServerWrite: after.history === before.history && after.server.attempts.length === 0,
};
});
for (const opening of ['same-residual', 'absorbed', 'new-residual']) {
await scenario(`opening ${opening}`, garderoba({ opening }), async (page) => {
const before = await snapshot(page);
await clickPoint(page, 88, 122);
await decide(page, 'Opening room');
await settleWrites(page);
const after = await snapshot(page);
if (opening !== 'same-residual') return {
actualBackendRejection: after.server.attempts.length === 1 && after.server.rejected.length === 1,
serverRetainsOriginal: after.server.accepted.length === 0 && equal(after.server.saved, before.server.saved),
optimisticGeometryRolledBack: equal(geometry(after.config), geometry(before.config)),
noRejectedHistory: after.history === before.history,
};
const oldOpening = before.config.spaces[0].openings[0];
const newOpening = after.config.spaces[0].openings[0];
const { host: _oldHost, ...oldFields } = oldOpening;
const { host: _newHost, ...newFields } = newOpening;
return {
sameResidualAccepted: after.server.accepted.length === 1 && after.server.rejected.length === 0,
openingGeometryAndMetadataPreserved: equal(oldFields, newFields),
originalPartitionHostRetained: newOpening.host.kind === 'partition'
&& newOpening.host.id === oldOpening.host.id && newOpening.host.t !== oldOpening.host.t,
};
});
}
await finish(lastBrowser, results);
+6
View File
@@ -272,6 +272,12 @@ revision. Model and lineage: [`WALL-THICKNESS.md`](WALL-THICKNESS.md) §1;
migrations and stale-client guard: [`CONFIG-COMPATIBILITY.md`](CONFIG-COMPATIBILITY.md)
(model v8–v10); rationale: [`adr/282-wall-geometry-representation.md`](adr/282-wall-geometry-representation.md).
Room-face acceptance uses `wall-face-lineage.ts` to choose provisional carriers
and settle only new-room hints against the partitions left after reconciliation
(#804). A residual keeps its identity without colliding with the promoted room
edge. The common model barrier, host validation and atomic history remain the
authorities; this helper neither allocates IDs nor writes configuration.
Rooms may not partially overlap (lying on a shared wall is legal, a fully nested
island is supported). Merge/Split use **polyclip-ts** (not `polygon-clipping`,
see [`DEVELOPMENT.md`](DEVELOPMENT.md)): Merge accepts a pair only when the union
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Fixed creating a room from part of a longer independent wall failing with
conflicting wall identifiers. The outside wall pieces retain their geometry
and thickness; no redraw or Optimize workaround is needed
([#804](https://github.com/Matysh/houseplan-card/issues/804)).
## v1.80.0-beta.4 — 2026-10-05
- Zigbee links to another space now name the destination device too. Device
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- Исправлен отказ создания комнаты на части длинной отдельной стены из-за
конфликта идентификаторов. Продолжения стены сохраняют геометрию и толщину;
обводить стену заново или запускать оптимизацию больше не требуется
([#804](https://github.com/Matysh/houseplan-card/issues/804)).
## v1.80.0-beta.4 — 2026-10-05
- Подписи Zigbee-связей в другое пространство теперь содержат имя устройства.
+10
View File
@@ -1,5 +1,15 @@
# Development and deployment
## Room-face lineage regression (#804)
`node demo/smoke_wall_face_lineage.mjs` exercises creation from a partial
independent wall using synthetic geometry, without a second exact-span carrier
over the room edge. Adding that duplicate carrier would hide the original bug.
Run against a fresh demo bundle; private runtime writes are not a test API.
Pure lineage cases and shared frontend/backend candidates cover retained IDs,
partial/full promotion and the unchanged partition-opening host restrictions.
Never add a user's complete exported home configuration to the test fixtures.
## Input support contract
Read `docs/TOUCH-SUPPORT.md` before changing interaction code.
+1
View File
@@ -31,6 +31,7 @@ Everything computable from the tree and git; regenerate, never edit by hand
| Branches | `main` carries stable releases only; pre-release tags point at `dev`. Work lands on `dev`, which is equal to or ahead of `main`, never behind. |
| Zigbee routes | #798 replaces inferred neighbour trees with integration-reported end-parent and active coordinator next-hop evidence. Unknown/conflicting routes are not guessed; stale/partial snapshots remain labelled. Solid arrows have a separate 0–255 palette; ordinary device LQI colours are unchanged. |
| Zigbee caption layout | #802 adds remote destination names and keeps the matching pointer tooltip clear of diagnostic text; only an unplaceable tooltip yields in a small card. Focus/touch/actions and provider transport remain unchanged. |
| Partial wall promotion | #804 settles new-room lineage after reconciliation: surviving independent wall pieces reserve their IDs, preventing the Garderoba duplicate-ID failure without changing geometry or relaxing opening-host validation. |
| Zigbee scan lifecycle | #800 replaces #799's 10-minute total budget with integration-owned background jobs: closing the browser does not stop waiting; after 10 minutes an exact-job cancel becomes available. MQTT setup/publish caps and the retained-info check remain bounded. Tests cover 15-minute success, cross-client restore, cancel races, stale-cache retention and reload on a live HA connection. |
| LED regression acceptance | #788 covers mixed face/free and fractional-coordinate joins, winding-safe field unions, retained endpoints/acute vertices and circle–wall transition events. The actual owner export is exercised locally; public fixtures are synthetic. Pixel oracles, not just SVG counts, protect the visible falloff. |
| LED core colour | #790 makes the on core follow the resolved light colour with or without Glow. Off, unavailable and non-live static strips keep their neutral presentation; geometry and field brightness are unchanged. |
+8
View File
@@ -47,6 +47,14 @@ room deletion, opening edits, Undo/Redo/recovery, Optimize and import/export
apply the same lineage and validation rules before one atomic persistence write.
Initial legacy IDs are deterministic, so frontend, backend and repeated
migrations converge; only genuinely new segments get UUIDs.
Room-face acceptance settles its provisional partition lineage after coincident
carriers have been reconciled (#804). A surviving partition or residual reserves
its ID: only new room-edge hints that still claim that ID are cleared, preserving
their slots. The common model barrier allocates new room-wall IDs; fully consumed
carriers retain their usual promotion lineage. Existing room IDs and the global
duplicate-ID guard are not relaxed. Ordinary config writes still reject changes
to the kind or ID of a surviving partition-opening host; the narrowly proved
Optimize rehost capability is not granted to room creation.
`src/wall-segment-model.ts` and `custom_components/houseplan/wall_segment_model.py`
share `test/fixtures/282-wall-identity-parity.json`. Writer-bypass mutants in
`scripts/mutation-registry.mjs` guard every structural writer entrance. A `cm:0`
@@ -36,8 +36,8 @@ that transition events alone prove disposal.
| Paint, cascade and layer composition | 40 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
| Pointer geometry and trusted interaction | 49 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
| Responsive DOM layout | 39 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. |
| Custom-element and HA browser lifecycle | 103 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **239 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
| Custom-element and HA browser lifecycle | 104 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **240 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
## Measured effect
@@ -240,6 +240,11 @@ The invariant depends on measured element boxes, responsive breakpoints, native/
### Custom-element and HA browser lifecycle
`wall-face-lineage-skips-post-reconcile-settlement` (#804) needs the production room
dialog, post-reconciliation runtime wiring, history, asynchronous `config/set` and
reload. Pure lineage-helper tests cannot prove that the editor actually settles
residual-owned IDs before the strict wall-model barrier or rolls rejected writes back.
The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition.
- `accepted-marker-rolled-back-by-layout-failure`
@@ -329,6 +334,7 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy
- `view-current-space-aria-removed`
- `volumetric-kiosk-ignores-setting`
- `wall-face-apply-skips-overlap-guard`
- `wall-face-lineage-skips-post-reconcile-settlement`
- `wallthick-hit-narrowed`
- `warm-late-resume-beats-user-mode`
- `warm-late-resume-crosses-space`
+12
View File
@@ -72,6 +72,18 @@ function relocateEditorPatch(patch, cardSource, editorSource) {
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
const MUTANT_DEFINITIONS = [
{
id: 'wall-face-lineage-skips-post-reconcile-settlement',
guard: 'node demo/smoke_wall_face_lineage.mjs',
because: '#804 AC1/AC3/AC5: only the production room-dialog save proves that residual '
+ 'partition IDs are settled before the strict wall-model barrier; pure helper tests '
+ 'cannot detect an omitted runtime call, and the duplicate-ID guard must remain intact.',
patches: [{
file: 'src/houseplan-editor-runtime.ts',
find: ' if (room.wall_ids) room.wall_ids = settleWallFaceLineage(room.wall_ids, remainingPartitionIds);\n',
replace: ' // mutant: stale partition lineage reaches the wall-model barrier\n',
}],
},
{
id: 'stairs-continuous-transform-snaps-to-lattice',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+7
View File
@@ -29,6 +29,13 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
symbols: ['selectWallFaceLineage', 'settleWallFaceLineage'],
smokes: ['smoke_wall_face_lineage.mjs'],
because: '#804: a partial partition carrier is promoted through the production room dialog, '
+ 'actual config/set, history and reload; the smoke observes persisted IDs and atomic '
+ 'rollback rather than importing or naming the lineage helper.',
},
{
files: ['src/zigbee-topology-runtime.ts', 'src/hp-zigbee-topology-settings.ts'],
smokes: ['smoke_zigbee_topology_job.mjs'],
+8 -23
View File
@@ -269,6 +269,7 @@ import { safeStoredColor } from './color';
import { gridCellFieldValue, gridVisualUnits, newSpaceCellCm } from './grid-scale';
import { applyOpeningMoves, mergeCollinearPartitions, spaceMergeGeometry } from './wall-merge';
import { reconcileCoincidentPartitions } from './coincident-partitions';
import { selectWallFaceLineage, settleWallFaceLineage } from './wall-face-lineage';
import { finalizeWallChainSpace } from './writer-fixed-point';
import {
captureMarkerRoomReferences, restoreMarkerRoomReferences,
@@ -6599,29 +6600,9 @@ public _applyWallFaceBatch(): void {
}
const activePartitionIds = new Set(batch.activePartitionIds);
const epsilon = this.host._gridPitch * 0.0002;
const roomLineage = accepted.map((decision) => decision.candidate.ring.map((a, index, ring) => {
const b = ring[(index + 1) % ring.length];
const carriers = model.partitions.filter((partition) => {
const length = Math.hypot(partition.b[0] - partition.a[0], partition.b[1] - partition.a[1]);
if (!(length > epsilon)) return false;
const ux = (partition.b[0] - partition.a[0]) / length;
const uy = (partition.b[1] - partition.a[1]) / length;
const along = (point: number[]) => (
(point[0] - partition.a[0]) * ux + (point[1] - partition.a[1]) * uy
);
return distToSegment(a, [partition.a[0], partition.a[1], partition.b[0], partition.b[1]]) <= epsilon
&& distToSegment(b, [partition.a[0], partition.a[1], partition.b[0], partition.b[1]]) <= epsilon
&& along(a) >= -epsilon && along(a) <= length + epsilon
&& along(b) >= -epsilon && along(b) <= length + epsilon;
});
carriers.sort((left, right) => (
Number(activePartitionIds.has(right.id)) - Number(activePartitionIds.has(left.id))
|| Math.hypot(left.b[0] - left.a[0], left.b[1] - left.a[1])
- Math.hypot(right.b[0] - right.a[0], right.b[1] - right.a[1])
|| left.id.localeCompare(right.id)
));
return carriers[0]?.id || '';
}));
const roomLineage = accepted.map((decision) => selectWallFaceLineage(
decision.candidate.ring, model.partitions, activePartitionIds, epsilon,
));
const before = this._geometrySnapshot();
if (!before) {
abort('toast.geometry_unsafe');
@@ -6743,6 +6724,10 @@ public _applyWallFaceBatch(): void {
else delete sp.partitions;
if (reconciled.openings.length) sp.openings = reconciled.openings;
else delete sp.openings;
const remainingPartitionIds = new Set(reconciled.partitions.map((partition) => partition.id));
for (const { room } of newRooms) {
if (room.wall_ids) room.wall_ids = settleWallFaceLineage(room.wall_ids, remainingPartitionIds);
}
}
if (!this._commitPhysicalGeometry(this.host._t('history.wall_face_batch'), before)) return;
+51
View File
@@ -0,0 +1,51 @@
import { distToSegment } from './logic';
interface WallFaceCarrier {
id: string;
a: readonly number[];
b: readonly number[];
}
/** Provisional promotion hints; coordinates share the caller's model scale. */
export function selectWallFaceLineage(
ring: readonly number[][],
partitions: readonly WallFaceCarrier[],
activePartitionIds: ReadonlySet<string>,
epsilon: number,
): string[] {
return ring.map((a, index) => {
const b = ring[(index + 1) % ring.length];
const carriers = partitions.filter((partition) => {
const length = Math.hypot(partition.b[0] - partition.a[0], partition.b[1] - partition.a[1]);
if (!(length > epsilon)) return false;
const ux = (partition.b[0] - partition.a[0]) / length;
const uy = (partition.b[1] - partition.a[1]) / length;
const along = (point: readonly number[]) => (
(point[0] - partition.a[0]) * ux + (point[1] - partition.a[1]) * uy
);
return distToSegment(a, [partition.a[0], partition.a[1], partition.b[0], partition.b[1]]) <= epsilon
&& distToSegment(b, [partition.a[0], partition.a[1], partition.b[0], partition.b[1]]) <= epsilon
&& along(a) >= -epsilon && along(a) <= length + epsilon
&& along(b) >= -epsilon && along(b) <= length + epsilon;
});
carriers.sort((left, right) => (
Number(activePartitionIds.has(right.id)) - Number(activePartitionIds.has(left.id))
|| Math.hypot(left.b[0] - left.a[0], left.b[1] - left.a[1])
- Math.hypot(right.b[0] - right.a[0], right.b[1] - right.a[1])
|| left.id.localeCompare(right.id)
));
return carriers[0]?.id || '';
});
}
/**
* A partially consumed partition keeps its ID on a residual, so that ID cannot
* also be promoted into the room catalogue. Clear only those provisional hints;
* the model barrier owns fresh identity allocation. Empty slots preserve edges.
*/
export function settleWallFaceLineage(
lineage: readonly string[],
remainingPartitionIds: ReadonlySet<string>,
): string[] {
return lineage.map((id) => remainingPartitionIds.has(id) ? '' : id);
}
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
{
"grid": 240,
"cellCm": 8,
"source": { "id": "long-source", "a": [74, 90], "b": [74, 200], "cm": 25 },
"face": [[74, 112], [102, 112], [102, 133], [74, 133]],
"neighbor": [[102, 112], [130, 112], [130, 133], [102, 133]],
"crossCm": 15
}
+299
View File
@@ -0,0 +1,299 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { selectWallFaceLineage, settleWallFaceLineage } from '../test-build/wall-face-lineage.js';
import { commitWallSegmentModel } from '../test-build/wall-segment-model.js';
import { reconcileCoincidentPartitions } from '../test-build/coincident-partitions.js';
import { spaceModels, GRID_PITCH, GRID_STEP_N, NORM_W } from '../test-build/space-geometry.js';
import { setWallThickness, applyWallThicknessToNewRoom } from '../test-build/wall-thickness.js';
import { resolvePartitionOpeningStrict } from '../test-build/partition-openings.js';
import { checkSpacePhysicalGeometry } from '../test-build/plan-geometry-preflight.js';
const fixture = JSON.parse(readFileSync(new URL('./fixtures/804-wall-face-lineage.json', import.meta.url), 'utf8'));
const sourceId = fixture.source.id;
const normalized = (point) => point.map((value) => value / fixture.grid);
const modelPoint = (point) => normalized(point).map((value) => value * NORM_W);
const close = (actual, expected, message) => assert.ok(Math.abs(actual - expected) < 1e-9,
`${message}: ${actual} != ${expected}`);
function makeBefore(options = {}) {
const cm = options.sourceCm ?? fixture.source.cm;
const crossCm = options.crossCm ?? fixture.crossCm;
const a = [fixture.source.a[0], options.start ?? fixture.source.a[1]];
const b = [fixture.source.b[0], options.end ?? fixture.source.b[1]];
if (options.reverse) [a[1], b[1]] = [b[1], a[1]];
const room = { id: 'neighbor', name: 'Neighbor', area: null, poly: fixture.neighbor.map(normalized) };
const source = { id: sourceId, a: normalized(a), b: normalized(b), cm };
const partition = (id, first, second) => ({ id, a: normalized(first), b: normalized(second), cm: crossCm });
const partitions = [
source,
partition('top-source', fixture.face[0], fixture.face[1]),
partition('bottom-source', fixture.face[3], fixture.face[2]),
];
if (options.multiple) partitions.push(partition('divider-source', [74, 122], [102, 122]));
let walls = [];
for (const [index, first] of room.poly.entries()) {
walls = setWallThickness(walls, first, room.poly[(index + 1) % room.poly.length], crossCm, GRID_STEP_N, 1);
}
const space = { id: 'floor', title: 'Floor', cell_cm: fixture.cellCm,
view_box: [0, 0, 1, 1], rooms: [room], partitions, walls };
if (options.openingY !== undefined) {
const opening = {
id: 'hosted-opening', type: options.openingType || 'door',
x: a[0] / fixture.grid, y: options.openingY / fixture.grid,
angle: -90, length: (options.openingLength ?? 8) / fixture.grid,
contact: 'binary_sensor.synthetic_door', lock: 'lock.synthetic_door',
cover: 'cover.synthetic_opening', invert: true, flip_h: true,
future_field: { keep: ['opaque', 7] },
host: { kind: 'partition', id: sourceId, t: (options.openingY - a[1]) / (b[1] - a[1]) },
};
space.openings = [opening];
}
return commitWallSegmentModel({ spaces: [space, {
id: 'unrelated-space', title: 'Unrelated', cell_cm: 8, view_box: [0, 0, 1, 1], rooms: [],
}], markers: [], settings: {} }).config;
}
/** Pure production stages; emit mode exposes actual frontend candidates to Python. */
function acceptFaces(before, options = {}, settle = true) {
const candidate = structuredClone(before);
const space = candidate.spaces[0];
const initialModel = spaceModels(candidate)[0];
const rings = options.multiple ? [
[[74, 112], [102, 112], [102, 122], [74, 122]],
[[74, 122], [102, 122], [102, 133], [74, 133]],
] : [fixture.face];
const newRooms = rings.map((ring, index) => ({
id: `accepted-${index}`, name: `Accepted ${index}`, area: null,
poly: ring.map(normalized),
wall_ids: selectWallFaceLineage(ring.map(modelPoint), initialModel.partitions,
new Set(), GRID_PITCH * 0.0002),
}));
space.rooms.push(...newRooms);
for (const room of newRooms) {
space.walls = applyWallThicknessToNewRoom(space.walls, spaceModels(candidate)[0].rooms,
room.id, options.crossCm ?? fixture.crossCm, GRID_STEP_N, [], NORM_W);
for (const [index, point] of room.poly.entries()) {
const carrier = initialModel.partitions.find((partition) => partition.id === room.wall_ids[index]);
if (carrier) space.walls = setWallThickness(space.walls, point,
room.poly[(index + 1) % room.poly.length], carrier.cm, GRID_STEP_N, 1);
}
}
const reconciled = reconcileCoincidentPartitions(space, spaceModels(candidate)[0], space.walls, [], {
pitch: GRID_STEP_N, cellCm: fixture.cellCm, gridPitch: GRID_PITCH,
coordScale: NORM_W, allowCoincidentPartitions: true,
});
for (const key of ['walls', 'partitions', 'openings']) {
if (reconciled[key].length) space[key] = reconciled[key];
else delete space[key];
}
if (settle) {
const remainingIds = new Set(reconciled.partitions.map((partition) => partition.id));
for (const room of newRooms) room.wall_ids = settleWallFaceLineage(room.wall_ids, remainingIds);
}
return commitWallSegmentModel(candidate).config;
}
function assertIdentity(space) {
const physicalIds = [...(space.partitions || []), ...space.wall_segments].map((wall) => wall.id);
assert.equal(new Set(physicalIds).size, physicalIds.length, 'physical IDs are globally disjoint');
for (const room of space.rooms) {
assert.equal(room.wall_ids.length, room.poly.length);
for (const id of room.wall_ids) assert.equal(space.wall_segments.filter((wall) => wall.id === id).length, 1);
}
}
// Captures keep real UUIDs for Python. Replay compares every field while giving
// only newly allocated contour IDs a deterministic name from their exact span.
function comparableNewIdentities(config, before) {
const result = structuredClone(config);
const existing = new Set(before.spaces.flatMap((space) => [
...(space.wall_segments || []), ...(space.partitions || []),
]).map((wall) => wall.id));
for (const space of result.spaces) {
const replacement = new Map((space.wall_segments || []).filter((wall) => !existing.has(wall.id))
.map((wall) => [wall.id, `new:${JSON.stringify([wall.a, wall.b].sort((a, b) => a[0] - b[0] || a[1] - b[1]))}`]));
for (const wall of space.wall_segments || []) wall.id = replacement.get(wall.id) || wall.id;
for (const room of space.rooms) room.wall_ids = room.wall_ids?.map((id) => replacement.get(id) || id);
for (const opening of space.openings || []) {
if (opening.host?.kind === 'wall') opening.host.id = replacement.get(opening.host.id) || opening.host.id;
}
}
return result;
}
const variants = [
{ name: 'two residuals, mixed thickness', options: {}, residuals: 2 },
{ name: 'one trailing residual', options: { start: 112 }, residuals: 1 },
{ name: 'one leading residual', options: { end: 133 }, residuals: 1 },
{ name: 'fully consumed carrier', options: { start: 112, end: 133 }, residuals: 0 },
{ name: 'reversed source', options: { reverse: true }, residuals: 2 },
{ name: 'uniform positive thickness', options: { sourceCm: 15 }, residuals: 2 },
{ name: 'zero carrier', options: { sourceCm: 0 }, residuals: 2 },
{ name: 'fully consumed zero carrier', options: { sourceCm: 0, start: 112, end: 133 }, residuals: 0 },
];
const hostVariants = [
{ name: 'same-id-residual-host', options: { openingY: 100 }, expectedPartitionHostResult: 'ok' },
{ name: 'other-residual-host', options: { openingY: 170 }, expectedPartitionHostResult: 'reject' },
{ name: 'consumed-partition-host', options: { openingY: 122 }, expectedPartitionHostResult: 'reject' },
];
if (process.argv.includes('--emit-backend-fixtures')) {
const cases = [
...variants.map(({ name, options }) => ({ name, options, expectedPartitionHostResult: 'ok' })),
...hostVariants,
].map(({ name, options, expectedPartitionHostResult }) => {
const before = makeBefore(options);
return { name, before, after: acceptFaces(before, options), expectedPartitionHostResult };
});
process.stdout.write(`${JSON.stringify(cases)}\n`);
} else {
test('carrier selection keeps active, length and ID priority without mutating inputs', () => {
const ring = [[0, 0], [10, 0], [10, 10], [0, 10]];
const partitions = [
{ id: 'long', a: [-5, 0], b: [15, 0] },
{ id: 'short-z', a: [0, 0], b: [10, 0] },
{ id: 'short-a', a: [10, 0], b: [0, 0] },
{ id: 'degenerate', a: [0, 0], b: [0, 0] },
{ id: 'partial', a: [0, 0], b: [5, 0] },
];
const original = structuredClone({ ring, partitions });
assert.deepEqual(selectWallFaceLineage(ring, partitions, new Set(), 0.001), ['short-a', '', '', '']);
assert.deepEqual(selectWallFaceLineage(ring, partitions, new Set(['long']), 0.001), ['long', '', '', '']);
assert.deepEqual({ ring, partitions }, original);
assert.deepEqual(selectWallFaceLineage([], partitions, new Set(), 0.001), []);
});
test('settlement clears only occupied hints and preserves edge slots and full promotions', () => {
const hints = ['long-source', '', 'fully-consumed', 'long-source'];
const remaining = new Set(['long-source', 'unrelated']);
assert.deepEqual(settleWallFaceLineage(hints, remaining), ['', '', 'fully-consumed', '']);
assert.deepEqual(hints, ['long-source', '', 'fully-consumed', 'long-source']);
assert.deepEqual([...remaining], ['long-source', 'unrelated']);
assert.deepEqual(settleWallFaceLineage([], remaining), []);
});
for (const { name, options, residuals } of variants) test(`AC2: ${name}`, () => {
const before = makeBefore(options);
const original = structuredClone(before);
const after = acceptFaces(before, options);
const space = after.spaces[0];
const room = space.rooms.find((item) => item.id === 'accepted-0');
assert.equal(space.rooms.length, 2);
assert.deepEqual(room.poly, fixture.face.map(normalized));
assert.deepEqual(space.rooms.find((item) => item.id === 'neighbor'), before.spaces[0].rooms[0]);
assert.deepEqual(after.spaces[1], before.spaces[1]);
assert.deepEqual(after.markers, before.markers);
assert.deepEqual(before, original, 'pure pipeline does not mutate its source');
assert.equal((space.partitions || []).length, residuals);
assert.equal((space.partitions || []).some((wall) => wall.id === sourceId), residuals > 0);
const leftId = room.wall_ids[3];
assert.equal(leftId === sourceId, residuals === 0, 'source identity belongs to a residual before promotion');
assert.deepEqual(room.wall_ids.slice(0, 3), ['top-source', before.spaces[0].rooms[0].wall_ids[3], 'bottom-source']);
assert.deepEqual(room.wall_ids.map((id) => space.wall_segments.find((wall) => wall.id === id).cm),
[fixture.crossCm, fixture.crossCm, fixture.crossCm, options.sourceCm ?? fixture.source.cm]);
for (const residual of space.partitions || []) {
close(residual.a[0], 74 / fixture.grid, 'residual x');
close(residual.b[0], 74 / fixture.grid, 'residual x');
const low = Math.min(residual.a[1], residual.b[1]);
const high = Math.max(residual.a[1], residual.b[1]);
assert.ok(high <= 112 / fixture.grid + 1e-9 || low >= 133 / fixture.grid - 1e-9,
'residual does not duplicate the consumed span');
assert.equal(residual.cm, options.sourceCm ?? fixture.source.cm);
}
const source = before.spaces[0].partitions.find((wall) => wall.id === sourceId);
const residualLength = (space.partitions || []).reduce((sum, wall) => sum + Math.abs(wall.b[1] - wall.a[1]), 0);
close(residualLength + 21 / fixture.grid, Math.abs(source.b[1] - source.a[1]), 'source coverage');
assertIdentity(space);
assert.equal(checkSpacePhysicalGeometry(after, space.id).ok, true);
assert.deepEqual(commitWallSegmentModel(after).config, after, 'no identity churn on the second commit');
});
test('the original unsatisfied hints still fail the unchanged duplicate-ID barrier', () => {
const before = makeBefore();
const original = structuredClone(before);
assert.throws(() => acceptFaces(before, {}, false), (error) => error.reason === 'duplicate-id'
&& error.message.includes(sourceId));
assert.deepEqual(before, original);
const malformed = acceptFaces(before);
malformed.spaces[0].partitions[0].id = malformed.spaces[0].wall_segments[0].id;
const unchanged = structuredClone(malformed);
assert.throws(() => commitWallSegmentModel(malformed), (error) => error.reason === 'duplicate-id');
assert.deepEqual(malformed, unchanged, 'failed model commit is pure');
});
for (const fullyConsumed of [false, true]) test(`two accepted faces share one divider atom; full consumption=${fullyConsumed}`, () => {
const options = { multiple: true, ...(fullyConsumed ? { start: 112, end: 133 } : {}) };
const before = makeBefore(options);
const after = acceptFaces(before, options);
const space = after.spaces[0];
const first = space.rooms.find((room) => room.id === 'accepted-0');
const second = space.rooms.find((room) => room.id === 'accepted-1');
assert.equal(first.wall_ids[2], 'divider-source');
assert.equal(second.wall_ids[0], first.wall_ids[2]);
assert.notEqual(first.wall_ids[3], second.wall_ids[3]);
assert.equal([first.wall_ids[3], second.wall_ids[3]].filter((id) => id === sourceId).length,
fullyConsumed ? 1 : 0, 'a fully consumed source ID belongs to exactly one physical atom');
assertIdentity(space);
assert.deepEqual(commitWallSegmentModel(after).config, after);
});
for (const { name, options } of hostVariants) test(`AC4 frontend candidate: ${name}`, () => {
const before = makeBefore(options);
const original = structuredClone(before);
const after = acceptFaces(before, options);
const space = after.spaces[0];
assert.equal(space.openings.length, 1);
const oldOpening = before.spaces[0].openings[0];
const opening = space.openings[0];
const { host: oldHost, ...oldData } = oldOpening;
const { host, ...data } = opening;
assert.deepEqual(data, oldData, 'absolute geometry and all metadata are preserved');
if (options.openingY === 122) {
assert.equal(host.kind, 'wall');
assert.equal(space.wall_segments.filter((wall) => wall.id === host.id).length, 1);
} else {
assert.equal(host.kind, 'partition');
assert.equal(host.id === oldHost.id, options.openingY === 100);
assert.notEqual(host.t, oldHost.t, 'the parameter changes to preserve world position');
const resolved = resolvePartitionOpeningStrict(opening, space.partitions, 1,
fixture.cellCm, GRID_STEP_N).resolved;
assert.ok(resolved, 'residual host resolves with strict jamb margins');
close(resolved.center[0], oldOpening.x, 'opening x');
close(resolved.center[1], oldOpening.y, 'opening y');
close(resolved.length, oldOpening.length, 'opening length');
}
assertIdentity(space);
assert.deepEqual(commitWallSegmentModel(after).config, after);
assert.deepEqual(before, original);
});
test('ambiguous overlapping openings retain their hosts instead of disappearing', () => {
const before = makeBefore({ openingY: 122 });
before.spaces[0].openings.push({ ...structuredClone(before.spaces[0].openings[0]), id: 'overlapping-opening' });
const after = acceptFaces(before);
assert.deepEqual(after.spaces[0].openings, before.spaces[0].openings);
for (const opening of after.spaces[0].openings) {
assert.ok(after.spaces[0].partitions.some((wall) => wall.id === opening.host.id));
}
assertIdentity(after.spaces[0]);
});
const backendFixture = new URL('./fixtures/804-wall-face-lineage-backend.json', import.meta.url);
for (const captured of JSON.parse(readFileSync(backendFixture, 'utf8'))) {
test(`shared backend candidate is a fixed point: ${captured.name}`, () => {
const scenario = [...variants, ...hostVariants].find((item) => item.name === captured.name);
assert.ok(scenario, 'captured scenario has a current generator');
assert.deepEqual(captured.before, makeBefore(scenario.options), 'capture input matches the synthetic seed');
const crossCm = captured.before.spaces[0].partitions.find((wall) => wall.id === 'top-source').cm;
const replay = acceptFaces(captured.before, { crossCm });
assert.deepEqual(comparableNewIdentities(replay, captured.before),
comparableNewIdentities(captured.after, captured.before), 'capture matches current frontend stages');
assertIdentity(captured.after.spaces[0]);
assert.deepEqual(commitWallSegmentModel(captured.after).config, captured.after);
assert.equal(checkSpacePhysicalGeometry(captured.after, captured.after.spaces[0].id).ok, true);
assert.deepEqual(captured.after.spaces[1], captured.before.spaces[1]);
});
}
}
+64
View File
@@ -0,0 +1,64 @@
"""#804: actual frontend room candidates obey unchanged backend barriers."""
from __future__ import annotations
import copy
import json
from pathlib import Path
import pytest
from custom_components.houseplan.junction_limits import validate_junction_limits
from custom_components.houseplan.validation import (
CONFIG_SCHEMA,
PartitionOpeningHostError,
validate_partition_opening_hosts,
validate_wall_model_transition,
)
from custom_components.houseplan.wall_segment_model import (
WallSegmentMigrationError,
commit_wall_segment_model,
)
_CASES = json.loads((
Path(__file__).parents[1]
/ "test" / "fixtures" / "804-wall-face-lineage-backend.json"
).read_text(encoding="utf-8"))
@pytest.mark.parametrize("case", _CASES, ids=lambda case: case["name"])
def test_frontend_room_face_candidate_and_partition_host_policy(case: dict) -> None:
before = copy.deepcopy(case["before"])
after = copy.deepcopy(case["after"])
original_before, original_after = copy.deepcopy(before), copy.deepcopy(after)
# This material is emitted by the frontend production-function pipeline,
# not a Python reconstruction which could miss the original provisional-ID
# conflict. The Node test replays the same captured cases independently.
checked = CONFIG_SCHEMA(after)
committed, _ = commit_wall_segment_model(checked)
assert committed == checked
assert commit_wall_segment_model(committed)[0] == committed
validate_wall_model_transition(committed, before)
validate_junction_limits(committed, before)
if case["expectedPartitionHostResult"] == "reject":
with pytest.raises(PartitionOpeningHostError, match="host changed"):
validate_partition_opening_hosts(committed, before)
else:
assert case["expectedPartitionHostResult"] == "ok"
validate_partition_opening_hosts(committed, before)
assert before == original_before
assert after == original_after
def test_real_duplicate_catalogue_ids_are_still_rejected_without_mutation() -> None:
source = copy.deepcopy(_CASES[0]["after"])
space = source["spaces"][0]
assert len(space["wall_segments"]) >= 2
space["wall_segments"][1]["id"] = space["wall_segments"][0]["id"]
original = copy.deepcopy(source)
with pytest.raises(WallSegmentMigrationError, match="duplicate-id"):
commit_wall_segment_model(source)
assert source == original
+1 -1
View File
@@ -40,7 +40,7 @@
"src/hp-help.ts", "src/hp-dialog.ts", "src/hp-color-opacity.ts", "src/danger-confirm.ts",
"src/opening-placement.ts", "src/opening-dimensions.ts", "src/partition-openings.ts", "src/plan-snap-overlay.ts", "src/wall-face-graph.ts", "src/wall-face-repair.ts", "src/room-deletion.ts", "src/render/opening-symbol.ts",
"src/wall-thickness.ts",
"src/wall-segment-model.ts",
"src/wall-segment-model.ts", "src/wall-face-lineage.ts",
"src/wall-merge.ts", "src/writer-fixed-point.ts", "src/room-reference-transaction.ts",
"src/physical-geometry.ts",
"src/labs.ts", "src/iso-projection.ts", "src/iso-walls.ts", "src/iso-openings.ts", "src/iso-overlays.ts", "src/iso-scene-render.ts",