mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
In 2.5D with a backdrop image every floor switch rendered the card twice before the first frame. The paper key of the first-frame state (#654) held the space id, so each switch cleared the ready paper: the first render inserted the loading veil, updated() probed the computed card background with a temporary span and asked for a second full update, which removed the veil again. The colour itself never changed: it is the theme card background, and no space sets those variables. Locally this second pass was about 50 ms per warm switch on the large house. The paper under a backdrop is now resolved once per theme identity (dark mode, default and dark default theme, theme) and card mode. The state keeps the resolved paper of the current theme and mode beside the current paper, so a floor with a backdrop is ready in prepare() when that paper is known -- also after a drawn floor in between -- and the switch renders once: no veil, no probe, no second update. A drawn plan keeps its white paper without the DOM. Any change of the theme identity or the mode, also one made in Flat or in an editor, drops the kept paper, so the first backdrop floor after load, a theme change and a trip to an editor take the #654 path unchanged. isoPaperContext still takes the floor; it deliberately leaves it out of the identity. Witnesses: the #739 unit test is red on dev at "a floor switch shows no veil" and on a key-only variant (space dropped, no theme cache) at "drawn -> backdrop keeps the known theme paper"; the new smoke_iso_floor_switch is red on dev (2 updates, 1 colour probe and a veil insertion in every click task). The iso-paper-resolved-per-floor mutant puts the floor back into the theme identity. Issue: #739 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
359 lines
20 KiB
Markdown
359 lines
20 KiB
Markdown
# Isometric (2.5D) View internals
|
||
|
||
Issue [#89](https://github.com/Matysh/houseplan-card/issues/89) started a
|
||
presentation-only volumetric View experiment; the normative Stage 1 contract is
|
||
`docs/specs/089-isometric-view-stage1.md`, the fixed rendering decisions are in
|
||
`docs/adr/089-isometric-stage1-renderer.md`. Since Stage 6
|
||
([#649](https://github.com/Matysh/houseplan-card/issues/649)) the 2.5D View is a
|
||
public mode, see [Stage 6](#stage-6-public-mode-tiles-sun-and-materials-649).
|
||
This document describes the current View only; how it got here, stage by
|
||
stage, is in the ADRs (`docs/adr/089-…`, `122-…`, `160-…`,
|
||
`570-isometric-stage4-visual-handoff.md`) and their specs.
|
||
|
||
## Activation
|
||
|
||
One installation-wide setting, `settings.volumetric_view: boolean`, switched in
|
||
**General settings › Display › Show the plan in 2.5D** (third item after «Show
|
||
live presence on the plan»). It is stored only when `true`; missing or `false`
|
||
means Flat, which is also the rollback. The rule is one for the card, the
|
||
sidebar page and the kiosk: the View is 2.5D when the setting is on and Flat
|
||
otherwise. Editors and `houseplan-space-card` are always Flat.
|
||
|
||
Saving the setting switches the View at once, without a reload, and keeps the
|
||
camera: the floor is the same plane in both projections (#713), so the plan,
|
||
the decor and the room names stay on the same pixels; only the scalar zoom is
|
||
re-read against the new frame (see [Camera and overlay placement](#camera-and-overlay-placement)). The lazy
|
||
`iso-scene-render` graph is loaded only while the setting is on. The fingerprint
|
||
fallback (#89) is unchanged: a failed scene falls back to Flat for that key.
|
||
|
||
On a cold dashboard load, the first visible successful frame is already 2.5D:
|
||
the existing neutral House Plan loading surface remains above the plan until
|
||
both the lazy graph and the paper-dependent floor classification are ready.
|
||
This is the same in the ordinary card and kiosk mode. A real lazy-load failure
|
||
releases that surface and uses the existing safe Flat fallback; it does not
|
||
change the saved setting. Flat View and every editor do not wait for the 2.5D
|
||
runtime. Paper colour is resolved after the DOM commit (white for a drawn plan,
|
||
the theme card background under an image plan) and the resulting light-floor
|
||
set is reused until the paper, resolved room fills or room membership changes
|
||
([#654](https://github.com/Matysh/houseplan-card/issues/654)). The theme
|
||
card background is resolved once per theme identity (dark mode, default and
|
||
dark default theme, theme) and card mode, not per floor: switching between
|
||
floors — with an image plan or a drawn one — reuses it, so a floor switch
|
||
renders the card once and shows no loading surface; a theme or mode change
|
||
resolves it again on the path above
|
||
([#739](https://github.com/Matysh/houseplan-card/issues/739)).
|
||
|
||
There is no toggle on the card and no alpha entry: `iso` is gone from
|
||
`LABS_FLAGS`, the header `projection-toggle` and the phone-menu item
|
||
`projection` (#616) are removed, and the former per-device, per-space choice
|
||
`houseplan_card_view_v1` is no longer read (owner decision: not migrated). The
|
||
`hp_alpha` switch itself remains as a mechanism without experiments.
|
||
|
||
## Coordinate systems
|
||
|
||
- Plan points are the existing 1000-unit logical floor coordinates.
|
||
- Scene points are coordinates in the effective SVG viewBox.
|
||
- HTML devices, vacuum pucks, room labels/cards and lock markers stay
|
||
screen-facing. Their anchors use the same `projectPlanPoint()` snapshot as the
|
||
SVG floor.
|
||
- `clientToScenePoint()` maps a client point to the current scene; floor hit
|
||
testing then uses `unprojectFloorPoint()`.
|
||
|
||
The presentation uses a fixed vertical oblique projection (#713, owner's «third
|
||
way»): the floor is not transformed at all, and a height moves a point straight
|
||
up the screen.
|
||
|
||
```text
|
||
rotDeg=0, tiltDeg=20, xyScale=1, zScale=1, origin=[500,500]
|
||
screen(x, y, z) = (x, y − z·sin 20°) floor (z = 0): the Flat plane
|
||
wallHeight=84 scale-aware visual units → the wall top rises 0.342·H on screen
|
||
```
|
||
|
||
`tiltDeg` now only sets the on-screen wall height (`sin 20°`, the same 28.73
|
||
units as the former 20° orthographic camera); there is no `cos 20°` floor
|
||
foreshortening anywhere. There is no perspective, free rotation or user tilt.
|
||
`projectedFrame()` includes both floor corners and wall-top corners so fit/home
|
||
cannot clip the volume.
|
||
|
||
Room fit (#152) projects every final floor vertex at floor and floor-edge depth
|
||
and every selected-room boundary-wall vertex at floor and wall-top height before
|
||
building the AABB. It never projects a plan-space bounding rectangle, so a
|
||
concave room does not gain fictitious corners. The resulting camera still uses
|
||
the shared View controller; changing projection cancels the room-focus intent.
|
||
|
||
## Geometry and composition
|
||
|
||
`src/iso-projection.ts` owns pure projection math. `src/iso-walls.ts` consumes
|
||
the canonical `wallBodiesGeometry()` MultiPolygon after openings and extra
|
||
physical bodies have been resolved. It normalizes outer/hole winding, builds one
|
||
evenodd top path and at most one visible side per ring edge, then uses a stable
|
||
depth/order tie-break. Complexity is O(E) in canonical ring edges.
|
||
|
||
For connected drafts and partitions those extras already contain computed
|
||
bounded junction patches. Isometric wall tops/sides therefore use the same
|
||
seamless L/T footprint as flat full/static cards; raw per-record rectangles are
|
||
reserved for editor identity and never projected as competing wall faces.
|
||
|
||
The content fingerprint includes rooms, wall geometry/thickness, open cuts,
|
||
openings, partitions, drafts, columns, scale/grid inputs, camera, wall height and
|
||
algorithm version. It deliberately excludes `_cfgEpoch`, HA state, hover and
|
||
`show_borders`. The per-card LRU is capped at eight scenes. Pan, zoom and HA-only
|
||
updates reuse it.
|
||
|
||
Composition remains SVG-first:
|
||
|
||
1. the existing floor SVG and all its current live layers;
|
||
2. explicit visible wall sides and wall top;
|
||
3. screen-facing HTML overlays.
|
||
|
||
The floor keeps the same nodes and order for paper/backdrop, room fills/hover,
|
||
Glow/spill, sun, decor/furniture, flat stair symbols, opening symbols and vacuum path/outline. The
|
||
volumetric View adds no second light source/layer; markers and room cards intentionally
|
||
remain above walls without geometric occlusion.
|
||
|
||
## Failure boundary
|
||
|
||
Projection/topology failures latch flat fallback for the current
|
||
`space|fingerprint`. One diagnostic is emitted without config, entity ids or URL
|
||
data. The saved iso preference is retained; an explicit iso request retries the
|
||
fingerprint, and changed geometry receives a new fingerprint. Flat rendering is
|
||
the rollback path and does not depend on the iso cache.
|
||
|
||
Internal fail-closed evidence, not public API (`STYLING-HOOKS.md` §7.7):
|
||
`.stage[data-hp-iso-stage="4"]` with `data-hp-iso-structural-builds`,
|
||
`data-hp-iso-overlay-kind|raised|nudged` on screen-facing roots (`nudged` is always
|
||
`false` since #713), and
|
||
`data-hp-iso-material-def` on shared material definitions. The structural LRU is
|
||
`_isoGeometryCache`.
|
||
|
||
## Limits
|
||
|
||
- No volumetric editor and no volumetric `houseplan-space-card`: editors and
|
||
the static card are always Flat (see Activation).
|
||
- No perspective, free rotation or user tilt; no marker occlusion by walls —
|
||
device tiles and lock badges stand on the wall-top plane and may overlap wall
|
||
bodies and each other (owner's decision, #713), never hidden.
|
||
- No per-opening schema field for heights or leaves: every vertical element is
|
||
a fixed presentation ratio of `ISO_WALL_HEIGHT`.
|
||
- No YAML/config option beyond `settings.volumetric_view`.
|
||
|
||
Golden references are accepted only from the complete reviewed Linux artifact.
|
||
The full `large-house-isometric-v1` performance comparison is also canonical on
|
||
the exact Linux CI SHA.
|
||
|
||
## Structural scene and cache
|
||
|
||
The per-card LRU is capped at eight entries. A scene contains:
|
||
|
||
- canonical wall top/sides and the physical-wall contact path;
|
||
- a room/exterior floor footprint and its low visible outer faces;
|
||
- immutable opening jamb/axis bases, including type, flips and selected wall
|
||
face;
|
||
- the shared projected frame, including wall/opening tops and the low floor
|
||
edge.
|
||
|
||
The key fingerprints rooms, masonry/opening geometry, flips, scale/camera, wall
|
||
and edge heights, the `0°/20°/84` profile, opening policy revision 3 and
|
||
structural algorithm 6 (the #713 oblique projection). It excludes HA state, live opening amount, theme,
|
||
hover/selection, day/night, SUN and filter capability. `openingAmount()` is
|
||
applied only after an LRU hit by `projectIsoOpening()`, so a contact update
|
||
projects O(O) leaves without repeating a wall or floor boolean operation.
|
||
Topology, projection or module mismatch enters the fingerprint-latched Flat
|
||
fallback (Failure boundary).
|
||
|
||
`floorFootprintGeometry()` deliberately accepts no independent physical-body
|
||
input. The slab is the union of room floors and derived exterior masonry:
|
||
internal room boundaries and nested holes make no decorative step, detached
|
||
room components keep separate outside edges, while partitions and columns do
|
||
not enlarge it.
|
||
|
||
## Layer order and materials
|
||
|
||
All geometry roots use one scene `viewBox`. The existing floor/live nodes are
|
||
grouped in `.iso-floor-scene` without a transform (the floor is the Flat plane);
|
||
HTML anchors still use `projectPlanPoint()`.
|
||
|
||
```text
|
||
stage background
|
||
→ shared ambient shadow + low exterior floor edge
|
||
→ existing floor SVG (paper/image, room fills/hover, decor, Glow, sun)
|
||
→ canonical wall sides/top + inert vertical opening panels
|
||
→ existing HTML devices, labels/cards, locks and vacuum overlays
|
||
```
|
||
|
||
Wall top and side use two shared matte gradients. One shared filter supplies
|
||
only the soft exterior ambient shadow of the complete building footprint;
|
||
internal wall-contact and opening-leaf shadows are deliberately absent.
|
||
Definition count is constant per card, never per face or opening. Forced
|
||
colours use solid `Canvas`/`CanvasText` faces and omit decoration. A runtime
|
||
without the required filter paint keeps solid structure, floor edge and
|
||
vertical panels but emits no ambient shadow; this does not enter the structural
|
||
fallback latch. The 2.5D View adds no window beam, Glow source, sun renderer,
|
||
material config, network request or HA service path.
|
||
|
||
## Vertical openings and display settings
|
||
|
||
`src/iso-openings.ts` mirrors the existing opening-symbol transform algebra:
|
||
door has one jamb-hinged leaf, gate has two leaves with the established
|
||
0–10° exterior-face turn, and window has two light neutral casements. A saved
|
||
`passage` keeps the same full-height masonry cut but has zero leaves/panels.
|
||
Door/gate leaves are matte prisms `0.04H` thick with state-independent
|
||
full-depth reveals.
|
||
Heights are fixed presentation ratios of `ISO_WALL_HEIGHT`; there is no schema
|
||
field. The saved opening axis and Flat symbol remain on their canonical
|
||
centreline. Derived 2.5D door/gate leaves pivot on the selected physical host
|
||
face so their prisms do not start inside masonry; windows remain centred across
|
||
the reveal. `flip_v` selects/determines the physical face and opening direction
|
||
without changing saved coordinates. Jamb/cut depth remains physical and
|
||
independent of the Flat symbol. Panels are pointer- and ARIA-inert. Existing
|
||
lock badges/cards and HA actions remain the only interactive opening surface.
|
||
|
||
Door leaves turn by `50° × openingAmount`, paired window leaves by `65° ×
|
||
openingAmount`, and gates retain their established 0–10° behaviour. The same
|
||
canonical flips/host face that drive the floor symbol determine hinges and
|
||
direction; missing, unknown or unavailable state keeps the existing static-plan
|
||
fallback.
|
||
|
||
For wall height `H`, the fixed window frame spans `0.38H..1.00H`, its sash
|
||
`0.40H..0.98H`, and clear glass `0.45H..0.93H`; frame/sash rails are `0.05H`.
|
||
Frames and sill are neutral, glass side is `#c9e4f3` and its top face is
|
||
`#e3f2fa`. Fixed and live faces remain in `buildIsoWallDepthQueue()`. The slots
|
||
belonging to one opening are resolved by physical camera depth, so raised glass
|
||
covers the rear sill and rotating door/gate prism faces retain their physical
|
||
order without reordering unrelated walls or openings. Door/gate faces use fill
|
||
differences instead of strokes; window frame/glass borders remain.
|
||
|
||
- borders visible: vertical panels replace the floor-plane symbols;
|
||
- `hide_openings: true`: panels disappear, while masonry cuts, Glow/sun and
|
||
contact/lock meaning remain;
|
||
- `show_borders: false` is the exact no-volume branch: the volumetric roots are
|
||
absent, the floor is the Flat plane, the floor symbols and the projected
|
||
frame return (subject to `hide_openings`) and interactive overlays return to
|
||
their floor anchors — geometrically the scene is the Flat View;
|
||
- Flat, editors and `houseplan-space-card` retain their old symbols and DOM.
|
||
|
||
## Camera and overlay placement
|
||
|
||
The projection is the fixed vertical oblique one above: rotation 0, the
|
||
`[500,500]` pivot, a scale-aware 84-unit wall height rising `0.342·H` straight
|
||
up. Floor SVG, wall/opening projection, inverse hit mapping (the identity on the
|
||
floor), invisible overlay footprints and fit bounds share that one affine
|
||
authority. `isoPlaneMatrix()` (`src/iso-projection.ts`) is that authority; the
|
||
floor plane matrix is the identity.
|
||
|
||
**Placement (#713, owner's variant B).** Device tiles and opening-lock badges
|
||
keep their canonical floor anchors and stand on the wall-top plane: every one of
|
||
them is drawn at its Flat position shifted straight up by the same `H·sin 20°`.
|
||
There is no placement search, no rigid clusters and no per-marker vector; tiles
|
||
may meet wall bodies and each other, and the overlapping pairs are the Flat ones
|
||
(scaled by the 1.12 tile). Room names with their metrics row stay on the floor
|
||
exactly where Flat puts them, without a position correction. A device never
|
||
moves because its Home Assistant state changed (#711 holds trivially: nothing
|
||
is laid out). Neither zoom nor a stage resize is a layout event: a placement
|
||
depends only on the anchor, its owner room, the footprint and the rise. The
|
||
placement and render-scene caches are keyed by the wall geometry of the
|
||
structural scene (#724): a wall, room or opening edit starts them afresh, and
|
||
the fit envelope and the live frame read one snapshot.
|
||
|
||
History: #651 used to search a place for every marker (rigid same-room
|
||
clusters, a group collision resolver and a nudge of up to 48 CSS px towards the
|
||
owning room's safe point, clear of walls by a 4 px gap). #713 stopped calling
|
||
that search, and [#714](https://github.com/Matysh/houseplan-card/issues/714)
|
||
removed it together with its constants and nudge fields.
|
||
|
||
Vertical openings are ordered along the oblique projector: a face's
|
||
`cameraDepth` is the mean of `s·y + z` over its corners (`s = sin 20°`).
|
||
|
||
**Fit.** Home and room fit (#152) project floor vertices at floor and floor-edge
|
||
depth and boundary-wall vertices at floor and wall-top height; the overlay fit
|
||
envelope adds the visible tiles themselves and reserves no nudge budget.
|
||
|
||
**Switching projection keeps the camera.** When the projection changes while the
|
||
previous one was on screen — saving the setting (also when the 2.5D runtime
|
||
arrives afterwards), entering an editor from the 2.5D View, or adopting a warm
|
||
memo saved in the other projection (the window size is part of its key) — the
|
||
viewBox itself is kept and the scalar zoom is re-read as `fit'.w / view.w`
|
||
against the new frame, clamped to `[1/3, 8]`. Entering an editor from 2.5D
|
||
therefore produces the camera a Flat View with the same floor picture would.
|
||
Leaving the editor restores the View camera as before. A cold 2.5D start
|
||
without a memo opens the 2.5D home (zoom 1, frame with wall tops); a setting
|
||
changed while an editor is open restores the View snapshot by centre and scalar
|
||
zoom. Witness: `demo/smoke_iso_flat_parity.mjs`.
|
||
|
||
The placement is runtime-only and never written to configuration. There is no
|
||
painted plate, long tether, ground dot or per-marker shadow beyond the #649
|
||
tile shadow. The original screen-facing HTML root remains the only hit, focus,
|
||
tooltip and action target, and selection/hover cannot invalidate the placement
|
||
cache. Vacuum, Glow/spill, SUN, room fills/hover, arbitrary decor,
|
||
furniture/backdrop, stairs and every persisted coordinate remain on `z=0`, which
|
||
is the Flat plane.
|
||
|
||
Room names remain screen-facing and lose stroke, text shadow, drop shadow and
|
||
halo. Iso uses `#303936` on a light presentation and `#f2f0e8` on a dark one;
|
||
contrast comes from colour, never an outline.
|
||
|
||
## Stage 6: public mode, tiles, sun and materials (#649)
|
||
|
||
The visual language and the numbers come from the designer lab (sketch 07,
|
||
attachments 09–13 of #649); the lab's code, coordinates and ids are not carried
|
||
over. Lab units are converted into two bases:
|
||
|
||
- **D** — the base marker core diameter of the space (`--device-base-size`)
|
||
already × `ISO_ICON_SCALE = 1.12`; the lab disc is 80 units.
|
||
- **H** — the 2.5D wall height (`gridVisualUnits(ISO_WALL_HEIGHT, cellCm)`); the
|
||
lab wall is 218.8 units.
|
||
|
||
Everything below applies only to `.stage.projection-iso.mode-view`; Flat is
|
||
byte-for-byte unchanged. Side-by-side acceptance frames:
|
||
`docs/design/649-25d-stage6/ACCEPTANCE.md`.
|
||
|
||
### Raised tiles (`src/iso-tiles.ts`, `src/styles/iso-tiles.styles.ts`)
|
||
|
||
- Every marker body (core, value pill, lock core) is a rounded rectangle with
|
||
radius `min(0.275 D, 0.3·h)`; the ring is not drawn (virtual markers too).
|
||
- A solid edge 0.1 D straight down, the body colour through
|
||
`brightness(.7) saturate(.85)` (`brightness(.82) saturate(.8)` on a light
|
||
floor); bodies with luma < 70 → `#5b5e5a`, white and dark bodies in the dark
|
||
theme → `#4a4a4a`. The colours are evaluated once in TypeScript
|
||
(`isoEdgeColor`) and emitted as a generated state table.
|
||
- Marker and lock are 1.12 × Flat; layout and fit
|
||
(`iso-scene-render`) use the same factor. The whole marker is lifted 0.075 D.
|
||
- **One floor-shadow layer** `.iso-tile-shadows` inside `.devlayer`, rendered
|
||
after the markers in DOM order but with `z-index: -1` (`.devlayer` is a
|
||
stacking context), so no shadow ever lands on a neighbour's tile. Each marker
|
||
and lock has an inert twin (`data-shadow-of`, `aria-hidden`, no pointer
|
||
events) painted only as its inset, blurred box-shadow. Offset, blur and
|
||
opacity follow the theme × floor table of the ТЗ (`isoTileShadow`).
|
||
- Light or dark floor is decided per room: the room fill at its opacity over
|
||
the plan paper, luma > 0.55 is light (`isoLightFloorRooms`). A marker belongs
|
||
to the room of its overlay owner or, without walls, to the room under it.
|
||
- A raised room label keeps its 44 × 44 px touch floor in an invisible
|
||
`::before`, like door locks; the label box itself is sized by its text, so
|
||
the metrics row keeps the Flat distance from the name at every zoom (#665).
|
||
- Hover / focus-visible / selected / alert frames hug tile and edge
|
||
(bbox + 0.075 D per side, + edge height) and float with the tile: `#0C82F0`,
|
||
`#0C82F0`, `#F0A00C`, `#F0410C`, priority Alert > Focus > Selected > Hover.
|
||
The body is not repainted on hover.
|
||
- `forced-colors: active` or no `filter` support: no edge and no shadow; size,
|
||
tiles and frames stay.
|
||
|
||
### Soft sun wash (`src/iso-sun.ts`)
|
||
|
||
In 2.5D the Flat wedges of `sun_rays` are replaced by a soft wash; the gates are
|
||
the Flat ones (`sun_rays`, `north_deg`, `sun.sun`, elevation ≥ 3°, fade, no light
|
||
in editors or at night) and so are the windows (`windowLit()`). Details:
|
||
`docs/SUN.md` § 2.5D.
|
||
|
||
### Walls, openings and labels (`src/iso-materials.ts`)
|
||
|
||
- The top face is the user's `fill_colors.wall_fill.c`, opaque (the Flat opacity
|
||
does not apply to the prism), gradient to `c × 0.93`; the side face is
|
||
`c × 0.77 → × 0.68 @0.58 → × 0.60`. The stage carries them as
|
||
`--iso-top-hi/lo` and `--iso-side-hi/mid/lo`.
|
||
- The theme never repaints walls, openings, the floor edge, the texture or room
|
||
labels: the `theme-dark` and `prefers-color-scheme: dark` rules for `.iso-*`
|
||
are gone. Room labels use the Flat label colour. The building ambient shadow
|
||
on the card background may follow the theme.
|
||
- Furniture keeps its Flat line width: stroke px = width × the plan screen scale
|
||
in both views (the former iso constant 1 made it thicker).
|