fix: give static Glow the full card's cache hierarchy (#375)

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

User-Visible: yes
Issue: #375
This commit is contained in:
Codex
2026-08-29 20:59:08 +03:00
parent 97bb0eceea
commit d762ae0231
7 changed files with 270 additions and 40 deletions
+8
View File
@@ -2,6 +2,14 @@
## Unreleased
- Glow in `houseplan-space-card` no longer rebuilds its light graph and wall
scene from scratch on every Home Assistant tick: the static path now shares
the caching machinery of the full card (light-graph cache, an LRU pool of
barrier scenes so a moving door reuses both of its states, the fast masonry
recut path, and a cached room clip). This also fixes a light-graph cache
regression the full card picked up in beta.4
([#375](https://github.com/Matysh/houseplan-card/issues/375)).
## v1.69.0-beta.4 — 2026-08-29
- The read-only `houseplan-space-card` can now opt into the full plan's radial
+8
View File
@@ -8,6 +8,14 @@
## Не выпущено
- Glow в `houseplan-space-card` больше не пересобирает граф света и сцену стен
с нуля на каждый тик Home Assistant: статический путь получил кэш-механику
полной карты (кэш графа света, LRU-пул барьерных сцен — движущаяся дверь
переиспользует оба своих состояния, быстрый recut-путь кладки и кэш выреза
комнат). Заодно исправлен регресс кэша графа света, который полная карта
получила в beta.4
([#375](https://github.com/Matysh/houseplan-card/issues/375)).
## v1.69.0-beta.4 — 2026-08-29
- В read-only `houseplan-space-card` теперь можно включить радиальный Glow и
+22
View File
@@ -691,6 +691,28 @@ const MUTANT_DEFINITIONS = [
replace: " : await import(/* @vite-ignore */ new URL(`${FRENCH_RETRY_ASSET}?retry`, import.meta.url).href);\n void module.dictionary;\n return { dictionary: en, fingerprint: module.fingerprint };",
}],
},
{
id: 'static-glow-light-cache-spread',
guard: 'node --test test/space-render-caches.test.mjs',
because: 'a fresh spread on the devices array silently defeats RESOLVED_LIGHT_CACHE '
+ '(WeakMap by array identity) in BOTH cards — invisible to CI perf profiles (#375 V6a)',
patches: [{
file: 'src/glow-scene.ts',
find: " input.hass, input.devices, null, input.virtualLights,",
replace: " input.hass, [...input.devices], null, input.virtualLights,",
}],
},
{
id: 'static-glow-scene-lru-single',
guard: 'node --test test/space-render-caches.test.mjs',
because: 'a single-entry scene cache makes a door ping-pong rebuild the barrier scene '
+ 'on every flip — the LRU capacity is the contract, parity with the full card (#375 V6c)',
patches: [{
file: 'src/space-render.ts',
find: "const STATIC_LIGHT_BARRIER_LRU = 8;",
replace: "const STATIC_LIGHT_BARRIER_LRU = 1;",
}],
},
{
id: 'opening-light-quantum-identity',
guard: 'node --test --test-name-pattern="#366" test/logic.test.mjs',
+3 -1
View File
@@ -173,8 +173,10 @@ export function resolveGlowCandidates(input: {
gridPitch: number;
position: (device: DevItem) => { x: number; y: number };
}): GlowCandidate[] {
// #375: pass the array as-is — RESOLVED_LIGHT_CACHE is keyed by the array's
// identity, so a fresh spread would guarantee a miss on every render.
const sources = resolvedLightSources(
input.hass, [...input.devices], null, input.virtualLights,
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) {
+119 -38
View File
@@ -70,8 +70,28 @@ 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>>();
// #375: the scene cache is an LRU of 8 per space — parity with the full
// card's _lightBarrierPool. A single-entry cache made a door flipping
// open<->close rebuild the scene on every state change (fingerprint ping-pong).
const STATIC_LIGHT_BARRIER_LRU = 8;
const staticLightBarrierCache = new WeakMap<object, Map<string, Map<string, LightBarrierScene>>>();
// #375: enabledClip is rebuilt only when the geometry or the set of
// glow-disabled rooms changes; the full card keeps the analogous clean-floor
// LRU (600) — static cards need far fewer entries.
const STATIC_ENABLED_CLIP_LRU = 8;
const staticEnabledClipCache = new WeakMap<object, Map<string, Map<string, string[]>>>();
function lruGet<V>(cache: Map<string, V>, key: string): V | undefined {
const value = cache.get(key);
if (value !== undefined) { cache.delete(key); cache.set(key, value); }
return value;
}
function lruSet<V>(cache: Map<string, V>, key: string, value: V, limit: number): void {
cache.delete(key);
cache.set(key, value);
while (cache.size > limit) cache.delete(cache.keys().next().value as string);
}
/** Static cards receive the same immutable server-config object on HA ticks. */
function cachedStaticWallGeometry(
@@ -110,7 +130,8 @@ function cachedStaticPhysicalBodies(
return value;
}
function cachedStaticLightBarriers(
/** Exported for tests only (#375): the LRU behaviour is the contract. */
export function cachedStaticLightBarriers(
cfg: ServerConfig,
spaceId: string,
fingerprint: string,
@@ -118,13 +139,42 @@ function cachedStaticLightBarriers(
): LightBarrierScene {
let spaces = staticLightBarrierCache.get(cfg as object);
if (!spaces) {
spaces = new Map<string, StaticLightBarrierEntry>();
spaces = new Map<string, Map<string, LightBarrierScene>>();
staticLightBarrierCache.set(cfg as object, spaces);
}
const cached = spaces.get(spaceId);
if (cached?.fingerprint === fingerprint) return cached.value;
let scenes = spaces.get(spaceId);
if (!scenes) {
scenes = new Map<string, LightBarrierScene>();
spaces.set(spaceId, scenes);
}
const cached = lruGet(scenes, fingerprint);
if (cached) return cached;
const value = build();
spaces.set(spaceId, { fingerprint, value });
lruSet(scenes, fingerprint, value, STATIC_LIGHT_BARRIER_LRU);
return value;
}
/** Exported for tests only (#375): reuse-by-identity is the contract. */
export function cachedStaticEnabledClip(
cfg: ServerConfig,
spaceId: string,
key: string,
build: () => string[],
): string[] {
let spaces = staticEnabledClipCache.get(cfg as object);
if (!spaces) {
spaces = new Map<string, Map<string, string[]>>();
staticEnabledClipCache.set(cfg as object, spaces);
}
let clips = spaces.get(spaceId);
if (!clips) {
clips = new Map<string, string[]>();
spaces.set(spaceId, clips);
}
const cached = lruGet(clips, key);
if (cached) return cached;
const value = build();
lruSet(clips, key, value, STATIC_ENABLED_CLIP_LRU);
return value;
}
@@ -499,14 +549,26 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: { rooms: space.rooms, walls, extras, cellCm, zero: zeroWalls.contour })
: '';
const canonicalWallGeometry = needsCanonicalWallGeometry
? cachedStaticWallGeometry(o.cfg, space.id, wallGeometryFingerprint, () => wallBodiesUnionPath(
space.rooms, walls, zeroWalls.contour, [
...staticPassages.filter((opening) => opening.host?.kind !== 'partition').map((opening) => ({
x: opening.rx, y: opening.ry, angle: opening.angle, length: opening.rlen,
})),
...hostedCompositeOpenings,
], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras,
))
? cachedStaticWallGeometry(o.cfg, space.id, wallGeometryFingerprint, () => {
const built = wallBodiesUnionPath(
space.rooms, walls, zeroWalls.contour, [
...staticPassages.filter((opening) => opening.host?.kind !== 'partition').map((opening) => ({
x: opening.rx, y: opening.ry, angle: opening.angle, length: opening.rlen,
})),
...hostedCompositeOpenings,
], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras,
);
// #375: the same non-enumerable tag the full card attaches
// (houseplan-card.ts, _wallGeometryR): buildLightBarrierScene only takes
// the fast recutWallBodiesGeometry path when this fingerprint matches
// revision.geometryFingerprint — without the tag the static path always
// rebuilt the wall bodies from scratch on a door state change.
if (built) Object.defineProperty(built, 'sourceFingerprint', {
value: contentFingerprint([spCfg, cellCm, GRID_PITCH]),
enumerable: false,
});
return built;
})
: null;
const passageTunnelGeometry = staticPassages.length && walls.length
? openingTunnelGeometries(
@@ -651,29 +713,48 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
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(' ')];
});
// #375: the clip depends only on the geometry (revision.geometryFingerprint
// covers spCfg) and on WHICH rooms have glow disabled — not on entity
// states. Cache it instead of recomputing boolean geometry every hass tick.
const enabledClipKey = `${revision.geometryFingerprint}|${
space.rooms.filter((room) => !glowEnabledRooms.includes(room))
.map((room) => room.id || `#${space.rooms.indexOf(room)}`).sort().join(',')}`;
const enabledClip = allEnabled ? null : cachedStaticEnabledClip(
o.cfg, space.id, enabledClipKey, () => 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;
// #375: bbox prefilter, same as the full card's clean-floor cache —
// bodies entirely outside the room's bounding box cannot cut it.
const xs = floorPoly.map((point: number[]) => point[0]);
const ys = floorPoly.map((point: number[]) => point[1]);
const box = [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
const bodies = extras.filter((body) => {
const bx = body.map((point: number[]) => point[0]);
const by = body.map((point: number[]) => point[1]);
return Math.max(...bx) >= box[0] && Math.min(...bx) <= box[2]
&& Math.max(...by) >= box[1] && Math.min(...by) <= box[3];
});
const cleanGeometry = bodies.length ? floorMinusBodies(floorPoly, bodies) : 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,
+109
View File
@@ -0,0 +1,109 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolvedLightSources } from '../test-build/devices.js';
import {
cachedStaticLightBarriers, cachedStaticEnabledClip,
} from '../test-build/space-render.js';
// #375 AC1: the light-graph cache is keyed by the ARRAY IDENTITY. The glow
// scene must therefore pass the caller's array as-is; a fresh spread would
// guarantee a miss on every render — in the space card AND the full card.
test('#375 AC1: resolvedLightSources reuses the cached graph for a stable array', () => {
const hass = {
states: { 'light.l': { state: 'on', attributes: {} } },
entities: {},
devices: {},
};
const lamp = {
id: 'lamp', name: 'Lamp', model: '', area: 'room', space: 'floor',
icon: 'mdi:lightbulb', is_light: true, binding: 'entity:light.l',
entity: 'light.l', entities: ['light.l'], x: 0.5, y: 0.5,
};
const devices = [lamp];
const manual = { rev: 1, configRev: 1, off: new Set() };
const first = resolvedLightSources(hass, devices, null, manual);
const second = resolvedLightSources(hass, devices, null, manual);
assert.equal(first, second,
'same array + same state must return the SAME cached sources object');
const spreadMiss = resolvedLightSources(hass, [...devices], null, manual);
assert.notEqual(first, spreadMiss,
'a spread copy is a different WeakMap key — full rebuild (the bug of #375)');
});
test('#375 AC1: the glow scene passes input.devices without a spread', () => {
const source = readFileSync(new URL('../src/glow-scene.ts', import.meta.url), 'utf8');
assert.match(source, /resolvedLightSources\(\s*input\.hass, input\.devices,/,
'resolveGlowCandidates must hand the stable array to the cache');
assert.ok(!source.includes('[...input.devices]'),
'no spread on the cache key (#375 V6a)');
});
// #375 AC2: the static path attaches the same sourceFingerprint tag the full
// card attaches, from the same triple buildLightBarrierRevision fingerprints —
// proven the same way the full card's recut wiring is proven
// (performance-contract.test.mjs): by pinning the source.
test('#375 AC2: static wall geometry carries the recut fingerprint tag', () => {
const render = readFileSync(new URL('../src/space-render.ts', import.meta.url), 'utf8');
assert.match(render,
/Object\.defineProperty\(built, 'sourceFingerprint', \{\s*\n\s*value: contentFingerprint\(\[spCfg, cellCm, GRID_PITCH\]\),/,
'the tag must be the exact revision triple (rawSpaceConfig=spCfg, cellCm, gridPitch=GRID_PITCH)');
const scene = readFileSync(new URL('../src/glow-scene.ts', import.meta.url), 'utf8');
assert.match(scene, /sharedFingerprint === revision\.geometryFingerprint/,
'glow-scene honours the tag by comparing it with the revision fingerprint');
});
// #375 AC3: a door flipping open<->close alternates two scene fingerprints;
// the static cache must hold both (LRU 8, parity with the full card's pool).
test('#375 AC3: scene cache survives a fingerprint ping-pong', () => {
const cfg = { marker: 'cfg-a' };
let builds = 0;
const build = () => ({ built: ++builds });
const sequence = ['open', 'closed', 'open', 'closed', 'open', 'closed'];
const seen = sequence.map((fp) => cachedStaticLightBarriers(cfg, 'space-1', fp, build));
assert.equal(builds, 2,
'six alternating lookups must build exactly twice — one per fingerprint');
assert.equal(seen[0], seen[2], 'the "open" scene is reused by identity');
assert.equal(seen[1], seen[3], 'the "closed" scene is reused by identity');
});
test('#375 AC3: scene cache evicts least-recently-used beyond 8 entries', () => {
const cfg = { marker: 'cfg-b' };
let builds = 0;
const build = () => ({ built: ++builds });
for (let i = 0; i < 9; i++) cachedStaticLightBarriers(cfg, 's', `fp-${i}`, build);
assert.equal(builds, 9);
cachedStaticLightBarriers(cfg, 's', 'fp-8', build);
assert.equal(builds, 9, 'the newest entry is still cached');
cachedStaticLightBarriers(cfg, 's', 'fp-0', build);
assert.equal(builds, 10, 'the oldest entry was evicted at capacity 8');
});
test('#375 AC3: scene cache is namespaced by space and by config object', () => {
let builds = 0;
const build = () => ({ built: ++builds });
const cfg = { marker: 'cfg-c' };
cachedStaticLightBarriers(cfg, 'space-1', 'fp', build);
cachedStaticLightBarriers(cfg, 'space-2', 'fp', build);
cachedStaticLightBarriers({ marker: 'cfg-d' }, 'space-1', 'fp', build);
assert.equal(builds, 3, 'no cross-talk between spaces or config revisions');
});
// #375 AC4: the enabled-rooms clip is pure in the geometry fingerprint and the
// disabled-room set — a repeated hass tick must not redo boolean geometry.
test('#375 AC4: enabledClip is reused by identity for an unchanged key', () => {
const cfg = { marker: 'cfg-e' };
let builds = 0;
const build = () => { builds++; return ['M 0 0 L 1 0 L 1 1 Z']; };
const key = 'geom-fp|kitchen';
const first = cachedStaticEnabledClip(cfg, 'space-1', key, build);
const second = cachedStaticEnabledClip(cfg, 'space-1', key, build);
assert.equal(builds, 1, 'the second tick must be a cache hit');
assert.equal(first, second, 'reuse is by identity — no fresh arrays per tick');
cachedStaticEnabledClip(cfg, 'space-1', 'geom-fp|kitchen,porch', build);
assert.equal(builds, 2, 'a different disabled set is a different clip');
});
+1 -1
View File
@@ -15,7 +15,7 @@
"src/ha-binding-status.ts",
"src/integration-provider.ts", "src/vacuum.ts",
"src/sun.ts",
"src/light-visibility.ts", "src/glow-scene.ts",
"src/light-visibility.ts", "src/glow-scene.ts", "src/space-render.ts",
"src/resize.ts", "src/resize-labels.ts", "src/resize-controller.ts", "src/wall-record-preservation.ts",
"src/rules.ts",
"src/devices.ts", "src/device-inbox.ts",