mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 22:49:16 +00:00
feat: LED strips — editor notes, docs, demo, smokes, golden scenes, profile (#780)
Stage 5 of #780. - Import summary: «Strips left unbound after import: {n}» from the backend `unbound_led_strips` count; «Optimize plans» reports strips passing through walls per space and edits none (AC16). - Linear field for long strips (ТЗ §13.2): pieces of at most the radius along the polyline, emitters thinned to r/4, each piece clipped to the visibility fans of its own emitters as separate clipPath children (no boolean pass per piece), one floor clip for the whole layer, no fan at all where nothing blocks within the radius; a grid index of body faces and boxed inside tests; unchanged fields skip re-diffing. 50×50 on the large house: first stable frame ~1.4 s, warm space ~1.1 s locally. - led-strips-v1 profile: demo/benchmark_led_strips.mjs with the derived large-house fixture (10×5, 50×50, none), absolute limits of the ТЗ table in demo/performance/budgets-led-strips.json, exact counters (zero recomputes on HA ticks/camera/colour, ≤50 cache entries, no growth over 20 cycles); added to the full performance workflow. - Bundle: LAZY_LED_GZIP_CEILING 10 KiB, LAZY_LED_EDITOR_GZIP_CEILING 11 KiB (measured + 10 %, rounded up); overlaps with the initial and editor graphs refused; the lazy editor graph stays inside its ceiling. - Smokes smoke_led_strip_draw/bind/glow, linked in smoke-links; 13 mutants in the registry (7 browser guards in the inventory); config field registry entry `spaces[].led_strips`. - Golden: five new scenes on the `golden-led` space of the visual fixture (`ledStrips` option, the designer's four strips on #868D94), matrix v71. - Docs: LIGHT, DEVICE-PRESENTATION, USER-GUIDE (en/ru), UX-MODES, ARCHITECTURE, ISOMETRIC, CONFIG-COMPATIBILITY, TOUCH-SUPPORT, demo/stand README, performance README; docs/design/led-strips with the unchanged designer archive, two paired frames and ACCEPTANCE.md; both changelogs. Issue: #780 User-Visible: yes
This commit is contained in:
@@ -192,6 +192,7 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
|
||||
'houseplan-assets/de-HASH.js', 'houseplan-assets/iso-scene-render-HASH.js',
|
||||
'houseplan-assets/furniture-plan-art.generated-HASH.js',
|
||||
'houseplan-assets/pdf-export-HASH.js', 'houseplan-assets/moon-runtime-HASH.js',
|
||||
'houseplan-assets/led-strip-runtime-HASH.js', 'houseplan-assets/led-strip-editor-HASH.js',
|
||||
],
|
||||
},
|
||||
'shared.js': {
|
||||
@@ -237,8 +238,27 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
|
||||
code: 'moon runtime', isEntry: false, imports: ['shared.js'], dynamicImports: [],
|
||||
modules: { '/repo/src/moon-runtime.ts': {}, '/repo/src/moon-art.generated.ts': {} },
|
||||
},
|
||||
// #780: the LED runtime loads its field chunk; the LED tool is a third lazy root.
|
||||
'houseplan-assets/led-strip-runtime-HASH.js': {
|
||||
type: 'chunk', fileName: 'houseplan-assets/led-strip-runtime-HASH.js',
|
||||
code: 'LED runtime', isEntry: false, imports: ['shared.js'],
|
||||
dynamicImports: ['houseplan-assets/led-strip-field-HASH.js'],
|
||||
modules: { '/repo/src/led-strip-runtime.ts': {} },
|
||||
},
|
||||
'houseplan-assets/led-strip-field-HASH.js': {
|
||||
type: 'chunk', fileName: 'houseplan-assets/led-strip-field-HASH.js',
|
||||
code: 'LED field', isEntry: false, imports: ['shared.js'], dynamicImports: [],
|
||||
modules: { '/repo/src/led-strip-field.ts': {} },
|
||||
},
|
||||
'houseplan-assets/led-strip-editor-HASH.js': {
|
||||
type: 'chunk', fileName: 'houseplan-assets/led-strip-editor-HASH.js',
|
||||
code: 'LED editor', isEntry: false, imports: ['shared.js'], dynamicImports: [],
|
||||
modules: { '/repo/src/led-strip-editor.ts': {} },
|
||||
},
|
||||
}, 'fingerprint');
|
||||
assert.equal(manifest.entry, 'houseplan-card.js');
|
||||
assert.deepEqual(manifest.lazyLedFiles, ['houseplan-assets/led-strip-field-HASH.js', 'houseplan-assets/led-strip-runtime-HASH.js']);
|
||||
assert.deepEqual(manifest.lazyLedEditorFiles, ['houseplan-assets/led-strip-editor-HASH.js']);
|
||||
assert.equal(manifest.panelEntry, 'houseplan-panel.js');
|
||||
assert.deepEqual(manifest.initialViewFiles, ['houseplan-card.js', 'shared.js']);
|
||||
assert.deepEqual(
|
||||
@@ -271,6 +291,8 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
|
||||
'houseplan-assets/furniture-plan-art.generated-HASH.js',
|
||||
'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
|
||||
'houseplan-assets/iso-scene-render-HASH.js',
|
||||
'houseplan-assets/led-strip-editor-HASH.js',
|
||||
'houseplan-assets/led-strip-runtime-HASH.js',
|
||||
'houseplan-assets/moon-runtime-HASH.js',
|
||||
'houseplan-assets/pdf-export-HASH.js',
|
||||
]);
|
||||
@@ -1018,6 +1040,7 @@ const runBudgetCli = (initialViewGzipBytes) => {
|
||||
].join('\n'));
|
||||
writeFileSync(join(dir, 'dist/onboarding.js'), english('settings'));
|
||||
writeFileSync(join(dir, 'dist/led-editor.js'), english('led'));
|
||||
writeFileSync(join(dir, 'dist/led.js'), 'lazy LED runtime');
|
||||
for (const entry of NAMESPACE_LOCALE_CHUNKS) {
|
||||
const marker = namespaceMarkers.find((candidate) => candidate.namespace === entry.namespace
|
||||
&& candidate.language === entry.language).text;
|
||||
@@ -1050,6 +1073,9 @@ const runBudgetCli = (initialViewGzipBytes) => {
|
||||
initialPanelOnlyGzipBytes: 1,
|
||||
lazyEditorFiles: ['editor.js'],
|
||||
lazyLedEditorFiles: ['led-editor.js'],
|
||||
lazyLedEditorGzipBytes: 100,
|
||||
lazyLedFiles: ['led.js'],
|
||||
lazyLedGzipBytes: 100,
|
||||
// #593: у ленивых графов теперь свои потолки, и фикстура обязана лежать
|
||||
// внутри полосы — иначе CLI краснеет не на том, что проверяет тест.
|
||||
lazyEditorGzipBytes: LAZY_EDITOR_GZIP_CEILING - 1_000,
|
||||
|
||||
@@ -446,7 +446,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
|
||||
assert.ok(scenario);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 70);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 71);
|
||||
assert.equal(space.settings.sun_rays, true);
|
||||
assert.equal(scenario.northDeg, 90,
|
||||
'the sign-sensitive golden must keep a non-zero north direction');
|
||||
@@ -940,7 +940,7 @@ test('issue 570 Stage 4 reuses the historical iso goldens for visual handoff cov
|
||||
|
||||
test('#673 Stage 6 designer acceptance scenes are canonical golden entries', () => {
|
||||
const ids = STAGE6_ACCEPTANCE_SCENARIOS.map((scenario) => scenario.id);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 70);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 71);
|
||||
assert.deepEqual(ids, [
|
||||
'isometric-stage6-light-lightfloor',
|
||||
'isometric-stage6-light-darkfloor',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ledAnchor, ledStripsByMarker } from '../test-build/led-strip-gate.js';
|
||||
import { faceContext, ledStripView } from '../test-build/led-strip-runtime.js';
|
||||
import { faceContext, ledFrame, ledStripView } from '../test-build/led-strip-runtime.js';
|
||||
import { LedFieldCache, buildFieldGeometry, falloffAt } from '../test-build/led-strip-field.js';
|
||||
import { GLOW_FALLOFF } from '../test-build/glow-scene.js';
|
||||
import { stripAnchor } from '../test-build/led-strip-geometry.js';
|
||||
@@ -91,7 +91,20 @@ test('ТЗ §6: every piece is clipped to what its own emitters see; a buried st
|
||||
const geometry = buildFieldGeometry({ points: [[1, 1], [9, 1]], radius: 2, scene, polygons, faces, spaceId: 's' });
|
||||
assert.ok(geometry, 'a free strip has a field');
|
||||
assert.equal(geometry.pieces.length, 4, 'an 8-unit segment with r = 2 makes four pieces');
|
||||
for (const piece of geometry.pieces) assert.ok(piece.clip.length > 0);
|
||||
for (const piece of geometry.pieces) assert.equal(piece.clip, null, 'nothing within r: the bands are the bound');
|
||||
// Passing 0.5 below the body: the pieces near it are clipped to their own fans.
|
||||
const near = buildFieldGeometry({ points: [[0.5, 3.5], [9.5, 3.5]], radius: 1, scene, polygons, faces, spaceId: 's' });
|
||||
const clipped = near.pieces.filter((piece) => piece.clip);
|
||||
assert.ok(clipped.length >= 2 && clipped.length < near.pieces.length, `${clipped.length} of ${near.pieces.length}`);
|
||||
for (const piece of clipped) {
|
||||
assert.ok(piece.clip.length > 0);
|
||||
// No fan vertex lies inside the body: light never passes into or through it.
|
||||
for (const d of piece.clip) {
|
||||
for (const [, x, y] of d.matchAll(/[ML]([-\d.e]+) ([-\d.e]+)/g)) {
|
||||
assert.ok(!(+x > 4 + 1e-6 && +x < 6 - 1e-6 && +y > 4 + 1e-6 && +y < 6 - 1e-6), `${x},${y}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const buried = buildFieldGeometry({ points: [[4.5, 5], [5.5, 5]], radius: 2, scene, polygons, faces, spaceId: 's' });
|
||||
assert.equal(buried, null, 'entirely inside the body: no field');
|
||||
});
|
||||
@@ -107,3 +120,19 @@ test('AC17: the field cache is bounded, per space, and counts geometry rebuilds'
|
||||
cache.forSpace('b');
|
||||
assert.equal(cache.size, 0, 'another space frees the previous one');
|
||||
});
|
||||
|
||||
test('AC9: the frame gives every strip the 50 cm default, not the shared radius; unbound strips have no view', () => {
|
||||
const lamp = { id: 'm1', name: 'Lamp', primary: 'light.led', entities: ['light.led'], space: 's', marker: { id: 'm1', binding: 'device:m1' } };
|
||||
const frame = ledFrame({
|
||||
space: { id: 's', vb: [0, 0, 1000, 1000], rooms: [], led_strips: [
|
||||
{ id: 'a', points: [[0.1, 0.1], [0.4, 0.1]], marker: 'm1' },
|
||||
{ id: 'b', points: [[0.1, 0.3], [0.4, 0.3]], marker: null },
|
||||
] },
|
||||
devices: [lamp],
|
||||
hass: { states: { 'light.led': { state: 'on', attributes: {} } } },
|
||||
defaultColor: '#ffd27b', paletteAlpha: 0.7, cellCm: 5, gridPitch: 1000 / 240, iconPct: 3.4,
|
||||
scene: null, polygons: [], glowFor: () => true, inRoom: () => false, showHidden: false,
|
||||
});
|
||||
assert.equal(frame.views.length, 1, 'the unbound strip is not a View strip');
|
||||
assert.ok(Math.abs(frame.views[0].radius - (50 / 5) * (1000 / 240)) < 1e-9, `radius ${frame.views[0].radius}`);
|
||||
});
|
||||
|
||||
@@ -139,7 +139,7 @@ test('#659 marker handlers preserve callouts, baselines and explicit user choice
|
||||
"? callout({ kind: 'warning', role: 'status', text: t('marker.run_target_gone', { id: d.tapTarget }) })",
|
||||
'&& (edit ? dirty : true);',
|
||||
'@click=${() => { void this._saveMarker(); }}',
|
||||
'if (edit && dirty && !await this.host._confirmDanger({',
|
||||
'if (!edit || !dirty) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); return; }',
|
||||
"${effectiveTapAction === 'run' || effectiveTapAction === 'toggle'",
|
||||
"markerglowblock ${glowSourceDisabled ? 'hpf-disabled' : ''}",
|
||||
'valueBadgePosition: position,\n valueBadgeTouched: true,',
|
||||
|
||||
@@ -90,6 +90,8 @@ function fakePort(overrides = {}) {
|
||||
clearGeometryGesture: () => { calls.gesture += 1; },
|
||||
commit: async (config, layout) => { calls.commit.push([config, layout]); },
|
||||
reloadAfterConflict: async () => { calls.reload += 1; },
|
||||
// #780: strips through walls come from the lazy LED tool; none in this fixture.
|
||||
ledNote: () => "",
|
||||
...overrides,
|
||||
};
|
||||
return { port, calls, state, dialog: new OptimizePlansDialog(port) };
|
||||
|
||||
@@ -49,13 +49,18 @@ test('full performance is isolated to stable, scheduled and manual entry points'
|
||||
'- overlay',
|
||||
'- space-default',
|
||||
'- space-glow',
|
||||
'- led-strips',
|
||||
'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, 20);
|
||||
// #780: led-strips-v1 runs three candidate-only sizes judged by absolute limits.
|
||||
assert.equal((workflow.match(/--samples=7 --warmups=1/g) || []).length, 23);
|
||||
for (const size of ['10x5', '50x50', 'none']) {
|
||||
assert.ok(workflow.includes(`npm run benchmark:led-strips -- --size=${size} --samples=7 --warmups=1`), size);
|
||||
}
|
||||
assert.equal((workflow.match(/--allow-stage2-base/g) || []).length, 1,
|
||||
'only the Stage 3 comparison base may bypass the candidate-only DOM contract');
|
||||
assert.ok(workflow.includes('budgets-isometric-stage3-dense.json'));
|
||||
@@ -281,3 +286,27 @@ test('#347: a rewritten before forces the full run instead of guessing the range
|
||||
assert.ok(!fallback.includes('cat-file'),
|
||||
'merge-base-фолбэк остаётся только для нулевого before — без повторной проверки существования');
|
||||
});
|
||||
|
||||
test('#780 led-strips-v1: the derived fixture converts devices without adding icons, the budget is the ТЗ table', async () => {
|
||||
const { makeLedStripsFixture } = await import('../demo/performance/led-strips-fixture.mjs');
|
||||
const { makeLargeHouseFixture } = await import('../demo/fixtures/large-house.mjs');
|
||||
const base = makeLargeHouseFixture();
|
||||
const led = makeLedStripsFixture(50, 50);
|
||||
assert.equal(Object.keys(led.devices).length, Object.keys(base.devices).length, 'no device added');
|
||||
assert.equal(Object.keys(led.layout).length, Object.keys(base.layout).length, 'no icon position added');
|
||||
for (const space of led.config.spaces) {
|
||||
assert.equal(space.led_strips.length, 50);
|
||||
for (const strip of space.led_strips) {
|
||||
assert.equal(strip.points.length, 50);
|
||||
assert.equal(led.states[`light.perf_led_${space.id.slice(-1) - 1}_${strip.id.split('-').pop()}`]?.state, 'on');
|
||||
assert.equal(led.config.markers.filter((marker) => marker.id === strip.marker).length, 1);
|
||||
assert.equal(led.config.markers.find((marker) => marker.id === strip.marker).space, space.id);
|
||||
}
|
||||
}
|
||||
const budgets = JSON.parse(readFileSync(new URL('../demo/performance/budgets-led-strips.json', import.meta.url), 'utf8'));
|
||||
assert.deepEqual(budgets.sizes['10x5'], { firstStableRenderMs: 3400, warmSpaceReadyMs: 1500, stateUpdateMs: 1000,
|
||||
panZoomMs: 500, panZoomLongTaskMaxMs: 150, retainedHeapBytes: 64 * 1024 * 1024 });
|
||||
assert.deepEqual(budgets.sizes['50x50'], { firstStableRenderMs: 5000, warmSpaceReadyMs: 1500, stateUpdateMs: 1500,
|
||||
panZoomMs: 500, panZoomLongTaskMaxMs: 150, retainedHeapBytes: 64 * 1024 * 1024 });
|
||||
assert.equal(budgets.cacheEntries, 50);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user