Files
houseplan-card/src/glow-scene.ts
T
Codexandclaude[bot] 187c90ba69 fix: give static Glow the full card's cache hierarchy (#375)
V6a: pass the stable devices array to resolvedLightSources — the WeakMap
cache is keyed by array identity, a spread guaranteed a miss on every
render in BOTH cards (full-card regression since beta.4).
V6b: the static wall geometry now carries the same non-enumerable
sourceFingerprint tag the full card attaches, so buildLightBarrierScene
takes the fast recutWallBodiesGeometry path on door state changes.
V6c: the static barrier-scene cache is an LRU of 8 per space (parity
with _lightBarrierPool) — a flipping door reuses both of its scenes.
V6d: enabledClip is cached by geometry fingerprint + disabled-room set,
with the full card's bbox prefilter for decor bodies.

User-Visible: yes
Issue: #375
2026-08-29 18:09:53 +00:00

625 lines
21 KiB
TypeScript

/**
* Canonical Glow transport, source projection and SVG field shared by the
* full plan and the opt-in static space card (#374).
*
* This module deliberately knows neither card class. Callers own configuration
* snapshots and structural caches; the algorithms below own the meaning of a
* light opening, one spatial source and one painted pool.
*/
import { nothing, svg, type TemplateResult } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import {
resolvedLightSources, selectSpatialGlowSource,
} from './devices';
import {
glowAlpha, isInteriorLightOpeningType, openingLightApertureLength,
openingLightStateSignature, outlineWithout, pointInPolygon,
quantizeOpeningLightAmount, resolveGlowAppearance, roomPoly,
} from './logic';
import {
geometryAllRings, intersectionPaths, pointInOpaquePlanBody,
scalePartitionOpeningCut, type PartitionOpeningCut,
} from './physical-geometry';
import { partitionOpeningCut } from './partition-openings';
import {
geometryRoomOpeningInputs, type GeometryOpeningProjection,
} from './plan-geometry-preflight';
import {
type LightSegment, polygonSegments, splitAtIntersections, visibilityPolygon,
} from './light-visibility';
import {
recutWallBodiesGeometry, wallBodiesGeometry,
type WallBodiesGeometryResult, type WallEntry,
} from './wall-thickness';
import type { DevItem, RoomCfg, SpaceModel } from './types';
import type { VirtualLightSnapshot } from './virtual-light-state';
import { contentFingerprint } from './visual-continuity';
/** 96 steps keep an unobstructed arc below a tenth of a tablet pixel. */
export const GLOW_ARC_STEPS = 96;
/** The visible penumbra is one screen-space hair, not plan-space masonry. */
export const GLOW_EDGE_FEATHER_PX = 2;
/** One calibrated monotonic field per source. */
export const GLOW_FALLOFF: readonly (readonly [number, number])[] = [
[0, 1], [45, 0.88], [70, 0.62], [86, 0.32], [100, 0],
];
export const GLOW_FADE_MS = 500;
export type GlowClipGeometry = { lit: string[] };
export interface GlowCandidate {
key: string;
sourceEid: string;
pos: { x: number; y: number };
radius: number;
appearance: { c: string; alpha: number } | null;
}
export interface GlowSpot {
key: string;
sourceEid: string;
domId: number;
entering: boolean;
leaving: boolean;
pos: { x: number; y: number };
c: string;
alpha: number;
geometry: GlowClipGeometry | null;
r: number;
}
export interface LightRoomPolygon {
room: RoomCfg;
poly: number[][];
}
export interface LightBarrierRevision {
geometryFingerprint: string;
fingerprint: string;
polygons: LightRoomPolygon[];
passageStates: Array<{ opening: GeometryOpeningProjection; amount: number }>;
}
export interface LightBarrierScene {
occluders: LightSegment[];
floor: number[][][];
fingerprint: string;
masonryGeometry: unknown;
opaqueBodies: number[][][];
}
export interface LightZeroWalls {
contour: number[][];
barriers: number[][];
transmissive: number[][];
}
export interface GlowRuntimeHost {
window: () => Window;
isConnected: () => boolean;
requestUpdate: () => void;
reducedMotion: () => boolean;
}
export interface GlowRuntimeState {
clipCache: Map<string, GlowClipGeometry | null>;
geometryWarnings: Set<string>;
featherUnits: number | null;
renderedSources: Map<string, number>;
lastAppearance: Map<string, { c: string; alpha: number }>;
enteringSources: Set<string>;
enterRafs: Map<string, number>;
fadeTimers: Map<string, number>;
featherSuspendUntil: number;
featherResumeTimer: number;
sourceSeq: number;
}
type GlowHass = Record<string, unknown> & { states: Record<string, unknown> };
type SharedWallGeometry = Pick<WallBodiesGeometryResult,
'status' | 'roomGeom' | 'roomComponents' | 'openingIndex' | 'depthUnits'
| 'openingPadUnits'> & { sourceFingerprint?: string };
export function createGlowRuntimeState(): GlowRuntimeState {
return {
clipCache: new Map(),
geometryWarnings: new Set(),
featherUnits: null,
renderedSources: new Map(),
lastAppearance: new Map(),
enteringSources: new Set(),
enterRafs: new Map(),
fadeTimers: new Map(),
featherSuspendUntil: 0,
featherResumeTimer: 0,
sourceSeq: 0,
};
}
export function readGlowClip(
state: GlowRuntimeState, key: string,
): { hit: boolean; value: GlowClipGeometry | null } {
if (!state.clipCache.has(key)) return { hit: false, value: null };
const value = state.clipCache.get(key) ?? null;
state.clipCache.delete(key);
state.clipCache.set(key, value);
return { hit: true, value };
}
export function writeGlowClip(
state: GlowRuntimeState, key: string, value: GlowClipGeometry | null,
limit = 256,
): void {
state.clipCache.delete(key);
state.clipCache.set(key, value);
while (state.clipCache.size > limit) {
const oldest = state.clipCache.keys().next().value;
if (oldest === undefined) break;
state.clipCache.delete(oldest);
}
}
/** The exact spatial-source decision shared by both public cards. */
export function resolveGlowCandidates(input: {
hass: GlowHass;
devices: readonly DevItem[];
virtualLights?: VirtualLightSnapshot | null;
spaceId: string;
defaultColor: string;
paletteAlpha: number;
defaultRadiusUnits: number;
cellCm: number;
gridPitch: number;
position: (device: DevItem) => { x: number; y: number };
}): GlowCandidate[] {
// #375: pass the array as-is — RESOLVED_LIGHT_CACHE is keyed by the array's
// identity, so a fresh spread would guarantee a miss on every render.
const sources = resolvedLightSources(
input.hass, input.devices, null, input.virtualLights,
).filter((source) => source.device.space === input.spaceId);
const byDevice = new Map<string, typeof sources>();
for (const source of sources) {
if (!source.device.id) continue;
const current = byDevice.get(source.device.id) || [];
current.push(source);
byDevice.set(source.device.id, current);
}
const result: GlowCandidate[] = [];
for (const device of input.devices) {
if (!device.id || device.space !== input.spaceId) continue;
const source = selectSpatialGlowSource(byDevice.get(device.id) || []);
if (!source) continue;
const appearance = resolveGlowAppearance(
source.passive
? { state: source.on ? 'on' : 'off', attributes: {} }
: input.hass.states[source.eid],
device.marker?.glow_color,
input.defaultColor,
);
const ownCm = Number(device.marker?.glow_radius_cm);
const radius = Number.isFinite(ownCm) && ownCm > 0
? (ownCm / input.cellCm) * input.gridPitch
: input.defaultRadiusUnits;
result.push({
key: `${input.spaceId}|${device.id}`,
sourceEid: source.eid,
pos: input.position(device),
radius,
appearance: appearance
? { c: appearance.c, alpha: glowAlpha(appearance.bri, input.paletteAlpha) }
: null,
});
}
return result;
}
/**
* Resolve the part of a barrier revision that changes with an architectural
* opening. The fingerprint is intentionally available before structural work.
*/
export function resolveLightBarrierRevision(input: {
rawSpaceConfig: unknown;
space: SpaceModel;
openings: readonly GeometryOpeningProjection[];
cellCm: number;
gridPitch: number;
openingAmount: (opening: GeometryOpeningProjection) => number;
}): LightBarrierRevision {
const geometryFingerprint = contentFingerprint([
input.rawSpaceConfig, input.cellCm, input.gridPitch,
]);
const polygons = input.space.rooms.flatMap((room) => {
const poly = roomPoly(room);
return poly ? [{ room, poly }] : [];
});
const probe = Math.max(
(10 / input.cellCm) * input.gridPitch,
input.gridPitch * 0.5,
);
const onFloor = (point: number[]): boolean =>
polygons.some(({ poly }) => pointInPolygon(point, poly));
const passageStates = input.openings.flatMap((opening) => {
if (!isInteriorLightOpeningType(String(opening.type))) return [];
const rad = (opening.angle * Math.PI) / 180;
const nx = -Math.sin(rad);
const ny = Math.cos(rad);
const interior = onFloor([opening.rx + nx * probe, opening.ry + ny * probe])
&& onFloor([opening.rx - nx * probe, opening.ry - ny * probe]);
return interior ? [{
opening,
amount: quantizeOpeningLightAmount(input.openingAmount(opening)),
}] : [];
});
const openingStateSignature = openingLightStateSignature(
passageStates.map(({ opening, amount }) => ({
id: opening.id,
type: opening.type,
contact: opening.contact,
amount,
})),
);
return {
geometryFingerprint,
fingerprint: contentFingerprint([geometryFingerprint, openingStateSignature]),
polygons,
passageStates,
};
}
/**
* Build the one canonical light transport scene after a caller-owned cache
* miss. Full and static cards may supply different cache owners, never a
* different barrier algorithm.
*/
export function buildLightBarrierScene(input: {
space: SpaceModel;
revision: LightBarrierRevision;
walls: readonly WallEntry[];
zeroWalls: LightZeroWalls;
wallKeyPitch: number;
cellCm: number;
gridPitch: number;
coordScale: number;
sharedWallGeometry?: SharedWallGeometry | null;
physicalBodies: (partitionCuts: PartitionOpeningCut[], cacheKey: string) => number[][][];
}): LightBarrierScene {
const { revision } = input;
const cacheKey = `${input.space.id}|${revision.fingerprint}`;
const outlineCuts: number[][] = [...input.zeroWalls.transmissive];
const passages = revision.passageStates
.filter(({ amount }) => amount > 0)
.map(({ opening, amount }) => ({
...opening,
rlen: openingLightApertureLength(opening.rlen, amount),
}));
const roomPassages = geometryRoomOpeningInputs(
passages,
input.space,
[...input.walls],
input.zeroWalls.contour,
input.wallKeyPitch,
input.cellCm,
input.gridPitch,
input.coordScale,
);
for (const opening of roomPassages) {
const rad = (opening.angle * Math.PI) / 180;
const dx = (Math.cos(rad) * opening.length) / 2;
const dy = (Math.sin(rad) * opening.length) / 2;
outlineCuts.push([
opening.x - dx, opening.y - dy,
opening.x + dx, opening.y + dy,
]);
}
const partitionCuts = revision.passageStates.flatMap(({ opening, amount }) =>
amount > 0 && opening.partitionHost
? [scalePartitionOpeningCut(partitionOpeningCut(opening.partitionHost), amount)]
: []);
const opaqueBodies = input.physicalBodies(partitionCuts, cacheKey);
const occluders: LightSegment[] = [];
const sharedFingerprint = input.sharedWallGeometry?.sourceFingerprint;
const recut = input.sharedWallGeometry
&& sharedFingerprint === revision.geometryFingerprint
? recutWallBodiesGeometry(input.sharedWallGeometry, roomPassages, opaqueBodies)
: null;
const masonry = recut || (input.walls.length || opaqueBodies.length
? wallBodiesGeometry(
input.space.rooms,
[...input.walls],
input.zeroWalls.contour,
roomPassages,
input.wallKeyPitch,
input.cellCm,
input.gridPitch,
input.coordScale,
opaqueBodies,
)
: null);
if (masonry && (masonry.status === 'ok' || masonry.status === 'degraded-extra')) {
for (const component of masonry.components) {
for (const ring of geometryAllRings(component.geom)) {
occluders.push(...polygonSegments(ring));
}
}
} else {
for (const body of opaqueBodies) occluders.push(...polygonSegments(body));
}
const eps = input.gridPitch * 0.02;
for (const { poly } of revision.polygons) {
const segments = outlineCuts.length
? outlineWithout(poly, outlineCuts, eps)
: polygonSegments(poly);
for (const segment of segments) occluders.push(segment as LightSegment);
}
for (const barrier of input.zeroWalls.barriers) {
occluders.push(barrier as LightSegment);
}
return {
occluders: splitAtIntersections(occluders),
floor: revision.polygons.map(({ poly }) => poly),
fingerprint: revision.fingerprint,
masonryGeometry: masonry && (masonry.status === 'ok' || masonry.status === 'degraded-extra')
? masonry.components.flatMap((component) => component.geom)
: [],
opaqueBodies,
};
}
export function buildGlowClipGeometry(input: {
spaceId: string;
source: { x: number; y: number };
radius: number;
scene: LightBarrierScene;
polygons: readonly LightRoomPolygon[];
onBoundsFailure?: (roomId: string, phase: string) => void;
}): GlowClipGeometry {
const seen = visibilityPolygon(
[input.source.x, input.source.y], input.radius,
input.scene.occluders, GLOW_ARC_STEPS,
);
return {
lit: seen.length >= 3
? intersectionPaths([seen], input.scene.floor, {
onBoundsFailure: ({ boundIndex, phase }) => {
const room = input.polygons[boundIndex]?.room;
input.onBoundsFailure?.(room?.id || `#${boundIndex}`, phase);
},
})
: [],
};
}
export function glowSourceInOpaqueBody(
source: { x: number; y: number }, scene: LightBarrierScene,
): boolean {
return pointInOpaquePlanBody(
[source.x, source.y], scene.masonryGeometry, scene.opaqueBodies,
);
}
function suspendGlowFeather(
state: GlowRuntimeState, host: GlowRuntimeHost,
): void {
if (host.reducedMotion()) return;
const win = host.window();
state.featherSuspendUntil = Math.max(
state.featherSuspendUntil, Date.now() + GLOW_FADE_MS,
);
win.clearTimeout(state.featherResumeTimer);
const resume = () => {
state.featherResumeTimer = 0;
if (Date.now() < state.featherSuspendUntil) {
state.featherResumeTimer = win.setTimeout(
resume, state.featherSuspendUntil - Date.now() + 17,
);
return;
}
state.featherSuspendUntil = 0;
if (host.isConnected()) host.requestUpdate();
};
const delay = Math.max(0, state.featherSuspendUntil - Date.now()) + 17;
state.featherResumeTimer = win.setTimeout(resume, delay);
}
export function transitionGlowSource(
state: GlowRuntimeState,
host: GlowRuntimeHost,
key: string,
active: boolean,
): { domId: number; entering: boolean; leaving: boolean } | null {
const win = host.window();
let domId = state.renderedSources.get(key);
if (active) {
const timer = state.fadeTimers.get(key);
if (timer != null) {
win.clearTimeout(timer);
state.fadeTimers.delete(key);
}
if (domId == null) {
suspendGlowFeather(state, host);
domId = ++state.sourceSeq;
state.renderedSources.set(key, domId);
state.enteringSources.add(key);
const raf = win.requestAnimationFrame(() => {
if (state.enterRafs.get(key) !== raf) return;
state.enterRafs.delete(key);
state.enteringSources.delete(key);
if (host.isConnected()) host.requestUpdate();
});
state.enterRafs.set(key, raf);
}
return { domId, entering: state.enteringSources.has(key), leaving: false };
}
if (domId == null) return null;
const enterRaf = state.enterRafs.get(key);
if (enterRaf != null) win.cancelAnimationFrame(enterRaf);
state.enterRafs.delete(key);
state.enteringSources.delete(key);
if (!state.fadeTimers.has(key)) {
suspendGlowFeather(state, host);
const timer = win.setTimeout(() => {
if (state.fadeTimers.get(key) !== timer) return;
state.fadeTimers.delete(key);
state.renderedSources.delete(key);
state.lastAppearance.delete(key);
if (host.isConnected()) host.requestUpdate();
}, GLOW_FADE_MS + 34);
state.fadeTimers.set(key, timer);
}
return { domId, entering: false, leaving: true };
}
export function forgetGlowSource(
state: GlowRuntimeState, host: GlowRuntimeHost, key: string,
): void {
const win = host.window();
const timer = state.fadeTimers.get(key);
const raf = state.enterRafs.get(key);
if (timer != null) win.clearTimeout(timer);
if (raf != null) win.cancelAnimationFrame(raf);
state.fadeTimers.delete(key);
state.enterRafs.delete(key);
state.enteringSources.delete(key);
state.renderedSources.delete(key);
state.lastAppearance.delete(key);
}
export function forgetGlowSpace(
state: GlowRuntimeState, host: GlowRuntimeHost, spaceId: string,
): void {
const prefix = `${spaceId}|`;
for (const key of [...state.renderedSources.keys()]) {
if (key.startsWith(prefix)) forgetGlowSource(state, host, key);
}
}
export function pruneGlowSources(
state: GlowRuntimeState,
host: GlowRuntimeHost,
spaceId: string,
seen: ReadonlySet<string>,
): void {
const prefix = `${spaceId}|`;
for (const key of [...state.renderedSources.keys()]) {
if (key.startsWith(prefix) && !seen.has(key)) forgetGlowSource(state, host, key);
}
}
export function disposeGlowRuntime(
state: GlowRuntimeState, host: GlowRuntimeHost,
): void {
const win = host.window();
for (const timer of state.fadeTimers.values()) win.clearTimeout(timer);
for (const raf of state.enterRafs.values()) win.cancelAnimationFrame(raf);
win.clearTimeout(state.featherResumeTimer);
state.clipCache.clear();
state.geometryWarnings.clear();
state.fadeTimers.clear();
state.enterRafs.clear();
state.enteringSources.clear();
state.renderedSources.clear();
state.lastAppearance.clear();
state.featherUnits = null;
state.featherSuspendUntil = 0;
state.featherResumeTimer = 0;
state.sourceSeq = 0;
}
export function warnGlowGeometryFallback(
state: GlowRuntimeState,
spaceId: string,
fingerprint: string,
roomId: string,
phase: string,
): void {
const key = `${spaceId}|${fingerprint}|${roomId}`;
if (state.geometryWarnings.has(key)) return;
if (state.geometryWarnings.size >= 128) {
const oldest = state.geometryWarnings.values().next().value;
if (oldest) state.geometryWarnings.delete(oldest);
}
state.geometryWarnings.add(key);
console.warn(
`HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space ${spaceId}, room ${roomId}, phase ${phase}`,
);
}
export function resolveGlowFeather(
state: GlowRuntimeState,
perUnit: number,
allowRefresh: boolean,
): { feather: number; enabled: boolean } {
const next = GLOW_EDGE_FEATHER_PX / 2 / (perUnit > 0 ? perUnit : 1);
const enabled = allowRefresh && Date.now() >= state.featherSuspendUntil;
if (state.featherUnits == null || enabled) state.featherUnits = next;
return { feather: state.featherUnits ?? next, enabled };
}
/** One shared SVG field: one circle and one clip per source, one layer blur. */
export function renderGlowPools(input: {
spots: readonly GlowSpot[];
enabledClip?: readonly string[] | null;
feather: number;
featherEnabled: boolean;
screenBlend: boolean;
}): TemplateResult {
if (!input.spots.length) return svg`` as unknown as TemplateResult;
const pad = input.feather * 4;
const featherBox = input.spots.reduce((box, spot) => ({
x: Math.min(box.x, spot.pos.x - spot.r - pad),
y: Math.min(box.y, spot.pos.y - spot.r - pad),
maxX: Math.max(box.maxX, spot.pos.x + spot.r + pad),
maxY: Math.max(box.maxY, spot.pos.y + spot.r + pad),
w: 0,
h: 0,
}), { x: Infinity, y: Infinity, maxX: -Infinity, maxY: -Infinity, w: 0, h: 0 });
featherBox.w = featherBox.maxX - featherBox.x;
featherBox.h = featherBox.maxY - featherBox.y;
const enabledClip = input.enabledClip?.length ? input.enabledClip : null;
return svg`<defs>
${repeat(input.spots, (spot) => spot.key, (spot) => {
const id = spot.domId;
return svg`
<radialGradient id="hp-glow-${id}" gradientUnits="userSpaceOnUse"
cx="${spot.pos.x}" cy="${spot.pos.y}" r="${spot.r}">
${GLOW_FALLOFF.map(([offset, scale]) => svg`
<stop offset="${offset}%" stop-color="${spot.c}"
stop-opacity="${(spot.alpha * scale).toFixed(4)}"></stop>`)}
</radialGradient>
${spot.geometry ? svg`
<clipPath id="hp-glowclip-${id}">
<path class="glow-lit" d="${spot.geometry.lit.join(' ')}"
clip-rule="evenodd" fill-rule="evenodd"></path>
</clipPath>` : nothing}`;
})}
${enabledClip ? svg`<clipPath id="hp-glow-enabled">${enabledClip.map((d) => svg`
<path d=${d} clip-rule="evenodd" fill-rule="evenodd"></path>`)}
</clipPath>` : nothing}
<filter id="hp-glowfeather" filterUnits="userSpaceOnUse"
x="${featherBox.x}" y="${featherBox.y}"
width="${featherBox.w}" height="${featherBox.h}"
color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="${input.feather.toFixed(4)}" edgeMode="none"></feGaussianBlur>
</filter>
</defs>
<g class="glowlayer glow-pools-frame" pointer-events="none" aria-hidden="true"
filter=${input.featherEnabled ? 'url(#hp-glowfeather)' : nothing}>
<g class="glow-pools ${input.screenBlend ? 'blend-screen' : 'blend-normal'}"
data-blend=${input.screenBlend ? 'screen' : 'normal'}
data-feather-px="${GLOW_EDGE_FEATHER_PX}"
clip-path=${enabledClip ? 'url(#hp-glow-enabled)' : nothing}>
${repeat(input.spots, (spot) => spot.key, (spot) => svg`
<g class="glow-spot ${spot.entering ? 'is-entering' : ''} ${spot.leaving ? 'is-leaving' : ''}"
data-glow-spot="${spot.domId}" data-glow-source="${spot.sourceEid}">
<circle class="glow-pool"
cx="${spot.pos.x}" cy="${spot.pos.y}" r="${spot.r}"
data-lit-parts="${spot.geometry?.lit.length || 0}"
data-feather-px="${GLOW_EDGE_FEATHER_PX}"
fill="url(#hp-glow-${spot.domId})"
clip-path=${spot.geometry ? `url(#hp-glowclip-${spot.domId})` : nothing}></circle>
</g>`)}
</g>
</g>` as unknown as TemplateResult;
}