fix: inherit parent thickness for atomic walls

Issue: #201
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-19 18:04:11 +03:00
parent 8b8b9ed90d
commit f7abf14abd
13 changed files with 170 additions and 19 deletions
File diff suppressed because one or more lines are too long
+34
View File
@@ -195,6 +195,40 @@ const res = await page.evaluate(async () => {
out.virtualTJunctionMitred = values.some((x, i) => i % 2 === 0
&& Math.abs(x - (500 + half)) < 1e-6
&& Math.abs(values[i + 1] - (500 - half)) < 1e-6);
// #201: a fully virtual stretch has no own cm to restore. Its nearest
// solid continuation is one exact 22 cm parent run, atomised at x=600 by a
// third room. Close must read the parent through either child instead of
// falling back to the 15 cm draw default.
sp().rooms = [
{ id: 'parent', name: 'Parent', poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.5], [0.1, 0.5]], open_to: ['left'] },
{ id: 'left', name: 'Left', poly: [[0.1, 0.5], [0.3, 0.5], [0.3, 0.9], [0.1, 0.9]], open_to: ['parent'] },
{ id: 'middle', name: 'Middle', poly: [[0.3, 0.5], [0.6, 0.5], [0.6, 0.9], [0.3, 0.9]] },
{ id: 'right', name: 'Right', poly: [[0.6, 0.5], [0.9, 0.5], [0.9, 0.9], [0.6, 0.9]] },
];
sp().walls = [{
key: '0.600000,0.500000@0.0000', cm: 22,
a: [0.3, 0.5], b: [0.9, 0.5],
}];
sp().open_spans = [{ a: [0.1, 0.5], b: [0.3, 0.5] }];
delete sp().openings;
c._tool = 'boundary';
await upd();
const atomicParentBefore = JSON.stringify({
rooms: sp().rooms, walls: sp().walls, open_spans: sp().open_spans,
});
const atomicParentCut = c._openCuts()[0];
const atomicParentPlan = atomicParentCut && c._planClosedOpenSpan(atomicParentCut);
out.atomicParentClosePreviewInherits = atomicParentPlan?.cm === 22;
if (atomicParentCut) c._closeOpenSpan(atomicParentCut);
await upd();
out.atomicParentClosePersistsNeighbourCm = !sp().open_spans
&& c._intervalCm([100, 500, 300, 500]) === 22;
c._undoGeometry();
await upd();
out.atomicParentCloseUndo = JSON.stringify({
rooms: sp().rooms, walls: sp().walls, open_spans: sp().open_spans,
}) === atomicParentBefore;
return out;
});
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -2,6 +2,11 @@
## v1.65.0-beta.4 — 2026-08-19
- Closing a virtual room boundary now inherits the real thickness of an
adjacent wall even when another room splits that saved wall into smaller
atomic stretches. A 20/22 cm wall therefore no longer receives an accidental
15 cm step, while partial thickness spans remain isolated
([#201](https://github.com/Matysh/houseplan-card/issues/201)).
- Room labels in the Plan editor now keep the same name, Home Assistant Area
icon and state-row geometry as View, so switching modes no longer shifts a
carefully positioned label. The icon still opens the Area only in View; in
+6
View File
@@ -8,6 +8,12 @@
## v1.65.0-beta.4 — 2026-08-19
- При закрытии виртуальной границы теперь наследуется реальная толщина соседней
стены, даже если другая комната разделила сохранённую стену на несколько
атомарных участков. Поэтому на стене 20/22 см больше не появляется случайная
ступень 15 см, а частичные участки толщины по-прежнему не распространяются
дальше своих границ
([#201](https://github.com/Matysh/houseplan-card/issues/201)).
- Подписи комнат в редакторе Плана теперь сохраняют ту же геометрию имени,
иконки зоны Home Assistant и строк состояний, что и в Просмотре, поэтому при
переключении режимов аккуратно выставленная подпись больше не сдвигается.
+8
View File
@@ -2217,6 +2217,14 @@ require hands on real hardware — they remain for the human pass.
across a Split boundary: spans owned by different room pairs must stay separate
[auto: test/open-spans.test.mjs + test/wall-thickness.test.mjs +
smoke_resize_virtual_thick]
- [ ] **Atomic child inherits its exact parent on Close (#201)**: let a third
room split a non-default exact wall run into atomic solid children, then
close a fully virtual collinear neighbour. Preview and persisted masonry
inherit the nearest parent's real thickness instead of the 15 cm draw
default; Undo restores the virtual span. A partial exact span never leaks
onto a longer query, and scale/direction/row order do not change the
resolver [auto: test/wall-thickness.test.mjs + test/open-spans.test.mjs +
smoke_resize_virtual_thick + mutation-gate].
- [ ] **Unit + backend**: inset/mitre/bevel, key from either end, degrade,
rekey, cm↔inches; `walls` schema bounds
[auto: test/wall-thickness.test.mjs + tests_backend/test_validation.py]
+2 -1
View File
@@ -185,7 +185,8 @@ atomic rekey after edge/scale; corner Split exterior equality across
production-scale `0 ↔ 10`, `10 ↔ 20` and `1 ↔ 100` collinear transitions at
their exact endpoint; full 8-room/25-wall/3-cut virtual-junction resilience,
ULP-equivalent patch vertices, per-patch failure isolation and record-order
invariance (#197).
invariance (#197); exact parent-run thickness inherited by atomic children when
closing a virtual neighbour, without partial-span leakage (#201).
Browser: seamless frame; fill not in hatch; m² drops with thickness; a partial
virtual stretch, its solid thick remainders and Undo move as one real resize;
the virtual rubber band paints above the real body; sun starts at the room-side
+11 -11
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"fixture": "synthetic-only",
"sourceFingerprint": "3425175abf2903733116e8846a9ea0003da0ea7a7a83db5eb85594f789e7c9ed",
"sourceFingerprint": "51c4bdb005b2539779085578bcce5ee1b4b3e3137542ef1aeb440232344c3105",
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -13,7 +13,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3425175abf2903733116e8846a9ea0003da0ea7a7a83db5eb85594f789e7c9ed",
"sourceSha256": "51c4bdb005b2539779085578bcce5ee1b4b3e3137542ef1aeb440232344c3105",
"imageSha256": "d36b6f9f8139f31ef73a780c6511a640a26055efd9d7a24c24fd48b1d8379bf0"
},
"view-touch": {
@@ -24,7 +24,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3425175abf2903733116e8846a9ea0003da0ea7a7a83db5eb85594f789e7c9ed",
"sourceSha256": "51c4bdb005b2539779085578bcce5ee1b4b3e3137542ef1aeb440232344c3105",
"imageSha256": "358e25ff9984d0fb0c03cfbb848df40c425fdfe64cd5f4f613b1e754ca9d4249"
},
"space-create": {
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3425175abf2903733116e8846a9ea0003da0ea7a7a83db5eb85594f789e7c9ed",
"sourceSha256": "51c4bdb005b2539779085578bcce5ee1b4b3e3137542ef1aeb440232344c3105",
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
},
"room-contour-close": {
@@ -46,7 +46,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3425175abf2903733116e8846a9ea0003da0ea7a7a83db5eb85594f789e7c9ed",
"sourceSha256": "51c4bdb005b2539779085578bcce5ee1b4b3e3137542ef1aeb440232344c3105",
"imageSha256": "2f13ea645af306eee9cc7c52699c928377b72f412b08c1a3db6beb848ace1b51"
},
"plan-context-tray": {
@@ -57,7 +57,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3425175abf2903733116e8846a9ea0003da0ea7a7a83db5eb85594f789e7c9ed",
"sourceSha256": "51c4bdb005b2539779085578bcce5ee1b4b3e3137542ef1aeb440232344c3105",
"imageSha256": "a6c526fede11bc3503fd2384bd4a3f6afa008f481c5c47578f06cae6c81c6f9b"
},
"device-editor": {
@@ -68,7 +68,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3425175abf2903733116e8846a9ea0003da0ea7a7a83db5eb85594f789e7c9ed",
"sourceSha256": "51c4bdb005b2539779085578bcce5ee1b4b3e3137542ef1aeb440232344c3105",
"imageSha256": "7c3e25534fc819c45431907616c3d523961505859ee68af27cce2daec9f04fb0"
},
"device-display-preview": {
@@ -79,7 +79,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3425175abf2903733116e8846a9ea0003da0ea7a7a83db5eb85594f789e7c9ed",
"sourceSha256": "51c4bdb005b2539779085578bcce5ee1b4b3e3137542ef1aeb440232344c3105",
"imageSha256": "df3a54395c83bffd7a161f1fb3de0143e12e76567535aedf9883873ead74df91"
},
"background-editor": {
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3425175abf2903733116e8846a9ea0003da0ea7a7a83db5eb85594f789e7c9ed",
"sourceSha256": "51c4bdb005b2539779085578bcce5ee1b4b3e3137542ef1aeb440232344c3105",
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
},
"room-card": {
@@ -101,7 +101,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3425175abf2903733116e8846a9ea0003da0ea7a7a83db5eb85594f789e7c9ed",
"sourceSha256": "51c4bdb005b2539779085578bcce5ee1b4b3e3137542ef1aeb440232344c3105",
"imageSha256": "176abba71d41cfb045a33f82a794d9fbb2a5d3c48e6df66e6ec3a8e448311b11"
},
"device-info": {
@@ -112,7 +112,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3425175abf2903733116e8846a9ea0003da0ea7a7a83db5eb85594f789e7c9ed",
"sourceSha256": "51c4bdb005b2539779085578bcce5ee1b4b3e3137542ef1aeb440232344c3105",
"imageSha256": "2199ed88b215bf2bff63bf665028f78b2aa6a92c3032b803931790f2ef071893"
}
}
+13
View File
@@ -40,6 +40,19 @@ import { fileURLToPath } from 'node:url';
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
export const MUTANTS = [
{
id: 'atomic-child-thickness-parent-fallback',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="exact parent|atomic solid children" '
+ 'test/wall-thickness.test.mjs test/open-spans.test.mjs',
because: 'closing a fully virtual stretch beside an atomised exact parent run must inherit '
+ 'the real neighbouring thickness instead of silently writing the 15 cm default',
patches: [{
file: 'src/wall-thickness.ts',
find: ' const exact = exactCoveringWall(walls, a, b, pitch, coordScale);',
replace: ' const exact = false ? exactCoveringWall(walls, a, b, pitch, coordScale) : null;',
}],
},
{
id: 'stale-space-position-guard-removed',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+46 -1
View File
@@ -215,7 +215,52 @@ export function thicknessCmAt(
coordScale = 1,
): number {
const e = lookupWall(walls, a, b, pitch, coordScale);
return e && e.cm > 0 ? clampWallCm(e.cm) : 0;
if (e && e.cm > 0) return clampWallCm(e.cm);
const exact = exactCoveringWall(walls, a, b, pitch, coordScale);
return exact ? clampWallCm(exact.cm) : 0;
}
/**
* Lossless parent-run fallback for an atomic child query.
*
* `lookupWall` intentionally keeps the narrow "one key = one stretch"
* contract (AUD-159B6-01). Closing a virtual span, however, asks about the
* atomic solid children around it while persisted exact endpoints may describe
* their longer parent run. Exact endpoints can prove containment without
* broadening the ambiguous legacy key-only fallback.
*/
function exactCoveringWall(
walls: WallEntry[] | null | undefined,
a: number[], b: number[],
pitch: number,
coordScale: number,
): WallEntry | null {
if (!walls?.length) return null;
const scale = coordScale > 0 ? coordScale : 1;
const queryLen = Math.hypot(b[0] - a[0], b[1] - a[1]);
if (queryLen < 1e-12) return null;
const queryAngle = segAngle(a, b);
const tol = Math.max(pitch * 0.5, 1e-9) * scale;
let best: { wall: WallEntry; extra: number; stable: string } | null = null;
for (const wall of walls) {
if (!(wall.cm > 0)) continue;
const span = entrySpan(wall, scale);
if (!span) continue;
const spanLen = Math.hypot(span[1][0] - span[0][0], span[1][1] - span[0][1]);
if (spanLen < 1e-12 || !angleClose(segAngle(span[0], span[1]), queryAngle)) continue;
if (distToSeg(a[0], a[1], span[0][0], span[0][1], span[1][0], span[1][1]) > tol
|| distToSeg(b[0], b[1], span[0][0], span[0][1], span[1][0], span[1][1]) > tol) continue;
// A shorter span may sit within endpoint tolerance but cannot prove that
// it covers the query. Keep that tolerance scale-relative, like lookup.
if (spanLen + tol < queryLen) continue;
const extra = Math.max(0, spanLen - queryLen);
const stable = `${wall.key}|${clampWallCm(wall.cm)}|${span.flat().join(',')}`;
if (!best || extra < best.extra - 1e-12
|| (Math.abs(extra - best.extra) <= 1e-12 && stable < best.stable)) {
best = { wall, extra, stable };
}
}
return best?.wall || null;
}
/**
+10
View File
@@ -95,6 +95,16 @@ describe('open-spans', () => {
assert.equal(def, DRAW_WALL_DEFAULT_CM);
});
it('thicknessOnClose inherits an exact parent run through atomic solid children', () => {
const walls = setWallThickness([], [5, 0], [5, 10], 22, pitch);
const cm = thicknessOnClose(
walls, [5, 4, 5, 6],
[[5, 0, 5, 4], [5, 6, 5, 10]],
pitch, 1, DRAW_WALL_DEFAULT_CM,
);
assert.equal(cm, 22);
});
it('purgeOpeningsOnSpan removes openings on the stretch', () => {
const openings = [
{ id: '1', x: 0.005, y: 0.002, angle: 90 }, // on x=5 if scale=1000 → wait normalised
+29
View File
@@ -146,6 +146,35 @@ test('lookupWall finds an entry and thicknessCmAt reads it', () => {
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', () => {