docs: design for open spans and wall-centric Delete

Capture brainstorming decisions for partial virtual walls and Delete merge/room flow before implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Matysh
2026-08-05 15:53:10 +03:00
co-authored by Cursor
parent ee87d3d00e
commit b44d2fb958
@@ -0,0 +1,174 @@
# Open spans + wall-centric Delete — design
Status: **approved in chat** (2026-08-05). Implementation plan follows after
spec review.
## Goal
Replace whole-boundary open-wall toggle with **partial virtual stretches**
(anchor → second point on a shared wall), and make **Delete** operate on walls
(close virtual → merge / delete room) with confirmation.
## Current behaviour (baseline)
- Tool «Открытая граница»: one click toggles `room.open_to` for a room pair;
**all** `sharedBoundary` stretches between the pair become virtual.
- Tool «Удалить»: click inside a room → confirm → delete room.
- No geometric open-span storage; light zones use `open_to` only.
## Decisions (from brainstorming)
| Topic | Decision |
|-------|----------|
| Data model | Approach **1**: `space.open_spans` + keep `open_to` as connectivity index |
| Open target | Shared wall only; outer wall → toast |
| Gesture | Click1 = anchor P1; click2 = P2 on same edge; P2 clamped to nearest corners either side of P1 |
| Cursor (openwall) | **crosshair** (same as Draw), not pointer |
| Snap | Corner / existing open joint first; else grid projected onto the wall |
| Close virtual | Both openwall (click on span) and Delete (first hit on span) |
| Thickness on open | Clear `walls[]` cm on covered keys immediately |
| Thickness on close | Inherit cm from remaining solid collinear part of same wall; if none → `DRAW_WALL_DEFAULT_CM` (15 cm / imperial) |
| Delete + virtual | First click closes span (no confirm) |
| Delete + shared solid | Confirm → **merge** the two rooms along the **entire** shared boundary |
| Delete + outer wall | Confirm → **delete room** |
| Delete + inside room | Same as outer: confirm → delete room |
| Merge / delete room | Always `confirm()` |
| Openings on virtual | **Forbidden**; remove any openings on a span when it becomes virtual; cannot place new ones there |
| Legacy `open_to` | On read, treat full `sharedBoundary` of the pair as open spans; persist `open_spans` on first save/edit |
---
## §1 Data model
### Storage (per space)
- `open_spans: Array<{ key, … }>` — geometric virtual stretches. Keying aligns
with `walls[]` (quantised midpoint + direction mod 180°). Store enough to
recover segment endpoints after rekey (explicit `a`/`b` normalised coords
and/or length along the edge — implementation detail in the plan).
- `rooms[].open_to: string[]` — symmetric room-id links for
`openZoneOf` / glow (unchanged consumer API).
### Invariants
1. A span exists only on a **shared** boundary of exactly two rooms.
2. While a pair has ≥1 span → both rooms list each other in `open_to`.
Removing the last span clears the link.
3. An open span and a positive `walls[]` thickness on the same covered keys
do not coexist: opening clears thickness; closing restores per rules above.
4. Legacy: `open_to` present, `open_spans` absent/empty → **read path** expands
each linked pair to its full `sharedBoundary` segment list as virtual spans;
write them into `open_spans` on the next config save that touches the space
(or any openwall/delete edit).
5. Geometry lifecycle: resize / undo / scale / split / merge **rekey** spans
like walls; unmatched keys degrade silently; merge of a pair drops spans
between those two rooms.
---
## §2 Gestures
### Tool: Open boundary (`openwall`)
1. **Hover** on a shared solid wall: highlight eligible wall; cursor
**crosshair**. Outer wall: no “hot” affordance beyond default crosshair;
click → toast (shared only).
2. **Click1**: anchor P1 on the wall (snap: corner/joint → else grid on edge).
Remember the edge and nearest corners L, R along that edge.
3. **Preview**: segment P1→cursor, clamped to [L, R] on the same edge.
4. **Click2**: P2 in [L, R]. If length below minimum → toast, clear anchor.
Else write span, clear thickness on covered keys, remove openings on the
span, refresh `open_to`.
5. **Click on existing virtual span**: close that span; restore thickness
(neighbour solid / default 15); no confirm.
6. **Esc**, click away, tool change, leave markup: clear in-progress anchor.
### Tool: Delete (`delroom` — wall-centric)
Hit priority: virtual span → shared solid wall → outer wall → point inside room.
| Hit | Action |
|-----|--------|
| Virtual span | Close span (no confirm), same as openwall |
| Shared solid wall | Confirm → merge both rooms (entire shared boundary) |
| Outer wall | Confirm → delete the room that owns that outer edge |
| Inside room | Confirm → delete that room |
| Miss | No-op (optional light toast) |
After closing a virtual span, a later Delete click on that now-solid shared
wall runs the merge path (with confirm).
---
## §3 Thickness, openings, errors, tests
### Thickness
- Opening a span: strip `walls[]` entries (or null cm) for keys covered by the
span (atomic collinear pieces under the segment).
- Closing a span: if a collinear solid remainder of the same wall still has cm,
copy that cm onto the newly solid stretch; else apply `DRAW_WALL_DEFAULT_CM`.
- Wall-thickness tool continues to refuse open stretches (toast).
### Openings (doors / windows)
- **Not allowed** on virtual spans.
- When a stretch becomes virtual: **delete** any openings whose centre lies on
that stretch (same association rules as wall bodies / angle-aware hit).
- Opening tool: refuse placement on a virtual span (toast).
### Errors / cancel
- Outer / not-a-wall / too short / click2 off the anchored edge → toast and
clear the in-progress anchor.
- Merge and room delete always go through `confirm()`; cancel → no-op.
- Saves stay optimistic like other plan edits.
### Testing
**Unit**
- Clamp P2 to nearest corners; min length.
- Span write ↔ `open_to` add/remove.
- Thickness clear on open; restore from neighbour / default 15 on close.
- Legacy `open_to` → virtual full sharedBoundary spans.
- Rekey after edge move / scale; degrade orphans.
- Opening removal when span opens; placement blocked on virtual.
**Smoke**
- Anchor + P2 → dashed span; glow crosses only when span links rooms.
- Close via openwall and via Delete.
- Delete merge / delete-room confirms; outer open attempt toasts.
- Crosshair cursor in openwall tool.
### Docs / i18n
- Update `docs/ARCHITECTURE.md` (open boundaries), `WALL-THICKNESS.md` (open
refuses thickness + clear on open), `UX-MODES` / TESTING hints, CHANGELOG.
- New toasts: shared-only, short segment, openings removed, delete/merge
confirms copy; refresh `title.markup_openwall` / `delroom` hints.
### Out of scope
- Partial merge along only the former virtual segment (explicitly rejected: merge is whole shared boundary).
- Outer-wall virtual openings (“to outdoors”).
- Restoring thickness from pre-open snapshot (neighbour / default only).
- Changing glow math beyond “any span ⇒ rooms linked”.
---
## Implementation sketch (not a plan)
1. Pure helpers: expand legacy, clamp segment, span CRUD, thickness
clear/restore, opening purge on span, rekey/degrade.
2. Card: openwall two-click state machine; Delete hit priority + confirms.
3. Render: dashed strokes from `open_spans` (fallback legacy expand); trim
outlines/cuts from span list.
4. Smokes + unit tests; docs/i18n; version bump when shipping.
## Open points for the implementation plan only
- Exact JSON shape of one `open_spans` entry (`key` only vs `key`+`a`+`b`).
- Minimum segment length (e.g. `gridPitch` or fixed cm).
- Whether Delete merge reuses the existing merge-tool code path verbatim.