Внешний пользователь завёл #370 как баг: в houseplan-space-card нет теней от стен, хотя в houseplan-card они есть. Разбор показал, что кода это не касается — ограничение намеренное и записано в src/space-render.ts:353 («the compact card intentionally has no live radial pools»). visibilityPolygon из src/light-visibility.ts импортируют ровно два файла, houseplan-card.ts и houseplan-editor-runtime.ts; space-render.ts не импортирует его вовсе. Пулов нет, а тень существует только как форма пула — затенять нечего. Но претензия справедлива, просто адресована не туда. Единственная запись о намеренности жила в комментарии исходника, которого пользователь видеть не может, а руководство обещало обратное: «маркеры используют те же состояния, значения, тревоги и эффекты, что полный план» — про свет ни слова. В LIGHT.md про компактную карточку тоже не было ничего. Человек полез в код именно потому, что документация молчала, и сам корректно предположил, что это может быть намеренно. Теперь сказано в трёх местах: оба руководства и LIGHT.md, с причиной — пулы это самая дорогая часть отрисовки, у неё свой перф-воркфлоу и бюджеты, и компактная карточка платит за дешевизну именно ими. Issue: #370 User-Visible: no
15 KiB
Light on the plan — the model (source of truth)
Status: accepted by the owner after manual testing, 2026-08-11 (#71). Replaces the layered model of v1.61.0-beta.5 and earlier.
Principle
A lamp lights the floor it can see. That is the whole model. Everything the plan shows — a beam through a doorway, a shadow behind a column, a wall corner cutting that beam two rooms away, light flowing across a dashed zero wall — is one computation, so those things cannot disagree with each other.
The previous model computed them separately: a clip for the source's "open zone", a blurred sector pasted at each doorway, a mask for obstacle shadows. Every fix to one layer broke another; a doorway could be an unlit bar between two lit rooms, a beam could float detached from its aperture, a shadow could dissolve into a smear, and walls belonging to a farther room cast nothing at all. None of those states is expressible now.
What stops light, and what does not
_lightBarriers() (src/houseplan-card.ts) builds the barrier set once per
plan geometry and relevant opening-state signature, then shares it between
every lamp in the space.
Opaque
- the wall bodies exactly as the plan draws them (
wallBodiesGeometry), with their real thickness and mitred junctions; - independent bodies: partitions, columns and room drafts. Exact connected draft/partition segments enter as one joined volume, not as raw rectangles whose former butt faces could become false barriers;
- every zero-thickness wall when its space uses the Solid style. It enters the sweep as its exact axis, a zero-area barrier rather than fake masonry.
Transparent
- doorways and gates to floor on both sides, but only by their resolved opening amount. An unbound opening is fully transparent, a bound closed one is opaque, and a positional cover cuts a centre-aligned fraction of the full aperture;
- saved
passageopenings — always cut through the masonry, so an opening is a real gap between two jamb faces and a thick wall's returns narrow the beam; - every zero-thickness wall when its space uses the Dashed style.
The setting is intentionally one semantic switch, not just paint. Missing or
unknown zero_wall_style means dashed; solid blocks Glow and sunlight.
It applies equally to room-contour atoms, independent partitions and saved
draft segments. resolveZeroWalls() supplies both the lines and barrier set,
so renderers and light cannot disagree. The deprecated open_spans/open_to
input is only a v8 read projection and migrates to ordinary cm:0 atoms.
Deliberately opaque, although the plan draws an opening there
- windows: an indoor lamp must not wash the street;
- a door, gate or
passagewith no floor behind it. An opening is transparent only where BOTH sides are floor; otherwise a front door glows halfway — up to the centreline, where the room polygon ends — and the plan shows a lit doorway to nowhere.
The classifier is an explicit door | gate | passage allowlist. Door and gate
state is resolved by the same openingAmount() used for their visible symbol:
binary contacts yield zero or full aperture, finite current_position yields
clamp(position / 100), and invert mirrors a known amount. Missing,
disabled, unknown or unavailable contacts preserve the static-plan fallback
of a fully open door/gate rather than manufacturing a closure. Any unknown
future opening type remains opaque until its physical semantics are reviewed;
it never inherits transparency merely by not being a window.
So the light's masonry is cut by passages only and differs on purpose from the drawn one.
The rule is identical for a passage hosted by a finished independent wall. Only its explicit partition body (plus an exactly collinear covering room wall) is cut. Windows remain opaque to indoor Glow, one-sided door/gate/passage cuts remain opaque, and an invalid host fails dark. A window hosted by an independent wall never becomes a sunlight source; sunlight still belongs to exterior room windows.
Source placement follows the same geometry, fail-dark. If the source centre is inside an opaque wall body, a window tunnel, or an exterior door/gate opening, the source produces no Glow at all. It does not light the indoor half of the opening. An interior door/gate/saved-passage opening remains a real hole and is therefore a valid source position. This is an intentional placement rule, not a temporary availability state: move the source marker onto the clean room floor to make it emit Glow again (#92).
From barriers to a lit region
- Split at crossings (
splitAtIntersections). The sweep casts a ray at every barrier ENDPOINT. Two faces that cross in their middles — normal where wall bodies meet at a junction — would leave that corner unsampled, and the fan would close it with a chord: a sliver of floor next to a corner the lamp plainly sees goes dark. After the split every crossing is an endpoint and the sweep is exact, whatever shape the geometry arrived in. - Sweep (
visibilityPolygon,src/light-visibility.ts): a ray at every corner and just to either side of it, the nearest hit wins, and the fan is closed with an arc at the lamp's own radius (GLOW_ARC_STEPS= 96, chord error 0.05% of the radius). - Intersect with the floor (
intersectionPaths): light lands on rooms, not on the space around the house.
Room and fan coordinates cross the polygon-boolean boundary through a
1e-6 render-unit numeric grid. This removes sub-pixel arithmetic tails
without rewriting saved plan geometry. If the combined floor still cannot be
processed, clipping retries room by room: a failed room stays dark, every
healthy room keeps its visible light, and the card emits one redacted warning
per space-geometry revision and room. Returning the un-clipped visibility fan
is never a fallback (#218).
The result is ONE clipPath for ONE <circle> filled with the source's radial
gradient. A shadow is simply floor that is not in that region.
Brightness
glowAlpha remains the only intensity formula (docs/specs/067) and gives the
alpha at the centre of the pool. GLOW_FALLOFF then spends that alpha over
the whole radius (100/88/62/32/0 %). The old flat plateau out to 70% turned
every clipped shape into a slab of solid colour with a rim — which is exactly
how a doorway sector read in the next room. The gradient is userSpaceOnUse
and centred on the lamp, so attenuation depends on distance from the lamp and
on nothing else: the floor behind a door is faint because it is far.
A shadow currently keeps no light at all. This follows from "objects do not let light through" and is the owner's standing decision; a residual term would be one constant if a pitch-black corner next to a bright area ever needs softening.
Penumbra
One SVG feGaussianBlur over the whole light layer, sized in SCREEN pixels
(GLOW_EDGE_FEATHER_PX = 2, so σ = 1 px) and converted to plan units with the
current zoom — an edge stays a hairline when the plan is enlarged instead of
turning into a smear. Measured: the lit→unlit border crosses 20–80% in 1.5 px
and is fully done in 3.5 px.
Do NOT reach for CSS filter: blur() here: Chromium applies it to an SVG group
in name only (getComputedStyle reports the blur; the picture changes by a
couple of hundred pixels on a whole plan). And do not blur per source — one
pass over the layer costs a fifth of one blurred mask per source.
Caching
Barriers are keyed by their geometry fingerprint plus a compact, sorted
signature of bound interior door/gate opening amounts (quantised to the
0.05 grid of OPENING_LIGHT_AMOUNT_QUANTUM, #366 — a moving cover steps the
light in 5% increments instead of forcing a recompute per percent), never by
_cfgEpoch:
the epoch lags behind geometry edited in place, and a stale barrier set is
invisible — the plan simply keeps lighting through a wall or closed door that
now exists. Unrelated HA updates preserve the signature and hit the bounded
barrier cache. The combined fingerprint, plus source position and radius, keys
the per-source region cache (_glowClipCache).
The masonry boolean receives room walls after passage cuts plus the cached joined independent body set. Its outer/hole rings are the authoritative barriers for both visibility and the fail-dark source guard. A boolean failure falls back to the raw independent bodies as opaque obstacles; it never turns a malformed wall transparent.
Source, state and service identity
The geometry above consumes resolvedLightSources(); it never discovers light
entities on its own. Since #84/#88 a source has three deliberately separate
identities:
key(entity:*ormarker:*) identifies and de-duplicates the physical source on the plan;stateEidsare the real HA entities whose states feed a stateful source;serviceEidsare the real HA entities which may be sent tocallService.
marker:* is configuration graph syntax, never an HA entity id. It is never
looked up in hass.states and never sent to a service. A stateful marker target
projects to its selected leading light.*/switch.*; a passive marker may
legitimately have empty state and service lists.
is_light remains tri-state. Auto keeps functional device-role discovery,
Never suppresses only the marker's own source, and Always creates one spatial
source even when the marker has no controllable HA entity. That last case is a
passive forced source:
- with no incoming controller link it is explicitly constant-on;
- with one or more links it is on when any active controller driver is on;
- links with no active driver make it dormant/off, not constant-on;
- its position, room, colour, brightness and radius belong to the target lamp, not to the switch which drives it.
There is one explicit manual mode. An active marker with a virtual binding,
is_light: true (Always) and tap_action: toggle reads its on/off value from
the integration's revisioned operational store only while it has no valid
incoming controller link. Absence is on; a tap performs the operational
toggle, never an HA service call. Saved outgoing controls remain lossless and
do not override that unlinked manual state.
With one or more incoming links, the same exact marker enters linked mode.
Incoming controller drivers become the sole state authority for every consumer
of resolvedLightSources() — Glow, room fill/counts, device presentation,
preview and both card types. Tapping the source sends one ordinary HA group
operation to the deduplicated union of all incoming drivers; tapping a
controller still operates only that controller's effective driver group. No
operational toggle or optimistic visual flip occurs. Adding a link preserves
the stored manual bit, and removing the final link exposes that exact value
again. The operational revision remains part of the resolver cache key, but it
cannot change a linked source without a driver-state change.
The controller picker can therefore link a smart relay to a virtual marker for a dumb physical lamp. Multiple controllers use OR. A direct entity reference and a marker reference resolving to the same stateful source are deduplicated. The controller still presents the aggregate working state of its targets, but does not steal their Glow position or room statistics.
For Always devices with several own controllable entities, optional
marker.light_entity selects the leading state/service entity. Absence keeps
the compatibility fallback (entity: binding, resolved primary, then the
first controllable candidate). A stored selection which temporarily disappears
is retained and visibly warned about; runtime uses the fallback until it
returns. Capability comes from binding/registry metadata, never from a
transient unknown, unavailable or missing state snapshot.
The complete UI and runtime truth table lives in
DEVICE-LIGHT-SETTINGS-MATRIX.ru.md.
What the tests hold
test/light-visibility.test.mjs— the sweep itself: a wall stops light, a doorway lets a beam through and only through, a column's shadow has the angular width its size dictates, an occluder out of range changes nothing, a source on an opaque edge is rejected, the ±π seam cannot cut a wedge from the fan, and a corner made by two crossing barriers is lit right up to the corner.test/physical-geometry.test.mjsplustest/houseplan-runtime-contract.test.mjs— the source-placement guard: exterior opening masonry, wall bodies, partitions and columns are fail-dark, while a real interior passage remains a valid source position; the rendered Glow path is pinned to that shared guard.test/logic.test.mjsandtest/physical-geometry.test.mjs— the shared opening amount, stable state signature and centre-preserving partial cut for contour and independent walls.demo/smoke_glow.mjs— pixels on a rendered plan: the aperture itself is lit, the floor behind a door is lit, the visible beam is no wider than twice the opening, there is no light behind a column while the floor beside it is lit, the lit→unlit border is at most 4 px wide, a spot has exactly one painted child, the light layer contains exactly one blur and no mask, and an outside door does not change the lit region at all.demo/smoke_zero_walls.mjs— dashed zero walls transmit Glow while the same axes in solid mode stop it; changing the style invalidates the cached region.test/golden-matrix.test.mjs— the source contract: one region per source, no second layer of light, no barrier cache keyed by the epoch.- Golden:
lighting-opaque-glow-two-doorways-darkand the otherlighting-*scenes, re-shot and approved 2026-08-11.
Which surfaces render pools
Pools — and therefore wall shadows — exist on the full plan only:
houseplan-card and the geometry editors. visibilityPolygon()
(src/light-visibility.ts) is imported by those surfaces and by nothing else.
houseplan-space-card takes the static path (renderSpaceStatic() in
src/space-render.ts) and deliberately has no live radial pools; it shares the
room fills and the independent data/base projection with the full plan, so a lit
room is painted flat. Without a pool there is no shape to occlude, so the compact
card shows no wall shadows. This is a cost decision, not an omission: the pool
layer is the heaviest thing the product renders — it owns its own performance
workflow, budgets and a per-run smoke (large-house-glow-overlay-v1).
Reported from the field as a bug (#370), because the user guide promised that markers share everything with the full plan and said nothing about light.
Performance
The large cold geometry recalculation (20 rooms, 20 partitions, 14 columns,
61 sources) dropped from about 23.5 s in beta.5 to about 0.33 s in beta.6 on
the review runner — roughly 70×. The official warm large-light-blend-v1
path is generally level with beta.5; the 30-source sample was temporarily
slower and remains a performance watch item. During pinch/pan and the bounded
500 ms source fade, the whole-layer blur is bypassed and its parameters stay
frozen; the final screen-space feather is restored once after the transition
instead of rebuilding and evaluating the filter for every animation frame.