feat: add opt-in Glow to space card

Issue: #374
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-29 14:46:52 +03:00
parent 8914190c56
commit 0dfc74244b
55 changed files with 12101 additions and 11199 deletions
+618
View File
@@ -0,0 +1,618 @@
/**
* 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 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: any;
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;
}
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: any;
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[] {
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: any;
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: {
rawSpaceConfig: any;
space: SpaceModel;
revision: LightBarrierRevision;
walls: readonly WallEntry[];
zeroWalls: LightZeroWalls;
wallKeyPitch: number;
cellCm: number;
gridPitch: number;
coordScale: number;
sharedWallGeometry?: any;
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 as {
sourceFingerprint?: string;
} | null | undefined)?.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;
}
+118 -429
View File
@@ -241,6 +241,14 @@ import { LANGUAGE_RUNTIME, subscribeLanguageLoadFailures } from './i18n/registry
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
import { CommandStack } from './command-stack';
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
import {
buildGlowClipGeometry, buildLightBarrierScene, createGlowRuntimeState,
disposeGlowRuntime, forgetGlowSource, forgetGlowSpace, glowSourceInOpaqueBody,
pruneGlowSources, readGlowClip, renderGlowPools, resolveGlowCandidates, resolveGlowFeather,
resolveLightBarrierRevision, transitionGlowSource, warnGlowGeometryFallback,
writeGlowClip,
type GlowRuntimeHost, type GlowRuntimeState, type GlowSpot,
} from './glow-scene';
import {
CONTINUITY_LONG_HIDDEN_MS,
VisualContinuityController,
@@ -586,30 +594,6 @@ const POINTER_HOVER_TARGET_SELECTOR = 'hp-dialog, hp-help, hp-color-opacity, hp-
const NORM_W = 1000; // side of the render space — the canvas is square (v1.48.0)
/** Short semantic-event / direct-terminal-transition window. Event uses
three sequential 1.1 s waves; motion cool-down itself never animates. */
/**
* How finely the lit region is traced where nothing blocks the light. 96 steps
* put the chord error at 0.05% of the radius — under a tenth of a pixel on a
* wall tablet, and cheap because only unobstructed directions use them.
*/
const GLOW_ARC_STEPS = 96;
/**
* Width of the lit→unlit ramp along a shadow edge, in SCREEN pixels: the eye
* reads a perfectly geometric edge as a cut-out, and a real penumbra is never
* wider than a hair at this scale. Measured on screen on purpose, so zooming in
* does not turn a hairline into a smear.
*/
const GLOW_EDGE_FEATHER_PX = 2;
/**
* Radial profile of a pool, as [offset %, share of the calibrated alpha].
* Monotonic all the way out: a lamp is brightest under itself and dies at its
* radius. The centre keeps the full calibrated alpha, so nothing about the
* brightness maths (docs/specs/067) changes — only where that alpha is spent.
*/
const GLOW_FALLOFF: readonly (readonly [number, number])[] = [
[0, 1], [45, 0.88], [70, 0.62], [86, 0.32], [100, 0],
];
/** A source pool fades in/out without changing its final calibrated alpha. */
const GLOW_FADE_MS = 500;
/** Smallest rectangle holding both (docs/CANVAS.md §4). */
const unionRect = (a: Rect, b: Rect): Rect => {
@@ -653,12 +637,6 @@ type WallFaceBatch = {
activeCms: number[];
activeDraftId: string | null;
};
/**
* The floor a source can see, and nothing else. One region means one clip:
* a beam through a doorway, the room it lands in and the shadow of a column
* are all the same computation, so they can never disagree with each other.
*/
type GlowClipGeometry = { lit: string[] };
const MARKUP_TOOLS = new Set<MarkupTool>([
'select', 'draw', 'column', 'merge', 'split', 'resize',
'opening', 'wallthick', 'delroom',
@@ -1815,9 +1793,16 @@ export class HouseplanCard extends LitElement {
floor: number[][]; geom: any; path: string; area: number;
}>();
private _innerContourCache = new Map<string, number[][] | null>();
private _glowClipCache = new Map<string, GlowClipGeometry | null>();
/** Redacted, bounded dedupe for numerical floor fallbacks (#218). */
private _glowGeometryWarnings = new Set<string>();
private readonly _glowRuntimeState: GlowRuntimeState = createGlowRuntimeState();
private readonly _glowRuntimeHost: GlowRuntimeHost = {
window: () => this.ownerDocument.defaultView || window,
isConnected: () => this.isConnected,
requestUpdate: () => this.requestUpdate(),
reducedMotion: () => this._reducedMotion,
};
/** Compatibility aliases keep the performance/smoke diagnostic contract. */
private get _glowClipCache() { return this._glowRuntimeState.clipCache; }
private get _glowGeometryWarnings() { return this._glowRuntimeState.geometryWarnings; }
private _lightBarrierCache: {
key: string;
value: {
@@ -1830,16 +1815,20 @@ export class HouseplanCard extends LitElement {
private _lightBarrierPool = new Map<string, NonNullable<typeof this._lightBarrierCache>>();
/** Freeze the SVG blur while a pinch/pan emits animation frames, then adopt
* the final screen-space value after the gesture. */
private _glowFeatherUnits: number | null = null;
private get _glowFeatherUnits() { return this._glowRuntimeState.featherUnits; }
private set _glowFeatherUnits(value: number | null) { this._glowRuntimeState.featherUnits = value; }
/** Active pools survive an off transition until their 500 ms fade completes. */
private _glowRenderedSources = new Map<string, number>();
private _glowLastAppearance = new Map<string, { c: string; alpha: number }>();
private _glowEnteringSources = new Set<string>();
private _glowEnterRafs = new Map<string, number>();
private _glowFadeTimers = new Map<string, number>();
private _glowFeatherSuspendUntil = 0;
private _glowFeatherResumeTimer = 0;
private _glowSourceSeq = 0;
private get _glowRenderedSources() { return this._glowRuntimeState.renderedSources; }
private get _glowLastAppearance() { return this._glowRuntimeState.lastAppearance; }
private get _glowEnteringSources() { return this._glowRuntimeState.enteringSources; }
private get _glowEnterRafs() { return this._glowRuntimeState.enterRafs; }
private get _glowFadeTimers() { return this._glowRuntimeState.fadeTimers; }
private get _glowFeatherSuspendUntil() { return this._glowRuntimeState.featherSuspendUntil; }
private set _glowFeatherSuspendUntil(value: number) { this._glowRuntimeState.featherSuspendUntil = value; }
private get _glowFeatherResumeTimer() { return this._glowRuntimeState.featherResumeTimer; }
private set _glowFeatherResumeTimer(value: number) { this._glowRuntimeState.featherResumeTimer = value; }
private get _glowSourceSeq() { return this._glowRuntimeState.sourceSeq; }
private set _glowSourceSeq(value: number) { this._glowRuntimeState.sourceSeq = value; }
/** Pending/false uses the exact historical normal-layer fallback. */
private _glowScreenBlend = false;
private _duplicateColumnId: string | null = null;
@@ -2667,17 +2656,7 @@ export class HouseplanCard extends LitElement {
this._liveSyncConnection = null;
clearTimeout(this._layoutSyncTimer);
clearTimeout(this._duplicateColumnTimer);
for (const timer of this._glowFadeTimers.values()) clearTimeout(timer);
for (const raf of this._glowEnterRafs.values()) cancelAnimationFrame(raf);
clearTimeout(this._glowFeatherResumeTimer);
this._glowFeatherResumeTimer = 0;
this._glowFeatherSuspendUntil = 0;
this._glowFadeTimers.clear();
this._glowEnterRafs.clear();
this._glowEnteringSources.clear();
this._glowRenderedSources.clear();
this._glowLastAppearance.clear();
this._glowSourceSeq = 0;
disposeGlowRuntime(this._glowRuntimeState, this._glowRuntimeHost);
// DEV-B703-03: the last thing this instance was showing, then the
// tombstone that lets exactly one successor adopt the open dialog.
// AUD-159B1-02: the snapshot runs while `_warmRevivePending` is still
@@ -10233,105 +10212,24 @@ export class HouseplanCard extends LitElement {
key: string,
active: boolean,
): { domId: number; entering: boolean; leaving: boolean } | null {
let domId = this._glowRenderedSources.get(key);
if (active) {
const timer = this._glowFadeTimers.get(key);
if (timer != null) {
clearTimeout(timer);
this._glowFadeTimers.delete(key);
}
if (domId == null) {
this._suspendGlowFeatherForTransition();
domId = ++this._glowSourceSeq;
this._glowRenderedSources.set(key, domId);
this._glowEnteringSources.add(key);
const raf = requestAnimationFrame(() => {
if (this._glowEnterRafs.get(key) !== raf) return;
this._glowEnterRafs.delete(key);
this._glowEnteringSources.delete(key);
if (this.isConnected) this.requestUpdate();
});
this._glowEnterRafs.set(key, raf);
}
return { domId, entering: this._glowEnteringSources.has(key), leaving: false };
}
if (domId == null) return null;
const enterRaf = this._glowEnterRafs.get(key);
if (enterRaf != null) cancelAnimationFrame(enterRaf);
this._glowEnterRafs.delete(key);
this._glowEnteringSources.delete(key);
if (!this._glowFadeTimers.has(key)) {
this._suspendGlowFeatherForTransition();
const timer = window.setTimeout(() => {
if (this._glowFadeTimers.get(key) !== timer) return;
this._glowFadeTimers.delete(key);
this._glowRenderedSources.delete(key);
this._glowLastAppearance.delete(key);
if (this.isConnected) this.requestUpdate();
}, GLOW_FADE_MS + 34); // keep one frame of slack after the CSS transition
this._glowFadeTimers.set(key, timer);
}
return { domId, entering: false, leaving: true };
}
/** A whole-layer Gaussian blur is needlessly re-evaluated for every frame
* of a source opacity transition. The 1 px penumbra is imperceptible while
* the pool itself is moving between transparent and opaque, so bypass it
* for that bounded interval and restore it once at the settled frame. */
private _suspendGlowFeatherForTransition(): void {
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
this._glowFeatherSuspendUntil = Math.max(
this._glowFeatherSuspendUntil, Date.now() + GLOW_FADE_MS,
return transitionGlowSource(
this._glowRuntimeState, this._glowRuntimeHost, key, active,
);
clearTimeout(this._glowFeatherResumeTimer);
const delay = Math.max(0, this._glowFeatherSuspendUntil - Date.now()) + 17;
const resume = () => {
this._glowFeatherResumeTimer = 0;
if (Date.now() < this._glowFeatherSuspendUntil) {
this._glowFeatherResumeTimer = window.setTimeout(
resume, this._glowFeatherSuspendUntil - Date.now() + 17,
);
return;
}
this._glowFeatherSuspendUntil = 0;
if (this.isConnected) this.requestUpdate();
};
this._glowFeatherResumeTimer = window.setTimeout(resume, delay);
}
private _forgetGlowSource(key: string): void {
const timer = this._glowFadeTimers.get(key);
if (timer != null) clearTimeout(timer);
const raf = this._glowEnterRafs.get(key);
if (raf != null) cancelAnimationFrame(raf);
this._glowFadeTimers.delete(key);
this._glowEnterRafs.delete(key);
this._glowEnteringSources.delete(key);
this._glowRenderedSources.delete(key);
this._glowLastAppearance.delete(key);
forgetGlowSource(this._glowRuntimeState, this._glowRuntimeHost, key);
}
private _forgetGlowSpace(spaceId: string): void {
const prefix = `${spaceId}|`;
for (const key of this._glowRenderedSources.keys()) {
if (key.startsWith(prefix)) this._forgetGlowSource(key);
}
forgetGlowSpace(this._glowRuntimeState, this._glowRuntimeHost, spaceId);
}
private _warnGlowGeometryFallback(
spaceId: string, fingerprint: string, roomId: string, phase: string,
): void {
// One diagnostic per geometry revision and room, even if that room happens
// to fail in more than one boolean phase while the card is re-rendering.
const key = `${spaceId}|${fingerprint}|${roomId}`;
if (this._glowGeometryWarnings.has(key)) return;
if (this._glowGeometryWarnings.size >= 128) {
const oldest = this._glowGeometryWarnings.values().next().value;
if (oldest) this._glowGeometryWarnings.delete(oldest);
}
this._glowGeometryWarnings.add(key);
console.warn(
`HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space ${spaceId}, room ${roomId}, phase ${phase}`,
warnGlowGeometryFallback(
this._glowRuntimeState, spaceId, fingerprint, roomId, phase,
);
}
@@ -10357,140 +10255,44 @@ export class HouseplanCard extends LitElement {
masonryGeometry: any; opaqueBodies: number[][][];
} {
const raw = this._curSpaceCfg;
// Exact content identity keeps the old in-place-mutation protection while
// moving the cache lookup ahead of passage classification, physical-body
// booleans and wall topology. `_cfgEpoch` alone is deliberately not used:
// old editor paths and third-party harnesses can still mutate a structure
// before advancing it.
const geometryFingerprint = contentFingerprint([raw, this._cellCm, this._gridPitch]);
// Gates are door-like: their different symbol must not change how light
// crosses the clear opening.
// An opening is transparent only where it leads from floor to floor. A
// front door has nothing behind it to light, so for light it is masonry
// like a window — otherwise its tunnel glows halfway, up to the centreline
// where the room polygon ends, and the plan shows a lit doorway to nowhere.
const probe = Math.max(this._cmToUnits(10), this._gridPitch * 0.5);
const onFloor = (point: number[]): boolean =>
polys.some((x) => this._pointInRoom(point, x.r));
const passageStates = this._openingsR.flatMap((opening) => {
if (!isInteriorLightOpeningType(String(opening.type))) return [];
const o = opening;
const rad = (o.angle * Math.PI) / 180;
const nx = -Math.sin(rad);
const ny = Math.cos(rad);
const interior = onFloor([o.rx + nx * probe, o.ry + ny * probe])
&& onFloor([o.rx - nx * probe, o.ry - ny * probe]);
// #366: the light pipeline consumes the quantised amount — the door
// LEAF animation stays smooth (_openingAmt elsewhere), only light steps.
return interior ? [{ opening: o, amount: quantizeOpeningLightAmount(this._openingAmt(o)) }] : [];
const revision = resolveLightBarrierRevision({
rawSpaceConfig: raw,
space,
openings: this._openingsR,
cellCm: this._cellCm,
gridPitch: this._gridPitch,
openingAmount: (opening) => this._openingAmt(opening),
});
const openingStateSignature = openingLightStateSignature(
passageStates.map(({ opening, amount }) => ({
id: opening.id, type: opening.type, contact: opening.contact, amount,
})),
);
const fingerprint = contentFingerprint([geometryFingerprint, openingStateSignature]);
const cacheKey = `${space.id}|${fingerprint}`;
const cacheKey = `${space.id}|${revision.fingerprint}`;
const pooled = lruRead(this._lightBarrierPool, cacheKey);
if (pooled.hit) {
this._lightBarrierCache = pooled.value;
return pooled.value.value;
}
const zeroWalls = this._zeroWalls();
// Physical masonry has no body at every zero wall, independent of style.
// Light transport differs: solid zero walls are added back below as exact
// centre-line barriers rather than inflated fake masonry.
const openCuts = this._openCuts();
// Only dashed zero axes are openings in the visibility contour. Solid
// zero axes stay in the room outline and are also registered below as
// exact standalone barriers for independent/outer-wall parity.
const cuts: number[][] = [...zeroWalls.transmissive];
const passages = passageStates
.filter(({ amount }) => amount > 0)
.map(({ opening, amount }) => ({
...opening,
rlen: openingLightApertureLength(opening.rlen, amount),
}));
const roomPassages = this._roomWallOpeningInputs(passages, space);
// Openings omitted from `passages` remain part of light's opaque masonry.
// Consequently a source centred in an exterior door/gate or any window is
// rejected below exactly like a source centred inside the wall itself.
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;
cuts.push([opening.x - dx, opening.y - dy, opening.x + dx, opening.y + dy]);
}
const lightPartitionCuts = passageStates.flatMap(({ opening, amount }) =>
amount > 0 && opening.partitionHost
? [scalePartitionOpeningCut(partitionOpeningCut(opening.partitionHost), amount)]
: []);
const lightPhysicalKey = cacheKey;
if (this._lightPhysicalBodiesCache?.key !== lightPhysicalKey) {
this._lightPhysicalBodiesCache = {
key: lightPhysicalKey,
all: physicalBodyParts(
space, this._cellCm, this._gridPitch, this._gridPitch * 0.0002,
lightPartitionCuts,
).all,
};
}
const lightPhysical = this._lightPhysicalBodiesCache.all;
const walls = this._spaceWalls;
const eps = this._gridPitch * 0.02;
const occluders: LightSegment[] = [];
// The masonry the plan draws, cut by passages only — real thickness, real
// jamb faces. Treating a wall as its centreline let light bleed half a wall
// deep (a bright bar at every opening) and started every shadow half a wall
// away from the corner that casts it.
const sharedWallGeometry = this._wallUnionGeometry();
const sharedFingerprint = (sharedWallGeometry as unknown as {
sourceFingerprint?: string;
} | null)?.sourceFingerprint;
const recut = sharedWallGeometry && sharedFingerprint === geometryFingerprint
? recutWallBodiesGeometry(sharedWallGeometry, roomPassages, lightPhysical)
: null;
const masonry = recut || (walls.length || lightPhysical.length
? wallBodiesGeometry(
space.rooms, walls, openCuts,
roomPassages,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, lightPhysical,
)
: 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 {
// Malformed legacy geometry must remain opaque even when the canonical
// boolean pass cannot produce a joined result.
for (const body of lightPhysical) occluders.push(...polygonSegments(body));
}
// Edges without any thickness are still walls; so is a room outline when
// the boolean pass above could not run at all.
for (const { poly } of polys) {
for (const seg of (cuts.length ? outlineWithout(poly, cuts, eps) : polygonSegments(poly))) {
occluders.push(seg as LightSegment);
}
}
// A solid zero wall has topology and opacity but deliberately no area.
// Add its axis directly to the visibility sweep; do not manufacture a
// narrow polygon, because that would alter floor area and doorway maths.
for (const barrier of zeroWalls.barriers) {
occluders.push(barrier as LightSegment);
}
const value = {
occluders: splitAtIntersections(occluders),
floor: polys.map((x) => x.poly),
fingerprint,
masonryGeometry: masonry && (masonry.status === 'ok' || masonry.status === 'degraded-extra')
? masonry.components.flatMap((component) => component.geom) : [],
// The source guard must fail dark against the same type/floor-filtered
// bodies used to build the light masonry. The ordinary render bodies are
// cut by every hosted opening, including opaque windows and exterior
// doors, and therefore cannot be used when the boolean geometry is empty.
opaqueBodies: lightPhysical,
};
const value = buildLightBarrierScene({
rawSpaceConfig: raw,
space,
revision,
walls: this._spaceWalls,
zeroWalls: this._zeroWalls(),
wallKeyPitch: this._wallKeyPitch,
cellCm: this._cellCm,
gridPitch: this._gridPitch,
coordScale: NORM_W,
sharedWallGeometry: this._wallUnionGeometry(),
physicalBodies: (partitionCuts, lightPhysicalKey) => {
if (this._lightPhysicalBodiesCache?.key !== lightPhysicalKey) {
this._lightPhysicalBodiesCache = {
key: lightPhysicalKey,
all: physicalBodyParts(
space, this._cellCm, this._gridPitch, this._gridPitch * 0.0002,
partitionCuts,
).all,
};
}
return this._lightPhysicalBodiesCache.all;
},
});
const entry = { key: cacheKey, value };
lruWrite(this._lightBarrierPool, cacheKey, entry, 8);
this._lightBarrierCache = entry;
@@ -10509,84 +10311,48 @@ export class HouseplanCard extends LitElement {
this._forgetGlowSpace(space.id);
return svg`` as unknown as TemplateResult;
}
const {
occluders, floor, fingerprint, masonryGeometry, opaqueBodies,
} = this._lightBarriers(space, polys);
// Resolve against the whole plan: a controller and its passive lamp may
// legitimately live in different spaces. Ownership is filtered afterwards.
const resolvedSources = resolvedLightSources(
this._renderPlanHass, this._renderDevices, null, this._virtualLights,
)
.filter((source) => source.device.space === space.id);
const sourcesByDevice = new Map<string, typeof resolvedSources>();
for (const source of resolvedSources) {
if (!source.device.id) continue;
const list = sourcesByDevice.get(source.device.id) || [];
list.push(source);
sourcesByDevice.set(source.device.id, list);
}
const spots: {
key: string;
sourceEid: string;
domId: number;
entering: boolean;
leaving: boolean;
pos: { x: number; y: number };
c: string;
alpha: number;
geometry: GlowClipGeometry | null;
r: number;
}[] = [];
const scene = this._lightBarriers(space, polys);
const candidates = resolveGlowCandidates({
hass: this._renderPlanHass,
devices: this._renderDevices,
virtualLights: this._virtualLights,
spaceId: space.id,
defaultColor: colors.glow_light.c,
paletteAlpha: colors.glow_light.a,
defaultRadiusUnits: defaultR,
cellCm: this._cellCm,
gridPitch: this._gridPitch,
position: (device) => this._pos(device),
});
const spots: GlowSpot[] = [];
const seenSourceKeys = new Set<string>();
for (const d of this._renderDevices) {
if (d.space !== space.id) continue;
const source = selectSpatialGlowSource(sourcesByDevice.get(d.id) || []);
if (!source) continue;
// One marker owns one spatial pool even when its resolved HA source
// changes; keeping the key marker-stable avoids a false fade/recreate.
const key = `${space.id}|${d.id}`;
for (const candidate of candidates) {
const { key, pos } = candidate;
seenSourceKeys.add(key);
const visibleGlow = resolveGlowAppearance(
source.passive
? { state: source.on ? 'on' : 'off', attributes: {} }
: this._renderPlanHass.states[source.eid],
d.marker?.glow_color,
colors.glow_light.c,
);
// per-source radius (owner's decision v1.36.2): marker override, else global
const ownCm = Number(d.marker?.glow_radius_cm);
const R = Number.isFinite(ownCm) && ownCm > 0 ? (ownCm / this._cellCm) * this._gridPitch : defaultR;
const pos = this._pos(d);
// Invalid placement inside any opaque body must remain dark. The
// masonry geometry already contains the exact passage cuts, so a valid
// interior doorway remains transparent while a source embedded in the
// surrounding wall cannot light one side of its tunnel. The visibility
// sweep separately rejects a source that lies exactly on an opaque edge.
const sourcePoint = [pos.x, pos.y];
// Exterior opening tunnels deliberately remain in `masonryGeometry`,
// so placing the source there suppresses the entire pool rather than
// lighting only the indoor half of the tunnel.
if (pointInOpaquePlanBody(sourcePoint, masonryGeometry, opaqueBodies)) {
if (glowSourceInOpaqueBody(pos, scene)) {
// This placement cannot produce a valid previous-frame fade: the old
// clip belongs to a different position. Remove its transition state as
// well, rather than leaving a timer for a DOM node no longer rendered.
this._forgetGlowSource(key);
continue;
}
const transition = this._glowTransition(key, !!visibleGlow);
const transition = this._glowTransition(key, !!candidate.appearance);
if (!transition) continue;
if (visibleGlow) {
this._glowLastAppearance.set(key, {
c: visibleGlow.c,
alpha: glowAlpha(visibleGlow.bri, colors.glow_light.a),
});
}
if (candidate.appearance) this._glowLastAppearance.set(key, candidate.appearance);
const appearance = this._glowLastAppearance.get(key);
if (!appearance) continue;
let geometry: GlowClipGeometry | null = null;
let geometry: GlowSpot['geometry'] = null;
const clipKey =
`${space.id}|${fingerprint}|${pos.x.toFixed(4)},${pos.y.toFixed(4)}|${R.toFixed(4)}`;
const cachedClip = lruRead(this._glowClipCache, clipKey);
`${space.id}|${scene.fingerprint}|${pos.x.toFixed(4)},${pos.y.toFixed(4)}|${candidate.radius.toFixed(4)}`;
const cachedClip = readGlowClip(this._glowRuntimeState, clipKey);
if (cachedClip.hit) {
geometry = cachedClip.value;
} else {
@@ -10596,24 +10362,21 @@ export class HouseplanCard extends LitElement {
// notion of a "spill", a "sector", a "tunnel" or an "open zone" — and
// a wall corner two rooms away casts its shadow for exactly the same
// reason a column does.
const seen = visibilityPolygon([pos.x, pos.y], R, occluders, GLOW_ARC_STEPS);
geometry = {
lit: seen.length >= 3
? intersectionPaths([seen], floor, {
onBoundsFailure: ({ boundIndex, phase }) => {
const room = polys[boundIndex]?.r;
this._warnGlowGeometryFallback(
space.id, fingerprint, room?.id || `#${boundIndex}`, phase,
);
},
})
: [],
};
lruWrite(this._glowClipCache, clipKey, geometry, 256);
geometry = buildGlowClipGeometry({
spaceId: space.id,
source: pos,
radius: candidate.radius,
scene,
polygons: polys.map(({ r, poly }) => ({ room: r, poly })),
onBoundsFailure: (roomId, phase) => this._warnGlowGeometryFallback(
space.id, scene.fingerprint, roomId, phase,
),
});
writeGlowClip(this._glowRuntimeState, clipKey, geometry);
}
spots.push({
key,
sourceEid: source.eid,
sourceEid: candidate.sourceEid,
domId: transition.domId,
entering: transition.entering,
leaving: transition.leaving,
@@ -10621,13 +10384,12 @@ export class HouseplanCard extends LitElement {
c: appearance.c,
alpha: appearance.alpha,
geometry,
r: R,
r: candidate.radius,
});
}
const sourcePrefix = `${space.id}|`;
for (const key of this._glowRenderedSources.keys()) {
if (key.startsWith(sourcePrefix) && !seenSourceKeys.has(key)) this._forgetGlowSource(key);
}
pruneGlowSources(
this._glowRuntimeState, this._glowRuntimeHost, space.id, seenSourceKeys,
);
if (!spots.length) return svg`` as unknown as TemplateResult;
// Per-room Glow overrides are visual clips only. The transport calculation
// above still crosses a disabled room, but no base/pool pixels are painted
@@ -10654,89 +10416,16 @@ export class HouseplanCard extends LitElement {
const perUnit = this._stageEl?.clientWidth && view.w
? this._stageEl.clientWidth / view.w
: 1;
const nextFeather = GLOW_EDGE_FEATHER_PX / 2 / (perUnit > 0 ? perUnit : 1);
const featherEnabled = !this._pinchStart && !this._panStart
&& Date.now() >= this._glowFeatherSuspendUntil;
if (this._glowFeatherUnits == null || featherEnabled)
this._glowFeatherUnits = nextFeather;
const feather = this._glowFeatherUnits ?? nextFeather;
const pad = feather * 4;
const featherBox = spots.reduce((box, sp) => ({
x: Math.min(box.x, sp.pos.x - sp.r - pad),
y: Math.min(box.y, sp.pos.y - sp.r - pad),
maxX: Math.max(box.maxX, sp.pos.x + sp.r + pad),
maxY: Math.max(box.maxY, sp.pos.y + sp.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;
return svg`<defs>
${repeat(spots, (sp) => sp.key, (sp) => {
const i = sp.domId;
const geom = sp.geometry;
return svg`
${''/* One field per source, in plan coordinates. Attenuation is a
property of DISTANCE FROM THE LAMP and nothing else, so the
floor two rooms away is dimmer because it is far, not because
a second gradient was pasted at a doorway. The profile decays
over the whole radius: the old flat plateau out to 70% turned
every clipped shape into a slab of solid colour with a rim. */}
<radialGradient id="hp-glow-${i}" gradientUnits="userSpaceOnUse"
cx="${sp.pos.x}" cy="${sp.pos.y}" r="${sp.r}">
${GLOW_FALLOFF.map(([offset, scale]) => svg`
<stop offset="${offset}%" stop-color="${sp.c}"
stop-opacity="${(sp.alpha * scale).toFixed(4)}"></stop>`)}
</radialGradient>
${''/* The one and only shape of this light: the floor this lamp can
actually see. A shadow is simply floor that is not in here.
The single blur below is the penumbra of that shape — one
pass over one path, never a second layer of light. */}
${geom ? svg`
<clipPath id="hp-glowclip-${i}">
<path class="glow-lit" d="${geom.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}
${''/* Penumbra, once for the whole layer. Every region is cut
geometrically; blurring the composited result by a hair is what
keeps a shadow edge from reading as a cut-out. One pass costs a
fraction of one blurred mask per source, and the difference is
invisible — neighbouring pools are already smooth where they
meet. CSS `filter: blur()` is not usable here: Chromium applies
it to an SVG group in name only. */}
<filter id="hp-glowfeather" filterUnits="userSpaceOnUse"
x="${featherBox.x}" y="${featherBox.y}"
width="${featherBox.w}" height="${featherBox.h}"
color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="${feather.toFixed(4)}" edgeMode="none"></feGaussianBlur>
</filter>
</defs>
${''/* Glow is presentation only. It is painted above room fills, but must
not become the pointer target: room hover and its tooltip still
belong to the room underneath the light pool. */}
<g class="glowlayer glow-pools-frame" pointer-events="none"
filter=${featherEnabled ? 'url(#hp-glowfeather)' : nothing}>
<g class="glow-pools ${this._glowScreenBlend ? 'blend-screen' : 'blend-normal'}"
data-blend=${this._glowScreenBlend ? 'screen' : 'normal'}
data-feather-px="${GLOW_EDGE_FEATHER_PX}"
clip-path=${enabledClip ? 'url(#hp-glow-enabled)' : nothing}>
${repeat(spots, (sp) => sp.key, (sp) => {
const i = sp.domId;
return svg`
<g class="glow-spot ${sp.entering ? 'is-entering' : ''} ${sp.leaving ? 'is-leaving' : ''}"
data-glow-spot="${i}" data-glow-source="${sp.sourceEid}">
<circle class="glow-pool"
cx="${sp.pos.x}" cy="${sp.pos.y}" r="${sp.r}"
data-lit-parts="${sp.geometry?.lit.length || 0}"
data-feather-px="${GLOW_EDGE_FEATHER_PX}"
fill="url(#hp-glow-${i})"
clip-path=${sp.geometry ? `url(#hp-glowclip-${i})` : nothing}></circle>
</g>`;
})}
</g>
</g>` as unknown as TemplateResult;
const feather = resolveGlowFeather(
this._glowRuntimeState, perUnit, !this._pinchStart && !this._panStart,
);
return renderGlowPools({
spots,
enabledClip,
feather: feather.feather,
featherEnabled: feather.enabled,
screenBlend: this._glowScreenBlend,
});
}
/**
+1
View File
@@ -351,6 +351,7 @@
"editor.icon_size": "Symbolgröße, % der Planbreite",
"editor.show_temperature": "Temperatur anzeigen",
"editor.live_states": "Live-Zustände (ein/aus, offen…)",
"editor.light_pools": "Lichtkegel und Wandschatten",
"editor.show_signal": "Zigbee-Signal anzeigen (LQI)",
"editor.language": "Schnittstellensprache",
"editor.lang_auto": "Automatisch (HA-Profil)",
+1
View File
@@ -351,6 +351,7 @@
"editor.icon_size": "Icon size, % of plan width",
"editor.show_temperature": "Show temperature",
"editor.live_states": "Live states (on/off, open…)",
"editor.light_pools": "Light pools and wall shadows",
"editor.show_signal": "Show zigbee signal (LQI)",
"editor.language": "Interface language",
"editor.lang_auto": "Auto (HA profile)",
+1
View File
@@ -351,6 +351,7 @@
"editor.icon_size": "Taille des icônes, % de la largeur du plan",
"editor.show_temperature": "Afficher la température",
"editor.live_states": "États en direct (on/off, ouvert…)",
"editor.light_pools": "Halos lumineux et ombres des murs",
"editor.show_signal": "Afficher le signal Zigbee (LQI)",
"editor.language": "Langue de l’interface",
"editor.lang_auto": "Auto (profil HA)",
+1
View File
@@ -351,6 +351,7 @@
"editor.icon_size": "Размер иконок, % ширины плана",
"editor.show_temperature": "Показывать температуру",
"editor.live_states": "Живые состояния (вкл/выкл, открыто…)",
"editor.light_pools": "Световые пулы и тени от стен",
"editor.show_signal": "Показывать сигнал zigbee (LQI)",
"editor.language": "Язык интерфейса",
"editor.lang_auto": "Авто (профиль HA)",
+42
View File
@@ -38,6 +38,11 @@ import {
bgModeOf, resolveDayCycle, dayCycleFingerprint, type DayCycleState,
} from './sun';
import { PointerModalityController } from './pointer-modality';
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
import {
createGlowRuntimeState, disposeGlowRuntime,
type GlowRuntimeHost, type GlowRuntimeState,
} from './glow-scene';
import './space-editor';
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
@@ -61,6 +66,7 @@ interface SpaceCardConfig {
show_temperature?: boolean;
live_states?: boolean;
show_signal?: boolean;
light_pools?: boolean;
language?: string;
}
@@ -103,6 +109,15 @@ class HouseplanSpaceCard extends LitElement {
private _capturedSnapshotActivity = '';
private _capturedSnapshotVirtual = '';
private _activityRuntime = new Map<string, FiniteActivityRuntime>();
private readonly _glowRuntimeState: GlowRuntimeState = createGlowRuntimeState();
private readonly _glowRuntimeHost: GlowRuntimeHost = {
window: () => this.ownerDocument.defaultView || window,
isConnected: () => this.isConnected,
requestUpdate: () => this.requestUpdate(),
reducedMotion: () => this._reducedMotion,
};
/** Pending/false is the exact historical normal-layer fallback. */
private _glowScreenBlend = false;
private _reducedMotion = false;
private readonly _pointerModality = new PointerModalityController(this);
private _motionMedia?: MediaQueryList;
@@ -258,6 +273,7 @@ class HouseplanSpaceCard extends LitElement {
return {
type: 'custom:houseplan-space-card', space: first, show_button: true,
live_states: true, show_temperature: true, show_signal: true,
light_pools: false,
};
}
@@ -268,8 +284,14 @@ class HouseplanSpaceCard extends LitElement {
this._config = {
show_button: true, button_target: '/plan-doma',
live_states: true, show_temperature: true, show_signal: true,
light_pools: false,
...config,
};
if (!this._config.light_pools) {
disposeGlowRuntime(this._glowRuntimeState, this._glowRuntimeHost);
} else if (this.isConnected) {
this._resolveGlowBlend();
}
// instant paint from the full card's localStorage snapshot, refresh in the background
this._snap = this._snap || cachedSnapshot();
}
@@ -285,6 +307,7 @@ class HouseplanSpaceCard extends LitElement {
this._motionMedia = window.matchMedia?.('(prefers-reduced-motion: reduce)');
this._reducedMotion = !!this._motionMedia?.matches;
this._motionMedia?.addEventListener?.('change', this._onMotionChange);
this._resolveGlowBlend();
if (this.hass) this._ensureHaRegistryAuthority();
this._continuityUnsub?.();
this._continuityUnsub = subscribePageVisibility(this.ownerDocument, this._pageVisibility);
@@ -325,12 +348,24 @@ class HouseplanSpaceCard extends LitElement {
this._connHooked = null;
for (const runtime of this._activityRuntime.values()) window.clearTimeout(runtime.timer);
this._activityRuntime.clear();
disposeGlowRuntime(this._glowRuntimeState, this._glowRuntimeHost);
this._continuityHistory = [...this._continuityHistory, ...this._continuity.trace].slice(-80);
this._continuity.dispose();
this._continuityDisposed = true;
super.disconnectedCallback();
}
private _resolveGlowBlend(): void {
if (!this._config?.light_pools) return;
const resolved = resolvedSvgScreenBlend(this.ownerDocument);
if (resolved !== undefined) this._glowScreenBlend = resolved;
void svgScreenBlendSupported(this.ownerDocument).then((supported) => {
if (supported === this._glowScreenBlend) return;
this._glowScreenBlend = supported;
if (this.isConnected) this.requestUpdate();
});
}
protected willUpdate(changed: PropertyValues): void {
if (changed.has('hass') && this.hass) {
this._hassSequence++;
@@ -525,6 +560,7 @@ class HouseplanSpaceCard extends LitElement {
snap?.layoutFingerprint || contentFingerprint(snap?.layout),
snap?.virtualLights ? `${snap.virtualLights.configRev}:${snap.virtualLights.rev}` : '',
this._config?.space || '',
this._config?.light_pools === true,
this._stageWidth,
this.hass?.themes?.darkMode ?? this.hass?.themes?.default_theme ?? '',
]);
@@ -803,6 +839,12 @@ class HouseplanSpaceCard extends LitElement {
activityRuntime: this._activityRuntime,
reducedMotion: this._reducedMotion,
virtualLights: this._snap?.virtualLights,
lightPools: this._config.light_pools === true,
glowRuntime: {
state: this._glowRuntimeState,
host: this._glowRuntimeHost,
screenBlend: this._glowScreenBlend,
},
liveStates: this._config.live_states !== false,
showTemperature: this._config.show_temperature !== false,
showSignal: this._config.show_signal !== false,
+2
View File
@@ -50,6 +50,7 @@ class HouseplanSpaceCardEditor extends LitElement {
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
{ name: 'show_temperature', selector: { boolean: {} } },
{ name: 'live_states', selector: { boolean: {} } },
{ name: 'light_pools', selector: { boolean: {} } },
{ name: 'show_signal', selector: { boolean: {} } },
];
}
@@ -72,6 +73,7 @@ class HouseplanSpaceCardEditor extends LitElement {
icon_size: t(L, 'editor.icon_size'),
show_temperature: t(L, 'editor.show_temperature'),
live_states: t(L, 'editor.live_states'),
light_pools: t(L, 'editor.light_pools'),
show_signal: t(L, 'editor.show_signal'),
};
return html`<ha-form
+185 -7
View File
@@ -14,12 +14,12 @@ import {
import {
spaceDisplayOf, fillColorsOf, roomFillModeOf, roomGlowOf,
roomCustomFillOf, resolveEffectiveRoomFill, stageBgOf, paperRoomShapes,
openingAmount, roomPoly, outlineWithout,
openingAmount, roomPoly, outlineWithout, islandsOf,
type ResolvedRoomFill,
} from './logic';
import {
openingTunnelGeometries, wallBodiesUnionPath, wallBodyNeedsSolid, wallHatchNeedsSolid,
wallHatchStepUnits, wallIntervals, HATCH_BASE_STEP_UNITS,
wallHatchStepUnits, wallIntervals, innerContourForRoom, HATCH_BASE_STEP_UNITS,
type WallEntry,
} from './wall-thickness';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
@@ -27,7 +27,7 @@ import { t, type Lang } from './i18n';
import { bgModeOf, resolveDayCycle } from './sun';
import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render';
import type { DevItem, OpeningCfg, ServerConfig } from './types';
import { physicalBodyParts } from './physical-geometry';
import { floorMinusBodies, physicalBodyParts, polyclipPathD } from './physical-geometry';
import {
materializePartitionOpening, partitionOpeningCut,
partitionOpeningFace, partitionOpeningHasCompositeRoomWall, resolvePartitionOpeningCompat,
@@ -54,6 +54,14 @@ import {
type Layout, type ContentItem,
} from './space-geometry';
import { resolveZeroWalls } from './zero-walls';
import { geometryOpenings } from './plan-geometry-preflight';
import {
buildGlowClipGeometry, buildLightBarrierScene, forgetGlowSource, forgetGlowSpace,
glowSourceInOpaqueBody, pruneGlowSources, readGlowClip, renderGlowPools,
resolveGlowCandidates, resolveGlowFeather, resolveLightBarrierRevision,
transitionGlowSource, warnGlowGeometryFallback, writeGlowClip,
type GlowRuntimeHost, type GlowRuntimeState, type GlowSpot, type LightBarrierScene,
} from './glow-scene';
export { spaceModels } from './space-geometry';
@@ -62,6 +70,8 @@ type StaticWallGeometryEntry = { fingerprint: string; value: StaticWallGeometry
const staticWallGeometryCache = new WeakMap<object, Map<string, StaticWallGeometryEntry>>();
type StaticPhysicalBodiesEntry = { fingerprint: string; value: number[][][] };
const staticPhysicalBodiesCache = new WeakMap<object, Map<string, StaticPhysicalBodiesEntry>>();
type StaticLightBarrierEntry = { fingerprint: string; value: LightBarrierScene };
const staticLightBarrierCache = new WeakMap<object, Map<string, StaticLightBarrierEntry>>();
/** Static cards receive the same immutable server-config object on HA ticks. */
function cachedStaticWallGeometry(
@@ -100,6 +110,30 @@ function cachedStaticPhysicalBodies(
return value;
}
function cachedStaticLightBarriers(
cfg: ServerConfig,
spaceId: string,
fingerprint: string,
build: () => LightBarrierScene,
): LightBarrierScene {
let spaces = staticLightBarrierCache.get(cfg as object);
if (!spaces) {
spaces = new Map<string, StaticLightBarrierEntry>();
staticLightBarrierCache.set(cfg as object, spaces);
}
const cached = spaces.get(spaceId);
if (cached?.fingerprint === fingerprint) return cached.value;
const value = build();
spaces.set(spaceId, { fingerprint, value });
return value;
}
export interface StaticGlowRuntime {
state: GlowRuntimeState;
host: GlowRuntimeHost;
screenBlend: boolean;
}
export interface StaticRenderOpts {
hass: any;
registry?: HaRegistrySnapshot;
@@ -120,6 +154,9 @@ export interface StaticRenderOpts {
showTemperature?: boolean;
showSignal?: boolean;
reducedMotion?: boolean;
/** Full radial Glow is opt-in; omitted/false preserves the cheap static path. */
lightPools?: boolean;
glowRuntime?: StaticGlowRuntime;
/** Deterministic clock injection for unit/smoke fixtures; production omits it. */
dayCycleNow?: Date | number;
virtualLights?: VirtualLightSnapshot | null;
@@ -354,12 +391,13 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
return shape;
});
// The compact card intentionally has no live radial pools, but it shares the
// exact independent data/base projection with the full plan.
const glowBaseShapes = space.rooms
// Base projection is independent of radial pools: opt-in pools are painted
// above it through the same room-level Glow gates as the full plan.
const glowEnabledRooms = space.rooms.filter((room) => roomGlowOf(disp.glow, room));
const glowBaseShapes = glowEnabledRooms
.filter((room) => {
const fill = resolvedRoomFills.get(room);
return roomGlowOf(disp.glow, room) && (!fill || fill.opacity <= 0);
return !fill || fill.opacity <= 0;
})
.map((room) => room.poly
? svg`<polygon class="glow-base" aria-hidden="true" pointer-events="none"
@@ -510,6 +548,145 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const solidWall = !!wallUnion && (wallBodyNeedsSolid(wallUnion.depthUnits, pxPerUnit)
|| wallHatchNeedsSolid(hatchStep, pxPerUnit));
const wallStroke = disp.color || '#607d8b';
let glowPools: TemplateResult | typeof nothing = nothing;
const glowRuntime = o.glowRuntime;
if (!o.lightPools || !glowRuntime || !glowEnabledRooms.length) {
if (glowRuntime) forgetGlowSpace(glowRuntime.state, glowRuntime.host, space.id);
} else {
const lightOpenings = geometryOpenings(spCfg, space, cellCm, GRID_PITCH, NORM_W);
const revision = resolveLightBarrierRevision({
rawSpaceConfig: spCfg,
space,
openings: lightOpenings,
cellCm,
gridPitch: GRID_PITCH,
openingAmount: (opening) => {
const entity = opening.contact ? planHass.states?.[opening.contact] : null;
return openingAmount(
opening.type, entity?.state, !!opening.invert,
entity?.attributes?.current_position,
);
},
});
const scene = cachedStaticLightBarriers(
o.cfg,
space.id,
revision.fingerprint,
() => buildLightBarrierScene({
rawSpaceConfig: spCfg,
space,
revision,
walls,
zeroWalls,
wallKeyPitch: GRID_STEP_N,
cellCm,
gridPitch: GRID_PITCH,
coordScale: NORM_W,
sharedWallGeometry: canonicalWallGeometry,
physicalBodies: (partitionCuts) => physicalBodyParts(
space, cellCm, GRID_PITCH, GRID_PITCH * 0.0002, partitionCuts,
).all,
}),
);
const configuredRadius = Number((o.cfg.settings as any)?.glow_radius_cm);
const defaultRadiusCm = Number.isFinite(configuredRadius) && configuredRadius > 0
? configuredRadius : 300;
const candidates = resolveGlowCandidates({
hass: planHass,
devices: devs,
virtualLights: o.virtualLights,
spaceId: space.id,
defaultColor: colors.glow_light.c,
paletteAlpha: colors.glow_light.a,
defaultRadiusUnits: (defaultRadiusCm / cellCm) * GRID_PITCH,
cellCm,
gridPitch: GRID_PITCH,
position: (device) => markerPos(device, o.layout, o.cfg, defPos, space),
});
const seen = new Set<string>();
const spots: GlowSpot[] = [];
for (const candidate of candidates) {
seen.add(candidate.key);
if (glowSourceInOpaqueBody(candidate.pos, scene)) {
forgetGlowSource(glowRuntime.state, glowRuntime.host, candidate.key);
continue;
}
const transition = transitionGlowSource(
glowRuntime.state, glowRuntime.host,
candidate.key, !!candidate.appearance,
);
if (!transition) continue;
if (candidate.appearance) {
glowRuntime.state.lastAppearance.set(candidate.key, candidate.appearance);
}
const appearance = glowRuntime.state.lastAppearance.get(candidate.key);
if (!appearance) continue;
const clipKey = `${space.id}|${scene.fingerprint}|${candidate.pos.x.toFixed(4)},${candidate.pos.y.toFixed(4)}|${candidate.radius.toFixed(4)}`;
const cachedClip = readGlowClip(glowRuntime.state, clipKey);
const geometry = cachedClip.hit ? cachedClip.value : buildGlowClipGeometry({
spaceId: space.id,
source: candidate.pos,
radius: candidate.radius,
scene,
polygons: revision.polygons,
onBoundsFailure: (roomId, phase) => warnGlowGeometryFallback(
glowRuntime.state, space.id, scene.fingerprint, roomId, phase,
),
});
if (!cachedClip.hit) writeGlowClip(glowRuntime.state, clipKey, geometry);
spots.push({
key: candidate.key,
sourceEid: candidate.sourceEid,
domId: transition.domId,
entering: transition.entering,
leaving: transition.leaving,
pos: candidate.pos,
c: appearance.c,
alpha: appearance.alpha,
geometry,
r: candidate.radius,
});
}
pruneGlowSources(glowRuntime.state, glowRuntime.host, space.id, seen);
if (spots.length) {
const allEnabled = glowEnabledRooms.length === revision.polygons.length;
const enabledClip = allEnabled ? null : glowEnabledRooms.flatMap((room) => {
const poly = roomPoly(room);
if (!poly) return [];
const floorPoly = walls.length && room.id
? (innerContourForRoom(
space.rooms, room.id, walls, zeroWalls.contour,
GRID_STEP_N, cellCm, GRID_PITCH, NORM_W,
canonicalWallGeometry?.roomGeom,
canonicalWallGeometry?.multiWallNodes,
) || poly)
: poly;
const cleanGeometry = extras.length ? floorMinusBodies(floorPoly, extras) : null;
const clean = cleanGeometry ? polyclipPathD(cleanGeometry) : '';
const holes = islandsOf(
floorPoly,
revision.polygons
.filter(({ room: other }) => other !== room)
.map(({ poly: other }) => other),
);
const path = (points: number[][]) =>
`M ${points.map((point) => `${point[0]} ${point[1]}`).join(' L ')} Z`;
return [[clean || path(floorPoly), ...holes.map(path)].join(' ')];
});
const feather = resolveGlowFeather(
glowRuntime.state,
o.stageWidth && vb[2] ? o.stageWidth / vb[2] : 1,
true,
);
glowPools = renderGlowPools({
spots,
enabledClip,
feather: feather.feather,
featherEnabled: feather.enabled,
screenBlend: glowRuntime.screenBlend,
});
}
}
const hostedOpeningSymbols = disp.hideOpenings ? [] : resolvedHosted.map((resolved) => {
const opening = resolved.opening;
const entity = opening.type === 'passage' || !opening.contact
@@ -586,6 +763,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
? svg`<g class="glow-base-layer" aria-hidden="true" pointer-events="none">${glowBaseShapes}</g>`
: nothing}
${passageGlowTunnels}
${glowPools}
${wallUnion
? svg`<g class="wallbodies" style="--room-stroke:${wallStroke}">
${wallUnion.paths.map((component) => svg`