Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,31 @@ follows brightness; a light that's off, unavailable, or has no Cast light clears
lit room brightens itself and not the one next door. Walls are treated as solid along
their whole length — light reaches through no doorway, for the clearing or the pool.

## Ambient daylight

Set **`ambientDaylight: true`** for soft room-aware daylight from the sky, independently
of the directional **Sunlight** layer:

```yaml
type: custom:easy-floorplan-card
ambientDaylight: true
```

A north-facing window can therefore brighten its room even when no direct sun ray reaches
that wall. V1 uses your **Area polygons** to identify exterior openings and to hard-clip
the wash to the receiving room: an opening touching exactly one Area is a sky source; one
touching two Areas is interior; one touching none is ignored. With no Areas, nothing is
drawn rather than guessing the room topology.

The layer reuses the opening's existing travel, glazing and shutter state. `sunlight: false`
on an opening remains the natural-light opt-out. Sky strength follows `sun.sun` elevation
through civil twilight (-6° to +6°), but never uses azimuth/bearing. Missing or unreadable
sun elevation fails dark until a valid HA state returns.

The switch is off by default and appears under **Project -> Ambient daylight**. V1 keeps
strength, spread, tint and blur as implementation defaults rather than exposing unstable
calibration knobs. See `docs/ambient-daylight.md` for the geometry and renderer contract.

## Dead spaces

A **dead space** is a space the walls close off completely that no door and no window opens
Expand Down
83 changes: 83 additions & 0 deletions docs/ambient-daylight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Diffuse ambient daylight

`ambientDaylight` adds a soft room-aware daylight layer from the visible sky. It is deliberately separate from Easy Floorplan's existing direct `sunlight` layer.

Direct sunlight answers **where does the sun itself cast a patch right now?** It depends on sun bearing, elevation, openings and wall shadows.

Ambient daylight answers **how much daylight from the sky softly reaches this room even when the sun itself does not?** A north-facing window can therefore brighten a room without inventing a direct-sun beam.

## Configuration

```yaml
type: custom:easy-floorplan-card
ambientDaylight: true
```

The option is off by default, so existing plans keep their current rendering.

V1 intentionally exposes only the on/off switch. Strength, depth, spread, tint and blur are implementation defaults until they have enough real-plan calibration to justify stable public knobs.

## Geometry and source classification

Ambient daylight uses `Area` polygons for two jobs:

1. determining whether an opening is exterior or interior, and
2. hard-clipping the soft light to the room that receives it.

An opening that touches exactly one known Area boundary is an exterior daylight source. An opening touching two known Areas is an interior opening and is not a V1 sky-light source. An opening touching no known Area is ignored.

This makes complete room geometry important. If a real neighbouring room has no Area polygon, an opening between that room and a modeled room can look exterior because only one side is represented. V1 keeps that limitation explicit instead of guessing missing topology.

## Light behaviour

- Ambient daylight does not use sun azimuth or bearing. Directional direct sunlight remains the job of `sunlight`.
- Sun elevation controls day/twilight/night strength. The transition uses the same civil-twilight interval as the card's sun visual language: zero at or below -6°, full at or above +6°, smoothly eased between them.
- Missing, `unknown`, `unavailable` or otherwise unreadable sun elevation fails dark: the layer renders no invented daylight until Home Assistant supplies a valid elevation again.
- Each exterior opening creates a broad widening wash rather than a narrow sun beam.
- The exact Area polygon clips the result. Blur can soften the pool inside a room but cannot leak through a solid Area boundary.
- Multiple exterior sources combine without normalised brightness exceeding 1.
- The existing opening travel, glazing and shutter state are reused for transmission instead of introducing a second state model.
- `sunlight: false` remains the opening-level natural-light opt-out. This matters for intentionally schematic openings such as an unbound solid door that is drawn open as a floor-plan convention but should not illuminate the room.

The layer is rendered above Area fills and below the existing dead-space, artificial-light and direct-sun layers. It does not reorder those existing layers.

## Why Areas are required

Walls alone describe segments, but not which enclosed polygon is *the room that owns a window*. The Area gives the feature the room identity and an exact physical clip without adding a second room-topology format.

That choice also fails safely: with no Areas, ambient daylight renders nothing rather than spreading light across the whole plan.

## Renderer contract

`ambient-daylight.ts` owns deterministic daylight geometry and transmission math. `ambient-daylight-render.ts` owns SVG paint and clipping. `ambient-daylight-integration.ts` is the thin card-facing adapter that reuses the card's existing opening/shutter resolvers.

The SVG renderer uses:

- one exact Area clip path,
- one bounded Gaussian blur filter,
- one user-space linear gradient per opening patch,
- deterministic IDs with a per-card instance prefix,
- rejection of invalid/non-finite geometry and opacity.

The renderer owns the patch `fill` and `filter`. Card CSS must not replace either with a flat declaration; a regression guard covers the same class of live-browser compositing failure that previously affected direct sunlight.

## V1 boundaries

Deliberately outside the first public version:

- weather/cloud attenuation,
- calibration from local irradiance or lux sensors,
- orientation-dependent sky exposure,
- propagation through open interior doors,
- curtains/blinds beyond the existing shutter transmission,
- vertical opening geometry,
- moonlight or night-sky contribution,
- semantic/decorative room colouring.

These can be added later without changing the distinction between directional sunlight and diffuse sky light.

## Validation expectations

The feature follows the repository's normal validation path: project typecheck, the complete Vitest suite and production build. Geometry tests cover exterior/interior classification, twilight strength, transmission, falloff, clipping, invalid inputs and multiple-card SVG ID isolation. Host/editor tests pin opt-in behaviour, the `sun.sun` watcher contract, fail-dark behavior and independence from direct sunlight.

Before release, the built card must also be checked in a real browser with its actual stylesheet for layer order and gradient/filter composition. Markup-only rendering is not sufficient evidence because CSS participates in the final SVG composition.
40 changes: 40 additions & 0 deletions src/ambient-daylight-editor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import type { FloorplanCardConfig } from "./types";
import { projectReliefForm } from "./editor-forms";

function config(extra: Partial<FloorplanCardConfig> = {}): FloorplanCardConfig {
return {
type: "custom:easy-floorplan-card",
width: 100,
height: 100,
walls: [],
openings: [],
items: [],
texts: [],
furniture: [],
trackers: [],
areas: [],
...extra,
};
}

describe("ambient daylight editor contract", () => {
it("shows ambient daylight independently of direct sunlight", () => {
const form = projectReliefForm(config());
expect(form.fields.map((field) => field.name)).toEqual(["ambientDaylight", "sunlight"]);
expect(form.data.ambientDaylight).toBe(false);
});

it("stores only an explicit enabled ambient option", () => {
const form = projectReliefForm(config());
expect(form.toPatch({ ambientDaylight: true })).toEqual({ ambientDaylight: true });
expect(form.toPatch({ ambientDaylight: false })).toEqual({ ambientDaylight: undefined });
});

it("keeps ambient daylight enabled when direct sunlight is switched off", () => {
const form = projectReliefForm(config({ ambientDaylight: true, sunlight: true }));
const patch = form.toPatch({ ambientDaylight: true, sunlight: false });
expect(patch.ambientDaylight).toBe(true);
expect(patch.sunlight).toBeUndefined();
});
});
111 changes: 111 additions & 0 deletions src/ambient-daylight-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { nothing } from "lit";
import { describe, expect, it } from "vitest";
import type { Floor, FloorplanCardConfig, HomeAssistant } from "./types";
import { ambientDaylightEnabled, renderAmbientDaylightLayer } from "./ambient-daylight-integration";
import { collectWatchedEntities } from "./render";

function config(extra: Partial<FloorplanCardConfig> = {}): FloorplanCardConfig {
return {
type: "custom:easy-floorplan-card",
width: 100,
height: 100,
walls: [],
openings: [],
items: [],
texts: [],
furniture: [],
trackers: [],
areas: [],
...extra,
};
}

function floor(): Floor {
return {
id: "ground",
name: "Ground",
walls: [],
openings: [
{ id: "north-window", type: "window", x: 50, y: 0, length: 30, angle: 0 },
],
items: [],
texts: [],
furniture: [],
trackers: [],
areas: [
{
id: "bedroom",
name: "Bedroom",
points: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 100, y: 100 },
{ x: 0, y: 100 },
],
},
],
};
}

const openingState = {
amount: () => 0,
secondAmount: () => undefined,
};

function hassWithElevation(elevation: unknown): HomeAssistant {
return {
states: {
"sun.sun": {
state: "above_horizon",
attributes: { elevation },
},
},
} as unknown as HomeAssistant;
}

describe("ambient daylight host integration", () => {
it("is a strict opt-in and registers its sun input in the central watcher set", () => {
expect(ambientDaylightEnabled(config())).toBe(false);
expect(ambientDaylightEnabled(config({ ambientDaylight: false }))).toBe(false);
expect(ambientDaylightEnabled(config({ ambientDaylight: true }))).toBe(true);
expect(collectWatchedEntities(config())).not.toContain("sun.sun");
expect(collectWatchedEntities(config({ ambientDaylight: true }))).toContain("sun.sun");
});

it("returns no layer while disabled or without Area geometry", () => {
expect(renderAmbientDaylightLayer(floor(), config(), undefined, "card-a", openingState)).toBe(nothing);

const noAreas = floor();
noAreas.areas = [];
expect(
renderAmbientDaylightLayer(noAreas, config({ ambientDaylight: true }), undefined, "card-a", openingState),
).toBe(nothing);
});

it("builds a daytime layer from an exterior window without direct sunlight", () => {
expect(
renderAmbientDaylightLayer(
floor(),
config({ ambientDaylight: true, sunlight: false }),
hassWithElevation(25),
"card-a",
openingState,
),
).not.toBe(nothing);
});

it("fails dark while sun elevation is missing or unreadable", () => {
expect(
renderAmbientDaylightLayer(floor(), config({ ambientDaylight: true }), undefined, "card-a", openingState),
).toBe(nothing);
expect(
renderAmbientDaylightLayer(
floor(),
config({ ambientDaylight: true }),
hassWithElevation("unavailable"),
"card-a",
openingState,
),
).toBe(nothing);
});
});
68 changes: 68 additions & 0 deletions src/ambient-daylight-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { nothing, svg, type SVGTemplateResult } from "lit";
import type { Floor, FloorplanCardConfig, HomeAssistant, Opening } from "./types";
import { openingClearFraction, shutterAmount } from "./render";
import {
ambientDaylightPatches,
ambientOpeningSources,
ambientOpeningTransmission,
} from "./ambient-daylight";
import { renderAmbientDaylight } from "./ambient-daylight-render";

/** Explicit opt-in: existing plans remain on their old render path. */
export function ambientDaylightEnabled(
config: Pick<FloorplanCardConfig, "ambientDaylight"> | null | undefined,
): boolean {
return config?.ambientDaylight === true;
}

export interface AmbientDaylightOpeningState {
/** Primary opening travel, normalized to 0..1 by the card's existing resolver. */
amount(opening: Opening): number;
/** Optional second-leaf travel for two-panel openings. */
secondAmount(opening: Opening): number | undefined;
}

/**
* Render the complete diffuse-daylight layer for one active floor.
*
* Kept separate from `floorplan-card.ts` so the host card only needs one
* additive render call. The feature uses the same opening-clear and shutter
* resolvers as existing light behavior; geometry and SVG painting remain in
* the pure ambient modules.
*/
export function renderAmbientDaylightLayer(
floor: Pick<Floor, "areas" | "openings">,
config: FloorplanCardConfig,
hass: HomeAssistant | undefined,
idPrefix: string,
openingState: AmbientDaylightOpeningState,
): SVGTemplateResult | typeof nothing {
if (!ambientDaylightEnabled(config) || floor.areas.length === 0) return nothing;

const sources = ambientOpeningSources(floor.areas, floor.openings);
if (sources.length === 0) return nothing;

const openingsById = new Map(floor.openings.map((opening) => [opening.id, opening]));
const transmission = (openingId: string): number => {
const opening = openingsById.get(openingId);
if (!opening) return 0;
const clear = openingClearFraction(
opening,
openingState.amount(opening),
openingState.secondAmount(opening),
);
const shutterOpen = opening.shutterEntity
? shutterAmount(hass?.states[opening.shutterEntity], opening.shutterInvert)
: 1;
return ambientOpeningTransmission(opening, clear, shutterOpen);
};

const elevation = hass?.states["sun.sun"]?.attributes?.elevation;
const rendered = floor.areas.map((area) => {
const patches = ambientDaylightPatches(area, sources, elevation, transmission);
return patches.length
? renderAmbientDaylight(area, patches, { idPrefix })
: nothing;
});
return rendered.some((layer) => layer !== nothing) ? svg`${rendered}` : nothing;
}
Loading