Files
houseplan-card/docs/SUN.md
2026-09-15 13:41:51 +03:00

469 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sun on the plan — the spec (source of truth)
Status: window rays approved 2026-08-03 and shipped in v1.56.0; the
four-phase background was replaced by the owner-approved #146 contract in
2026-08. The current background contract below supersedes the retained legacy
notes explicitly marked as historical.
Scope decisions final: the compass lives in the GENERAL settings with a
per-space override, the feature is silent until `north_deg` is set
anywhere, wedges ship for the FULL card only in v1, and mutual shading
of the building's wings is explicitly NOT computed.
## Principle
Two independent display-only visuals use solar data. The four-phase stage
background follows a valid `sun.sun` sample or browser-local clock fallback and
does not need a compass. Window rays use `sun.sun` plus the plan's north to cast
soft wedges from exterior windows. Neither creates entities nor calls services.
## Data
- Real source: the `sun.sun` entity (`attributes.azimuth` 0–360, 0 = north,
clockwise; `attributes.elevation` in degrees, negative below the
horizon; boolean `attributes.rising`). Missing/invalid data sends the
background to local-clock fallback; window rays stay off and the settings
dialog says why.
- `sun.sun` is consumed from the live HA state machine. It is valid for
this core/runtime entity to have no Entity Registry row; registry
projection must preserve its live state while still excluding rows
that are explicitly disabled.
- Sun attributes update rarely (~30–120 s). Sun geometry is recomputed
ONLY when (azimuth, elevation) or the config change — never on every
`hass` tick. The wedge layer memoises on
`(azimuth, elevation, config rev, space id)`.
- Angle on the plan: `plan_angle = north_deg + azimuth` (normalised to
0–360). Both bearings increase clockwise: `north_deg` is the literal
direction of true north on the canvas, and `azimuth` is the clockwise
bearing from that north. With `north_deg = 0` the top of the canvas is north; the
direction TOWARD the sun on the canvas is
`(sin(plan_angle), −cos(plan_angle))` (y grows downward).
- `planSunAngle()` is the only source of the sun direction on the plan.
Rendering and contrast consumers must not repeat the composition or add a
second mirror/sign correction.
## Compass — `settings.north_deg`
- Integer 0–359, degrees clockwise from "up on the canvas" to true
north. Lives in the GENERAL settings (⚙) as a circular dial: drag
the «N» arrow around the ring, 1° steps, 15° with Shift held; a
plain number input sits next to it for accessibility and precision.
- The arrow is literal: point N toward the place where true north lies on the
drawing. It is not an instruction to enter how far the plan was rotated in
the opposite direction.
- Per-space override in the space settings (empty = inherit), the same
pattern as `show_lqi` / `fill_mode`.
- While `north_deg` is null at BOTH levels window rays are inert and the
settings dialogs show a hint. The four-phase background remains active.
- Backend validation: integer in 0–359 at both levels.
## Current four-phase background — `settings.bg_mode: 'static' | 'daynight'`
The public setting remains a two-value selector. `static` uses `bg_color`.
`daynight` paints a decorative environment behind the unchanged plan:
`dawn`, `day`, `dusk`, or `night`.
- A valid real sample is atomic: finite `sun.sun` azimuth and elevation plus a
boolean `rising`. Elevation `<= -6°` is night, `>= +6°` is day, and the band
between them is dawn while rising or dusk while falling. Invalid or missing
required data switches the whole resolver to browser-local clock time; real
and fallback fields are never mixed.
- Clock fallback uses dawn 05:00–08:00, day 08:00–18:00, dusk 18:00–21:00,
and night otherwise. A visible card checks it every 30 seconds, catches up on
`pageshow`/visibility return, pauses while hidden, and removes the timer on
disconnect.
- The soft decorative light follows real azimuth/elevation, or the fixed
05:00→13:00→21:00 fallback arc. It has zero visible opacity at night.
- Four constant environment layers cross-fade for exactly 1100 ms with
`cubic-bezier(.22,.61,.36,1)`. Reduced motion disables the transition.
- The zero-offset outline starts on the grouped paper footprint, preserving the
historical single-SVG alpha composition exactly. On the first pan or pinch,
the card instance switches to an exact pointer-inert copy in a sibling SVG
behind the visible plan. The filter and `will-change: filter` then belong to
that stage-sized root, while the inner `.hp-paperg` becomes unfiltered: a
1 cm/grid-point plan may span more than 4096 local SVG units without moving a
coordinate-sized filter layer. The sibling follows the same live viewport as
the visible plan for that and every later gesture. Without its own promoted
layer every repaint of the plan re-ran
three blur passes over the whole sheet. Measured in headless
Chromium (CDP tracing, summed `RasterTask`) on a demo-stand pan, that cost
about fifteen times the rasterization of a static background; the owner's
Firefox profile that opened #532 showed the same cause as 23 MB of texture
uploads per frame and about nine frames per second, without a comparable
ratio of its own. A static background creates no outline sibling and carries
neither the filter nor the hint. The non-interactive static space card uses
the same stage-sized day-cycle outline from its first frame because it has no
camera gesture that could activate a fallback later (#582).
- Only the environment and the zero-offset alpha-aware outline outside the
grouped plan-paper footprint change. The plan, paper, floors, room fills,
Glow/spill, devices, labels, decor/backdrop, vacuum, hover, and window rays
are never dimmed, tinted, faded, blended, or otherwise phase-filtered.
- Full View, kiosk, and `houseplan-space-card` share the same pure resolver,
palette, outline, and clock lifecycle. Plan/Devices/Decor editors remain
static.
- The background is independent of `north_deg`. North remains required only
for direction-dependent window rays.
New installations materialize global `daynight`; every manually or
Floors/Areas-created space materializes its own `daynight`. The storage v1.2
migration materializes a missing/invalid legacy global token as `static`, so an
upgrade cannot change an existing plan's appearance. Full and per-space
export/import likewise materialize compatibility modes; a per-space export
carries its effective mode and a legacy space import without one becomes
`static` before merge. Runtime's last-resort missing-token fallback remains
`static`.
The exact palette, positioning formulas, migration matrix, lifecycle, and
acceptance criteria are in `docs/specs/146-four-phase-sun-background.md`.
## Historical continuous background (removed by #146)
The remainder of this section documents the pre-#146 elevation-interpolated
implementation for archaeology only. It is not a current runtime or UX
contract: the 45-second sky glide, `skysnap`, compass gate, and night
brightness filter described below were removed.
- Global default in the general settings, per-space override (null =
inherit). Default `'static'`.
- `'static'` — the existing `bg_color` behaviour, color picker and
all. Nothing changes for existing installs.
- `'daynight'` — the stage background follows the sun's elevation:
WHITE at full day (the brightest moment of the day is white — owner,
2026-08-03), a warm bright shift in the golden hour (elevation below
~10°), cooling through dusk, deep darkening at night. The scale
(piecewise-linear between stops, `BG_STOPS` in `src/sun.ts`):
| elevation | color | phase |
| --- | --- | --- |
| −90°…−12° | `#070c14` | deep night |
| −4° | `#131a28` | dusk cools down |
| 0° | `#4a3527` | warm band right at the horizon |
| +10° | `#e8ddcf` | morning light — warm and bright |
| +30°…+90° | `#ffffff` | plain day, white |
The PLAN
itself dims only ~10% at night (`filter: brightness(.9)`), so the
daytime room fills stay readable. Transitions are a CSS
background/filter transition tens of seconds long;
`prefers-reduced-motion` gets the current colors statically.
- **Glide, but never lag behind reality** (owner 2026-08-04: «цвет фона
не меняется сам с течением времени суток, только после
обновления страницы»). The sky colour and the plan dimming are
delivered by a 45 s CSS transition, and a CSS transition only advances
while the card is being PAINTED. A card that was not painting — a
background tab, another dashboard view, a sleeping wall tablet, an
editor session — comes back holding a stale sky and then crawls toward
the truth 45 s at a time; a page reload, by contrast, paints the right
colour outright, because a freshly mounted element has nothing to
transition FROM. So the card measures the gap: HA refreshes `sun.sun`
every ~4 minutes by day, i.e. ≤1° per update, and anything from
`SKY_SNAP_DEG` = 3° up therefore means "we were not watching". Such a
step is applied with `transition: none` for a single frame
(`.stage.daynight.skysnap`, released on the next
`requestAnimationFrame`); everything smaller keeps the 45 s breathing.
Returning after a genuinely long browser suspension arms the catch-up
outright. A quick tab switch or a short browser minimise preserves the
already painted sky and hover, so it cannot introduce a one-frame flash.
- The elevation the sky is computed from is rounded to 0.1°
(`skyElevation()`) — finer than the eye can tell across a 45 s glide,
and it keeps `dayPhase` (and the style attribute lit has to commit)
from churning on every `hass` tick. The wedge GEOMETRY keeps its own,
coarser memo: the two have deliberately different granularity — the
sky is cheap, the polygon clipping is not.
- The UI is a two-option selector; the color picker shows only for
`'static'`.
- Backend validation: `In(['static', 'daynight'])` at both levels.
- `'daynight'` follows the general gate: without `north_deg` (or
without `sun.sun`) it behaves as `'static'`.
- **The scene background never bleeds through the plan** (owner,
2026-08-03). In BOTH modes the background — `bg_color` or the
daynight sky — is visible only AROUND the plan: opaque `.hp-paper`
shapes sit under everything the plan draws. An image plan papers the
backdrop image rect (the canvas IS the paper); a hand-drawn plan
papers the ROOM CONTOURS — one shape per room in exactly the room's
own geometry (fill only, no stroke), never their bounding box, so
the background reaches the exterior walls of an L-shaped house and
fills the gaps between detached buildings (an empty drawn space has
no paper). Open (virtual) boundaries do not affect the paper; a live
resize preview moves it together with the rooms. Its colour is the
pre-bg_color canvas — white for hand-drawn plans, the theme card
background under an image. The night dimming above is the
`brightness` filter on the zoomwrap ONLY; the paper's alpha never
changes. Applies to view/kiosk/editors and the static space-card
alike (smoke_bg_color).
## Window light wedges — `settings.sun_rays`
Boolean, global + per-space (null = inherit), default OFF.
For every opening of type «window» sitting on an EXTERIOR wall — a
wall stretch with no other room on its outer side, decided by probing
the existing room geometry just off both sides of the window; windows
on interior walls do not participate, windows explicitly hosted by independent
partitions do not participate, and a zero-thickness wall cannot host an
opening — the card draws a wedge
when BOTH hold:
- the sun is above the horizon (`elevation > 0`), and
- the dot product of the wall's outward normal with the direction
toward the sun — the cosine of the angle of incidence — is above
`RAY_MIN_COS` = 0.05, i.e. the sun faces this window AND clears the
plane of its wall by ~2.9° (~87.1° of incidence). Below that there is
nothing to paint: glass reflects almost all of it, and the shaft's
perpendicular depth (`len · cos`, see «Dissolving») would be thinner
than the wall it came through.
The wedge is a PARALLELOGRAM. The global `settings.sun_ray_origin` enum selects
its full source span: `inner` starts at the two room-side corners (the default
and the exact pre-#577 behaviour), while `outer` starts at the two exterior
corners. Missing or unknown values resolve to `inner`; this setting has no
per-space override. The selected span is extruded by the same
length along the direction AWAY from the sun (light falls inward), so
its far edge is parallel to the wall, clipped by the receiving room's
**inner contour** when wall thickness is set (`inset` of the polygon by
half the wall thickness — see `docs/WALL-THICKNESS.md`); otherwise by
the room polygon (`polyclip` intersection). In `outer`, the clip also includes
only the physical rectangular window tunnel between the exterior span and the
clean-floor contour. At oblique incidence, only parallel trajectories which
cross both the exterior and interior spans continue onto the room floor, so
the jamb cuts the visible width instead of letting light pass through the wall
body. The surrounding wall body and outside facade remain occluders. With wall
depth `d`, `inner` translates the source from the
centreline by `+d/2` along the inward normal and `outer` by `-d/2`. Thus the
full source span begins exactly at the selected two corners (`d = 0` makes both
modes identical); in `outer`, a jamb can replace one or both continuing room
edges with a new crisp boundary at the inner face. The nominal length and
gradient start at the selected span rather than adding the wall depth to the old reach.
Its length
is `k(elevation)` in window lengths: ~1.75 at sunrise/sunset tapering to ~0.56 at the zenith
(`0.56 + 1.19·(1 − elevation/90)^1.6` — the v1.56 curve
`0.8 + 1.7·(1 − elevation/90)^1.6` times `RAY_LENGTH_K` = 0.7, owner
2026-08-04: «лучи от солнца сделать короче на 30%»; scaling the whole
curve keeps the "a low sun reaches much further" shape intact). The
color is warm orange while `elevation < 10°` and neutral by day; peak
opacity is `RAY_MAX_ALPHA` = 0.30 (owner 2026-08-03: «лучи поярче,
иногда плохо видны» — raised from 0.18; two overlapping wedges
still stay under a readable ceiling on white paper and on the dark glow
canvas alike).
After room clipping, physical bodies and zero-thickness wall policy cast the
same directional shadows as Glow barriers. A **Dashed** zero wall is absent
from sun occluders; a **Solid** zero wall extrudes its exact axis along the ray
direction and clips the wedge without inventing wall area. The choice is
space-wide, remains active when `show_borders:false` hides the line, and is part
of the sun-geometry cache key.
### Dissolving — along the ray only (owner 2026-08-04)
Two rounds with the owner on the same day:
1. «Проверить, чтобы они всегда плавно рассеивались (сейчас есть
ощущение, что они упираются во что-то невидимое)» — the wedge was
ending on a visible line;
2. «С лучами солнца ты сделал фигню — не надо размывать их боковые
грани» — the first answer to (1) was a Gaussian blur over the whole
wedge, which feathered the SIDES too. Wrong: a shaft of sunlight
through a window has crisp sides. Only its reach fades.
So the falloff is one-dimensional: **along the ray, from the selected opening
inward, and nothing else.** Three invariants have to hold at once:
1. the whole selected opening span is at peak alpha — light does not start out
half-dark at one end of the window;
2. every ray fades over the same distance, its own `len`;
3. the wedge's far edge lies exactly on an iso-alpha line, so the shaft
dies of its gradient and never of its own outline (that visible
straight «bright kerb» hanging in mid-floor).
**The axis of the fade is the wall's INWARD NORMAL, not the ray.**
The light is a bundle of PARALLEL rays, so the distance a point has
travelled from the selected source span is `depth / cos`, where `depth`
is its perpendicular distance from that span and `cos = dir·normal` is fixed
for the whole wedge. That is an affine function of the point, and its
level sets are straight lines PARALLEL TO THE WALL. A linear gradient
whose axis is the normal therefore describes the travelled distance
exactly:
- `x1,y1` = the middle of the selected window span (any point of that span —
they all have depth 0);
- `x2,y2` = that point plus `normal · len · cos` — `SunRay.depth`, the
perpendicular depth a ray reaches after running the full `len`;
- a point `source + dir·u` lands on offset `u / len`, whichever ray it
rode in on.
Hence: the selected opening span is all at offset 0 (invariant 1), the alpha at any
point is a function of how far its own ray has run (invariant 2), and
the parallelogram's far edge — parallel to the wall — IS the gradient's
last iso-alpha line (invariant 3). The «30 % shorter» reach is then a
fact about every SIDE of every wedge, at any sun angle.
The stops (`rayStops()`) ease out to **zero at `RAY_FADE_END` = 85 %**
of the axis: `1 → .86 → .60 → .32 → .10 → 0`. The last 15 % of every
wedge is guaranteed empty, so a shaft that ends in mid-air has nothing
left to draw an edge with.
> **DEV-EB173-01 (fixed).** The previous attempt kept the gradient along
> `dir` from the span's midpoint and bent the GEOMETRY to match,
> extruding the two ends of the window by different amounts so both far
> corners projected onto the same point of that axis. It bought
> invariant 3 with the other two: at a grazing sun the ends of the glass
> themselves sat at offsets ±0.879 — one of them fully transparent
> before the shaft even started — and the two sides came out 5.41 and
> 84.19 long (ratio 15.6), the long one 31 % LONGER than the pre-cut 64
> rather than 30 % shorter. One linear gradient along the ray cannot
> satisfy all three; along the normal it satisfies all three by
> construction.
- the two SIDES carry no falloff at all, on purpose. They are hard
lines, because that is what light through a window looks like. There
is **no filter, no `feGaussianBlur`, no `clip-path`** anywhere in the
sun layer — the polygons arrive from `computeSunRays()` already
intersected with the room, so a wall stops the light by geometry;
- where the room outline does cut a still-lit shaft (the opposite wall,
the inner corner of an L, an OPEN boundary) the edge stays crisp:
that is light landing on a wall, and blurring it was the mistake.
Clipping by the room is unchanged; only the visible edge changed.
### The rim — a hairline along the sides (owner 2026-08-04)
The fill above is honest and nearly invisible on a light plan. Painting
light means ADDING luminance, and white paper has none left to give:
raising `RAY_MAX_ALPHA` does not buy contrast, it only tints the room
beige. That rejected analysis is archived in legacy/docs/SUN-CONTRAST.md, whose «shade
instead of light» answer the owner **rejected** on 2026-08-04 in favour
of its cheap half, verbatim: «тонкая (1px) чёрная граница по бокам
светящегося сектора, которая также плавно уходит в ноль вместе с самим
градиентом». Light is invisible on paper; its BOUNDARY is not.
The contract:
- **Two side edges only.** The rim runs along the two edges that leave
the selected opening corners and travel inward with the ray — `a → a+dir·len`
and `b → b+dir·len`. Never the source edge `a-b` (that is the source,
not a boundary) and never the far edge (there is nothing left to
outline there — the fill is already at zero, see below).
- **One screen pixel at any zoom**: `stroke-width="1"` plus
`vector-effect="non-scaling-stroke"`, so the hairline is a hairline on
a phone, on a 4K kiosk and at any zoom level of the infinite canvas.
- **Black**, and it dies exactly with the fill. A second gradient
`hp-sunrim-N` is emitted next to `hp-sun-N` with **the same
`x1,y1,x2,y2`** (the wall's inward normal, `depth` long) and **the same
normalised curve** — `rimStops()` returns `rayStops()` by identity, not
by copy, so the two can never drift apart. Only the colour and the peak
differ: `RIM_MAX_ALPHA` = 0.42 at the selected opening, tuned on the demo rig
against both extremes (below ~0.3 the line vanishes on paper at kiosk
scale, above ~0.5 it reads as an ink contour over the dark glow
canvas). Zero from `RAY_FADE_END` = 85 % on, like the fill.
- **Clipped by the room like the wedge**, and for free: `rayRimEdges()`
cuts the sides out of the ALREADY clipped polygons — a boundary segment
belongs to a side iff both of its ends lie on that side's line —
merging collinear pieces so an unclipped wedge yields exactly two
lines. No `clip-path` enters the sun layer, and light still cannot
cross a wall.
- **The same life as the wedge.** The rim lives inside the same
`<g class="sunlayer">`, so the 3° threshold, the 2 s layer fade,
`prefers-reduced-motion`, night, the editors and the memo key all apply
to it without a line of extra logic.
### The 3° threshold and the 2-second fade
Wedge opacity does NOT depend on elevation any more — the old ramp-in
over the first ~2° is gone. The contract (owner 2026-08-03) is a hard
threshold:
- `elevation < 3°` → NO rays at all;
- `elevation ≥ 3°` → rays at full strength (`rayPeakAlpha`).
Crossing the threshold is animated, but on the LAYER, never on the
geometry: the `<g class="sunlayer">` fades in with `hp-sunfade-in` and
out with `hp-sunfade-out`, both exactly 2 s (`RAY_FADE_MS` in
`src/sun.ts` must stay in sync with `styles.ts`). To let the fade-out
play at all, the card keeps the layer mounted with `.out` for those two
seconds and only then drops it. `prefers-reduced-motion: reduce` skips
the animation entirely — the rays are simply there or simply gone.
Everything else that removes wedges — leaving view mode, switching the
feature off or night (`elevation ≤ 0`) — is instant: those are not
threshold crossings, and a wedge lingering while you enter the editor
would just be a bug.
Layer order: ABOVE room fills (and the glow layer), BELOW devices and
labels (those live in the HTML `devlayer` anyway). Night
(`elevation ≤ 0`) → no wedges. Wedges work under BOTH `bg_mode`s.
## Weather independence and legacy `weather_entity`
Weather never changes the window rays. Once the feature, compass,
geometry and solar elevation allow a wedge, it is painted at full
strength regardless of any `weather.*` state. This keeps the plan a
stable geometric visualisation instead of making sunlight disappear
because of a provider-specific weather classification.
`settings.weather_entity` was used by older versions. It is no longer
shown or read by the frontend. Backend validation continues to accept
the old string/null field so existing stored configs remain loadable;
saving General settings removes it.
## Edge cases and limits
- No valid `sun.sun` sample → the background follows browser-local time;
window rays stay off and the settings dialog explains the fallback.
- `north_deg` unset everywhere → the background still works; window rays stay
off and the settings dialog shows the compass hint.
- Any weather state, including rain and snow → no effect on rays.
- `prefers-reduced-motion` → no transitions; colors and wedges render
statically for the current sun position.
- Kiosk mode → works (same view path).
- Static `houseplan-space-card` → the background honours the effective
four-phase `bg_mode`/color and fallback lifecycle; wedges are FULL-CARD ONLY.
- Editors (plan/devices/decor) → no wedges and no four-phase environment: the
editor canvases render exactly as before.
- Mutual shading of the building's own wings (an L-shaped house
shadowing its inner corner) is NOT computed — a lit window casts its
wedge even when another wing geometrically blocks the sun. Accepted
v1 limit.
- Wedges of windows on all four wall orientations are unit-tested,
including the 359→0 azimuth wrap.
## Files
- `src/sun.ts` — pure logic (angles, day phase, exterior walls, wedge
quads + clipping, the rim edges and its stops, settings
inheritance); unit-tested in `test/sun.test.mjs`.
- `src/day-cycle-render.ts` — the shared constant environment layers and
plan-outline variables for full and static cards.
- `src/houseplan-card.ts` — the memoised wedge layer, four-phase background
lifecycle, and both settings dialogs (compass dial included).
- `src/space-render.ts` / `src/space-card.ts` — static-card environment and
visible-only clock lifecycle.
- `custom_components/houseplan/validation.py` — the three active sun
settings plus the accepted legacy global weather field; tests in
`tests_backend/test_validation.py`.
- `demo/smoke_sun.mjs` — end-to-end behaviour against the demo rig.
- `demo/smoke_sun_soft.mjs` — the −30 % reach and the "dissolves along
the ray only" contract: the gradient axis is the wall normal and is
`len · cos` long, an offset is exactly how far a ray has run, the
stops die at 85 %, the sides are sharp (no filter on the wedge, no
`feGaussianBlur` at all), and at an oblique sun nothing is drawn past
the end of the gradient — the kerb cannot come back. It re-runs the
DEV-EB173-01 grazing repro end to end (west window 80, elevation 90,
azimuth 190): equal sides of the nominal length, peak alpha at BOTH
ends of the source span, and no wedge at all below `RAY_MIN_COS`.
- `demo/smoke_sun_rim.mjs` — the rim: exactly two lines per wedge, on the
two SIDE edges (their coordinates checked against the wedge's own
vertices), `url(#hp-sunrim-N)` with BLACK stops on the same axis and
the same offsets as the fill, monotone and dead at 85 %,
`vector-effect: non-scaling-stroke` at width 1, gone below 3° and in an
editor, unaffected by legacy weather settings — and a pixel probe on
WHITE paper proving the side of the wedge is measurably darker than the paper on either hand
(the point of the whole change).
- `demo/smoke_sun_live_bg.mjs` — phases follow real samples on plain `hass`
ticks, the plan remains unfiltered, compass independence and atomic clock
fallback are preserved, and the fallback timer follows visibility.
- `demo/golden/matrix.mjs` — deterministic dawn/day/dusk/night visual scenes.
- `demo/shot_sun_short.mjs` — stills at a low and a high sun
(`node demo/shot_sun_short.mjs <outdir> <prefix>`).
- `demo/shot_sun_rim.mjs` — the rim on white paper and on the dark glow
canvas, the same frame at several rim peaks (0 = before), which is how
`RIM_MAX_ALPHA` was chosen: `node demo/shot_sun_rim.mjs <outdir>
[0,0.3,0.42,0.5]`.