Skip to content

Add room-aware ambient daylight through exterior openings - #204

Open
TomasTriska88 wants to merge 2 commits into
nicosandller:mainfrom
TomasTriska88:feature/ambient-daylight
Open

Add room-aware ambient daylight through exterior openings#204
TomasTriska88 wants to merge 2 commits into
nicosandller:mainfrom
TomasTriska88:feature/ambient-daylight

Conversation

@TomasTriska88

@TomasTriska88 TomasTriska88 commented Aug 22, 2026

Copy link
Copy Markdown

Motivation

Direct sunlight models the sun's actual rays, but it does not represent diffuse sky light entering a room when the sun is not shining directly through an opening. The most obvious case is a north-facing window: the room is still daylight-lit, even though it may receive no direct sunbeam.

What this does

  • Adds an opt-in ambientDaylight: true card setting. The default is false, so existing plans do not change.
  • Derives ambient sources from openings that border exactly one mapped Area; openings between two mapped Areas are treated as interior and do not become fake outdoor light sources.
  • Keeps ambient daylight independent of solar azimuth, so north-facing and otherwise non-sun-facing openings can still contribute.
  • Uses the sun's elevation only as a day/night strength signal. Missing, non-numeric or non-finite elevation fails dark instead of inventing daylight.
  • Respects opening amount, optical transmission, shutters and the existing sunlight: false opt-out.
  • Combines multiple sources with a bounded normalized contribution.
  • Clips every patch exactly to its Area polygon and renders a soft gradient/blurred falloff without leaking through walls.
  • Uses unique SVG ids and includes a stylesheet regression guard so CSS cannot silently replace renderer-owned gradient/filter paint.
  • Adds editor help and documentation for the feature.

Config

ambientDaylight: true

Areas are required because V1 uses Area adjacency both to determine which side of an opening is indoors and to provide the hard clipping boundary.

Screenshots

Captured from the repository Home Assistant dev container on one deterministic two-room scene. Direct sunlight and sun dimming are off, and the real sun.sun is pinned to daytime (elevation 62.95°). The only config change between captures is ambientDaylight: falsetrue.

Before - ambientDaylight: false After - ambientDaylight: true
Before - ambient daylight disabled After - ambient daylight enabled

The left room has north and west exterior windows. With ambient daylight enabled, broad diffuse daylight enters from both openings while the adjacent room without an exterior opening stays unchanged. Capture provenance (before config, after config).

Validation

The branch is rebased and squashed to one feature commit on current main / v1.5.4 (288146b):

  • Node 24.19 typecheck: pass
  • Full Vitest suite: pass
  • Production build: pass
  • Independent clean fork workflow using the repository's install/typecheck/test/build commands: pass
  • Real repository Home Assistant dev-container before/after capture: pass
  • null, "", " ", arrays, unavailable values and non-finite elevations are regression-covered and fail dark
  • sun.sun is registered through the central collectWatchedEntities path rather than a second card-level watcher

The upstream Validate workflow for the current head is presently action_required before any job is created (fork-run approval gate), so there is no current CI test failure to report or hide behind an older run.

Tests cover default/backward compatibility, north-facing openings, interior-opening rejection, opaque/closed opening behavior, shutters, night/fail-dark behavior, bounded multi-source composition, exact Area clipping, CSS renderer ownership, unique SVG ids, editor/config round-trip and invalid/incomplete configuration.

Known limitation

Exterior-vs-interior inference assumes complete Area topology. If a real adjacent room is not represented by an Area polygon, a one-sided opening at that boundary can be classified as exterior. V1 documents this requirement rather than guessing missing building topology.

@nicosandller

Copy link
Copy Markdown
Owner

@TomasTriska88 can you add screenshots of the before and after tested on the dev container?

I just want to make it easier to imagine.

Thanks!

@amadeobriones

Copy link
Copy Markdown
Collaborator

I run a fork and tested this against current main. A few things that might be useful.

It's still clean against latest main. The branch is ~10 commits behind, but merging it into
288146b is conflict-free, type-checks, and the suite passes 1204/1204.

The fail-dark path holds on a real plan. I built it into my fork's card and ran it on a live
Home Assistant with an 11-area, 16-opening floor plan. At sun.sun elevation −13.8° it
rendered zero ambient nodes, so "missing or non-finite elevation fails dark instead of inventing
daylight" behaves as written on real data rather than fixtures. Caveat: that build was my fork
(v1.5.4 plus three extra render layers and a long-press control), not stock main, so treat it as corroboration rather than
a clean-room result.

The lit case checks out too. Rather than fake sun.sun on a live instance (which would fire
sun-based automations), I drove your code directly in a clean clone of main + this PR, feeding
renderAmbientDaylightLayer the same plan geometry with a synthetic sun:

sun elevation opening sources areas lit layer
−13.8° 7 0 nothing
+45° 7 6 of 11 renders

and the ramp between them is smooth and monotonic: −6° → 0, −3° → 0.156, 0° → 0.5, +3° → 0.844,
+6° → 1. Both ends behave exactly as described.

One gap while I was in there. ambientDaylightDayFactor guards with
Number.isFinite(Number(elevation)), and four values survive that check by coercing to 0:
null, "", " " and []. Because the ramp runs −6°→+6°, zero is its exact midpoint — so any
of those yields 0.5, a half-strength wash, and renderAmbientDaylightLayer returns a real layer
instead of nothing. That's the one outcome the PR description rules out ("Missing or non-finite
elevation fails dark instead of inventing daylight"), and the source comment says the same.

Your existing tests pin "unavailable", undefined and NaN, which all correctly give 0
null and "" just aren't among them. I can't point at an HA state that actually emits
elevation: null, so treat this as hardening rather than a live bug; a typeof elevation !== "number"
check at the top closes it.

One note on testability, since it cost me a while: the feature needs areas, and my own plan had
none — an artefact of my fork having carried its own room model until recently, nothing to do with
this card. Areas have been in the card since v0.9.0, so that's almost certainly just me. Mentioning
it only because "nothing renders" has a boring explanation worth ruling out first.

One structural thought, aimed at the card rather than at this PR. Right now every watched entity is
collected in one place — collectWatchedEntities in render.ts handles sun.sun, shutters,
entity-bound furniture and the rest, and the card has a single line:
this._watchedEntities = collectWatchedEntities(this._config). This PR instead adds a second call
beside it in the card. Folding ambient's entities into collectWatchedEntities, next to the
sun.sun subscription it already owns, would keep that one collection point — and would avoid a
collision with #212, which edits the same method for its own state.

Rebased onto upstream v1.5.4. Includes review follow-ups: strict fail-dark handling for non-numeric sun elevation and central sun entity tracking via collectWatchedEntities.
@nicosandller

Copy link
Copy Markdown
Owner

@TomasTriska88 I meant some screenshots on the PR's description. hehe

Copy link
Copy Markdown
Author

Done 🙂 I added the before/after screenshots directly to the PR description, captured from the repository's Home Assistant dev container on the same seeded plan. Direct sunlight is disabled; the real sun.sun was pinned to solar noon at 63.18° elevation. The capture asserts 0 ambient nodes/patches before and 2 ambient nodes + 2 clipped patches after.

I also folded in the other review feedback while rebasing onto current v1.5.4 (288146b): the day-factor input now accepts only finite numeric elevation values (so null, empty/whitespace strings, arrays, etc. fail dark), and the sun.sun dependency goes through the central collectWatchedEntities path instead of a second card-level watcher.

The upstream Validate run for the current head is still sitting at the fork approval gate (action_required, with no job created yet); the dev-container screenshot workflow and the fork-side validation are green.

@nicosandller nicosandller left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is high-quality work — easily the most carefully built PR in the queue right now. Pure geometry module separated from rendering, 1205 tests green, typecheck and build clean, opt-in and off by default, README plus a docs/ page, and before/after captures. The fail-dark handling for a missing sun.sun is the right instinct.

I verified it myself. Tests and typecheck pass, and the layer clips correctly — here's an amplified difference map between the flag off and on, which shows the wash landing inside each Area polygon and not leaking through the wall between the two rooms:

ambient daylight difference map

That's the clipping doing its job. Two things to sort out first, then some smaller notes.

1. At the default strength you can barely see it

That diff map is amplified 14x. The real numbers on the demo plan at noon (elevation 42.5°) are:

  • max change on any pixel: 9 / 255
  • average change where anything changed at all: 3.8 / 255

Side by side at normal size, off vs on:

ambient daylight side by side

The PR deliberately keeps strength, spread, tint and blur as internal defaults — "rather than exposing unstable calibration knobs". I understand the reasoning, but the result is a feature someone turns on, sees almost nothing, and turns back off, with no knob to reach for. Over an Area that already has a colour fill it's swamped.

Please either raise the default meaningfully or expose ambientDaylightStrength. I'd lean toward exposing it — one number is not much surface, and it's the difference between the feature being usable and not.

2. The geometry is recomputed on every render

renderAmbientDaylightLayer calls ambientOpeningSources(...) each time, which walks every opening against every area against every vertex, plus point-in-polygon probes for the inward normal. None of that changes unless the config changes — but it re-runs on every hass update, so on a busy plan it runs several times a second.

The early return means there's no cost when the flag is off, which is the important half. But it's worth caching the sources per config. (Same note I left on #212 — it's becoming a pattern worth being careful about.)

3. Smaller things

  • The module docstring is now wrong. ambient-daylight.ts opens with "This module is deliberately pure and not wired into the card yet" — it is wired in, by this PR.
  • A comment got mangled in editor-forms.ts. The rewrite left a dangling fragment: "…all of the direct-sun state goes — ambientDaylight deliberately survives because it is a sibling layer. these keys is read only while the light is on…". The original sentence ended "every one of these keys is read only while…".
  • Patch edges are straight diagonal cuts. Visible in the diff map at the room corners. The blur softens them but the trapezoid is still geometric. Fine for V1, worth knowing.
  • Toggle ordering. "Ambient daylight" sits above "Let the sun in" in the panel. Direct sunlight is the better-known feature; I'd put ambient second.

4. One thing that connects to #215

ambientOpeningTransmission decides glass as glazed ?? type === "window" — so a glazed door passes ambient light. That's the sensible rule, and it's the same question I raised on #215. We now have three separate implementations of "is this opening transparent": openingSunFraction, this one, and glowClearFraction in #215. They don't all agree.

Nothing for you to fix here — your version is the one I'd keep. But whichever of these lands first should probably pull the rule into one shared helper the others call. Worth a quick chat before either merges.

On the known limitation

Classifying a one-sided opening as exterior when a neighbouring room simply has no Area drawn is a real trap, and documenting it rather than guessing is the right call for V1. Fine by me.

Get (1) and (2) sorted plus the two comment fixes and I'm happy with this.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants