fix: reconcile outer walls before resize

Issue: #281
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 10:43:33 +03:00
parent a3e5f30a3c
commit 07bd1a6781
18 changed files with 538 additions and 58 deletions
+10 -4
View File
@@ -168,12 +168,18 @@ def _safe_optimize_partition_rehost(
return False
owners: set[str] = set()
collinear_rooms: set[str] = set()
for room in space.get("rooms") or []:
poly = _room_polygon(room)
if any(_segment_covers(poly[index], poly[(index + 1) % len(poly)], a, b)
for index in range(len(poly))):
owners.add(str(room.get("id", "")))
if len(owners) != 2:
room_id = str(room.get("id", ""))
edges = [(poly[index], poly[(index + 1) % len(poly)])
for index in range(len(poly))]
if any(_segment_covers(edge_a, edge_b, a, b) for edge_a, edge_b in edges):
owners.add(room_id)
if any(_segments_overlap_on_axis(a, b, edge_a, edge_b)
for edge_a, edge_b in edges):
collinear_rooms.add(room_id)
if len(owners) not in (1, 2) or collinear_rooms != owners:
return False
if any(_segments_overlap_on_axis(a, b, span["a"], span["b"])
for span in space.get("open_spans") or []):
+125
View File
@@ -0,0 +1,125 @@
// #281: an exact outer-wall partition blocks Resize, explicit Optimize safely
// rehosts its windows, and the production pointer path then moves two rooms.
import { readFileSync } from 'node:fs';
import { launch, checkAll, finish } from './serve.mjs';
const source = JSON.parse(readFileSync(
new URL('../test/fixtures/281-resize-outer-partitions.json', import.meta.url),
'utf8',
));
const clone = (value) => JSON.parse(JSON.stringify(value));
const { page, browser } = await launch({ width: 900, height: 820 });
const out = await page.evaluate(async (fixture) => {
const result = {};
const card = window.__card;
const copy = (value) => JSON.parse(JSON.stringify(value));
let serverConfig = copy(fixture);
let serverLayout = {};
const sent = [];
const baseCall = card.hass.callWS.bind(card.hass);
card.hass = {
...card.hass,
callWS: async (message) => {
if (message.type === 'houseplan/plan/optimize') {
sent.push(message.type);
serverConfig = copy(message.config);
serverLayout = copy(message.layout);
return { ok: true, config_rev: 2, layout_rev: 2, can_undo: true };
}
if (message.type === 'houseplan/config/get')
return { config: copy(serverConfig), rev: 2, can_write: true, can_optimize_undo: true };
if (message.type === 'houseplan/layout/get')
return { layout: copy(serverLayout), rev: 2 };
return baseCall(message);
},
};
const resetResize = async () => {
card._setMode('plan');
card._tool = 'resize';
card._rszDrag = null;
card._rszPreview = null;
card._rszLive = null;
card._rszEligibilityCache = null;
card._modelCache = null;
card._frame = null;
card.requestUpdate();
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(resolve));
};
const targetHandles = () => [...card.renderRoot.querySelectorAll('.rszhandle')]
.filter((handle) => Math.abs(Number(handle.getAttribute('cx')) - 500) < 1
&& Math.abs(Number(handle.getAttribute('cy')) - 500) < 1);
const screenPoint = (x, y) => {
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const svg = stage.querySelector('svg');
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
return [rect.left + ((x - vx) / vw) * rect.width,
rect.top + ((y - vy) / vh) * rect.height];
};
const dispatch = (target, type, x, y, pointerId = 281) => target.dispatchEvent(
new PointerEvent(type, {
bubbles: true, cancelable: true, pointerId,
clientX: x, clientY: y, pointerType: 'mouse',
buttons: type === 'pointerup' ? 0 : 1,
}),
);
card._serverCfg = copy(fixture);
card._layout = {};
card._space = 'resize-outer';
await resetResize();
const beforeHandles = targetHandles();
result.beforeIsExplainedDisabled = beforeHandles.length === 2
&& beforeHandles.every((handle) => handle.getAttribute('aria-disabled') === 'true'
&& /independent wall|partition|перегород/i.test(handle.getAttribute('aria-label') || ''));
const [beforeX, beforeY] = screenPoint(500, 500);
dispatch(beforeHandles[0], 'pointerdown', beforeX, beforeY);
result.disabledCapturesNothing = card._rszDrag == null
&& card._geometryHistory.size === 0;
card._openAlignDialog();
await card.updateComplete;
result.previewProvesOuterRewrite = card._alignDialog?.changed === true
&& card._alignDialog?.preflight?.ok === true
&& card._alignDialog?.report?.partitionsReconciled === 3
&& card._alignDialog?.report?.openingsRehosted === 2
&& card._alignDialog?.config?.spaces?.[0]?.partitions == null
&& card._alignDialog?.config?.spaces?.[0]?.openings?.every((opening) => opening.host == null);
await card._runAlignToGrid();
await card.updateComplete;
result.optimizeUsesOneWrite = sent.filter((type) => type === 'houseplan/plan/optimize').length === 1;
const optimized = card._serverCfg.spaces[0];
result.optimizePreservesOpeningFields = optimized.partitions == null
&& optimized.openings[0].host == null
&& optimized.openings[0].cover === 'cover.left'
&& optimized.openings[0].future_field?.keep === 'left'
&& optimized.openings[1].host == null
&& optimized.openings[1].contact === 'binary_sensor.right';
await resetResize();
const afterHandles = targetHandles();
result.afterIsEnabled = afterHandles.length === 2
&& afterHandles.every((handle) => handle.getAttribute('aria-disabled') === 'false');
const [startX, startY] = screenPoint(500, 500);
const [moveX] = screenPoint(550, 500);
dispatch(afterHandles[0], 'pointerdown', startX, startY);
result.dragStarts = card._rszDrag != null;
dispatch(afterHandles[0], 'pointermove', moveX, startY);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
dispatch(afterHandles[0], 'pointerup', moveX, startY);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const left = card._serverCfg.spaces[0].rooms.find((room) => room.id === 'left');
const right = card._serverCfg.spaces[0].rooms.find((room) => room.id === 'right');
result.gestureMovesExactlySharedEndpoints = Math.abs(left.poly[1][0] - 0.55) < 0.006
&& Math.abs(left.poly[2][0] - 0.55) < 0.006
&& Math.abs(right.poly[0][0] - 0.55) < 0.006
&& Math.abs(right.poly[3][0] - 0.55) < 0.006
&& card._serverCfg.spaces[0].rooms.length === 2
&& card._geometryHistory.size === 1;
return result;
}, clone(source));
await finish(browser, checkAll(out));
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- “Optimize plans” now safely removes exact redundant partitions over outer
room walls, including lossless reattachment of their hosted windows. Resize
then actually moves an eligible shared wall; a handle with no valid grid step
no longer looks enabled and explains the blocking reason
([#281](https://github.com/Matysh/houseplan-card/issues/281)).
- The backend now accepts a proved Optimize reconciliation of a coincident
partition: its opening remains on the same shared wall through Apply, reload
and Undo. Arbitrary host stripping by an ordinary write or crafted candidate
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- «Оптимизировать планы» теперь безопасно убирает точные лишние перегородки
поверх внешних стен комнат, включая неизменное перепривязывание размещённых
на них окон. После этого «Изменить размер» действительно двигает доступную
общую стену; ручка без единого допустимого шага больше не выглядит активной
и объясняет причину блокировки
([#281](https://github.com/Matysh/houseplan-card/issues/281)).
- Backend теперь принимает доказанное преобразование совпадающей перегородки
из Optimize: её проём сохраняется на той же общей стене и корректно проходит
Apply/reload/Undo. Произвольное снятие host обычной записью или поддельным
+5 -4
View File
@@ -137,10 +137,11 @@ explicit rebind.
The sole host-removal exception is the explicit Optimize reconciliation from
#276/#280. The server does not trust a client counter: it independently proves
that the old partition was removed, its complete segment is a solid shared
boundary of exactly two rooms, the replacement wall envelope is not narrower,
the materialized centre/angle and every unrelated opening field are unchanged,
and no new slot overlaps. This capability is enabled only by
that the old partition was removed, its complete segment is either one solid
outer boundary owned by exactly one room or one solid shared boundary owned by
exactly two rooms, the replacement wall envelope is not narrower, the
materialized centre/angle and every unrelated opening field are unchanged, and
no new slot overlaps. This capability is enabled only by
`houseplan/plan/optimize`; ordinary config writes and crafted candidates keep
the fail-closed `invalid_partition_opening_host` result.
+16 -2
View File
@@ -78,8 +78,17 @@ The closest stop in either direction wins:
- moving-wall opening that would no longer fit;
- loss of exact shared endpoints or any structural candidate failure.
The final persisted position is the last safe grid node. A safe range containing
only zero yields an explained no-op and no write.
The final persisted position is the last safe grid node. Eligibility probes one
grid step in both directions through the same exact validator. If neither step
is safe, the handle remains visible and focusable but is disabled with the
stable reason that blocks the move; it never starts a no-op gesture or write.
An exact independent partition over a room boundary remains a physical blocker.
The explicit **Optimize plans** action may remove that blocker first only when
the complete partition is provably identical to one solid outer wall or one
solid wall shared by exactly two rooms. Any hosted openings must be materialized
without changing their centre, angle or fields, and the backend independently
proves the complete rewrite. Resize itself never moves or ignores partitions.
## Preview and commit
@@ -133,6 +142,11 @@ building the preview frame itself.
- `demo/smoke_room_resize.mjs`: production bundle pointer handlers,
preview/commit/Undo, disabled accessibility, real fixture topology,
production-preflight failure and cancellation;
- `test/resize-optimize.test.mjs` and
`demo/smoke_resize_outer_reconciliation.mjs`: an exact outer-wall partition
blocks a zero-range handle, Optimize safely rehosts its windows and removes
the blocker, then the same production Resize gesture changes exactly two
rooms;
- `demo/benchmark_safe_resize.mjs`: same-run pointer and cached pointerup budgets;
- `demo/benchmark_safe_resize_render.mjs`: warm 20-room/80-handle layer p95
and exactly one geometry snapshot per rendered frame;
+13
View File
@@ -2868,6 +2868,19 @@ require hands on real hardware — they remain for the human pass.
`optimize-preflight-bypassed`; performance:
`npm run benchmark:coincident-partitions`; golden:
`coincident-partition-{before,thin,thick,virtual}-dark`].
- [ ] **Optimize unlocks only proved zero-range Resize handles (#281)**: three
exact independent partitions over solid one-room outer boundaries block
the affected shared-wall Resize before maintenance. Optimize removes all
three, materializes both hosted windows without changing their fields,
passes independent backend proof and is idempotent. Afterwards the target
handle has a non-zero grid step in both directions and one production
pointer gesture changes exactly the two adjacent rooms. Partial, unknown
or opening-overlapped outer candidates remain untouched. Every handle
reported enabled on the anonymized `44.json` fixture has a non-zero
contiguous range; a zero-range handle stays visible/focusable but disabled
and captures no pointer [unit: test/resize-optimize.test.mjs; backend:
tests_backend/test_validation.py + tests_backend/test_ha_websocket.py;
auto: smoke_resize_outer_reconciliation].
- [ ] **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]
+10 -9
View File
@@ -385,15 +385,16 @@ delete requires cascade confirmation, and malformed/orphan hosts remain opaque.
Opening cuts change physical masonry, not the structural wall axes used for
room-face detection (#185).
Explicit Optimize has one stricter reconciliation pass (#276). It may remove a
partition only when its axis is endpoint-to-endpoint identical to one uniform,
solid shared interval owned by exactly two rooms and no draft, column, second
partition or conflicting opening makes the result ambiguous. Hosted openings
are materialised at the same centre/angle as ordinary room-wall openings. The
canonical shared thickness is `max(roomCm, partitionCm)`, which is exactly the
union envelope of two centred coincident bodies. The pass is immutable,
idempotent and followed by the common whole-plan geometry preflight; rendering
and ordinary Save never perform it implicitly.
Explicit Optimize has one stricter reconciliation pass (#276/#281). It may
remove a partition only when its axis is endpoint-to-endpoint identical to one
uniform, solid outer interval owned by one room or shared interval owned by
exactly two rooms, and no draft, column, second partition or conflicting
opening makes the result ambiguous. Hosted openings are materialised at the
same centre/angle as ordinary room-wall openings. The canonical thickness is
`max(roomCm, partitionCm)`, which is exactly the union envelope of the centred
coincident bodies. The pass is immutable, idempotent and followed by the common
whole-plan geometry preflight; rendering, Resize and ordinary Save never
perform it implicitly.
`physicalBodySet()` separates raw draft/partition/column bodies from computed
junction patches and their joined geometry. Raw bodies remain authoritative for
+5 -3
View File
@@ -153,9 +153,11 @@ Disabled handle:
6. Openings moving shared wall смещаются один раз и остаются на той же shared
boundary. Side-wall openings остаются на месте и ограничивают range.
Если safe range после grid snap содержит только исходную позицию, handle
остаётся visible enabled для объяснимости, но попытка drag даёт stop/no-op и не
создаёт запись.
Если safe range после grid snap содержит только исходную позицию, resolver
проверяет один grid step в обе стороны тем же exact validator. Когда оба
направления запрещены, handle остаётся visible/focusable, но disabled с
приоритетной причиной; pointer capture, preview, history и write не создаются.
Это уточнение принято в #281 и заменяет прежний enabled no-op контракт.
## 7. Stops и live clamp
+4 -10
View File
@@ -953,20 +953,14 @@ export const MUTANTS = [
+ 'be deleted just because its hosted opening happens to fit the longer room boundary (#276)',
patches: [{
file: 'src/coincident-partitions.ts',
find: ' .filter((interval) => sameSegment(\n'
find: ' const owners = (solidByKey.get(segmentKey(partition.a, partition.b)) || [])\n'
+ ' .filter((interval) => sameSegment(\n'
+ ' interval.a, interval.b, partition.a, partition.b, eps,\n'
+ ' ));',
replace: ' .filter((interval) => collinearOverlap(\n'
replace: ' const owners = (solidByKey.get(segmentKey(partition.a, partition.b)) || [])\n'
+ ' .filter((interval) => collinearOverlap(\n'
+ ' interval.a, interval.b, partition.a, partition.b, eps,\n'
+ ' ) > eps);',
}, {
file: 'src/coincident-partitions.ts',
find: ' if (shared.some((interval) => !sameSegment(\n'
+ ' interval.a, interval.b, partition.a, partition.b, eps,\n'
+ ' ))) continue;',
replace: ' if (false && shared.some((interval) => !sameSegment(\n'
+ ' interval.a, interval.b, partition.a, partition.b, eps,\n'
+ ' ))) continue;',
}],
},
{
+25 -19
View File
@@ -1,6 +1,6 @@
/**
* Lossless explicit-Optimize reconciliation for an independent partition that
* is exactly the same centred physical wall as one solid shared room edge.
* is exactly the same centred physical wall as one solid room edge.
* Runtime renderers never call this module and it never mutates its inputs.
*/
import {
@@ -180,13 +180,14 @@ export function reconcileCoincidentPartitions(
[b[0] / keyScale, b[1] / keyScale],
options.pitch,
);
const sharedByKey = new Map<string, WallInterval[]>();
const solidByKey = new Map<string, WallInterval[]>();
for (const interval of intervals) {
if (interval.kind !== 'shared' || interval.open || !(interval.cm > 0)) continue;
if ((interval.kind !== 'shared' && interval.kind !== 'outer')
|| interval.open || !(interval.cm > 0)) continue;
const key = segmentKey(interval.a, interval.b);
const bucket = sharedByKey.get(key);
const bucket = solidByKey.get(key);
if (bucket) bucket.push(interval);
else sharedByKey.set(key, [interval]);
else solidByKey.set(key, [interval]);
}
const modelById = new Map(model.partitions.map((partition) => [partition.id, partition]));
let walls = walls0;
@@ -204,21 +205,25 @@ export function reconcileCoincidentPartitions(
partition.b[0] - partition.a[0], partition.b[1] - partition.a[1],
) <= eps) continue;
const owners = (sharedByKey.get(segmentKey(partition.a, partition.b)) || [])
const owners = (solidByKey.get(segmentKey(partition.a, partition.b)) || [])
.filter((interval) => sameSegment(
interval.a, interval.b, partition.a, partition.b, eps,
));
if (owners.length !== 2) continue;
if (!owners.length) continue;
const byRoom = new Map<string, WallInterval>();
for (const owner of owners) byRoom.set(owner.roomId, owner);
if (byRoom.size !== 2) continue;
const shared = [...byRoom.values()];
if (shared.some((interval) => !sameSegment(
const solid = [...byRoom.values()];
const kinds = new Set(solid.map((interval) => interval.kind));
if (kinds.size !== 1) continue;
const kind = solid[0]?.kind;
if ((kind === 'shared' && byRoom.size !== 2)
|| (kind === 'outer' && byRoom.size !== 1)) continue;
if (solid.some((interval) => !sameSegment(
interval.a, interval.b, partition.a, partition.b, eps,
))) continue;
const sharedCms = new Set(shared.map((interval) => interval.cm));
if (sharedCms.size !== 1) continue;
const roomCm = shared[0].cm;
const solidCms = new Set(solid.map((interval) => interval.cm));
if (solidCms.size !== 1) continue;
const roomCm = solid[0].cm;
const anotherPartition = model.partitions.some((other) => (
other.id !== partition.id
@@ -244,7 +249,7 @@ export function reconcileCoincidentPartitions(
}));
if (resolvedHosted.some((item) => !item.resolved)) continue;
if (resolvedHosted.some((item) => !partitionOpeningHasCompositeRoomWall(
item.resolved!, shared, eps,
item.resolved!, solid, eps,
))) continue;
const finalCm = Math.max(roomCm, partition.cm);
@@ -282,7 +287,7 @@ export function reconcileCoincidentPartitions(
model.rooms, nextWalls, openCuts,
options.pitch, options.cellCm, options.gridPitch, options.coordScale,
);
const roomIds = new Set(shared.map((interval) => interval.roomId));
const roomIds = new Set(solid.map((interval) => interval.roomId));
const associationsOk = nextHosted.every((opening) => {
const association = resolveOpeningWallAssociation(index, {
x: opening.x * options.coordScale,
@@ -290,10 +295,11 @@ export function reconcileCoincidentPartitions(
angle: opening.angle,
length: opening.length * options.coordScale,
}, true);
return !!association.negative?.full && !!association.positive?.full
&& roomIds.has(association.negative.roomId)
&& roomIds.has(association.positive.roomId)
&& association.negative.roomId !== association.positive.roomId;
const full = [association.negative, association.positive]
.filter((side): side is NonNullable<typeof association.negative> => !!side?.full);
const associated = new Set(full.map((side) => side.roomId));
return full.length === roomIds.size && associated.size === roomIds.size
&& [...roomIds].every((roomId) => associated.has(roomId));
});
if (!associationsOk) continue;
+1
View File
@@ -8403,6 +8403,7 @@ class HouseplanCard extends LitElement {
return {
minDim: this._cmToUnits(MIN_ROOM_CM),
eps: this._gridPitch * 0.05,
step: this._gridPitch,
movingHalf: cm > 0 ? wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2 : 0,
obstacles: this._rszObstacles(),
thicknessConflict: exactCms.size > 1,
+18
View File
@@ -46,6 +46,8 @@ export type SafeResizeObstacle =
| { kind: 'circle'; center: number[]; radius: number };
export interface SafeResizeOptions extends StopOpts {
/** One snapped editor step; eligibility must permit a non-zero neighbour. */
step?: number;
/** Physical half-depth of the moving wall in render units. */
movingHalf?: number;
/** Independent walls, drafts and columns. They are immutable hard stops. */
@@ -706,6 +708,22 @@ export function resolveSafeResize(
if (!validateSafeResize(rooms, openings, plan, 0, opts)) {
return { enabled: false, reason: 'invalid-geometry' };
}
const step = Math.abs(Number(opts.step));
if (Number.isFinite(step) && step > eps) {
const neighbours = [-step, step];
if (!neighbours.some((delta) => validateSafeResize(
rooms, openings, plan, delta, opts,
))) {
const withoutObstacles = { ...opts, obstacles: [] };
if (neighbours.some((delta) => validateSafeResize(
rooms, openings, plan, delta, withoutObstacles,
))) return { enabled: false, reason: 'duplicate-physical-wall' };
if (openings.length && neighbours.some((delta) => validateSafeResize(
rooms, [], plan, delta, opts,
))) return { enabled: false, reason: 'opening-conflict' };
return { enabled: false, reason: 'invalid-geometry' };
}
}
return { enabled: true, plan };
}
+37
View File
@@ -0,0 +1,37 @@
{
"model_version": 7,
"spaces": [
{
"id": "resize-outer",
"title": "Resize outer",
"view_box": [0, 0, 1, 1],
"cell_cm": 5,
"rooms": [
{ "id": "left", "name": "Left", "area": null,
"poly": [[0, 0], [0.5, 0], [0.5, 1], [0, 1]] },
{ "id": "right", "name": "Right", "area": null,
"poly": [[0.5, 0], [1, 0], [1, 1], [0.5, 1]] }
],
"walls": [
{ "key": "0.500000,0.500000@1.5706", "cm": 20, "a": [0.5, 0], "b": [0.5, 1] },
{ "key": "0.250000,0.000000@0.0000", "cm": 30, "a": [0, 0], "b": [0.5, 0] },
{ "key": "0.750000,0.000000@0.0000", "cm": 30, "a": [0.5, 0], "b": [1, 0] },
{ "key": "0.750000,1.000000@0.0000", "cm": 20, "a": [1, 1], "b": [0.5, 1] }
],
"openings": [
{
"id": "left-window", "type": "window",
"x": 0.25, "y": 0, "angle": 0, "length": 0.2,
"cover": "cover.left", "future_field": { "keep": "left" }
},
{
"id": "right-window", "type": "window",
"x": 0.75, "y": 0, "angle": 0, "length": 0.2,
"contact": "binary_sensor.right"
}
]
}
],
"markers": [],
"settings": {}
}
+52
View File
@@ -0,0 +1,52 @@
{
"model_version": 7,
"spaces": [
{
"id": "resize-outer",
"title": "Resize outer",
"view_box": [0, 0, 1, 1],
"cell_cm": 5,
"rooms": [
{
"id": "left",
"name": "Left",
"area": null,
"poly": [[0, 0], [0.5, 0], [0.5, 1], [0, 1]]
},
{
"id": "right",
"name": "Right",
"area": null,
"poly": [[0.5, 0], [1, 0], [1, 1], [0.5, 1]]
}
],
"walls": [
{ "key": "0.500000,0.500000@1.5706", "a": [0.5, 0], "b": [0.5, 1], "cm": 20 },
{ "key": "0.250000,0.000000@0.0000", "a": [0, 0], "b": [0.5, 0], "cm": 30 },
{ "key": "0.750000,0.000000@0.0000", "a": [0.5, 0], "b": [1, 0], "cm": 30 },
{ "key": "0.750000,1.000000@0.0000", "a": [0.5, 1], "b": [1, 1], "cm": 20 }
],
"partitions": [
{ "id": "top-left", "a": [0, 0], "b": [0.5, 0], "cm": 30 },
{ "id": "top-right", "a": [0.5, 0], "b": [1, 0], "cm": 30 },
{ "id": "bottom-right", "a": [0.5, 1], "b": [1, 1], "cm": 20 }
],
"openings": [
{
"id": "left-window", "type": "window",
"x": 0, "y": 0, "angle": 0, "length": 0.2,
"cover": "cover.left", "future_field": { "keep": "left" },
"host": { "kind": "partition", "id": "top-left", "t": 0.5 }
},
{
"id": "right-window", "type": "window",
"x": 0, "y": 0, "angle": 0, "length": 0.2,
"contact": "binary_sensor.right",
"host": { "kind": "partition", "id": "top-right", "t": 0.5 }
}
]
}
],
"markers": [],
"settings": {}
}
+157
View File
@@ -0,0 +1,157 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { optimizePlans } from '../test-build/plan-optimizer.js';
import {
applySafeResize, clampSafeResize, resolveSafeResize,
} from '../test-build/resize.js';
import { GRID_PITCH, NORM_W } from '../test-build/space-geometry.js';
import { thicknessCmAt, wallCmToUnits } from '../test-build/wall-thickness.js';
const source = JSON.parse(readFileSync(
new URL('./fixtures/281-resize-outer-partitions.json', import.meta.url), 'utf8',
));
const expected = JSON.parse(readFileSync(
new URL('./fixtures/281-resize-outer-candidate.json', import.meta.url), 'utf8',
));
const clone = (value) => structuredClone(value);
const resizeInputs = (space) => ({
rooms: space.rooms.map((room) => ({
id: room.id,
poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
})),
openings: (space.openings || []).map((opening) => ({
id: opening.id,
x: opening.x * NORM_W,
y: opening.y * NORM_W,
length: opening.length * NORM_W,
angle: opening.angle,
hosted: !!opening.host,
})),
obstacles: (space.partitions || []).map((partition) => ({
kind: 'segment',
a: partition.a.map((value) => value * NORM_W),
b: partition.b.map((value) => value * NORM_W),
half: wallCmToUnits(partition.cm, space.cell_cm, GRID_PITCH) / 2,
})),
});
const options = (obstacles) => ({
minDim: wallCmToUnits(30, 5, GRID_PITCH),
eps: GRID_PITCH * 0.05,
step: GRID_PITCH,
movingHalf: wallCmToUnits(20, 5, GRID_PITCH) / 2,
obstacles,
});
test('issue 281 Optimize removes exact outer duplicates and rehosts their windows', () => {
const input = clone(source);
const before = clone(input);
const result = optimizePlans(input, {});
assert.deepEqual(input, before, 'Optimize preview must stay immutable');
assert.equal(result.report.partitionsReconciled, 3);
assert.equal(result.report.openingsRehosted, 2);
assert.deepEqual(result.config, expected);
assert.ok(result.config.spaces[0].openings.every((opening) => !opening.host));
const second = optimizePlans(result.config, result.layout);
assert.equal(second.changed, false);
assert.deepEqual(second.config, result.config);
});
test('issue 281 disables the old zero-range handle and enables it after Optimize', () => {
const before = resizeInputs(source.spaces[0]);
const blocked = resolveSafeResize(
before.rooms, before.openings, 'left', 1, options(before.obstacles),
);
assert.deepEqual(blocked, { enabled: false, reason: 'duplicate-physical-wall' });
const optimized = optimizePlans(clone(source), {}).config.spaces[0];
const after = resizeInputs(optimized);
const opts = options(after.obstacles);
const resolution = resolveSafeResize(after.rooms, after.openings, 'left', 1, opts);
assert.equal(resolution.enabled, true);
const plan = resolution.plan;
assert.equal(clampSafeResize(
after.rooms, after.openings, plan, GRID_PITCH, GRID_PITCH, opts,
), GRID_PITCH);
assert.equal(clampSafeResize(
after.rooms, after.openings, plan, -GRID_PITCH, GRID_PITCH, opts,
), -GRID_PITCH);
const moved = applySafeResize(after.rooms, after.openings, plan, GRID_PITCH);
assert.deepEqual(Object.keys(moved.polys).sort(), ['left', 'right']);
assert.ok(Object.values(moved.polys).every((poly) => poly.length === 4));
});
test('issue 281 leaves unsafe outer duplicates as hard stops', () => {
const variants = [];
const partial = clone(source);
partial.spaces[0].partitions[0].b = [0.4, 0];
variants.push(partial);
const unknown = clone(source);
unknown.spaces[0].partitions[0].future_semantics = true;
variants.push(unknown);
const overlap = clone(source);
overlap.spaces[0].openings.push({
id: 'ordinary-overlap', type: 'window',
x: 0.25, y: 0, angle: 0, length: 0.2,
});
variants.push(overlap);
for (const input of variants) {
const result = optimizePlans(input, {});
assert.ok(
result.config.spaces[0].partitions?.some((partition) => partition.id === 'top-left'),
'unsafe outer partition must remain explicit',
);
}
});
test('issue 281 private exact fixture has no enabled zero-range handle', (t) => {
const path = 'C:\\Temp\\44.json';
if (!existsSync(path)) return t.skip('private #281 fixture is not present');
const raw = JSON.parse(readFileSync(path, 'utf8'));
const previous = raw.payload?.config || raw.config || raw;
const optimized = optimizePlans(previous, {}).config.spaces[0];
const inputs = resizeInputs(optimized);
const openings = inputs.openings;
const obstacles = inputs.obstacles;
let enabled = 0;
let zeroRange = 0;
let target = null;
for (const room of inputs.rooms) {
for (let edge = 0; edge < room.poly.length; edge++) {
const a = room.poly[edge], b = room.poly[(edge + 1) % room.poly.length];
const cm = thicknessCmAt(optimized.walls, a, b, 1 / 240, NORM_W) || 15;
const opts = {
minDim: wallCmToUnits(30, optimized.cell_cm, GRID_PITCH),
eps: GRID_PITCH * 0.05,
step: GRID_PITCH,
movingHalf: wallCmToUnits(cm, optimized.cell_cm, GRID_PITCH) / 2,
obstacles,
};
const resolution = resolveSafeResize(inputs.rooms, openings, room.id, edge, opts);
if (!resolution.enabled) continue;
enabled++;
const negative = clampSafeResize(
inputs.rooms, openings, resolution.plan, -GRID_PITCH, GRID_PITCH, opts,
);
const positive = clampSafeResize(
inputs.rooms, openings, resolution.plan, GRID_PITCH, GRID_PITCH, opts,
);
if (negative === 0 && positive === 0) zeroRange++;
const configA = a.map((value) => value / NORM_W);
const configB = b.map((value) => value / NORM_W);
if ([configA, configB].every((point) =>
Math.abs(point[0] - 0.4) < 1e-8
&& point[1] >= -0.208333334 && point[1] <= 1.266666668)) {
target = { negative, positive };
}
}
}
assert.equal(zeroRange, 0, `${zeroRange}/${enabled} enabled handles cannot move`);
assert.ok(target && target.negative < 0 && target.positive > 0,
'the reported shared wall must move at least one step both ways');
});
+11 -6
View File
@@ -534,10 +534,15 @@ async def test_optimize_undo_restores_geometry_but_not_legacy_noisy_bits(
assert restored_layout["lamp"]["x"] == -0.12345679
@pytest.mark.parametrize(("source_name", "candidate_name", "partition_id"), [
("276-coincident-partition.json", "280-optimize-rehost-candidate.json", "redundant"),
("281-resize-outer-partitions.json", "281-resize-outer-candidate.json", "top-left"),
])
async def test_optimize_accepts_proved_rehost_and_undo_restores_host(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
hass: HomeAssistant, hass_ws_client: WebSocketGenerator,
source_name: str, candidate_name: str, partition_id: str,
) -> None:
"""#280: the real WS boundary accepts exactly the #276 candidate."""
"""#280/#281: the real WS boundary accepts shared and outer proof."""
from custom_components.houseplan.store import get_data
await _setup(hass)
@@ -545,10 +550,10 @@ async def test_optimize_accepts_proved_rehost_and_undo_restores_host(
runtime = get_data(hass)
fixture_dir = Path(__file__).parents[1] / "test" / "fixtures"
previous = json.loads(
(fixture_dir / "276-coincident-partition.json").read_text(encoding="utf-8")
(fixture_dir / source_name).read_text(encoding="utf-8")
)
candidate = json.loads(
(fixture_dir / "280-optimize-rehost-candidate.json").read_text(encoding="utf-8")
(fixture_dir / candidate_name).read_text(encoding="utf-8")
)
await runtime.config_store.async_save({"config": previous, "rev": 1})
await runtime.store.async_save({"layout": {}, "rev": 1})
@@ -575,9 +580,9 @@ async def test_optimize_accepts_proved_rehost_and_undo_restores_host(
assert undone["success"] and undone["result"]["can_undo"] is False
restored = (await runtime.config_store.async_load())["config"]
restored_space = restored["spaces"][0]
assert restored_space["partitions"][0]["id"] == "redundant"
assert any(item["id"] == partition_id for item in restored_space["partitions"])
assert restored_space["openings"][0]["host"] == {
"kind": "partition", "id": "redundant", "t": 0.5,
"kind": "partition", "id": partition_id, "t": 0.5,
}
+38 -1
View File
@@ -1764,7 +1764,7 @@ def test_optimize_accepts_only_proved_partition_to_room_wall_rehost():
"b": [0.504166667, 0.995833333],
"cm": 20,
}]))
rejected(lambda space: space["rooms"].pop())
rejected(lambda space: space.update(rooms=[]))
rejected(lambda space: space["walls"][0].update(cm=10))
rejected(lambda space: space["openings"][0].update(x=0.51))
rejected(lambda space: space["openings"][0].update(angle=-89))
@@ -1782,6 +1782,43 @@ def test_optimize_accepts_only_proved_partition_to_room_wall_rehost():
}]))
def test_optimize_accepts_proved_outer_rehost_for_resize_preparation():
fixture_dir = os.path.join(_ROOT, "test", "fixtures")
previous = json.load(open(
os.path.join(fixture_dir, "281-resize-outer-partitions.json"),
encoding="utf-8",
))
candidate = json.load(open(
os.path.join(fixture_dir, "281-resize-outer-candidate.json"),
encoding="utf-8",
))
with pytest.raises(v.PartitionOpeningHostError):
v.validate_partition_opening_hosts(candidate, previous)
v.validate_partition_opening_hosts(
candidate, previous, allow_optimize_rehost=True
)
conflict = json.loads(json.dumps(candidate))
conflict["spaces"][0]["openings"].append({
"id": "overlap", "type": "window",
"x": 0.25, "y": 0, "angle": 0, "length": 0.1,
})
with pytest.raises(v.PartitionOpeningHostError):
v.validate_partition_opening_hosts(
conflict, previous, allow_optimize_rehost=True
)
partial_owner = json.loads(json.dumps(candidate))
partial_owner["spaces"][0]["rooms"].append({
"id": "partial", "name": "Partial", "area": None,
"poly": [[0.2, 0], [0.3, 0], [0.3, -0.2], [0.2, -0.2]],
})
with pytest.raises(v.PartitionOpeningHostError):
v.validate_partition_opening_hosts(
partial_owner, previous, allow_optimize_rehost=True
)
def test_optimize_rehost_validation_is_atomic_across_the_batch():
root = os.path.dirname(os.path.dirname(__file__))
fixture_dir = os.path.join(root, "test", "fixtures")