6.2 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 virtual boundary — 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 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, room drafts;
- the bare outline of any room edge that carries no thickness — a wall is still a wall when it is drawn as a line.
Transparent
- doorways, gates and arches — cut through the masonry, so an opening is a real gap between two jamb faces and a thick wall's returns narrow the beam;
- virtual (open) boundaries, which are not walls to begin with.
Deliberately opaque, although the plan draws an opening there
- windows: an indoor lamp must not wash the street;
- a door with 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.
So the light's masonry is cut by passages only and differs on purpose from the drawn one.
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.
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 a fingerprint of their own geometry, 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 that now
exists. The same fingerprint, plus position and radius, keys the per-source
region cache (_glowClipCache).
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, an edge through the lamp is ignored, and a corner made by two crossing barriers is lit right up to the corner.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_openwall.mjs— a lamp lights across one virtual boundary, and across two in sequence, only where it can see through both.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.
Performance
One clipped circle per source and one blur for the layer: 60 sources in the
large-light-blend-v1 profile render in ~75 ms against ~259 ms for the layered
model on the same runner (CPU throttled 4×).