fix: collapse thickness island beside one T-node

Issue: #273
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-23 20:20:03 +03:00
parent 3e8733ad27
commit 105a8f70eb
13 changed files with 191 additions and 22 deletions
File diff suppressed because one or more lines are too long
+20 -8
View File
@@ -19,16 +19,22 @@ const out = await page.evaluate(async () => {
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${bucket.toFixed(4)}`;
};
const entry = (a, b, cm) => ({ key: wallKey(a, b), a, b, cm });
const y = 0.2, split = 0.5, microEnd = split + S / 3;
const y = 0.345833333, split = 0.8875, microEnd = split + 0.001381904;
const original = {
model_version: 6,
model_version: 7,
spaces: [{
id: 'micro', title: 'Micro wall', view_box: [0, 0, 1, 1], cell_cm: 5,
rooms: [{ id: 'room', name: 'Room', poly: [[0.2, y], [0.8, y], [0.8, 0.8], [0.2, 0.8]] }],
rooms: [
{ id: 'room', name: 'Room', poly: [[0.8, y], [0.95, y], [0.95, 0.5], [0.8, 0.5]] },
{ id: 'branch', name: 'Branch', poly: [
[0.845833333, 0.245833333], [split, 0.245833333],
[split, y], [0.845833333, y],
] },
],
walls: [
entry([0.2, y], [split, y], 22),
entry([0.8, y], [split, y], 22),
entry([split, y], [microEnd, y], 15),
entry([microEnd, y], [0.8, y], 22),
entry([microEnd, y], [0.95, y], 22),
],
}],
markers: [], settings: {},
@@ -52,7 +58,7 @@ const out = await page.evaluate(async () => {
return { ok: true, config_rev: 3, layout_rev: 3, can_undo: false };
}
if (message.type === 'houseplan/config/get')
return { config: clone(serverConfig), rev: 3, can_write: true };
return { config: clone(serverConfig), rev: 3, can_write: true, can_optimize_undo: !!backup };
if (message.type === 'houseplan/layout/get')
return { layout: clone(serverLayout), rev: 3 };
return baseCall(message);
@@ -81,10 +87,16 @@ const out = await page.evaluate(async () => {
const appliedWalls = card._serverCfg.spaces[0].walls;
result.applyUsesOneAtomicWrite = sent.filter((type) => type === 'houseplan/plan/optimize').length === 1;
result.applyStoresOneUniformRun = appliedWalls.length === 1 && appliedWalls[0].cm === 22
&& JSON.stringify(appliedWalls[0].a) === JSON.stringify([0.2, 0.2])
&& JSON.stringify(appliedWalls[0].b) === JSON.stringify([0.8, 0.2]);
&& JSON.stringify(appliedWalls[0].a) === JSON.stringify([0.8, y])
&& JSON.stringify(appliedWalls[0].b) === JSON.stringify([0.95, y]);
result.applyEnablesUndo = card._canOptimizeUndo === true;
await card._loadFromServer(); await card.updateComplete;
const reloadedWalls = card._serverCfg.spaces[0].walls;
result.reloadKeepsCanonicalRun = reloadedWalls.length === 1 && reloadedWalls[0].cm === 22
&& JSON.stringify(reloadedWalls[0].a) === JSON.stringify([0.8, y])
&& JSON.stringify(reloadedWalls[0].b) === JSON.stringify([0.95, y]);
await card._undoPlanOptimization(); await card.updateComplete;
result.undoUsesServerSnapshot = sent.filter((type) => type === 'houseplan/plan/optimize_undo').length === 1;
result.undoRestoresExactEntries = JSON.stringify(card._serverCfg.spaces[0].walls)
+2 -2
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- “Optimize plans” now removes a microscopic wall-thickness step beside a
T-junction when the short fragment is bounded by two equal-thickness pieces
of the same straight wall. The T-node and its perpendicular wall stay in
place; fragments between two topology nodes or touching an opening boundary
remain protected. Preview, Apply, reload and maintenance Undo use the same
canonical result ([#273](https://github.com/Matysh/houseplan-card/issues/273)).
## v1.67.0-beta.5 — 2026-08-23
- After deleting a complete HA device from the plan, any one of its active
+8
View File
@@ -8,6 +8,14 @@
## Не выпущено
- «Оптимизировать планы» теперь удаляет микроскопическую ступень толщины рядом
с T-стыком, если короткий фрагмент ограничен двумя участками одинаковой
толщины одной прямой стены. T-узел и перпендикулярная стена остаются на
месте; фрагменты между двумя топологическими узлами или на границе проёма
по-прежнему защищены. Preview, Apply, перезагрузка и служебная отмена
используют один канонический результат
([#273](https://github.com/Matysh/houseplan-card/issues/273)).
## v1.67.0-beta.5 — 2026-08-23
- После удаления целого HA-устройства с плана теперь можно отдельно вернуть
+11
View File
@@ -86,6 +86,17 @@ Explicit Optimize rewrites the compatibility key, retains `cm`, endpoints and
unknown siblings, and its next in-memory or backend storage round-trip is a
no-op. Legacy key-only records continue through the previous midpoint fallback.
Explicit Optimize also has one deliberately lossy wall-thickness repair. A
positive interval shorter than half a grid step may inherit its two equal
positive neighbours only when all three belong to one original straight room
edge and exact owners are unambiguous. One endpoint may be a room T-node: only
the interval `cm` changes, so that node and its perpendicular incident geometry
remain intact. An opening/open-span endpoint, two room topology endpoints,
unequal neighbours, a half-step-or-longer interval or conflicting owners always
block the repair. Normal read, render, Save and editor paths remain lossless;
only confirmed Optimize applies it, with the ordinary preview and server Undo
(#198, #273).
## Open-passage opening type (#157)
`space.openings[].type` additionally accepts the literal `passage`. Its
+9
View File
@@ -2733,6 +2733,15 @@ require hands on real hardware — they remain for the human pass.
[unit: test/plan-optimizer.test.mjs; auto:
smoke_optimize_micro_interval; mutation:
`optimizer-micro-interval-cleanup-disabled`].
- [ ] **A single T-node does not preserve an artificial thickness island
(#273)**: the minimized beta.5 `22 → 15 → 22` profile has a 1.381904-unit
centre beside one perpendicular room edge. Preview/Apply store one 22 cm
run, the T coordinate and incident room stay unchanged, render probes
see one continuous outer face, reload is idempotent and server Undo
restores the exact entries. A second topology endpoint or any open-span
endpoint still blocks cleanup [unit: test/plan-optimizer.test.mjs; auto:
smoke_optimize_micro_interval; mutation:
`optimizer-single-topology-island-blocked`].
- [ ] **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]
+8
View File
@@ -635,6 +635,14 @@ there is no exact copy, an active real device follows its unambiguous HA Area;
otherwise only its missing placement is detached, so the marker becomes
available on a valid plan without losing its settings.
Equal neighbouring wall-thickness fragments are compacted. Optimize may also
remove a different-thickness fragment shorter than half a grid step when equal
pieces of the same straight wall prove the replacement. This includes a
fragment touching exactly one room T-junction: the junction and perpendicular
wall do not move. A fragment between two room vertices or touching an opening
boundary is preserved. Ordinary opening, rendering, Save and editing never
perform this cleanup without explicit Optimize confirmation.
Old positions are classified before Apply. A position whose room label, device
or light-group owner is proven absent is removed automatically and counted by a
plain-language category. A live owner in a deleted space is named and preserved
+1 -1
View File
@@ -1391,7 +1391,7 @@ show_signal: true
| Декор и мебель | Положение и размеры округляются к сетке |
| Устройства и подписи комнат | Позиции округляются к сетке |
| Проёмы | Возвращаются на ближайшую стену, смещение вдоль стены округляется, угол исправляется |
| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, только если с обеих сторон находятся участки одной толщины и на его концах нет вершины комнаты или границы проёма |
| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, если с обеих сторон находятся участки одной толщины той же прямой стены. Допустим один T-узел комнаты: он и перпендикулярная стена не двигаются. Два топологических узла или граница проёма защищают участок |
| Перегородки | Коллинеарные соседние отрезки одинаковой толщины сращиваются в одну запись; узел сохраняется, если в нём сходится третья стена, стена комнаты, колонна или конец черновика. Проёмы остаются на своих местах |
| Виртуальные стены | Соседние/перекрывающиеся участки объединяются и приводятся к общей границе |
| Ссылки устройств | Точная подпись независимого импорта восстанавливает пространство, комнату и позицию. Иначе реальное устройство следует однозначной Area HA либо теряет только мёртвую привязку; настройки маркера сохраняются |
+4 -1
View File
@@ -282,7 +282,10 @@ production-scale `0 ↔ 10`, `10 ↔ 20` and `1 ↔ 100` collinear transitions a
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); explicit Optimize-only collapse of a sub-half-step isolated
thickness island with strict threshold, topology and ambiguity guards (#198);
thickness island with strict threshold and ambiguity guards (#198), including
the proven `equal → micro → equal` case beside exactly one room T-node while
opening endpoints and spans between two room topology nodes stay protected
(#273);
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
+13
View File
@@ -719,6 +719,19 @@ export const MUTANTS = [
+ ' const eps = Math.max(pitch * scale * 0.02, 1e-9);',
}],
},
{
id: 'optimizer-single-topology-island-blocked',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 273 Optimize" '
+ 'test/plan-optimizer.test.mjs',
because: 'a proven 22→15→22 island beside exactly one room T-node must not survive '
+ 'merely because the old #198 guard classified both endpoints identically',
patches: [{
file: 'src/plan-optimizer.ts',
find: ' if (isNode(a, roomNodes) && isNode(b, roomNodes)) continue;',
replace: ' if (isNode(a, roomNodes) || isNode(b, roomNodes)) continue;',
}],
},
{
id: 'atomic-child-thickness-parent-fallback',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+14 -6
View File
@@ -92,7 +92,9 @@ const optimizerSpanKey = (a: number[], b: number[], coordScale: number): string
*
* A positive interval shorter than half a grid step may inherit its two equal
* neighbours only when all three pieces belong to one original straight room
* edge and neither endpoint is a room/opening topology node. Candidates are
* edge. Opening endpoints and spans between two room topology nodes stay
* protected; one room T-node plus one synthetic endpoint is safe because the
* node coordinate and its incident edges are not changed. Candidates are
* collected from the untouched effective profile first, so replacements never
* cascade and input order cannot change the result.
*/
@@ -108,16 +110,17 @@ export function collapseIsolatedWallThicknessIslands(
if (!walls?.length) return [];
const scale = coordScale > 0 ? coordScale : 1;
const eps = Math.max(pitch * scale * 0.02, 1e-9);
const nodes: number[][] = [];
const roomNodes: number[][] = [];
for (const room of rooms || []) {
for (const point of roomPoly(room) || []) nodes.push([point[0], point[1]]);
for (const point of roomPoly(room) || []) roomNodes.push([point[0], point[1]]);
}
const openingNodes: number[][] = [];
for (const cut of openCuts || []) {
if (Array.isArray(cut) && cut.length >= 4 && cut.slice(0, 4).every(Number.isFinite)) {
nodes.push([cut[0], cut[1]], [cut[2], cut[3]]);
openingNodes.push([cut[0], cut[1]], [cut[2], cut[3]]);
}
}
const isTopologyNode = (point: number[]): boolean => nodes.some((node) => (
const isNode = (point: number[], nodes: number[][]): boolean => nodes.some((node) => (
Math.hypot(point[0] - node[0], point[1] - node[1]) <= eps * 2
));
@@ -152,7 +155,12 @@ export function collapseIsolatedWallThicknessIslands(
// scale-relative numeric guard instead of accidentally deleting it.
const halfStep = gridPitch * 0.5;
if (!(length > eps) || !(length < halfStep - gridPitch * 1e-9)) continue;
if (isTopologyNode(a) || isTopologyNode(b)) continue;
// An opening boundary is always semantic. Room vertices are different:
// a micro interval between two vertices is semantic, while exactly one
// vertex is the confirmed T-junction shape from #273. Replacing only
// the interval cm keeps that vertex and every incident edge intact.
if (isNode(a, openingNodes) || isNode(b, openingNodes)) continue;
if (isNode(a, roomNodes) && isNode(b, roomNodes)) continue;
const key = optimizerSpanKey(a, b, scale);
const found = candidates.get(key);
if (found) found.targets.add(leftCm);
+92 -2
View File
@@ -8,9 +8,11 @@ import {
import {
canonicalizeConfigGeometry, canonicalizeLayoutGeometry, canonicalizeNumber,
} from '../test-build/coordinate-canonicalization.js';
import { unionBodies } from '../test-build/physical-geometry.js';
import { pointInPhysicalGeometry, unionBodies } from '../test-build/physical-geometry.js';
import { GRID_PITCH, GRID_STEP_N as S, NORM_W } from '../test-build/space-geometry.js';
import { wallKey } from '../test-build/wall-thickness.js';
import {
wallBodiesGeometry, wallIntervals, wallKey,
} from '../test-build/wall-thickness.js';
const room = (id, x0, x1, openTo) => ({
id,
@@ -76,6 +78,29 @@ const microIntervalFixture = (length = S / 3, middleCm = 15, rightCm = 22) => {
};
};
// Privacy-minimised beta.5 topology from #273. The main room keeps one
// original straight parent edge. The upper room contributes only the
// perpendicular incident edge at `split`, so it is a real T-node rather than
// a collinear vertex inserted into the candidate's parent edge.
const topologyMicroIntervalFixture = () => {
const x0 = 0.8, split = 0.8875, x1 = 0.95, y = 0.345833333;
const microEnd = split + 0.001381904;
return {
split,
microEnd,
rooms: [
{ id: 'main', poly: [[x0, y], [x1, y], [x1, 0.5], [x0, 0.5]] },
{ id: 'branch', poly: [[0.845833333, 0.245833333], [split, 0.245833333],
[split, y], [0.845833333, y]] },
],
walls: [
exactWall([x0, y], [split, y], 22),
exactWall([split, y], [microEnd, y], 15),
exactWall([microEnd, y], [x1, y], 22),
],
};
};
test('Optimize collapses one isolated thickness micro-interval and is idempotent', () => {
const fixture = microIntervalFixture();
const config = {
@@ -104,6 +129,53 @@ test('Optimize collapses one isolated thickness micro-interval and is idempotent
assert.deepEqual(second.config, first.config);
});
test('issue 273 Optimize collapses the beta.5 island beside one T-node', () => {
const fixture = topologyMicroIntervalFixture();
const config = {
model_version: PLAN_MODEL_VERSION,
spaces: [{
id: 'topology-micro', title: 'Topology micro', view_box: [0, 0, 1, 1], cell_cm: 5,
rooms: fixture.rooms, walls: fixture.walls,
}],
markers: [], settings: {},
};
const before = structuredClone(config);
const rawIntervals = wallIntervals(fixture.rooms, fixture.walls, [], S, 5, S);
assert.ok(rawIntervals.some((interval) => interval.cm === 15),
'runtime remains lossless before explicit Optimize');
const first = optimizePlans(config, {});
assert.deepEqual(config, before, 'preview must not mutate the T-node source');
assert.equal(first.changed, true);
assert.equal(first.report.canonicalized, 1);
assert.equal(first.report.wallsMerged, 2);
const canonicalBefore = canonicalizeConfigGeometry(before);
assert.deepEqual(first.config.spaces[0].rooms, canonicalBefore.spaces[0].rooms,
'T coordinate and perpendicular incident room stay byte-equivalent');
assert.equal(first.config.spaces[0].walls.length, 1);
assert.equal(first.config.spaces[0].walls[0].cm, 22);
assert.deepEqual(first.config.spaces[0].walls[0].a, [0.8, 0.345833333]);
assert.deepEqual(first.config.spaces[0].walls[0].b, [0.95, 0.345833333]);
const afterIntervals = wallIntervals(
first.config.spaces[0].rooms, first.config.spaces[0].walls, [], S, 5, S,
);
assert.equal(afterIntervals.some((interval) => interval.cm === 15), false);
const geometry = wallBodiesGeometry(
first.config.spaces[0].rooms, first.config.spaces[0].walls, [], [], S, 5, S,
);
assert.ok(geometry, 'optimized profile must render a masonry body');
for (const x of [fixture.split - S, (fixture.split + fixture.microEnd) / 2,
fixture.microEnd + S]) {
assert.equal(pointInPhysicalGeometry([x, 0.345833333 - 0.008], geometry.geom), true,
`22 cm outer face must stay continuous at x=${x}`);
}
const second = optimizePlans(first.config, first.layout);
assertNoPersistedChanges(second);
assert.deepEqual(second.config, first.config);
});
test('Optimize canonicalizes the six-room ULP source without claiming a visible move', () => {
const config = {
model_version: PLAN_MODEL_VERSION,
@@ -265,6 +337,24 @@ test('micro-interval cleanup preserves ambiguous and topological boundaries', ()
'an open-cut endpoint at the island boundary blocks cleanup',
);
const betweenTwoNodes = topologyMicroIntervalFixture();
betweenTwoNodes.rooms.push({
id: 'branch-2',
poly: [
[betweenTwoNodes.microEnd, 0.245833333],
[betweenTwoNodes.microEnd + 5 * S, 0.245833333],
[betweenTwoNodes.microEnd + 5 * S, 0.345833333],
[betweenTwoNodes.microEnd, 0.345833333],
],
});
assert.deepEqual(
collapseIsolatedWallThicknessIslands(
betweenTwoNodes.rooms, betweenTwoNodes.walls, [], S, 5, S,
),
betweenTwoNodes.walls,
'an interval between two room topology nodes remains intentional',
);
const chain = microIntervalFixture(S / 3, 15, 22);
const y = 0.2, firstEnd = 0.5 + S / 3, secondEnd = firstEnd + S / 3;
chain.walls = [