mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
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:
@@ -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,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 и
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Executable
+109
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user