Files
houseplan-card/docs/RESIZE.md
T
2026-08-24 07:56:49 +03:00

145 lines
7.0 KiB
Markdown

# 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](https://github.com/Matysh/houseplan-card/issues/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 edge;
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.
## Pure pipeline
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 and moving ordinary openings.
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.
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 final persisted position is the last safe grid node. A safe range containing
only zero yields an explained no-op and no write.
## 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.
The renderer consumes that exact overlay, so fills, masonry, openings, labels
and measurements show one candidate. 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.
## Thickness, virtual spans and openings
`rekeyWallsAfterMove()` 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.
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, obstacles and bounded memoization;
- `test/fixtures/resize-safe-regression.json`: minimized/anonymized private
repro whose one long edge is owned by two neighbours;
- `test/resize-production-path.test.mjs`: old handlers unreachable, corner
frame absent and all reasons localized;
- `demo/smoke_room_resize.mjs`: production bundle pointer handlers,
preview/commit/Undo, disabled accessibility, real fixture topology,
production-preflight failure and cancellation;
- `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, jamb 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.