mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 06:08:59 +00:00
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user