diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index df055f72..e10bcb2f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 55f260e6..3e277264 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -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 и diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 32b2b3dd..57b4a5d8 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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', diff --git a/src/glow-scene.ts b/src/glow-scene.ts index 6a24c054..93fa3227 100644 --- a/src/glow-scene.ts +++ b/src/glow-scene.ts @@ -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(); for (const source of sources) { diff --git a/src/space-render.ts b/src/space-render.ts index 7245034f..571e50b8 100644 --- a/src/space-render.ts +++ b/src/space-render.ts @@ -70,8 +70,28 @@ type StaticWallGeometryEntry = { fingerprint: string; value: StaticWallGeometry const staticWallGeometryCache = new WeakMap>(); type StaticPhysicalBodiesEntry = { fingerprint: string; value: number[][][] }; const staticPhysicalBodiesCache = new WeakMap>(); -type StaticLightBarrierEntry = { fingerprint: string; value: LightBarrierScene }; -const staticLightBarrierCache = new WeakMap>(); +// #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>>(); +// #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>>(); + +function lruGet(cache: Map, key: string): V | undefined { + const value = cache.get(key); + if (value !== undefined) { cache.delete(key); cache.set(key, value); } + return value; +} + +function lruSet(cache: Map, 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(); + spaces = new Map>(); 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(); + 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>(); + staticEnabledClipCache.set(cfg as object, spaces); + } + let clips = spaces.get(spaceId); + if (!clips) { + clips = new Map(); + 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, diff --git a/test/space-render-caches.test.mjs b/test/space-render-caches.test.mjs new file mode 100755 index 00000000..61be5cbf --- /dev/null +++ b/test/space-render-caches.test.mjs @@ -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'); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index 08cdfd07..c364d74e 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -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",