feat(iso): the 2.5D floor is the Flat plane, one wall-top rise for tiles (#713)

- Vertical oblique projection: the floor matrix is the identity and a height
  rises straight up by z·sin 20°, so the on-screen wall height is unchanged
  and the cos 20° foreshortening of the plan, decor and anchors is gone.
- Device tiles and lock badges stand on the wall-top plane with one common
  shift; the #651 placement search no longer runs in the live scene (its
  removal is #714). Room names keep their Flat floor point.
- The 2.5D fit no longer reserves the 48 CSS px nudge budget.
- Switching projection keeps the camera when the previous projection was on
  screen: saving the setting, entering an editor from 2.5D and adopting a warm
  memo from the other projection re-read only the scalar zoom. A cold 2.5D
  start still opens the 2.5D home.
- Opening faces are ordered along the oblique projector (s·y + z).

Witness: demo/smoke_iso_flat_parity.mjs (AC2–AC5, AC11) is red on the old code.

Issue: #713
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-09-30 15:18:00 +03:00
parent e19f5b0a0e
commit 0d641ffbfd
26 changed files with 743 additions and 325 deletions
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- In 2.5D View the plan no longer moves: rooms, furniture, other decor and room
names stay exactly where they are on the flat plan, and walls grow straight up
from it. Every device icon and door lock is lifted by the same distance, the
wall height, instead of being shifted differently near walls. Turning 2.5D on
or off, or opening an editor from it, keeps the zoom and position on screen
([#713](https://github.com/Matysh/houseplan-card/issues/713)).
- On the "Follow the Sun" background, the moon in its current phase now appears
in the top-left corner of the scene at dawn, dusk and night, behind the plan.
It follows the real moon over your home (Home Assistant home location), fades
+7
View File
@@ -8,6 +8,13 @@
## Не выпущено
- В объёмном 2.5D-виде план больше не сдвигается: комнаты, мебель, прочий декор
и названия комнат остаются ровно там же, где на обычном плане, а стены
вырастают из него строго вверх. Все значки устройств и замков поднимаются на
одно и то же расстояние — высоту стен — вместо разных сдвигов у стен.
Включение и выключение объёма и вход в редактор из него сохраняют масштаб и
положение плана на экране
([#713](https://github.com/Matysh/houseplan-card/issues/713)).
- На фоне «Следует за Солнцем» в сумерках и ночью в левом верхнем углу сцены,
за планом, теперь видна луна в текущей фазе. Она следует настоящей луне над
вашим домом (по координатам дома из Home Assistant), плавно появляется и
+64 -65
View File
@@ -19,8 +19,10 @@ 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 the view
centre is carried over through the logical plan (#583 §6.3). The lazy
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.
@@ -51,15 +53,19 @@ There is no toggle on the card and no alpha entry: `iso` is gone from
- `clientToScenePoint()` maps a client point to the current scene; floor hit
testing then uses `unprojectFloorPoint()`.
The presentation uses a fixed orthographic affine camera:
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]
wallHeight=84 scale-aware visual units
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
```
There is no perspective, free rotation or user tilt. Switching projections
preserves scalar zoom and converts the view centre through logical floor space.
`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.
@@ -109,7 +115,8 @@ 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 low-plane roots, and
`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`.
@@ -118,8 +125,8 @@ Internal fail-closed evidence, not public API (`STYLING-HOOKS.md` §7.7):
- 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 —
screen-facing overlays sit on a low plane above the floor and are shifted,
never hidden.
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`.
@@ -141,7 +148,7 @@ The per-card LRU is capped at eight entries. A scene contains:
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 5. It excludes HA state, live opening amount, theme,
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.
@@ -157,7 +164,8 @@ not enlarge it.
## Layer order and materials
All geometry roots use one scene `viewBox`. The existing floor/live nodes are
grouped under the affine matrix; HTML anchors still use `projectPlanPoint()`.
grouped in `.iso-floor-scene` without a transform (the floor is the Flat plane);
HTML anchors still use `projectPlanPoint()`.
```text
stage background
@@ -213,72 +221,63 @@ differences instead of strokes; window frame/glass borders remain.
- `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 keeps the real 0°/20° affine matrix, the floor symbols
and the projected frame return (subject to `hide_openings`) and interactive
overlays return to their floor anchors;
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 camera is orthographic `rotDeg=0`, `tiltDeg=20`, with the `[500,500]`
pivot and scale-aware 84-unit wall height. Floor SVG, wall/opening projection,
inverse hit mapping, invisible collision footprints and fit bounds share that
one affine authority.
`isoPlaneMatrix()` (`src/iso-projection.ts`) is that authority. The projected
frame also includes the low overlay plane; blur and shadow extents never enter
fit.
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 collision footprints and fit bounds share that one affine
authority. `isoPlaneMatrix()` (`src/iso-projection.ts`) is that authority; the
floor plane matrix is the identity.
Device markers, room labels/cards and opening-lock badges keep their canonical
floor anchors but render on a low plane four visual units above the floor.
Devices and lock badges in the same room whose canonical reference-fit bounds
(expanded by 12 CSS px) connect form one rigid cluster. Every member receives
the same scene-space displacement, so pairwise vectors, rows and intervals are
the affine projection of the Flat layout rather than a per-marker fan toward a
room safe point. Room labels never enter a cluster and stay below interactive
roots.
**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). The #651 resolvers (`resolveIsoOverlayRigidGroups`,
`resolveIsoOverlayCollisions` and the nudge search in `resolveIsoOverlayPlacement`)
are no longer called by the scene; their removal is
[#714](https://github.com/Matysh/houseplan-card/issues/714).
A device never moves because its Home Assistant state changed (owner's
decision, #711). The layout sees the state-free tile of a device — its icon at
its configured size, without value text, value badges or supplemental metrics,
which change with state. Those still count in the fit bounds, and an HA-only
change refreshes that visual extent without a new collision search.
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°`).
`src/iso-overlays.ts` is the pure placement boundary. A device accepts its
explicit room only when that room strictly contains its floor anchor, otherwise
the smallest strictly containing room (stable id tie-break); room labels use
their own room; lock badges inherit the physical room side selected by
opening-host geometry. Wall clearance uses a 4 CSS px safety gap
(`ISO_OVERLAY_SAFETY_GAP_CSS_PX`), and every candidate path must stay strictly
inside the owner and outside its island holes. Boundary candidates are evaluated
on the integer CSS-pixel lattice through a bounded spatial grid (#585), so
sub-4 px legal slits are found without an all-pairs or disk scan.
**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.
The reference-fit view, not the current live view, converts CSS safety values
into scene units. Wheel/button zoom, pinch and pan therefore transform an
already resolved scene and cannot invalidate placement. Structural changes —
stage/camera, walls, rooms, marker membership or canonical anchors — rebuild it
deterministically; viewport movement and HA-only state do not. One common
vector clears the exact wall silhouettes and already placed clusters within an
absolute 48 CSS-pixel reference-fit budget, using stable size/required-shift/
kind-id order and boundary candidates instead of scanning the displacement
disk. The correction is runtime-only and is never written to configuration.
**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`.
If no completely legal common vector exists, the nearest deterministic result
keeps the cluster rigid and prioritises room ownership, then wall clearance,
then overlap with an earlier cluster. It never splits or shrinks a cluster;
residual overlap is an explicit degraded diagnostic. The two full isometric
profiles keep the ordinary 150/60/75 ms resize/pan/state noise allowances
(#585, #651). Fit probes reserve the maximum correction but do not execute
live collision search. There is no painted plate, long tether, ground dot or
per-marker 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`.
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 and the bounded position correction, never an
outline.
contrast comes from colour, never an outline.
## Stage 6: public mode, tiles, sun and materials (#649)
+11 -7
View File
@@ -327,13 +327,17 @@ device tooltips.
An administrator turns the volumetric plan on once: **General settings →
Display → Show the plan in 2.5D**. After saving, View and kiosk in every space
and on every device show walls, doors, windows and device markers with depth:
markers are raised tiles with a soft shadow on the floor, and windows cast a
soft wash of sunlight (when sun rays are on and north is set). Walls keep the
wall colour from General settings in light and dark themes alike; furniture and
other decor look exactly as on the flat plan. Editors are always flat. The
option is off by default; turning it off or **Reset** returns the flat plan.
There is no separate button on the card.
and on every device show walls, doors, windows and device markers with depth.
The plan itself does not move: rooms, furniture, other decor and room names
stay exactly where they are on the flat plan, and walls grow straight up from
it. Every device marker and door lock is lifted by the same distance — the wall
height — as a raised tile with a soft shadow; near a wall a tile may overlap it.
Windows cast a soft wash of sunlight (when sun rays are on and north is set).
Walls keep the wall colour from General settings in light and dark themes
alike. Switching the option, or opening an editor from the 2.5D View, keeps the
zoom and position on screen. Editors are always flat. The option is off by
default; turning it off or **Reset** returns the flat plan. There is no
separate button on the card.
For an occasional Zigbee placement check, an administrator can enable
**General settings → Show Zigbee links when hovering over a device**. The option
+11 -6
View File
@@ -344,12 +344,17 @@ Escape и закрытие диалога отбрасывают изменен
Объёмный вид плана включает администратор один раз: **Общие настройки →
Отображение → Объёмный вид плана (2.5D)**. После сохранения Просмотр и киоск во
всех пространствах и на всех устройствах сразу показывают стены, двери, окна и
значки устройств с объёмом: значки — приподнятые плитки с мягкой тенью на полу,
из окон на пол ложится мягкий свет по солнцу (если включены солнечные лучи и
задан север). Цвет стен — тот, что задан в общих настройках, одинаковый в
светлой и тёмной теме; мебель и прочий декор выглядят так же, как на обычном
плане. Редакторы всегда плоские. Выключенная опция (по умолчанию) и
**Сбросить** возвращают обычный план. Отдельной кнопки на карточке нет.
значки устройств с объёмом. Сам план не сдвигается: комнаты, мебель, прочий
декор и названия комнат остаются ровно там же, где на обычном плане, а стены
вырастают из него строго вверх. Все значки устройств и замков поднимаются на
одно и то же расстояние — высоту стен — и выглядят приподнятыми плитками с
мягкой тенью; у стены плитка может заходить на неё. Из окон на пол ложится
мягкий свет по солнцу (если включены солнечные лучи и задан север). Цвет стен —
тот, что задан в общих настройках, одинаковый в светлой и тёмной теме.
Переключение опции и вход в редактор из объёмного вида сохраняют масштаб и
положение плана на экране. Редакторы всегда плоские. Выключенная опция (по
умолчанию) и **Сбросить** возвращают обычный план. Отдельной кнопки на карточке
нет.
Для разовой проверки размещения Zigbee администратор может включить **Общие
настройки → Показывать связи Zigbee при наведении на устройство**. По умолчанию