Files
houseplan-card/docs/SUN.md
T
Matysh eb17396006 Sunlight has hard sides again and fades only along the ray
Owner, 2026-08-04, on yesterday's attempt: «с лучами солнца ты сделал фигню —
не надо размывать их боковые грани».

They are right. 22b588e answered "the shafts run into something invisible" with
a Gaussian blur of the WHOLE wedge (`raySoftness`, filter `hp-sunsoft`), which
feathered the sides as well as the tip. A shaft of light through a window has
crisp sides; only its reach fades. The blur turned every wedge into a smudge.

GONE. `raySoftness()`, the `<filter>`/`feGaussianBlur` in <defs>, the `<g
filter clip-path>` wrapper, and with it the `hp-sunclip` clipPath — that clip
existed only so the blur could not bleed through a wall. The polygons come out
of `computeSunRays()` already intersected with the room, so a wall still stops
the light by geometry (demo/smoke_sun.mjs, wedgeClippedToRoom). The sun layer
is plain `<polygon fill="url(#hp-sun-i)">` again.

THE KERB DID NOT COME BACK, and not by luck. The old bright edge floating in
mid-floor was never about softness: the gradient's iso-alpha lines are square
to the SUN, while a parallelogram's far edge is parallel to the WALL. Head-on
they coincide; at any other angle one far corner sits at offset `1 − 0.5/k` —
0.71 of the way at a low sun, 0.11 at a high one — i.e. still lit when the
polygon ends. So `rayQuad()` no longer builds a parallelogram: each side is
extruded until it reaches the same distance `len` ALONG `dir`, which puts the
far edge on one iso-alpha line of the gradient. Combined with the untouched
`RAY_FADE_END` = 85 %, the last 15 % of every wedge is empty and its outline
has nothing left to draw. The sides stay razor-sharp on purpose.

Length (×0.7) and the live sky catch-up are untouched.

Tests: unit — `rayQuad` at six sun angles (sides exactly parallel to the ray,
both far corners at offset 1, far edge ⊥ ray, nothing past the gradient) plus
the head-on parallelogram pinned; the `raySoftness` test is gone with the
function. Smoke — demo/smoke_sun_soft.mjs keeps the reach and the "dead at
85 %" checks and flips the feather assert into its opposite: no filter on any
wedge, no `feGaussianBlur` in the tree, and at an OBLIQUE sun (230°/8° and
225°/55°) no vertex is drawn past the end of the gradient. Verified to fail on
the previous bundle on exactly those four. All 247 unit tests and all 97 smokes
green. Stills: sun_sharp_low / sun_sharp_high (demo/shot_sun_short.mjs now
takes a file prefix).
2026-08-04 10:30:22 +03:00

270 lines
14 KiB
Markdown
Raw 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: approved by the owner 2026-08-03. Shipped in v1.56.0.
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
The plan learns where north is, and from that single number plus HA's
own `sun.sun` the card knows where the sun stands relative to every
wall. Two visuals follow: the stage background can breathe with the
day (day → golden hour → dusk → night), and windows on exterior walls
cast soft wedges of light into their rooms. Everything is display
only — no entities are created, no services are called.
## Data
- Source: the `sun.sun` entity (`attributes.azimuth` 0–360, 0 = north,
clockwise; `attributes.elevation` in degrees, negative below the
horizon). No `sun.sun` in the install → the whole feature stays
silent and the settings dialog says why.
- 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, weather state)`.
- Angle on the plan: `plan_angle = azimuth − north_deg` (normalised to
0–360). 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).
## 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.
- 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 the whole sun feature is
inert: static background, no wedges, nothing computed. The settings
dialogs show a hint.
- Backend validation: integer in 0–359 at both levels.
## Plan background — `settings.bg_mode: 'static' | 'daynight'`
- 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.
`visibilitychange → visible` arms the catch-up outright.
- 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, and open (virtual) boundaries
never qualify because both sides are rooms — 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 is positive (the sun actually faces this window).
The wedge is a quadrilateral cast from the window's span along the
direction AWAY from the sun (light falls inward) and cut off
PERPENDICULAR to the ray (see «Dissolving» below), clipped by the
room's polygon (`polyclip` intersection, the same dependency
`src/resize.ts` already uses). 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).
### 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 glass
inward, and nothing else.** The contract:
- the gradient spans the FULL wedge length (`x1,y1` at the glass,
`x2,y2` exactly `len` away), so geometry and gradient always describe
the same shaft — but its stops (`rayStops()`) ease out to **zero at
`RAY_FADE_END` = 85 %** of that length: `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;
- `rayQuad()` therefore ends the wedge ON an iso-alpha line of that
gradient: both sides are extruded until they reach the same distance
`len` ALONG `dir`, so the far edge is perpendicular to the RAY, not
parallel to the wall. This is what killed the old bright kerb. A
parallelogram (equal extrusion of both ends) has its far edge
parallel to the WALL, while the gradient's iso-alpha lines are square
to the sun; for any sun that does not face the glass head-on the two
disagree and one far corner sits at offset `1 − 0.5/k` — still lit
(~0.71 at a low sun, ~0.11 at a high one). That corner was the
straight bright kerb hanging in mid-floor;
- 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 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`, cloud cover
being the only multiplier).
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, night (`elevation ≤ 0`), rain — 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.
## Cloud cover — `settings.weather_entity` (optional)
String entity id or null; GLOBAL settings only. When set and the
entity's state reads overcast, the wedges fade by an opacity
multiplier — ~0.25 fully overcast, 0 (gone) in rain/snow:
| states | factor |
| --- | --- |
| clear, sunny, clear-night, windy, exceptional, unset entity | 1.0 |
| partlycloudy, windy-variant | 0.7 |
| cloudy | 0.4 |
| overcast, fog | 0.25 |
| rainy, pouring, snowy, snowy-rainy, hail, lightning, lightning-rainy | 0.0 |
| unknown, unavailable | 1.0 (a dead sensor must not kill the sun) |
Backend validation: string or null.
## Edge cases and limits
- No `sun.sun` → silent feature + a hint in the settings dialog.
- `north_deg` unset everywhere → silent feature + a hint.
- `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
`bg_mode`/color; wedges are v1 FULL-CARD ONLY (documented limit).
- Editors (plan/devices/decor) → no wedges and no day/night: 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, cloud factor, settings inheritance); unit-tested
in `test/sun.test.mjs`.
- `src/houseplan-card.ts` — the memoised wedge layer, the day/night
stage background, both settings dialogs (compass dial included).
- `src/space-render.ts` — the static card's background only.
- `custom_components/houseplan/validation.py` — the four settings at
both levels; 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 spans the wedge and dies 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.
- `demo/smoke_sun_live_bg.mjs` — the sky follows `sun.sun` on a plain
`hass` tick with no reload, asserted on the COMPUTED background of the
stage; small steps still glide, big ones catch up at once.
- `demo/shot_sun_short.mjs` — stills at a low and a high sun
(`node demo/shot_sun_short.mjs <outdir> <prefix>`).