Files
houseplan-card/docs/RESIZE.md
T
2026-08-27 20:30:07 +03:00

14 KiB

Room Resize — fixed-topology wall move

This document is the source of truth for the Plan editor Resize tool. The contract was narrowed in #277 after the former general-purpose transform corrupted wall thickness and could make all masonry disappear.

User contract

Resize moves one existing horizontal or vertical room wall parallel to itself. The two adjacent walls only change length. It never adds, removes, reorders or simplifies room vertices.

  • A non-shared wall changes exactly one room.
  • An exactly shared endpoint-to-endpoint wall changes exactly two rooms.
  • An irregular room is allowed while that exact pairing remains true. The wall stops at the first corner/grid position after which the moving segment or its topology would change.
  • A third room is always a stop; it never joins the gesture.
  • The old corner scale frame, diagonal resize and partial-shared cascade are removed.

Every room edge keeps a visible finger-sized handle. An ineligible handle is dimmed, focusable and marked aria-disabled; mouse hover/focus exposes the localized reason and click/tap repeats it in the card toast. It captures no pointer and creates no history or save.

Eligibility

resolveSafeResize() returns either enabled + SafeResizePlan or one stable reason, in this priority order:

  1. diagonal — the moving edge is not numerically horizontal/vertical;
  2. side-angle — either adjacent edge is not perpendicular;
  3. duplicate-physical-wall — a partition, unfinished outline or column overlaps the moving wall;
  4. partial-shared — another room owns only part of the moving edge, or a side wall would change between shared and outer material;
  5. unequal-shared — the candidate neighbour has different endpoints/length;
  6. multiple-rooms — the operation would involve more than two rooms;
  7. thickness-conflict — the physical profile cannot be mapped losslessly;
  8. opening-conflict — an opening on the moving wall is hosted/ambiguous or does not fit;
  9. invalid-geometry — the initial polygon fails structural checks.

The numerical epsilon only absorbs storage noise. It never snaps a visibly angled edge into eligibility.

auditSafeResizeEligibility() is the test-only diagnostic over this same resolver. It enumerates every room-edge handle, reports exact enabled and per-reason counts, and gives each handle a stable id made from room id, edge index and canonical endpoints. It contains no independent eligibility rules; the two tracked real-plan fixtures pin the post-Optimize result edge by edge.

Pure pipeline

src/resize-controller.ts owns the complete interaction state machine: selection, pointer ownership, immutable gesture snapshot, accepted preview, live labels and the bounded eligibility cache. houseplan-card.ts only adapts DOM events, rendering and persistence. A rejected move restores the last accepted candidate, and cancel/foreign-pointer paths cannot leak provisional geometry into the root configuration.

The production controller reaches only four pure operations in src/resize.ts:

  1. resolveSafeResize(rooms, openings, roomId, edge, options) captures the immutable plan: one/two room ids, exact edge indices, original endpoints, topology signatures, moving ordinary openings and the atomic physical-owner profile of both side walls. The profile is built once, not on pointermove.
  2. clampSafeResize(...) walks grid deltas contiguously from zero and stops at the first invalid position. It cannot jump through an opening/corner to a later valid position. Exact delta results are memoized per active plan and options, weakly held and capped at 4096 entries.
  3. applySafeResize(...) moves exactly the two endpoint vertices in each planned room and translates each ordinary moving-wall opening once.
  4. validateSafeResize(...) proves room identity/count, topology, orientation, simplicity, minimum clearance, exact shared endpoints, foreign-room relations, physical obstacles and opening jamb clearance. It also rejects any near-axis postcondition: a safe candidate is exact horizontal/vertical, never a sub-0.25° arithmetic slope (#290).

Historical general-transform helpers remain only for old pure-test history. houseplan-card.ts must not import or call applyRoomScale, clampRoomScale, shiftSharedSpans or simplifyPoly.

Stops

The closest stop in either direction wins:

  • zero/too-short side edge, orientation reversal or the 30 cm room clearance;
  • first irregular-room corner that would change moving-edge correspondence;
  • third/foreign room, island, independent partition/draft or column;
  • perpendicular side-wall opening: the moving wall axis stops at the jamb with half the moving wall thickness included;
  • moving-wall opening that would no longer fit;
  • loss of exact shared endpoints or any structural candidate failure.
  • the first atomic side-wall run that would change from shared to outer or outer to shared. A safe direction remains enabled and stops at the exact ownership boundary; Resize never stretches one thickness record across both roles.

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.

Disabled handles explain the blocking geometry in ordinary RU/EN text. Click, tap, Enter and Space repeat the same text in a toast. Only the independent partition/draft/column case suggests a repair (remove or move that object); angled walls never promise that Optimize can repair arbitrary authored shapes.

Preview and commit

The gesture owns an immutable _geometrySnapshot(). Every preview is rebuilt from it; _serverCfg is untouched until pointerup. The overlay contains rooms, openings, re-keyed wall thickness/open spans and byte-equivalent partitions, drafts, columns, decor and plan transform.

Pointer displacement is measured from the exact pointerdown position and projected onto the moving wall's immutable normal. It does not depend on which part of the handle was pressed. The handle captures the owning pointer, ignores other pointer ids and continues receiving movement outside its visible hit area. pointercancel or lostpointercapture aborts the gesture.

The renderer consumes that exact overlay, so fills, masonry, openings, labels and measurements show one candidate. Before an overlay becomes visible it must pass the same fail-closed physical-geometry barrier as persistence. A rejected candidate leaves the wall at the last safe visible position and shows one localized explanation per gesture. Its production wall/floor result is cached for the preview cfg epoch. Pointerup reuses that exact result (or computes it if the release happened before a frame) and then re-runs the pure invariants.

Success copies the exact overlay into the real space, creates one named Undo command and schedules one config write. There is no commit-time simplification, wall degradation or second geometry reconstruction. Failure, Esc, pointercancel, lostpointercapture, pinch and tool exit discard the overlay with zero Undo entries and zero writes.

Live measurements

During an accepted drag, Resize labels only the two side walls whose clear lengths change. Each length has a matching accent highlight on that exact wall; the moving wall no longer repeats its own length under the pointer.

The clean-floor area of every affected room is anchored beside its side of the moving wall. An outer wall therefore shows one area and an exactly shared wall shows two, on opposite sides. A short leader keeps ownership explicit. A label is never hidden or clipped merely because the room is narrow: it may leave the room outline, but two labels may not overlap.

src/resize-labels.ts performs placement from the accepted preview, current view and cached stage size. It accounts for the zoom-dependent room-settings button and shifts a conflicting area label along the wall. The production pointer path performs no DOM measurement; browser smoke compares the actual post-render rectangles at default and non-default zoom.

Thickness, virtual spans and openings

rekeyWallsAfterMoveChecked() and rekeyOpenSpansAfterMove() map the immutable snapshot to the fixed-topology candidate. Physical centimetre values and open span count must survive; the production geometry check is fail-closed.

src/wall-record-preservation.ts is the single wall-record preservation implementation used by both production Resize and the model-invariant CLI. Resize requests exact multiplicity for every finite value, including cm: 0; the CLI intentionally preserves its older positive-value presence semantics.

Safe Resize uses endpoint correspondence, not the historical affine mapping. Every breakpoint follows a moving wall by one rigid translation. On a side wall whose length changes, only the old topology endpoint moves to its paired new vertex; an interior thickness breakpoint stays on its physical boundary rather than keeping a proportional fraction of the new edge. Unrelated exact records remain byte-equivalent. The candidate then proves that every new exact record is lattice-safe and continuously covered by room-wall carriers. An unchanged historical endpoint may remain readable even when its record changes around it, but Resize cannot add or replace it with a different violation. Key-only legacy records move only when their key identifies one whole changed edge with one destination. An affected partial/ambiguous midpoint returns an explicit rejected result; preview, history and persistence remain untouched. The generic array-only helper retains its old affine fallback outside Safe Resize for compatibility with historical pure transforms.

When the two owners of a shared moving seam split one physically continuous side-wall record at their meeting point, the mapped atoms are joined back only if their endpoints still meet exactly and their directions remain collinear. This preserves one record and its thickness for the continuous wall without undoing the lossless split required by a genuinely partial or angled move.

Ordinary openings centred on the moving wall translate by the same vector. Their type, angle, length and compatibility fields stay unchanged. Hosted partition openings never move with a room wall. Openings on the two side walls stay fixed and limit the axis by opening.length / 2 + movingWallHalfDepth.

Performance

  • eligibility is memoized by the committed geometry snapshot;
  • pointer clamp p95 budget is 16 ms and no more than 20% over the historical same-run edge-drag baseline (small timer-noise allowance applies);
  • pointerup reads the final preview's cached production result and has a 75 ms p95 budget;
  • cache lifetime is the active committed geometry/gesture and is bounded.

The existing large-house render benchmark remains responsible for the cost of building the preview frame itself.

Verification

  • test/resize.test.mjs: eligibility, exact pair, first-corner clamp, third room, physical jamb, moving opening, side-wall ownership and bounded memoization;
  • test/fixtures/resize-safe-regression.json: minimized/anonymized private repro whose one long edge is owned by two neighbours;
  • test/fixtures/289-mixed-role-resize.json: the anonymized 43-step regression whose two shared side walls would otherwise gain outer continuations;
  • test/resize-production-path.test.mjs: old handlers unreachable, corner frame absent, one controller boundary and all reasons localized;
  • test/resize-controller.test.mjs: selection, pointer ownership, accepted preview rollback, cancellation and exact commit state-machine behaviour;
  • test/wall-record-preservation.test.mjs: shared presence/exact-multiplicity invariant semantics, including zero-thickness records;
  • demo/smoke_room_resize.mjs: production bundle pointer handlers, preview/commit/Undo, disabled accessibility, real fixture topology, production-preflight failure and cancellation;
  • demo/smoke_resize_pointer_real_plan.mjs: the tracked second-floor fixture entering through config/get, real browser mouse events, ten-grid-step live preview and atomic commit, wall metadata, Undo, pointer capture outside the handle by at least two hit diameters, foreign-pointer isolation, Escape and capture-loss 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;
  • test/optimize-hidden-obstacles.test.mjs: a real plan's composite hidden independent walls and wholly redundant saved chain are removed by explicit Optimize, while partial/free residuals remain and the post-Optimize Resize audit loses only the proven duplicate-physical-wall blockers (#296);
  • 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;
  • mutation gate: eligibility, third-room, topology, side ownership, jamb, fixed-topology wall endpoint mapping, pointer displacement/capture, shared-seam coalescing, preview rejection and commit-preflight bypass mutants.

Targeted light/dark golden scenes cover enabled/disabled handles, opening/corner stops and final masonry. Full golden, smoke and performance matrices run before every beta; Linux CI is canonical for the complete HA harness.