mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
A floor with stairs pays for them on every switch to it: the stair layer is emptied on other floors, so Lit recreates every symbol on each return and the browser lays out and paints it again. With 250 stairs (the large-house fixture, the per-floor limit) that was 2,875 SVG elements and about 40 ms per entry locally; each stair carried 3-7 separate tread lines with four bound coordinates each. The treads of one stair are now a single <path class="hp-stair-tread"> with one `M a L b` subpath per tread, in geometry order and with the numbers the lines carried. Treads of one stair never overlap (straight: parallel, >= 20 cm apart; spiral: inner ends >= 6.7 cm apart at the 3.6 cm stroke), so the path paints the same pixels at any opacity. The outline points and the tread data are built once per cached geometry object (cachedStairMarkup, weak keys), not on every render. The View and plan-editor layers share the strings; outline, hit polygon, trapezoid, arrow, attributes and handlers are unchanged. Floor 1 of the fixture drops from 4,210 to 2,960 elements. Witnesses: the unit test for the path data and its cache, and the smoke_stairs markup checks in View and in the plan editor, are red on dev. The stairs-view-tread-lines mutant restores the View lines. Issue: #740 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
166 lines
9.3 KiB
Markdown
166 lines
9.3 KiB
Markdown
# Stairs
|
||
|
||
Stairs are plan-level navigation objects. They show the physical place and
|
||
direction of an ascent on one space and can link that space to one other House
|
||
Plan space. The link is deliberately one-way: adding or editing a stair never
|
||
creates or changes anything on the target floor.
|
||
|
||
## User contract
|
||
|
||
The Plan editor has one **Stairs** group with **Straight** and **Spiral** tools.
|
||
Stairs are drawn like decor shapes (#676): press on the plan, drag and release.
|
||
The dominant drag axis is the rise axis, the ascent points from the press to
|
||
the release ("draw from the bottom up"), the sizes are the drawn extents (never
|
||
below one 30 cm tread) and the angle is 0°, 90°, 180° or 270°; equal extents
|
||
prefer the horizontal. A spiral stair takes the square of the drag, anchored at
|
||
the press. A drag shorter than one grid cell is a click and places the default
|
||
size (240 × 100 cm straight, R 90 cm spiral) at the press. The new stair stays
|
||
selected and the tool remains armed; `Esc` during the drag discards the draft.
|
||
|
||
The selected stair shows the same frame as furniture in the decor editor,
|
||
painted above wall bodies: a dashed outline, four corner and four side handles
|
||
(a spiral stair has four handles on its axis tangents), a stem with the
|
||
rotation handle above the local top side. Handle hit areas are finger-sized on
|
||
screen at every zoom. The cursor over a handle follows the handle's world
|
||
direction — `ew`/`ns` when it moves along an axis, a diagonal arrow otherwise —
|
||
and the rotation handle shows the circular cursor. Drag the body to move, a
|
||
handle to resize about the opposite side or corner (sizes stop at 30 cm and a
|
||
pointer dragged past the anchor never mirrors the stair; `Shift` keeps the
|
||
proportions), and the upper handle to rotate. Resizing never changes the angle.
|
||
Rotation is continuous; holding `Shift` snaps to the nearest multiple of 45
|
||
degrees. `Esc` cancels an active transform or clears the selection;
|
||
Delete/Backspace removes the selected stair; the ordinary Plan Undo/Redo
|
||
history covers create, edit, move, resize, rotate and delete. The click a
|
||
browser synthesizes after a gesture never reaches the plan tool: it places no
|
||
copy under Stairs and keeps the selection under Select. Under any other plan
|
||
tool the frame is not rendered at all.
|
||
|
||
Double click opens properties. A straight stair stores positive length and
|
||
width; a spiral stair stores a positive radius. The fields accept 30–10000 cm
|
||
(inches when Home Assistant is imperial); a field left untouched keeps the
|
||
stored number bit for bit, and saving without changes writes nothing. The
|
||
dialog also selects the rise direction, rotation, two colour/opacity pairs
|
||
(all linework; full-footprint fill) and an optional target space. A new stair
|
||
snapshots the current main decor colour for its outline, treads, trapezoid and
|
||
arrow; its full rectangle/circle fill starts completely transparent. The
|
||
colours then belong to that stair and do not follow later default-decor changes.
|
||
For a straight stair the choices are **Up** and **Down**. They flip only the
|
||
100%/80% trapezoid; the arrow always points along the canonical local axis, so
|
||
turn the whole stair to point the arrow elsewhere.
|
||
A target cannot be the current space. A missing, self or deleted target leaves
|
||
the stair visible and editable but shows a repair warning and makes View
|
||
activation a no-op.
|
||
|
||
In View, hovering a stair with a mouse shows the card tooltip "Go to floor
|
||
<title>" when — and only when — the stair is a valid link (`active` target
|
||
state); a missing, self, deleted or fixed-floor target shows no tooltip. The
|
||
mouse cursor follows the same condition: a pointer over a valid link, the
|
||
plan's ordinary cursor over any other stair. The move cursor is the Plan
|
||
editor's (#693).
|
||
|
||
In an ordinary multi-space card, a clean click/tap or keyboard activation on a
|
||
valid stair switches to the target tab and restores that floor's remembered
|
||
camera. A focused stair link draws no focus indicator — neither the browser's
|
||
ring nor an outline of its own (owner's decision, #686); it stays in the Tab
|
||
order, and Enter or Space still follow it. Pan, pinch, long press, swipe and pointer cancellation do not navigate.
|
||
In a card configured with `floor`, stairs are visible but inert. The target
|
||
floor receives no automatic stair, highlight or camera centring.
|
||
|
||
## Geometry and drawing
|
||
|
||
Both variants use the same continuous transform contract as furniture: their
|
||
authored position, size and angle are not grid-quantised on save or by
|
||
**Optimize plans**. The wall magnet works per side (#676): a side of the box
|
||
(a tangent of the circle) that is parallel to a visible physical wall face
|
||
within 5° and within six grid cells of it lands flush on that face. While
|
||
moving, the nearest such side wins and a straight stair turns by at most 5° to
|
||
become exactly parallel — a stair standing end-on to a wall snaps with its end
|
||
and keeps its angle. While resizing or drawing, only the dragged sides snap
|
||
and the angle never changes. Room faces are one-sided; the exposed faces of
|
||
partitions and columns are derived from the body's winding, so a stair
|
||
straddling a thin wall never snaps to the face hidden inside the masonry. Any
|
||
straight/circular pair of stairs can snap footprint-to-footprint. The other
|
||
stair is never modified or linked by the magnet.
|
||
|
||
The straight symbol contains a centred full-length trapezoid. Its wide base is
|
||
100% of the stair width and its narrow base is 80%, with symmetric 10% side
|
||
insets. **Up** widens toward the arrow tip; **Down** narrows toward it. Treads
|
||
end on the trapezoid sides and never enter the side strips. The full physical
|
||
length is divided into an integer number of equal intervals whose size is
|
||
closest to 30 cm (a tie chooses the larger count), so there is no remainder.
|
||
Spiral stairs similarly divide the complete travel-line circumference at two
|
||
thirds of the radius into equal sectors closest to 30 cm. Their direction is
|
||
clockwise or counter-clockwise when viewed from above. The arrow always means
|
||
physical ascent, not the direction of navigation between named tabs.
|
||
|
||
Every visible stair line has one fixed physical weight of **3.6 cm**: the
|
||
outer outline, straight-flight trapezoid, treads/sectors and complete direction
|
||
arrow all share it. There is no per-stair thickness setting. The weight follows
|
||
the plan camera like other physical drawing units, stays independent of stair
|
||
size and rotation, and is converted through the selected print scale in PDF.
|
||
Hover and selection may add their own temporary screen-space outline without
|
||
changing that physical symbol or persisted data.
|
||
|
||
The footprint overlap is removed from clean room floor area exactly once. It
|
||
does not cut the visible floor, change room or wall geometry, create a light
|
||
occluder, affect Glow/sun/vacuum, or participate in Optimize. In 2.5D the first
|
||
version remains a flat symbol projected with the floor/decor plane; it has no
|
||
height, risers, railings or shadows.
|
||
|
||
## Persisted model
|
||
|
||
Each space may contain `stairs: []`, bounded to 250 valid records:
|
||
|
||
```json
|
||
{
|
||
"id": "stair-main",
|
||
"kind": "straight",
|
||
"x": 0.42,
|
||
"y": 0.55,
|
||
"angle": 90,
|
||
"length": 0.24,
|
||
"width": 0.10,
|
||
"direction": "forward",
|
||
"target_space_id": "floor-2",
|
||
"color": "#607d8b",
|
||
"opacity": 1,
|
||
"fill_color": "#607d8b",
|
||
"fill_opacity": 0
|
||
}
|
||
```
|
||
|
||
`kind:"spiral"` replaces `length`/`width` with `radius` and uses direction
|
||
`clockwise` or `counterclockwise`. Coordinates and sizes use the normalized
|
||
plan coordinate system; physical labels are derived through `cell_cm`.
|
||
`target_space_id` is nullable. The four visual fields are optional for backward
|
||
compatibility. A legacy record resolves missing fields from the current main
|
||
decor colour without being rewritten; its first successful Properties save
|
||
materialises the complete visual quartet. Unknown sibling fields survive
|
||
validation and round trips for forward compatibility. Full backup/import/export,
|
||
one-space transfer, plan-only transfer, diagnostics and support packages
|
||
preserve the same records. When a complete backup removes a target space its
|
||
incoming stair
|
||
links are cleared; a one-space transfer cannot invent an external target.
|
||
|
||
## Implementation boundary
|
||
|
||
- `src/stairs.ts` owns validation, drawing geometry and area-subtraction
|
||
primitives. The View and plan-editor symbols draw all treads of a stair as
|
||
one `<path class="hp-stair-tread">` (one `M a L b` subpath per tread, #740);
|
||
its `d` and the outline points are built once per cached geometry object
|
||
(`cachedStairMarkup`), not on every render.
|
||
- `src/stairs-view.ts` is the eager read-only boundary for symbols, guarded
|
||
navigation and touch/pointer gesture suppression.
|
||
- `src/stairs-editor-model.ts` keeps the eager-safe helpers the View runtime
|
||
shares (defaults, kind conversion, target state, stair-to-stair magnet);
|
||
`src/stairs-box.ts` owns the editor-only oriented-box transforms —
|
||
drag-to-draw, resize about the anchor, edge magnets, handle bearings and
|
||
cursors, dialog conversion — and is imported only by the lazy editor chunk;
|
||
`src/stairs-editor.ts` owns the lazy Plan UI (gestures, the frame rendered
|
||
by the card in its top overlay, properties).
|
||
- `src/clean-floor.ts` and `src/summary-panel-metrics.ts` consume the same
|
||
footprint subtraction for room cards and summary totals; PDF rendering uses
|
||
the same stair outline/tread geometry without changing the room floor paint.
|
||
- Backend schemas and transfer/support surfaces treat `stairs` as a bounded,
|
||
additive space collection. Old configs without it remain unchanged.
|