feat: add opt-in Glow to space card

Issue: #374
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-29 14:46:52 +03:00
parent 8914190c56
commit 0dfc74244b
55 changed files with 12101 additions and 11199 deletions
+133
View File
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
createGlowRuntimeState, disposeGlowRuntime, readGlowClip,
resolveGlowCandidates, resolveLightBarrierRevision,
transitionGlowSource, writeGlowClip,
} from '../test-build/glow-scene.js';
const square = (id, x0, x1) => ({
id,
poly: [[x0, 0], [x1, 0], [x1, 100], [x0, 100]],
});
test('shared light revision admits only floor-to-floor architectural passages', () => {
const space = {
id: 's', rooms: [square('left', 0, 100), square('right', 100, 200)],
partitions: [], room_drafts: [], wall_columns: [],
};
const opening = (id, type, rx, amount) => ({
id, type, rx, ry: 50, rlen: 40, angle: 90, amount,
contact: type === 'door' ? `binary_sensor.${id}` : null,
});
const openings = [
opening('inside-door', 'door', 100, 0.51),
opening('outside-door', 'door', 0, 1),
opening('window', 'window', 100, 1),
opening('passage', 'passage', 100, 1),
];
const revision = resolveLightBarrierRevision({
rawSpaceConfig: { id: 's', rooms: space.rooms },
space,
openings,
cellCm: 5,
gridPitch: 20,
openingAmount: (candidate) => candidate.amount,
});
assert.deepEqual(
revision.passageStates.map(({ opening: candidate }) => candidate.id),
['inside-door', 'passage'],
);
assert.equal(revision.passageStates[0].amount, 0.5);
const sameBucket = resolveLightBarrierRevision({
rawSpaceConfig: { id: 's', rooms: space.rooms }, space,
openings: openings.map((candidate) => candidate.id === 'inside-door'
? { ...candidate, amount: 0.52 } : candidate),
cellCm: 5, gridPitch: 20,
openingAmount: (candidate) => candidate.amount,
});
assert.equal(sameBucket.fingerprint, revision.fingerprint,
'leaf animation inside one quantised aperture bucket must reuse barriers');
const nextBucket = resolveLightBarrierRevision({
rawSpaceConfig: { id: 's', rooms: space.rooms }, space,
openings: openings.map((candidate) => candidate.id === 'inside-door'
? { ...candidate, amount: 0.8 } : candidate),
cellCm: 5, gridPitch: 20,
openingAmount: (candidate) => candidate.amount,
});
assert.notEqual(nextBucket.fingerprint, revision.fingerprint);
});
test('one shared source projection owns radius, colour and marker-stable identity', () => {
const device = {
id: 'lamp', name: 'Lamp', model: '', area: 'living', space: 's',
icon: 'mdi:lightbulb', entities: [], virtual: true,
marker: {
id: 'lamp', binding: 'virtual', is_light: true,
glow_radius_cm: 175, glow_color: { c: '#123456' },
},
};
const [candidate] = resolveGlowCandidates({
hass: { states: {} }, devices: [device], spaceId: 's',
defaultColor: '#ffffff', paletteAlpha: 0.7,
defaultRadiusUnits: 1200, cellCm: 5, gridPitch: 20,
position: () => ({ x: 25, y: 75 }),
});
assert.equal(candidate.key, 's|lamp');
assert.equal(candidate.sourceEid, '');
assert.deepEqual(candidate.pos, { x: 25, y: 75 });
assert.equal(candidate.radius, 700);
assert.equal(candidate.appearance.c, '#123456');
assert.ok(candidate.appearance.alpha > 0 && candidate.appearance.alpha <= 0.7);
});
test('shared Glow runtime is bounded and tears down every timer and source', () => {
let seq = 0;
const timers = new Set();
const rafs = new Set();
const fakeWindow = {
setTimeout: () => { const id = ++seq; timers.add(id); return id; },
clearTimeout: (id) => timers.delete(id),
requestAnimationFrame: () => { const id = ++seq; rafs.add(id); return id; },
cancelAnimationFrame: (id) => rafs.delete(id),
};
const host = {
window: () => fakeWindow,
isConnected: () => true,
requestUpdate: () => undefined,
reducedMotion: () => false,
};
const state = createGlowRuntimeState();
const entering = transitionGlowSource(state, host, 's|lamp', true);
assert.equal(entering.entering, true);
assert.equal(state.renderedSources.has('s|lamp'), true);
transitionGlowSource(state, host, 's|lamp', false);
assert.equal(state.fadeTimers.has('s|lamp'), true);
for (let index = 0; index < 300; index++) {
writeGlowClip(state, `clip-${index}`, { lit: [`M ${index} 0 Z`] }, 256);
}
assert.equal(state.clipCache.size, 256);
assert.equal(readGlowClip(state, 'clip-299').hit, true);
assert.equal(readGlowClip(state, 'clip-0').hit, false);
disposeGlowRuntime(state, host);
assert.equal(state.renderedSources.size, 0);
assert.equal(state.clipCache.size, 0);
assert.equal(state.fadeTimers.size, 0);
assert.equal(timers.size, 0);
assert.equal(rafs.size, 0);
});
test('space card exposes one explicit default-off visual-editor flag', () => {
const card = readFileSync(new URL('../src/space-card.ts', import.meta.url), 'utf8');
const editor = readFileSync(new URL('../src/space-editor.ts', import.meta.url), 'utf8');
assert.match(card, /light_pools\?: boolean/);
assert.match(card, /light_pools: false/);
assert.match(card, /lightPools: this\._config\.light_pools === true/);
assert.match(editor, /name: 'light_pools', selector: \{ boolean: \{\} \}/);
assert.match(editor, /light_pools: t\(L, 'editor\.light_pools'\)/);
});
+21 -14
View File
@@ -760,34 +760,41 @@ test('golden overrides fail closed on misspelled fixture references', () => {
test('a light source paints exactly one region: the floor it can see', () => {
const source = readHouseplanProductionSource();
const glow = readFileSync(new URL('../src/glow-scene.ts', import.meta.url), 'utf8');
// One region per source, and it is the visibility polygon clipped to floor.
assert.match(source, /visibilityPolygon\(\[pos\.x, pos\.y\], R, occluders/);
assert.match(source,
/lit: seen\.length >= 3\s*\? intersectionPaths\(\[seen\], floor, \{/);
assert.match(source, /onBoundsFailure: \(\{ boundIndex, phase \}\) =>/,
assert.match(glow,
/visibilityPolygon\(\s*\[input\.source\.x, input\.source\.y\], input\.radius/);
assert.match(glow,
/lit: seen\.length >= 3\s*\? intersectionPaths\(\[seen\], input\.scene\.floor, \{/);
assert.match(glow, /onBoundsFailure: \(\{ boundIndex, phase \}\) =>/,
'room-local boolean failures must be observable without restoring an unclipped fan');
assert.match(source, /<circle class="glow-pool"/);
assert.match(glow, /<circle class="glow-pool"/);
assert.match(source, /return renderGlowPools\(\{/,
'the full card must consume the shared pool renderer');
// No second layer of LIGHT may come back: a spill path or a shadow mask of
// its own is how the rendered result and the computed geometry got to
// disagree (#71, #73). The single blur below is the penumbra of the one
// region, measured in screen pixels and applied in one pass.
const layer = source.slice(source.indexOf('_renderGlowLayer'));
const glow = layer.slice(0, layer.indexOf('_renderAlignDialog'));
assert.doesNotMatch(glow, /glow-spill|glow-shadow|glow-blocker/);
// Exactly one blur, for the whole layer, sized in screen pixels.
assert.equal((glow.match(/<feGaussianBlur/g) || []).length, 1);
assert.doesNotMatch(glow, /<mask /);
assert.match(source, /const nextFeather = GLOW_EDGE_FEATHER_PX \/ 2 \/ \(perUnit > 0 \? perUnit : 1\)/);
assert.match(source, /const featherEnabled = !this\._pinchStart && !this\._panStart[\s\S]*_glowFeatherSuspendUntil/);
assert.match(source, /filter=\$\{featherEnabled \? 'url\(#hp-glowfeather\)' : nothing\}/,
assert.match(glow, /const next = GLOW_EDGE_FEATHER_PX \/ 2 \/ \(perUnit > 0 \? perUnit : 1\)/);
assert.match(source,
/resolveGlowFeather\(\s*this\._glowRuntimeState, perUnit, !this\._pinchStart && !this\._panStart/);
assert.match(glow,
/filter=\$\{input\.featherEnabled \? 'url\(#hp-glowfeather\)' : nothing\}/,
'the expensive whole-layer filter must be bypassed during a viewport gesture/transition');
// Barriers are keyed by their own content: `_cfgEpoch` lags behind geometry
// edited in place, and a stale barrier set lights straight through a wall.
assert.doesNotMatch(glow, /_cfgEpoch/);
assert.match(source, /const geometryFingerprint = contentFingerprint\(\[raw, this\._cellCm, this\._gridPitch\]\)/);
assert.match(source, /const fingerprint = contentFingerprint\(\[geometryFingerprint, openingStateSignature\]\)/);
assert.match(source, /const cacheKey = `\$\{space\.id\}\|\$\{fingerprint\}`/);
assert.match(source, /sharedFingerprint === geometryFingerprint/,
assert.match(glow,
/const geometryFingerprint = contentFingerprint\(\[\s*input\.rawSpaceConfig, input\.cellCm, input\.gridPitch/);
assert.match(glow,
/fingerprint: contentFingerprint\(\[geometryFingerprint, openingStateSignature\]\)/);
assert.match(glow,
/const cacheKey = `\$\{input\.space\.id\}\|\$\{revision\.fingerprint\}`/);
assert.match(glow, /sharedFingerprint === revision\.geometryFingerprint/,
'an in-place edit must never recut stale shared masonry');
});
+16 -4
View File
@@ -5,6 +5,7 @@ import {
assertCardContract,
GLOW_CARD_CONTRACT,
LARGE_HOUSE_CARD_CONTRACT,
SPACE_GLOW_CARD_CONTRACT,
} from '../demo/performance/card-contract.mjs';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
@@ -31,7 +32,10 @@ test('large-house benchmark declares every private card member it consumes', ()
});
test('Glow benchmark declares every private card member it consumes', () => {
const declared = declaredMembers(GLOW_CARD_CONTRACT);
const declared = new Set([
...declaredMembers(GLOW_CARD_CONTRACT),
...declaredMembers(SPACE_GLOW_CARD_CONTRACT),
]);
assert.deepEqual(
directCardMembers('demo/benchmark_glow.mjs').filter((name) => !declared.has(name)),
[],
@@ -46,6 +50,11 @@ test('performance contracts reference real production members', () => {
`${contract.label} declares missing production member ${name}`);
}
}
const staticSource = readFileSync(new URL('../src/space-card.ts', import.meta.url), 'utf8');
for (const name of declaredMembers(SPACE_GLOW_CARD_CONTRACT)) {
assert.match(staticSource, new RegExp(`\\b(?:private\\s+(?:(?:declare|readonly|get)\\s+)*|get\\s+)${name}\\b`),
`${SPACE_GLOW_CARD_CONTRACT.label} declares missing production member ${name}`);
}
});
test('contract accepts recent optional fields only when their runtime type is valid', () => {
@@ -133,10 +142,13 @@ test('wall and light geometry reuse bounded caches before structural work', () =
const light = source.slice(lightStart, lightEnd);
assert.ok(
light.indexOf('lruRead(this._lightBarrierPool, cacheKey)')
< light.indexOf('const zeroWalls = this._zeroWalls();'),
< light.indexOf('buildLightBarrierScene({'),
'a cached light barrier must return before geometry classification',
);
assert.match(light, /contentFingerprint\(\[/);
assert.match(light, /recutWallBodiesGeometry\(sharedWallGeometry, roomPassages, lightPhysical\)/);
assert.match(light, /resolveLightBarrierRevision\(\{/);
const shared = readFileSync(new URL('../src/glow-scene.ts', import.meta.url), 'utf8');
assert.match(shared, /contentFingerprint\(\[/);
assert.match(shared,
/recutWallBodiesGeometry\(input\.sharedWallGeometry, roomPassages, opaqueBodies\)/);
assert.match(light, /lruWrite\(this\._lightBarrierPool, cacheKey, entry, 8\)/);
});
+4 -1
View File
@@ -13,6 +13,7 @@ test('ordinary Validate keeps only the bounded candidate performance smoke', ()
'--variants=60 --samples=3 --warmups=1',
'--absolute-only',
'budgets-glow-smoke.json',
'budgets-space-glow-smoke.json',
'cancel-in-progress: true',
]) assert.ok(workflow.includes(contract), `missing fast-gate contract: ${contract}`);
assert.ok(!workflow.includes('Capture base and candidate profiles'));
@@ -34,13 +35,15 @@ test('full performance is isolated to stable, scheduled and manual entry points'
'- plan-snap',
'- blend',
'- overlay',
'- space-default',
'- space-glow',
'PROFILE: ${{ matrix.profile }}',
'name: full-performance-${{ matrix.profile }}',
'--samples=7 --warmups=1',
]) assert.ok(workflow.includes(contract), `missing full-gate contract: ${contract}`);
assert.ok(workflow.includes('if [ -f baseline/scripts/bundle-sync.mjs ]; then'));
assert.equal((workflow.match(/--samples=7 --warmups=1/g) || []).length, 10);
assert.equal((workflow.match(/--samples=7 --warmups=1/g) || []).length, 14);
const release = readWorkflow('release.yml');
assert.ok(release.includes('if: ${{ !github.event.release.prerelease }}'));