diff --git a/custom_components/houseplan/validation.py b/custom_components/houseplan/validation.py index 55908b56..b630b857 100644 --- a/custom_components/houseplan/validation.py +++ b/custom_components/houseplan/validation.py @@ -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 []): diff --git a/demo/smoke_resize_outer_reconciliation.mjs b/demo/smoke_resize_outer_reconciliation.mjs new file mode 100644 index 00000000..6f87b94b --- /dev/null +++ b/demo/smoke_resize_outer_reconciliation.mjs @@ -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)); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0b71e58b..baac5343 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index a02382f3..f93c3e0b 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,12 @@ ## Не выпущено +- «Оптимизировать планы» теперь безопасно убирает точные лишние перегородки + поверх внешних стен комнат, включая неизменное перепривязывание размещённых + на них окон. После этого «Изменить размер» действительно двигает доступную + общую стену; ручка без единого допустимого шага больше не выглядит активной + и объясняет причину блокировки + ([#281](https://github.com/Matysh/houseplan-card/issues/281)). - Backend теперь принимает доказанное преобразование совпадающей перегородки из Optimize: её проём сохраняется на той же общей стене и корректно проходит Apply/reload/Undo. Произвольное снятие host обычной записью или поддельным diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md index 05389e60..c386c308 100644 --- a/docs/CONFIG-COMPATIBILITY.md +++ b/docs/CONFIG-COMPATIBILITY.md @@ -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. diff --git a/docs/RESIZE.md b/docs/RESIZE.md index 0461f844..17801ce0 100644 --- a/docs/RESIZE.md +++ b/docs/RESIZE.md @@ -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; diff --git a/docs/TESTING.md b/docs/TESTING.md index 85800375..e7b29ce7 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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] diff --git a/docs/WALL-THICKNESS.md b/docs/WALL-THICKNESS.md index fa04f57d..c3015f3d 100644 --- a/docs/WALL-THICKNESS.md +++ b/docs/WALL-THICKNESS.md @@ -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 diff --git a/docs/specs/277-safe-resize.md b/docs/specs/277-safe-resize.md index 5ad74bff..395c3959 100644 --- a/docs/specs/277-safe-resize.md +++ b/docs/specs/277-safe-resize.md @@ -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 diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index f4fae535..9ca71bff 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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;', }], }, { diff --git a/src/coincident-partitions.ts b/src/coincident-partitions.ts index 24c6e94e..c58ebb86 100644 --- a/src/coincident-partitions.ts +++ b/src/coincident-partitions.ts @@ -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(); + const solidByKey = new Map(); 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(); 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 => !!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; diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 45129cc0..187a1a97 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -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, diff --git a/src/resize.ts b/src/resize.ts index 112d472f..55d77efd 100644 --- a/src/resize.ts +++ b/src/resize.ts @@ -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 }; } diff --git a/test/fixtures/281-resize-outer-candidate.json b/test/fixtures/281-resize-outer-candidate.json new file mode 100644 index 00000000..9a6720b8 --- /dev/null +++ b/test/fixtures/281-resize-outer-candidate.json @@ -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": {} +} diff --git a/test/fixtures/281-resize-outer-partitions.json b/test/fixtures/281-resize-outer-partitions.json new file mode 100644 index 00000000..fc016013 --- /dev/null +++ b/test/fixtures/281-resize-outer-partitions.json @@ -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": {} +} diff --git a/test/resize-optimize.test.mjs b/test/resize-optimize.test.mjs new file mode 100644 index 00000000..d416903f --- /dev/null +++ b/test/resize-optimize.test.mjs @@ -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'); +}); diff --git a/tests_backend/test_ha_websocket.py b/tests_backend/test_ha_websocket.py index b7b68a6a..a6bfe4cc 100644 --- a/tests_backend/test_ha_websocket.py +++ b/tests_backend/test_ha_websocket.py @@ -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, } diff --git a/tests_backend/test_validation.py b/tests_backend/test_validation.py index b323226a..f85ac5e2 100644 --- a/tests_backend/test_validation.py +++ b/tests_backend/test_validation.py @@ -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")