mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
SUN: spec (docs/SUN.md) + pure logic src/sun.ts with unit tests
planSunAngle/sunDirOnPlan (compass wrap), dayPhase palette, exterior-wall probing, window wedges (rayQuad + polyclip room clipping), cloudFactor map, north_deg/bg_mode/sun_rays inheritance. 26 new unit tests.
This commit is contained in:
+140
@@ -0,0 +1,140 @@
|
||||
# Sun on the plan — the spec (source of truth)
|
||||
|
||||
Status: approved by the owner 2026-08-03. Dev-only for now (no release).
|
||||
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:
|
||||
neutral by day, a warm shift in the golden hour (elevation below
|
||||
~10°), cooling through dusk, deep darkening at night. 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.
|
||||
- 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'`.
|
||||
|
||||
## 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), clipped by the
|
||||
room's polygon (`polyclip` intersection, the same dependency
|
||||
`src/resize.ts` already uses). Its length is `k(elevation)` in window
|
||||
lengths: ~2.5 at sunrise/sunset tapering to ~0.8 at the zenith
|
||||
(`0.8 + 1.7·(1 − elevation/90)^1.6`). A linear gradient runs bright at
|
||||
the window and dissolves inward; the color is warm orange while
|
||||
`elevation < 10°` and neutral by day; peak opacity is modest (~0.18 —
|
||||
two overlapping wedges never exceed a readable ceiling). Near the
|
||||
horizon the opacity ramps in over the first ~2° so wedges never pop.
|
||||
|
||||
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.
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Sun on the plan — pure logic only (docs/SUN.md).
|
||||
*
|
||||
* Angles, the day phase palette, exterior-wall detection, window light
|
||||
* wedges and their clipping, the cloud factor and the settings
|
||||
* inheritance. Coordinates are render units (NORM_W-scaled canvas,
|
||||
* y grows DOWNWARD), same as the card's space model. Nothing here
|
||||
* touches Lit, the DOM or `hass` beyond a plain state object.
|
||||
*/
|
||||
import { intersection } from 'polyclip-ts';
|
||||
import { pointInPolygon, lerpColor } from './logic';
|
||||
|
||||
// ---------------- angles ----------------
|
||||
|
||||
/** Normalise any angle in degrees to [0, 360). */
|
||||
export function norm360(deg: number): number {
|
||||
const d = deg % 360;
|
||||
return d < 0 ? d + 360 : d;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sun's bearing on the CANVAS: 0 = up, clockwise (docs/SUN.md).
|
||||
* `north_deg` is how far true north is rotated clockwise from "canvas up".
|
||||
*/
|
||||
export function planSunAngle(azimuth: number, northDeg: number): number {
|
||||
return norm360(azimuth - northDeg);
|
||||
}
|
||||
|
||||
/** Unit vector TOWARD the sun on the canvas (x right, y down). */
|
||||
export function sunDirOnPlan(azimuth: number, northDeg: number): [number, number] {
|
||||
const a = (planSunAngle(azimuth, northDeg) * Math.PI) / 180;
|
||||
return [Math.sin(a), -Math.cos(a)];
|
||||
}
|
||||
|
||||
// ---------------- day phase (bg_mode: 'daynight') ----------------
|
||||
|
||||
export interface DayPhase {
|
||||
/** Stage background color for the current elevation. */
|
||||
bg: string;
|
||||
/** How much the PLAN itself dims (0..0.1 — readability first). */
|
||||
planDim: number;
|
||||
/** 1 = golden hour / horizon, 0 = plain daylight. Drives wedge color. */
|
||||
warmth: number;
|
||||
}
|
||||
|
||||
/** elevation° → color stops; piecewise-linear between neighbours. */
|
||||
const BG_STOPS: [number, string][] = [
|
||||
[-90, '#070c14'], // deep night
|
||||
[-12, '#070c14'],
|
||||
[-4, '#131a28'], // dusk cools down
|
||||
[0, '#4a3527'], // warm band right at the horizon
|
||||
[10, '#46505f'], // golden hour fades into neutral
|
||||
[30, '#5a6673'], // plain day
|
||||
[90, '#5a6673'],
|
||||
];
|
||||
|
||||
const clamp01 = (t: number) => Math.min(1, Math.max(0, t));
|
||||
|
||||
/** Background, plan dim and warmth for a sun elevation (docs/SUN.md). */
|
||||
export function dayPhase(elevation: number): DayPhase {
|
||||
const e = Math.min(90, Math.max(-90, Number(elevation) || 0));
|
||||
let bg = BG_STOPS[BG_STOPS.length - 1][1];
|
||||
for (let i = 1; i < BG_STOPS.length; i++) {
|
||||
const [e0, c0] = BG_STOPS[i - 1];
|
||||
const [e1, c1] = BG_STOPS[i];
|
||||
if (e <= e1) {
|
||||
bg = lerpColor(c0, c1, (e - e0) / (e1 - e0));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
bg,
|
||||
// full 10% below ~-6°, gone above +10° — a slow dusk, not a switch
|
||||
planDim: clamp01((10 - e) / 16) * 0.1,
|
||||
warmth: e < 0 ? 1 : clamp01(1 - e / 10),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- exterior walls & windows ----------------
|
||||
|
||||
export interface SunRoom { id: string; poly: number[][] }
|
||||
/** A window opening in render units: centre, wall angle°, full length. */
|
||||
export interface SunWindow { id: string; x: number; y: number; angle: number; length: number }
|
||||
|
||||
/**
|
||||
* Is the wall stretch at `mid` with outward normal `n` exterior — i.e. is
|
||||
* there NO room just outside it? Probes one point `probe` units out.
|
||||
*/
|
||||
export function isExteriorWall(mid: number[], n: number[], rooms: SunRoom[], probe = 6): boolean {
|
||||
const p = [mid[0] + n[0] * probe, mid[1] + n[1] * probe];
|
||||
return !rooms.some((r) => r.poly.length >= 3 && pointInPolygon(p, r.poly));
|
||||
}
|
||||
|
||||
/**
|
||||
* The wall a window sits on: probe both sides of the window centre. Exactly
|
||||
* one side inside a room → exterior wall; the outward normal points to the
|
||||
* empty side and the room on the other side hosts the wedge. Both sides in
|
||||
* rooms (interior walls, open/virtual boundaries) or neither (a window not
|
||||
* on any boundary) → null: this window never casts light (docs/SUN.md).
|
||||
*/
|
||||
export function windowWallInfo(
|
||||
win: { x: number; y: number; angle: number },
|
||||
rooms: SunRoom[],
|
||||
probe = 6,
|
||||
): { normal: [number, number]; roomId: string } | null {
|
||||
const rad = (win.angle * Math.PI) / 180;
|
||||
// perpendicular to the wall (the wall runs along `angle`)
|
||||
const n: [number, number] = [Math.sin(rad), -Math.cos(rad)];
|
||||
const roomAt = (side: 1 | -1): SunRoom | null => {
|
||||
const p = [win.x + n[0] * probe * side, win.y + n[1] * probe * side];
|
||||
return rooms.find((r) => r.poly.length >= 3 && pointInPolygon(p, r.poly)) || null;
|
||||
};
|
||||
const plus = roomAt(1);
|
||||
const minus = roomAt(-1);
|
||||
if (plus && minus) return null; // interior wall (incl. open boundaries)
|
||||
if (!plus && !minus) return null; // not on any room's wall
|
||||
return plus
|
||||
? { normal: [-n[0], -n[1]], roomId: plus.id! }
|
||||
: { normal: n, roomId: minus!.id };
|
||||
}
|
||||
|
||||
/** Does the sun actually shine INTO this window right now? (A grazing sun
|
||||
* exactly along the wall does not count — hence the epsilon, which also
|
||||
* swallows the sin/cos float dust of the right-angle directions.) */
|
||||
export function windowLit(normal: number[], sunDir: number[], elevation: number): boolean {
|
||||
return elevation > 0 && normal[0] * sunDir[0] + normal[1] * sunDir[1] > 1e-9;
|
||||
}
|
||||
|
||||
// ---------------- wedge geometry ----------------
|
||||
|
||||
/**
|
||||
* Wedge length in WINDOW LENGTHS: longest (~2.5) at sunrise/sunset, shortest
|
||||
* (~0.8) at the zenith. `0.8 + 1.7·(1 − e/90)^1.6` — long low shafts, short
|
||||
* noon pools, smooth in between (docs/SUN.md).
|
||||
*/
|
||||
export function rayLength(elevation: number): number {
|
||||
const e = Math.min(90, Math.max(0, elevation));
|
||||
return 0.8 + 1.7 * Math.pow(1 - e / 90, 1.6);
|
||||
}
|
||||
|
||||
/** The unclipped wedge: window span a-b extruded by `len` along `dir`. */
|
||||
export function rayQuad(a: number[], b: number[], dir: number[], len: number): number[][] {
|
||||
return [
|
||||
[a[0], a[1]],
|
||||
[b[0], b[1]],
|
||||
[b[0] + dir[0] * len, b[1] + dir[1] * len],
|
||||
[a[0] + dir[0] * len, a[1] + dir[1] * len],
|
||||
];
|
||||
}
|
||||
|
||||
/** Clip a wedge by the room outline. Returns outer rings (may be several). */
|
||||
export function clipToRoom(quad: number[][], room: number[][]): number[][][] {
|
||||
try {
|
||||
const res = intersection(
|
||||
[[...quad.map((p) => [p[0], p[1]]), [quad[0][0], quad[0][1]]]] as any,
|
||||
[[...room.map((p) => [p[0], p[1]]), [room[0][0], room[0][1]]]] as any,
|
||||
);
|
||||
const out: number[][][] = [];
|
||||
for (const poly of res as any) {
|
||||
const ring = poly?.[0];
|
||||
if (!Array.isArray(ring) || ring.length < 4) continue;
|
||||
out.push(ring.slice(0, ring.length - 1).map((p: number[]) => [p[0], p[1]]));
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return []; // a degenerate clip draws nothing rather than everything
|
||||
}
|
||||
}
|
||||
|
||||
export interface SunRay {
|
||||
openingId: string;
|
||||
roomId: string;
|
||||
/** Clipped wedge outline(s), render units. */
|
||||
polys: number[][][];
|
||||
/** Window span endpoints (the bright end of the gradient). */
|
||||
a: number[];
|
||||
b: number[];
|
||||
/** Direction the light travels (AWAY from the sun), unit vector. */
|
||||
dir: [number, number];
|
||||
/** Wedge reach in render units (the gradient's fade distance). */
|
||||
len: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* All wedges of a space for one sun position. Pure and deterministic — the
|
||||
* card memoises the result on (azimuth, elevation, config rev) and reuses
|
||||
* it across hass ticks (docs/SUN.md). Mutual shading of building wings is
|
||||
* NOT considered (documented limit).
|
||||
*/
|
||||
export function computeSunRays(
|
||||
rooms: SunRoom[],
|
||||
windows: SunWindow[],
|
||||
azimuth: number,
|
||||
elevation: number,
|
||||
northDeg: number,
|
||||
): SunRay[] {
|
||||
if (!(elevation > 0)) return [];
|
||||
const toSun = sunDirOnPlan(azimuth, northDeg);
|
||||
const away: [number, number] = [-toSun[0], -toSun[1]];
|
||||
const k = rayLength(elevation);
|
||||
const out: SunRay[] = [];
|
||||
for (const w of windows) {
|
||||
if (!(w.length > 0)) continue;
|
||||
const info = windowWallInfo(w, rooms);
|
||||
if (!info || !windowLit(info.normal, toSun, elevation)) continue;
|
||||
const room = rooms.find((r) => r.id === info.roomId);
|
||||
if (!room) continue;
|
||||
const rad = (w.angle * Math.PI) / 180;
|
||||
const hx = (Math.cos(rad) * w.length) / 2;
|
||||
const hy = (Math.sin(rad) * w.length) / 2;
|
||||
const a = [w.x - hx, w.y - hy];
|
||||
const b = [w.x + hx, w.y + hy];
|
||||
const len = k * w.length;
|
||||
const polys = clipToRoom(rayQuad(a, b, away, len), room.poly);
|
||||
if (!polys.length) continue;
|
||||
out.push({ openingId: w.id, roomId: info.roomId, polys, a, b, dir: away, len });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------- wedge dressing ----------------
|
||||
|
||||
/** Peak wedge opacity; two overlapping wedges stay readable (docs/SUN.md). */
|
||||
export const RAY_MAX_ALPHA = 0.18;
|
||||
|
||||
/** Wedge opacity: ramps in over the first ~2° so sunrise never pops. */
|
||||
export function rayAlpha(elevation: number, cloud = 1): number {
|
||||
if (!(elevation > 0)) return 0;
|
||||
return RAY_MAX_ALPHA * Math.min(1, elevation / 2) * clamp01(cloud);
|
||||
}
|
||||
|
||||
/** Wedge color: warm orange at the horizon → neutral daylight. */
|
||||
export function rayColor(warmth: number): string {
|
||||
return lerpColor('#ffe9c2', '#ff9a45', clamp01(warmth));
|
||||
}
|
||||
|
||||
// ---------------- cloud cover ----------------
|
||||
|
||||
/** weather.* state → wedge opacity multiplier (docs/SUN.md table). */
|
||||
const CLOUD_FACTORS: Record<string, number> = {
|
||||
'clear': 1, 'sunny': 1, 'clear-night': 1, 'windy': 1, 'exceptional': 1,
|
||||
'partlycloudy': 0.7, 'windy-variant': 0.7,
|
||||
'cloudy': 0.4,
|
||||
'overcast': 0.25, 'fog': 0.25,
|
||||
'rainy': 0, 'pouring': 0, 'snowy': 0, 'snowy-rainy': 0,
|
||||
'hail': 0, 'lightning': 0, 'lightning-rainy': 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Cloud multiplier for a weather entity state. Unset entity, unknown state
|
||||
* or a dead sensor → 1: a broken weather sensor must not kill the sun.
|
||||
*/
|
||||
export function cloudFactor(state: string | null | undefined): number {
|
||||
if (!state) return 1;
|
||||
const f = CLOUD_FACTORS[String(state).toLowerCase()];
|
||||
return f === undefined ? 1 : f;
|
||||
}
|
||||
|
||||
// ---------------- settings inheritance (global → space) ----------------
|
||||
|
||||
const intDeg = (v: any): number | null =>
|
||||
typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 359 ? v : null;
|
||||
|
||||
/** Effective compass: the space override wins, null = feature inert. */
|
||||
export function northDegOf(settings: any, spaceSettings: any): number | null {
|
||||
const sp = intDeg(spaceSettings?.north_deg);
|
||||
if (sp !== null) return sp;
|
||||
return intDeg(settings?.north_deg);
|
||||
}
|
||||
|
||||
export type BgMode = 'static' | 'daynight';
|
||||
|
||||
/** Effective background mode; anything unknown falls back to 'static'. */
|
||||
export function bgModeOf(settings: any, spaceSettings: any): BgMode {
|
||||
const pick = (v: any): BgMode | null => (v === 'static' || v === 'daynight' ? v : null);
|
||||
return pick(spaceSettings?.bg_mode) ?? pick(settings?.bg_mode) ?? 'static';
|
||||
}
|
||||
|
||||
/** Effective «sun in the windows» flag; default OFF (docs/SUN.md). */
|
||||
export function sunRaysOn(settings: any, spaceSettings: any): boolean {
|
||||
const sp = spaceSettings?.sun_rays;
|
||||
if (typeof sp === 'boolean') return sp;
|
||||
return settings?.sun_rays === true;
|
||||
}
|
||||
|
||||
/** The optional weather entity (GLOBAL settings only). */
|
||||
export function weatherEntityOf(settings: any): string | null {
|
||||
const v = settings?.weather_entity;
|
||||
return typeof v === 'string' && v.trim() ? v.trim() : null;
|
||||
}
|
||||
|
||||
/** Read sun.sun out of a hass-like object; null when absent/garbage. */
|
||||
export function sunStateOf(hass: any): { azimuth: number; elevation: number } | null {
|
||||
const attrs = hass?.states?.['sun.sun']?.attributes;
|
||||
const az = Number(attrs?.azimuth);
|
||||
const el = Number(attrs?.elevation);
|
||||
return Number.isFinite(az) && Number.isFinite(el) ? { azimuth: az, elevation: el } : null;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
norm360, planSunAngle, sunDirOnPlan, dayPhase,
|
||||
isExteriorWall, windowWallInfo, windowLit,
|
||||
rayLength, rayQuad, clipToRoom, computeSunRays,
|
||||
rayAlpha, rayColor, cloudFactor, RAY_MAX_ALPHA,
|
||||
northDegOf, bgModeOf, sunRaysOn, weatherEntityOf, sunStateOf,
|
||||
} from '../test-build/sun.js';
|
||||
|
||||
const near = (a, b, eps = 1e-9) => Math.abs(a - b) < eps;
|
||||
|
||||
// ---- the test house: two rooms sharing the x=500 wall, windows on all four
|
||||
// outer walls plus one on the shared (interior) wall --------------------
|
||||
const ROOMS = [
|
||||
{ id: 'r1', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] },
|
||||
{ id: 'r2', poly: [[500, 100], [800, 100], [800, 500], [500, 500]] },
|
||||
];
|
||||
const WIN = {
|
||||
north: { id: 'wN', x: 300, y: 100, angle: 0, length: 60 },
|
||||
south: { id: 'wS', x: 300, y: 500, angle: 0, length: 60 },
|
||||
west: { id: 'wW', x: 100, y: 300, angle: 90, length: 60 },
|
||||
east: { id: 'wE', x: 800, y: 300, angle: 90, length: 60 },
|
||||
inner: { id: 'wI', x: 500, y: 300, angle: 90, length: 60 },
|
||||
};
|
||||
const ALL = Object.values(WIN);
|
||||
|
||||
test('planSunAngle: plain subtraction, wraps around the circle (359→0)', () => {
|
||||
assert.equal(planSunAngle(180, 0), 180);
|
||||
assert.equal(planSunAngle(0, 1), 359);
|
||||
assert.equal(planSunAngle(359, 359), 0);
|
||||
assert.equal(planSunAngle(10, 350), 20);
|
||||
assert.equal(norm360(-90), 270);
|
||||
assert.equal(norm360(720), 0);
|
||||
});
|
||||
|
||||
test('sunDirOnPlan: compass points map to canvas vectors (y grows down)', () => {
|
||||
const cases = [
|
||||
[0, [0, -1]], // north = canvas up
|
||||
[90, [1, 0]], // east = right
|
||||
[180, [0, 1]], // south = down
|
||||
[270, [-1, 0]], // west = left
|
||||
];
|
||||
for (const [az, [x, y]] of cases) {
|
||||
const d = sunDirOnPlan(az, 0);
|
||||
assert.ok(near(d[0], x, 1e-12) && near(d[1], y, 1e-12), `az ${az}`);
|
||||
}
|
||||
// rotating the compass rotates the whole sky: east sun, north_deg=90 → up
|
||||
const d = sunDirOnPlan(90, 90);
|
||||
assert.ok(near(d[0], 0, 1e-12) && near(d[1], -1, 1e-12));
|
||||
});
|
||||
|
||||
test('dayPhase: night is dark and dim, noon is neutral, sunrise is warm', () => {
|
||||
const night = dayPhase(-20);
|
||||
const dawn = dayPhase(2);
|
||||
const noon = dayPhase(60);
|
||||
assert.equal(night.bg, '#070c14');
|
||||
assert.equal(noon.bg, '#5a6673');
|
||||
assert.notEqual(dawn.bg, night.bg);
|
||||
assert.notEqual(dawn.bg, noon.bg);
|
||||
assert.ok(near(night.planDim, 0.1));
|
||||
assert.equal(noon.planDim, 0);
|
||||
assert.ok(dawn.planDim > 0 && dawn.planDim < 0.1);
|
||||
assert.equal(night.warmth, 1);
|
||||
assert.equal(noon.warmth, 0);
|
||||
assert.ok(near(dawn.warmth, 0.8));
|
||||
// garbage elevation never throws and stays inside the palette
|
||||
assert.ok(dayPhase(NaN).bg.startsWith('#'));
|
||||
});
|
||||
|
||||
test('windowWallInfo: exterior windows on all four sides get outward normals', () => {
|
||||
const n = windowWallInfo(WIN.north, ROOMS);
|
||||
const s = windowWallInfo(WIN.south, ROOMS);
|
||||
const w = windowWallInfo(WIN.west, ROOMS);
|
||||
const e = windowWallInfo(WIN.east, ROOMS);
|
||||
assert.deepEqual(n.roomId, 'r1');
|
||||
assert.ok(near(n.normal[0], 0, 1e-12) && near(n.normal[1], -1, 1e-12));
|
||||
assert.ok(near(s.normal[0], 0, 1e-12) && near(s.normal[1], 1, 1e-12));
|
||||
assert.ok(near(w.normal[0], -1, 1e-12) && near(w.normal[1], 0, 1e-12));
|
||||
assert.equal(w.roomId, 'r1');
|
||||
assert.ok(near(e.normal[0], 1, 1e-12) && near(e.normal[1], 0, 1e-12));
|
||||
assert.equal(e.roomId, 'r2');
|
||||
});
|
||||
|
||||
test('windowWallInfo: interior and orphan windows never participate', () => {
|
||||
assert.equal(windowWallInfo(WIN.inner, ROOMS), null); // shared wall
|
||||
assert.equal(windowWallInfo({ x: 300, y: 300, angle: 0 }, ROOMS), null); // mid-room
|
||||
assert.equal(windowWallInfo({ x: 950, y: 950, angle: 0 }, ROOMS), null); // nowhere
|
||||
});
|
||||
|
||||
test('isExteriorWall probes the outer side', () => {
|
||||
assert.ok(isExteriorWall([300, 100], [0, -1], ROOMS));
|
||||
assert.ok(!isExteriorWall([500, 300], [1, 0], ROOMS)); // r2 is outside r1 here
|
||||
});
|
||||
|
||||
test('windowLit: above the horizon AND facing the sun', () => {
|
||||
const east = [1, 0];
|
||||
assert.ok(windowLit(east, sunDirOnPlan(90, 0), 10));
|
||||
assert.ok(!windowLit(east, sunDirOnPlan(270, 0), 10)); // sun behind the house
|
||||
assert.ok(!windowLit(east, sunDirOnPlan(90, 0), 0)); // sunset moment
|
||||
assert.ok(!windowLit(east, sunDirOnPlan(90, 0), -5)); // night
|
||||
});
|
||||
|
||||
test('rayLength: longest at the horizon, shortest at noon, monotonic', () => {
|
||||
assert.ok(near(rayLength(0), 2.5, 1e-9));
|
||||
assert.ok(near(rayLength(90), 0.8, 1e-9));
|
||||
assert.ok(rayLength(10) > rayLength(30));
|
||||
assert.ok(rayLength(30) > rayLength(60));
|
||||
assert.ok(near(rayLength(-5), 2.5, 1e-9)); // clamped
|
||||
});
|
||||
|
||||
test('rayQuad + clipToRoom: the wedge is cut by the room outline', () => {
|
||||
const quad = rayQuad([100, 270], [100, 330], [1, 0], 1000); // way past the wall
|
||||
const clipped = clipToRoom(quad, ROOMS[0].poly);
|
||||
assert.equal(clipped.length, 1);
|
||||
for (const [x, y] of clipped[0]) {
|
||||
assert.ok(x >= 100 - 1e-6 && x <= 500 + 1e-6, 'x inside the room');
|
||||
assert.ok(y >= 100 - 1e-6 && y <= 500 + 1e-6, 'y inside the room');
|
||||
}
|
||||
assert.ok(clipped[0].some(([x]) => near(x, 500, 1e-6)), 'reaches the far wall, not past it');
|
||||
// a wedge fully outside the room clips to nothing
|
||||
assert.equal(clipToRoom(rayQuad([900, 900], [960, 900], [0, 1], 50), ROOMS[0].poly).length, 0);
|
||||
});
|
||||
|
||||
test('computeSunRays: morning east sun lights ONLY the east window', () => {
|
||||
const rays = computeSunRays(ROOMS, ALL, 90, 5, 0);
|
||||
assert.deepEqual(rays.map((r) => r.openingId), ['wE']);
|
||||
assert.equal(rays[0].roomId, 'r2');
|
||||
// light travels AWAY from the sun: westward into the room
|
||||
assert.ok(near(rays[0].dir[0], -1, 1e-12) && near(rays[0].dir[1], 0, 1e-12));
|
||||
for (const [x, y] of rays[0].polys[0]) {
|
||||
assert.ok(x >= 500 - 1e-6 && x <= 800 + 1e-6 && y >= 100 - 1e-6 && y <= 500 + 1e-6);
|
||||
}
|
||||
});
|
||||
|
||||
test('computeSunRays: noon south sun → south window, short wedge', () => {
|
||||
const rays = computeSunRays(ROOMS, ALL, 180, 60, 0);
|
||||
assert.deepEqual(rays.map((r) => r.openingId), ['wS']);
|
||||
assert.ok(near(rays[0].len, rayLength(60) * 60, 1e-9));
|
||||
assert.ok(rays[0].len < computeSunRays(ROOMS, ALL, 90, 5, 0)[0].len);
|
||||
});
|
||||
|
||||
test('computeSunRays: evening west sun → west window', () => {
|
||||
const rays = computeSunRays(ROOMS, ALL, 270, 4, 0);
|
||||
assert.deepEqual(rays.map((r) => r.openingId), ['wW']);
|
||||
});
|
||||
|
||||
test('computeSunRays: night → nothing at all', () => {
|
||||
assert.deepEqual(computeSunRays(ROOMS, ALL, 90, 0, 0), []);
|
||||
assert.deepEqual(computeSunRays(ROOMS, ALL, 90, -10, 0), []);
|
||||
});
|
||||
|
||||
test('computeSunRays: rotating the compass swings the light to another window', () => {
|
||||
// the same morning east sun, but the plan is rotated 90°: what the canvas
|
||||
// shows as "up" is now east → the NORTH-drawn window faces the sun
|
||||
const rays = computeSunRays(ROOMS, ALL, 90, 5, 90);
|
||||
assert.deepEqual(rays.map((r) => r.openingId), ['wN']);
|
||||
// and the interior window still never lights up whatever the compass says
|
||||
for (const nd of [0, 45, 90, 180, 270]) {
|
||||
for (const az of [0, 90, 180, 270]) {
|
||||
assert.ok(!computeSunRays(ROOMS, ALL, az, 5, nd).some((r) => r.openingId === 'wI'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('rayAlpha: capped, ramps in near the horizon, scaled by clouds', () => {
|
||||
assert.equal(rayAlpha(0), 0);
|
||||
assert.equal(rayAlpha(-3), 0);
|
||||
assert.ok(near(rayAlpha(1), RAY_MAX_ALPHA / 2));
|
||||
assert.ok(near(rayAlpha(30), RAY_MAX_ALPHA));
|
||||
assert.ok(near(rayAlpha(30, 0.25), RAY_MAX_ALPHA * 0.25));
|
||||
assert.equal(rayAlpha(30, 0), 0);
|
||||
});
|
||||
|
||||
test('rayColor: warm at the horizon, neutral by day', () => {
|
||||
assert.equal(rayColor(1), '#ff9a45');
|
||||
assert.equal(rayColor(0), '#ffe9c2');
|
||||
assert.notEqual(rayColor(0.5), rayColor(0));
|
||||
});
|
||||
|
||||
test('cloudFactor: the state map, garbage-safe', () => {
|
||||
assert.equal(cloudFactor('sunny'), 1);
|
||||
assert.equal(cloudFactor('clear-night'), 1);
|
||||
assert.equal(cloudFactor('partlycloudy'), 0.7);
|
||||
assert.equal(cloudFactor('cloudy'), 0.4);
|
||||
assert.equal(cloudFactor('overcast'), 0.25);
|
||||
assert.equal(cloudFactor('fog'), 0.25);
|
||||
assert.equal(cloudFactor('rainy'), 0);
|
||||
assert.equal(cloudFactor('pouring'), 0);
|
||||
assert.equal(cloudFactor('snowy'), 0);
|
||||
assert.equal(cloudFactor('lightning-rainy'), 0);
|
||||
assert.equal(cloudFactor('unknown'), 1);
|
||||
assert.equal(cloudFactor('unavailable'), 1);
|
||||
assert.equal(cloudFactor(null), 1);
|
||||
assert.equal(cloudFactor(undefined), 1);
|
||||
assert.equal(cloudFactor('CLOUDY'), 0.4);
|
||||
});
|
||||
|
||||
test('northDegOf: space override wins, strict int 0–359, null = inert', () => {
|
||||
assert.equal(northDegOf({ north_deg: 90 }, {}), 90);
|
||||
assert.equal(northDegOf({ north_deg: 90 }, { north_deg: 0 }), 0); // 0 is a value, not "unset"
|
||||
assert.equal(northDegOf({}, { north_deg: 359 }), 359);
|
||||
assert.equal(northDegOf({}, {}), null);
|
||||
assert.equal(northDegOf(null, undefined), null);
|
||||
for (const bad of [360, -1, 1.5, '90', true, NaN]) {
|
||||
assert.equal(northDegOf({ north_deg: bad }, {}), null, String(bad));
|
||||
}
|
||||
// a garbage override falls back to the valid global
|
||||
assert.equal(northDegOf({ north_deg: 45 }, { north_deg: 999 }), 45);
|
||||
});
|
||||
|
||||
test('bgModeOf: inherit chain with a static fallback', () => {
|
||||
assert.equal(bgModeOf({}, {}), 'static');
|
||||
assert.equal(bgModeOf({ bg_mode: 'daynight' }, {}), 'daynight');
|
||||
assert.equal(bgModeOf({ bg_mode: 'daynight' }, { bg_mode: 'static' }), 'static');
|
||||
assert.equal(bgModeOf({}, { bg_mode: 'daynight' }), 'daynight');
|
||||
assert.equal(bgModeOf({ bg_mode: 'disco' }, {}), 'static');
|
||||
});
|
||||
|
||||
test('sunRaysOn: default OFF, per-space tri-state inherit', () => {
|
||||
assert.equal(sunRaysOn({}, {}), false);
|
||||
assert.equal(sunRaysOn({ sun_rays: true }, {}), true);
|
||||
assert.equal(sunRaysOn({ sun_rays: true }, { sun_rays: false }), false);
|
||||
assert.equal(sunRaysOn({}, { sun_rays: true }), true);
|
||||
assert.equal(sunRaysOn({ sun_rays: true }, { sun_rays: null }), true); // null = inherit
|
||||
assert.equal(sunRaysOn({ sun_rays: 'yes' }, {}), false);
|
||||
});
|
||||
|
||||
test('weatherEntityOf / sunStateOf: strings and hass shapes, garbage-safe', () => {
|
||||
assert.equal(weatherEntityOf({ weather_entity: 'weather.home' }), 'weather.home');
|
||||
assert.equal(weatherEntityOf({ weather_entity: ' ' }), null);
|
||||
assert.equal(weatherEntityOf({}), null);
|
||||
assert.deepEqual(
|
||||
sunStateOf({ states: { 'sun.sun': { attributes: { azimuth: 120.5, elevation: -3 } } } }),
|
||||
{ azimuth: 120.5, elevation: -3 },
|
||||
);
|
||||
assert.equal(sunStateOf({ states: {} }), null);
|
||||
assert.equal(sunStateOf({ states: { 'sun.sun': { attributes: { azimuth: 'x', elevation: 1 } } } }), null);
|
||||
assert.equal(sunStateOf(null), null);
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"include": [
|
||||
"src/logic.ts", "src/vacuum.ts",
|
||||
"src/sun.ts",
|
||||
"src/resize.ts",
|
||||
"src/rules.ts",
|
||||
"src/devices.ts",
|
||||
|
||||
Reference in New Issue
Block a user