mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
After #714 the 2.5D overlay scene still carried what decides nothing: - src/iso-overlays.ts: IsoOverlayPlacement loses tether and grounding (always invisible) and raisedScene (always equal to visualScene); IsoOverlayOwner loses area; IsoOverlayPlacementInput loses hovered, focused, selected and filtersSupported, which the resolver ignored. IsoWallSilhouette and tetherGeometry go with them. - src/iso-scene-render.ts: the structural scene no longer projects wall silhouettes (isoWallSilhouettesOf and IsoSceneCacheEntry.wallSilhouettes) that served only as a cache key. The placement and render-scene caches are keyed by the wall geometry the scene is drawn with (IsoOverlaySceneInput. structure = scene.geometry): the structural LRU hands out the same object across zoom, stage resize and HA state, and a new one after any wall, room or opening edit. The resolveCollisions flag and its fit/live cache slots are gone: since #713 both held equal placements, and 2.5D renders only in View, where the fit probe and the live frame ask with the same devices, so they now read one snapshot. - src/houseplan-card.ts: the fit call passes no flag; the overlay scene gets structural.geometry. data-hp-iso-nudged stays the constant "false" read by the golden requireOneRise preflight, the live-touch smoke and the benchmark. Tests: iso-overlays pins the placement fields; iso-scene-render builds the structure with buildIsoWallGeometry, the #714 zoom/resize and #711 state tests stay, fit and live are asserted to share one snapshot, and two #724 AC2 tests run the production path (createIsoStructuralSource -> resolveIsoScene -> buildIsoOverlayRenderScene): a thicker wall with the same room rebuilds the scene (red with a key without walls, e.g. keyed by the room rows), and a room edit that moves the owner gives the new owner (red with a constant key). The silhouette-construction test goes with the construction. Mutants: #473 W2 (iso-placement-cache-survives-silhouette-change, id kept for history) now keys the placement cache by a constant instead of input.structure and its guard also runs the #724 AC2 tests; W6 patches the new structure line; the W5 description no longer speaks of a nudge. The isometric-contract regex checks the new key instead of the silhouette construction. docs/ISOMETRIC.md names the key. Live 2.5D output is unchanged: the 21 isometric golden scenes pass on the accepted baselines. Issue: #724 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
353 lines
19 KiB
Markdown
353 lines
19 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)).
|
||
|
||
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).
|