mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
fix: inherit parent thickness for atomic walls
Issue: #201 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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 и строк состояний, что и в Просмотре, поэтому при
|
||||
переключении режимов аккуратно выставленная подпись больше не сдвигается.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user