mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: collapse thickness island beside one T-node
Issue: #273 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -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,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-устройства с плана теперь можно отдельно вернуть
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1391,7 +1391,7 @@ show_signal: true
|
||||
| Декор и мебель | Положение и размеры округляются к сетке |
|
||||
| Устройства и подписи комнат | Позиции округляются к сетке |
|
||||
| Проёмы | Возвращаются на ближайшую стену, смещение вдоль стены округляется, угол исправляется |
|
||||
| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, только если с обеих сторон находятся участки одной толщины и на его концах нет вершины комнаты или границы проёма |
|
||||
| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, если с обеих сторон находятся участки одной толщины той же прямой стены. Допустим один T-узел комнаты: он и перпендикулярная стена не двигаются. Два топологических узла или граница проёма защищают участок |
|
||||
| Перегородки | Коллинеарные соседние отрезки одинаковой толщины сращиваются в одну запись; узел сохраняется, если в нём сходится третья стена, стена комнаты, колонна или конец черновика. Проёмы остаются на своих местах |
|
||||
| Виртуальные стены | Соседние/перекрывающиеся участки объединяются и приводятся к общей границе |
|
||||
| Ссылки устройств | Точная подпись независимого импорта восстанавливает пространство, комнату и позицию. Иначе реальное устройство следует однозначной Area HA либо теряет только мёртвую привязку; настройки маркера сохраняются |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user