perf(geometry): key the opening index and sun wedges by their inputs (#814)

The opening wall index and the Flat/2.5D sun wedges carried the global
config epoch in their keys on top of a content fingerprint. Every save of
any floor, a shared setting or a server push bumps that epoch, so the
shown floor rebuilt both although none of their inputs had changed.

Both keys now live in src/floor-geometry-key.ts and list every input:
the opening index reads the floor, its rooms in order (id and the
polygon roomPoly reads, rect fallback included), its wall records, the
open cuts and the scale, afresh on every call, so an in-place edit is
seen without a new epoch. The sun key is that index key plus the
physical bodies key (the floor record the occluders, wall union and
inner contours are cached by), the exterior windows, the sun, the
compass, the ray origin and the zero walls. DEV-B701-01 still holds:
an own edit moves the key synchronously, before the WS ack.

The card counts sun builds (sunRays, isoSun) next to the floor-cache
families for the smoke; the #735 guard ignores them.

Issue: #814
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-07 20:19:14 +00:00
committed by claude[bot]
parent c267e9079a
commit 9738fc6ee3
4 changed files with 177 additions and 36 deletions
+38
View File
@@ -27,6 +27,7 @@
*/
import { lruWrite } from './card-runtime';
import { lightGeometryFingerprint } from './glow-scene';
import { roomPoly } from './logic';
import { contentFingerprint } from './visual-continuity';
/** The card members the key reads; the card passes itself. */
@@ -132,3 +133,40 @@ export function writeWallUnionPool<T>(
lruWrite(pool, entry.key, entry, WALL_UNION_POOL_LIMIT);
return entry;
}
/**
* #814: the key of the opening wall index (`openingWallIndex` in
* wall-thickness.ts), read afresh on every call — an in-place edit counts.
* Every input: the floor, its rooms in order (id and the polygon `roomPoly`
* reads, the rect fallback included), its wall records, the open cuts and the
* scale. Not the global config epoch: another floor's edit, a shared setting
* or a Home Assistant update leaves every one of them as it was.
*/
export const openingWallIndexKey = (
spaceId: string, rooms: readonly unknown[], walls: readonly { key: string; cm: number; a?: number[]; b?: number[] }[],
cuts: readonly number[][], scale: readonly number[],
): string => [
spaceId, ...scale,
rooms.map((room) => `${(room as { id?: string } | null)?.id || ''}:${roomPoly(room)?.join('/')}`).join(';'),
walls.map((wall) => `${wall.key}:${wall.a}:${wall.b}:${wall.cm}`).join(';'),
cuts.join(';'),
].join('|');
/** #814: an exterior window of the sun layer, in render units (docs/SUN.md). */
export interface SunWindow { id: string; x: number; y: number; angle: number; length: number }
/**
* #814: the key of the sun wedges (`_renderSunRays`; the 2.5D wash adds its
* cell size and lit floors). `index` is the opening wall index key (rooms,
* walls, cuts, scale), `bodies` the physical bodies key — the floor record the
* occluders, the wall union and the inner contours are cached by — then the
* windows, `sun` (azimuth, elevation, north, ray origin) and the zero walls.
* Not the global config epoch (see `openingWallIndexKey`).
*/
export const sunGeometryKey = (
index: string, bodies: string, windows: readonly SunWindow[], sun: readonly unknown[],
zero: { style: unknown; barriers: readonly number[][] },
): string => [
index, bodies, windows.map((w) => `${w.id}:${w.x},${w.y},${w.angle},${w.length}`).join(';'),
...sun, zero.style, zero.barriers.join(';'),
].join('|');
+25 -33
View File
@@ -275,8 +275,8 @@ import type { DecorShape, DecorStyle } from './editors/decor/types';
import { StairViewRuntime, type StairViewHostPort } from './stairs-view';
import { cleanFloorForRoom, type CleanFloorResult } from './clean-floor';
import {
floorGeometryKeyReader, physicalBodiesKey, wallUnionKey, wallUnionPoolEntry, writeWallUnionPool,
type FloorKeySource,
floorGeometryKeyReader, openingWallIndexKey, physicalBodiesKey, sunGeometryKey, wallUnionKey,
wallUnionPoolEntry, writeWallUnionPool, type FloorKeySource, type SunWindow,
} from './floor-geometry-key';
import {
DECOR_ASSETS_API_VERSION, decorAssetIds, projectDecorImage,
@@ -1791,10 +1791,11 @@ export class HouseplanCard extends LitElement {
/** #769: real builds of each floor-geometry cache, counted in the miss branch
* only — never on a hit, a recency refresh, a resize re-key or alias, or a
* clear. The #735 switch-cycle guard (demo/performance/switch-cycle-guard.mjs)
* and the floor-cache smoke judge them; nothing in the product reads them. */
* and the floor-cache smoke judge them; nothing in the product reads them.
* #814: the sun wedges (Flat, 2.5D) count theirs for the floor-cache-reuse smoke. */
private _floorCacheBuilds = {
wallUnion: 0, innerContour: 0, cleanFloor: 0, openingWallIndex: 0, lightBarrier: 0,
glowClip: 0, physicalBodies: 0, openingTunnel: 0, lightPhysicalBodies: 0,
glowClip: 0, physicalBodies: 0, openingTunnel: 0, lightPhysicalBodies: 0, sunRays: 0, isoSun: 0,
};
private _isoFallback = new Set<string>();
private _openingTunnelCache: {
@@ -8991,17 +8992,8 @@ export class HouseplanCard extends LitElement {
private _openingWallIndexFor(space: SpaceModel, openCuts: number[][]): {
key: string; value: OpeningWallIndex;
} {
const roomFingerprint = space.rooms.map((room) => (
`${room.id}:${room.poly?.map((point) => point.join(',')).join('/') || `${room.x},${room.y},${room.w},${room.h}`}`
)).join(';');
const wallFingerprint = this._spaceWalls.map((wall) => (
`${wall.key}:${wall.a?.join(',') || ''}:${wall.b?.join(',') || ''}:${wall.cm}`
)).join(';');
const cutFingerprint = openCuts.map((cut) => cut.join(',')).join(';');
const key = [
space.id, this._cfgEpoch, this._wallKeyPitch, this._cellCm, this._gridPitch,
roomFingerprint, wallFingerprint, cutFingerprint,
].join('|');
const key = openingWallIndexKey(space.id, space.rooms, this._spaceWalls, openCuts,
[this._wallKeyPitch, this._cellCm, this._gridPitch]);
let value = this._openingWallIndexCache.get(key);
if (value) {
// Refresh recency on hit; the pool is intentionally tiny because each
@@ -9919,18 +9911,12 @@ export class HouseplanCard extends LitElement {
* everything else in `hass` must not trigger the polygon clipping.
*/
/** Window-light inputs shared by the Flat wedges and the 2.5D soft light (#649). */
private _sunInputs(space: SpaceModel, zeroWalls: ReturnType<HouseplanCard['_zeroWalls']>) {
private _sunInputs(space: SpaceModel, zeroWalls: ReturnType<HouseplanCard['_zeroWalls']>, windows: SunWindow[]) {
const rooms = space.rooms
.map((r) => ({ id: r.id || '', poly: roomPoly(r) }))
.filter((r): r is { id: string; poly: number[][] } => !!r.id && !!r.poly);
const windows = this._openingsR
// A contour-wall host is stable identity metadata, not a different
// physical carrier. Only an independent partition window is excluded
// from exterior sunlight (#132, ADR 282 Stage 1).
.filter((o) => o.type === 'window' && o.host?.kind !== 'partition')
.map((o) => ({ id: o.id, x: o.rx, y: o.ry, angle: o.angle, length: o.rlen }));
const walls = this._spaceWalls;
const openCuts = this._openCuts();
const openCuts = zeroWalls.contour;
const openingWallIndex = this._openingWallIndexFor(space, openCuts).value;
const innerByRoom: Record<string, number[][]> = {};
const wallDepthByOpening: Record<string, number> = {};
@@ -9999,28 +9985,34 @@ export class HouseplanCard extends LitElement {
}, RAY_FADE_MS);
}
}
// DEV-B701-01: the geometry signal must be _cfgEpoch, not _cfgRev.
// Every local mutation ends in _saveConfig(), which bumps the epoch
// SYNCHRONOUSLY; _cfgRev only moves after the debounced WS write is
// acked, so a rev-keyed memo served wedges for the OLD window position
// during the whole write window (and forever if the write failed).
// DEV-B701-01: the key is the geometry itself, never _cfgRev (it moves only
// after the debounced WS ack). #814: nor the global _cfgEpoch — every input
// is read on each frame (floor-geometry-key.ts), so an own edit, in place
// too, moves it synchronously, and another floor's edit does not.
const zeroWalls = this._zeroWalls();
const zeroKey = zeroWalls.barriers.map((line) => line.join(',')).join(';');
const origin = this._effSunRayOrigin();
const key = `${space.id}|${sun.azimuth}|${sun.elevation}|${north}|${origin}|${this._cfgEpoch}`
+ `|${zeroWalls.style}|${zeroKey}`;
// A contour-wall host is stable identity metadata, not a different physical
// carrier. Only an independent partition window is excluded from exterior
// sunlight (#132, ADR 282 Stage 1).
const windows = this._openingsR.filter((o) => o.type === 'window' && o.host?.kind !== 'partition')
.map((o) => ({ id: o.id, x: o.rx, y: o.ry, angle: o.angle, length: o.rlen }));
const key = sunGeometryKey(this._openingWallIndexFor(space, zeroWalls.contour).key,
physicalBodiesKey(this._floorKey(space.id), this._cellCm, this._gridPitch), windows,
[sun.azimuth, sun.elevation, north, origin], zeroWalls);
const isoSun = this._renderProjection === 'iso' ? this._isoSceneRuntime : null;
if (isoSun) { // #649 п.2: the 2.5D View paints soft light along the sun instead of the Flat wedges
const isoKey = `${key}|${this._cellCm}|${[...(this._isoLightFloors ?? [])].join(',')}`;
if (this._isoSunCache?.key !== isoKey) {
const inputs = this._sunInputs(space, zeroWalls);
this._floorCacheBuilds.isoSun++;
const inputs = this._sunInputs(space, zeroWalls, windows);
const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, this._cellCm);
this._isoSunCache = { key: isoKey, wallHeight, beams: isoSun.computeIsoSunBeams({ ...inputs, azimuth: sun.azimuth, elevation: sun.elevation, northDeg: north!, wallHeight, lightFloorRooms: this._isoLightFloors ?? undefined }) };
}
return isoSun.renderIsoSunWash(this._isoSunCache.beams, this._isoSunCache.wallHeight, this._sunOut, this._modeTransitionVisual?.viewWeight ?? 1) as TemplateResult;
}
if (!this._sunRaysCache || this._sunRaysCache.key !== key) {
const { rooms, windows, innerByRoom, wallDepthByOpening, occluders: sunOccluders } = this._sunInputs(space, zeroWalls);
this._floorCacheBuilds.sunRays++;
const { rooms, innerByRoom, wallDepthByOpening, occluders: sunOccluders } = this._sunInputs(space, zeroWalls, windows);
let rays = computeSunRays(
rooms, windows, sun.azimuth, sun.elevation, north!, innerByRoom, wallDepthByOpening, origin,
);
+113 -2
View File
@@ -5,11 +5,13 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
floorGeometryKeyReader, floorRecordKeyMemo, physicalBodiesKey, WALL_UNION_POOL_LIMIT,
wallUnionKey, wallUnionPoolEntry, writeWallUnionPool,
floorGeometryKeyReader, floorRecordKeyMemo, openingWallIndexKey, physicalBodiesKey,
sunGeometryKey, WALL_UNION_POOL_LIMIT, wallUnionKey, wallUnionPoolEntry, writeWallUnionPool,
} from '../test-build/floor-geometry-key.js';
import { lightGeometryFingerprint } from '../test-build/glow-scene.js';
import { computeSunRays } from '../test-build/sun.js';
import { contentFingerprint } from '../test-build/visual-continuity.js';
import { openingWallIndex } from '../test-build/wall-thickness.js';
const record = (id, cm = 15) => ({
id, cell_cm: 5, rooms: [{ id: `${id}1`, poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5]] }],
@@ -96,3 +98,112 @@ test('#769 AC8: one format of the union and bodies keys, the pool bound and its
assert.deepEqual([...pool.keys()].at(-1), 'k2', 'a rewrite is the most recent');
assert.equal(pool.size, WALL_UNION_POOL_LIMIT);
});
/** A one-slot memo by `key`, counting its builds: what the card does with a key. */
const memoOf = (key, build) => {
let last = null;
const memo = (...input) => {
const k = key(...input);
if (last?.k !== k) { memo.builds++; last = { k, value: build(...input) }; }
return last.value;
};
memo.builds = 0;
return memo;
};
test('#814 AC1: the opening wall index key — every input moves it, the cached index is the uncached one', () => {
const scale = (input) => [1 / 240, input.cellCm, input.gridPitch];
const keyOf = (input) => openingWallIndexKey(input.spaceId, input.rooms, input.walls, input.cuts, scale(input));
const fresh = (input) => openingWallIndex(input.rooms, input.walls, input.cuts, 1 / 240, input.cellCm,
input.gridPitch, 1000);
const index = memoOf(keyOf, fresh);
const input = {
spaceId: 'f1', cellCm: 5, gridPitch: 1000 / 240,
rooms: [
{ id: 'r1', name: 'Living', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] },
{ id: 'r2', x: 500, y: 100, w: 300, h: 400 },
],
walls: [{ key: 'w', cm: 15, a: [0.5, 0.1], b: [0.5, 0.5] }],
cuts: [],
};
const steps = [
['a room point moved in place', () => { input.rooms[0].poly[1][0] = 520; }],
['a rect room moved in place', () => { input.rooms[1].w = 320; }],
['the room order', () => { input.rooms.reverse(); }],
['a room id', () => { input.rooms[0].id = 'r9'; }],
['a wall thickness in place', () => { input.walls[0].cm = 30; }],
['a wall endpoint in place', () => { input.walls[0].b[1] = 0.45; }],
['a wall key', () => { input.walls[0].key = 'w2'; }],
['an open cut', () => { input.cuts.push([500, 200, 500, 300]); }],
['the cell size', () => { input.cellCm = 10; }],
['the grid pitch', () => { input.gridPitch = 1000 / 120; }],
['the floor', () => { input.spaceId = 'f2'; }],
];
let previous = keyOf(input);
assert.deepEqual(index(input), fresh(input));
for (const [name, change] of steps) {
const builds = index.builds;
change();
assert.notEqual(keyOf(input), previous, `${name} moves the key`);
previous = keyOf(input);
assert.deepEqual(index(input), fresh(input), `${name}: the cached index is the uncached one`);
assert.equal(index.builds, builds + 1, `${name}: one build`);
}
// What the index never reads leaves the key: a rename, a setting, another
// floor, a new epoch with equal records (a server push), a Home Assistant tick.
const builds = index.builds;
input.rooms[0].name = 'Renamed';
input.rooms[0].settings = { fill_mode: 'light' };
assert.deepEqual(index(structuredClone(input)), fresh(input));
assert.equal(index.builds, builds, 'no build');
});
test('#814 AC1: the sun key — every input moves it, the cached wedges are the uncached ones', () => {
const rooms = [
{ id: 'r1', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] },
{ id: 'r2', poly: [[500, 100], [800, 100], [800, 500], [500, 500]] },
];
const input = {
rooms, walls: [], cuts: [], bodies: 'f1|record|5|1000',
windows: [{ id: 'wE', x: 800, y: 300, angle: 90, length: 60 }, { id: 'wS', x: 300, y: 500, angle: 0, length: 60 }],
azimuth: 90, elevation: 20, north: 0, origin: 'inner', zero: { style: 'solid', barriers: [] },
};
const keyOf = (i) => sunGeometryKey(openingWallIndexKey('f1', i.rooms, i.walls, i.cuts, [1, 5, 1000]),
i.bodies, i.windows, [i.azimuth, i.elevation, i.north, i.origin], i.zero);
const fresh = (i) => computeSunRays(structuredClone(i.rooms), structuredClone(i.windows), i.azimuth, i.elevation,
i.north, undefined, undefined, i.origin);
const rays = memoOf(keyOf, fresh);
assert.ok(rays(input).length > 0, 'the fixture is lit');
const steps = [
['a window moved in place', () => { input.windows[0].y = 340; }],
['a window resized in place', () => { input.windows[0].length = 80; }],
['a window added', () => { input.windows.push({ id: 'wN', x: 300, y: 100, angle: 0, length: 60 }); }],
['a room point moved in place', () => { input.rooms[1].poly[1][0] = 760; input.rooms[1].poly[2][0] = 760; }],
['the azimuth', () => { input.azimuth = 200; }],
['the elevation', () => { input.elevation = 40; }],
['the compass', () => { input.north = 90; }],
['the ray origin', () => { input.origin = 'outer'; }],
];
let previous = keyOf(input);
for (const [name, change] of steps) {
change();
assert.notEqual(keyOf(input), previous, `${name} moves the key`);
previous = keyOf(input);
assert.deepEqual(rays(input), fresh(input), `${name}: the cached wedges are the uncached ones`);
}
for (const [name, change] of [
['a wall record', () => { input.walls.push({ key: 'w', cm: 15 }); }],
['an open cut', () => { input.cuts.push([500, 200, 500, 300]); }],
['the floor record of the bodies', () => { input.bodies = 'f1|moved partition|5|1000'; }],
['a solid zero wall', () => { input.zero = { style: 'solid', barriers: [[100, 300, 300, 300]] }; }],
['the zero-wall style', () => { input.zero = { ...input.zero, style: 'dashed' }; }],
]) {
change();
assert.notEqual(keyOf(input), previous, `${name} moves the key`);
previous = keyOf(input);
}
rays(input);
const builds = rays.builds;
rays(structuredClone(input));
assert.equal(rays.builds, builds, 'equal inputs in new objects (a server push, an epoch) build nothing');
});
+1 -1
View File
@@ -339,7 +339,7 @@ test('#769 AC8: the union and bodies keys, the pool bound and its entry have one
const count = (text, pattern) => text.split(pattern).length - 1;
assert.equal(count(files.card, 'wallUnionKey('), 1, 'the card miss branch');
assert.equal(count(files.runtime, 'wallUnionKey('), 2, '_rszEdgeDown and the _rszCancelDrag alias');
assert.equal(count(files.card, 'physicalBodiesKey('), 1, 'the card miss branch');
assert.equal(count(files.card, 'physicalBodiesKey('), 2, 'the card bodies lookup and the #814 sun key');
assert.equal(count(files.runtime, 'physicalBodiesKey('), 2, 'the preview and cancel re-keys');
assert.equal(count(files.led, 'physicalBodiesKey('), 1, 'the LED editor bodies');
const module = read('../src/floor-geometry-key.ts');