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:
Codex
2026-10-02 20:54:50 +03:00
parent ade3078d53
commit d515c42177
64 changed files with 2075 additions and 134 deletions
+12
View File
@@ -48,6 +48,7 @@ jobs:
- overlay
- space-default
- space-glow
- led-strips
steps:
- name: Check out candidate
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -175,6 +176,14 @@ jobs:
cp ../artifacts/performance/space-glow-candidate.json ../artifacts/performance/space-glow-baseline.json
fi
;;
led-strips)
# #780 led-strips-v1: base predates LED strips, so the profile is
# judged by its absolute limits inside the runner; the zero-LED
# View stays covered by the relative interaction profile above.
npm run benchmark:led-strips -- --size=10x5 --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-10x5.json
npm run benchmark:led-strips -- --size=50x50 --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-50x50.json
npm run benchmark:led-strips -- --size=none --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-none.json
;;
*)
echo "::error::Unknown performance profile: $PROFILE"
exit 1
@@ -217,6 +226,9 @@ jobs:
space-glow)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-glow.json --baseline=../artifacts/performance/space-glow-baseline.json --candidate=../artifacts/performance/space-glow-candidate.json --output=../artifacts/performance/space-glow-comparison.json
;;
led-strips)
echo "led-strips-v1: absolute limits and counters already enforced by the runner (budgets-led-strips.json)"
;;
esac
- name: Upload full performance report
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env node
/**
* #780 ТЗ §13.2: the `led-strips-v1` performance profile.
*
* The `large-house-v1` fixture (200 devices, 3 floors, 60 rooms, 100
* openings) with 10 or 50 of each floor's existing devices shown as LED
* strips that are on: `--size=10x5` (10 strips × 5 points) or `--size=50x50`
* (50 strips × 50 points); `--size=none` is the same build and plan without
* strips. No icon is added: the converted devices keep their ids, only their
* entity becomes a light. Light radius 50 cm (the strip default), Glow on,
* viewport 1440×1000, DPR 1, reduced motion. Every sample is cold: a new
* browser with an empty cache mounts a new card.
*
* Metrics (judged on median and p95 against budgets-led-strips.json):
* firstStableRenderMs (mount → stripes and fields of every visible strip),
* warmSpaceReadyMs (space switch with the module loaded → stable LED frame),
* stateUpdateMs (all strip sources switched → every stripe in its new state),
* panZoomMs and its longest Long Task, retained heap after 20 A→B→C→A cycles.
* Counters (must be exact): field geometry recomputes over 100 unrelated HA
* ticks, 100 pan/zoom steps and a colour-only change (0), cache entries ≤ 50
* and equal after every cycle, LED chunk requests (none without strips).
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { launch } from './serve.mjs';
import { makeLedStripsFixture } from './performance/led-strips-fixture.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
const size = valueArg('size') ?? '10x5';
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const SIZES = { '10x5': [10, 5], '50x50': [50, 50], none: [0, 0] };
if (!SIZES[size]) throw new Error(`unknown size: ${size}`);
const [STRIPS, POINTS] = SIZES[size];
const PROFILE = 'led-strips-v1';
const CYCLES = Math.max(1, Math.min(20, Number(valueArg('cycles')) || 20));
const fixture = makeLedStripsFixture(STRIPS, POINTS);
const sourceSha = (() => {
try { return execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { return null; }
})();
const budgets = JSON.parse(readFileSync(new URL('./performance/budgets-led-strips.json', import.meta.url), 'utf8'));
const rows = [];
let chromium = null;
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const { page, browser } = await launch({ width: 1440, height: 1000 }, 1,
['--enable-precise-memory-info', '--js-flags=--expose-gc']);
chromium ??= await browser.version();
await page.emulateMedia({ reducedMotion: 'reduce' });
const requests = [];
page.on('request', (request) => {
const name = request.url().replace(/.*\//, '').replace(/\?.*$/, '');
if (/^led-strip-(runtime|field|editor)-/.test(name)) requests.push(name.replace(/-[^-]+\.js$/, ''));
});
try {
const row = await page.evaluate(async ({ fixture, strips, cycles }) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
const until = async (predicate, timeout = 20000) => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error('led-strips-v1 timed out');
await sleep(5);
}
};
const longTasks = () => {
const entries = [];
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
observer.observe({ type: 'longtask', buffered: false });
return async () => {
await sleep(0);
entries.push(...observer.takeRecords());
observer.disconnect();
return Number(Math.max(0, ...entries.map((entry) => entry.duration)).toFixed(2));
};
};
const gc = async () => { if (globalThis.gc) { globalThis.gc(); await frame(); globalThis.gc(); await frame(); } };
window.__card?.remove?.();
localStorage.clear();
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', title: 'LED strips', icon_size: 3.4 });
let states = fixture.states;
const hassBase = {
language: 'en', locale: { language: 'en' },
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas,
callWS: async (message) => {
if (message.type === 'houseplan/config/get') return { config: structuredClone(fixture.config), rev: 1, can_write: true };
if (message.type === 'houseplan/layout/get') return { layout: structuredClone(fixture.layout), rev: 1 };
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
return { ok: true };
},
callService: async () => undefined,
connection: { subscribeEvents: async () => () => undefined, subscribeMessage: async () => () => undefined },
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
};
const setStates = (next) => { states = next; card.hass = { ...hassBase, states }; };
const root = () => card.renderRoot;
const count = (selector) => root()?.querySelectorAll(selector).length ?? 0;
const ledStable = (state = 'on') => count(`[data-led-strip][data-state="${state}"]`) === strips
&& (state !== 'on' || count('.led-fields [data-led-field]') === strips);
const cache = () => {
const group = root().querySelector('.led-fields');
return group ? { size: Number(group.dataset.ledCache), recomputes: Number(group.dataset.ledRecomputes) } : null;
};
const started = performance.now();
document.getElementById('host').replaceChildren(card);
setStates(states);
await until(() => card._loadOk && card._model?.length === 3);
if (strips) await until(() => ledStable());
else { await until(() => card._booting === false); await frame(); }
await frame();
const firstStableRenderMs = Number((performance.now() - started).toFixed(2));
const warm = performance.now();
card._pickSpace('perf-floor-2');
await card.updateComplete;
if (strips) await until(() => ledStable());
await frame();
const warmSpaceReadyMs = Number((performance.now() - warm).toFixed(2));
const floorLights = Object.keys(states).filter((id) => id.startsWith('light.perf_led_1_'));
const stateStarted = performance.now();
setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, { ...states[id], state: 'off' }])) });
await card.updateComplete;
if (strips) await until(() => ledStable('off'));
await frame();
const stateUpdateMs = Number((performance.now() - stateStarted).toFixed(2));
setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, { ...states[id], state: 'on' }])) });
await card.updateComplete;
if (strips) await until(() => ledStable());
await frame();
const before = strips ? cache() : null;
// 100 unrelated HA ticks.
const sensor = Object.keys(states).find((id) => id.startsWith('sensor.'));
for (let tick = 0; tick < 100; tick++) {
setStates({ ...states, [sensor]: { ...states[sensor], state: String(20 + (tick % 10) / 10) } });
await card.updateComplete;
}
await frame();
const afterTicks = strips ? cache() : null;
// Pan/zoom: the camera scenario, then 100 steps for the counter.
const stage = root().querySelector('.stage');
const rect = stage.getBoundingClientRect();
const wheel = (deltaY) => stage.dispatchEvent(new WheelEvent('wheel', {
deltaY, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, bubbles: true, cancelable: true,
}));
const stopPan = longTasks();
const panStarted = performance.now();
wheel(-120);
await card.updateComplete;
await frame();
const panZoomMs = Number((performance.now() - panStarted).toFixed(2));
const panZoomLongTaskMaxMs = await stopPan();
// 100 more camera steps for the counters only (not a timing window).
for (let step = 0; step < 100; step++) {
wheel(step % 2 ? 60 : -60);
await card.updateComplete;
}
await frame();
const afterCamera = strips ? cache() : null;
// A colour-only change rebuilds no geometry or visibility.
setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id,
{ ...states[id], attributes: { ...states[id].attributes, rgb_color: [255, 120, 80] } }])) });
await card.updateComplete;
await frame();
const afterColour = strips ? cache() : null;
// 20 cycles A→B→C→A after one warm cycle; cache size equal after each.
const cycle = async () => {
for (const id of ['perf-floor-1', 'perf-floor-2', 'perf-floor-3', 'perf-floor-1']) {
card._pickSpace(id);
await card.updateComplete;
if (strips) await until(() => ledStable());
}
await frame();
return strips ? cache().size : 0;
};
await cycle();
await gc();
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
const sizes = [];
for (let index = 0; index < cycles; index++) sizes.push(await cycle());
await gc();
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
card.remove();
await frame();
return {
firstStableRenderMs, warmSpaceReadyMs, stateUpdateMs, panZoomMs, panZoomLongTaskMaxMs,
retainedHeapBytes: heapBefore == null || heapAfter == null ? null : Math.max(0, heapAfter - heapBefore),
counters: strips ? {
recomputesOnHaTicks: afterTicks.recomputes - before.recomputes,
recomputesOnCamera: afterCamera.recomputes - afterTicks.recomputes,
recomputesOnColour: afterColour.recomputes - afterCamera.recomputes,
cacheEntries: Math.max(...sizes),
cacheGrowthOverCycles: Math.max(...sizes) - Math.min(...sizes),
} : null,
};
}, { fixture, strips: STRIPS, cycles: CYCLES });
row.ledRequests = [...new Set(requests)].sort();
if (iteration >= warmups) rows.push(row);
} finally {
await browser.close();
}
}
const metric = (name) => {
const values = rows.map((row) => row[name]).filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
const at = (q) => values[Math.min(values.length - 1, Math.ceil(q * values.length) - 1)];
return { median: values.length ? at(0.5) : null, p95: values.length ? at(0.95) : null, samples: values };
};
const METRICS = ['firstStableRenderMs', 'warmSpaceReadyMs', 'stateUpdateMs', 'panZoomMs', 'panZoomLongTaskMaxMs', 'retainedHeapBytes'];
const report = {
profile: PROFILE, size, strips: STRIPS, points: POINTS, sourceSha, chromium,
samples, warmups, cycles: CYCLES, viewport: { width: 1440, height: 1000 }, dpr: 1,
metrics: Object.fromEntries(METRICS.map((name) => [name, metric(name)])),
counters: rows[0]?.counters ?? null,
counterSamples: rows.map((row) => row.counters),
ledRequests: [...new Set(rows.flatMap((row) => row.ledRequests))].sort(),
};
const failures = [];
const limits = budgets.sizes[size] || {};
for (const [name, limit] of Object.entries(limits)) {
for (const stat of ['median', 'p95']) {
const value = report.metrics[name]?.[stat];
if (value == null) { if (name !== 'retainedHeapBytes') failures.push(`${name} ${stat} missing`); continue; }
if (value > limit) failures.push(`${name} ${stat} ${value} > ${limit}`);
}
}
if (STRIPS) {
for (const counters of report.counterSamples) {
for (const key of ['recomputesOnHaTicks', 'recomputesOnCamera', 'recomputesOnColour', 'cacheGrowthOverCycles']) {
if (counters[key] !== 0) failures.push(`${key} = ${counters[key]}, expected 0`);
}
if (counters.cacheEntries > budgets.cacheEntries) failures.push(`cache entries ${counters.cacheEntries} > ${budgets.cacheEntries}`);
}
if (!report.ledRequests.includes('led-strip-runtime') || !report.ledRequests.includes('led-strip-field'))
failures.push(`LED chunks not loaded: ${report.ledRequests.join(', ')}`);
} else if (report.ledRequests.length) failures.push(`no strips but LED chunks requested: ${report.ledRequests.join(', ')}`);
if (report.ledRequests.includes('led-strip-editor')) failures.push('the View loaded the LED editor chunk');
report.failures = failures;
const text = `${JSON.stringify(report, null, 2)}\n`;
if (output) { mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, text); }
console.log(text);
if (failures.length) { console.error(`led-strips-v1 ${size}: ${failures.length} failure(s)`); process.exit(1); }
+66 -4
View File
@@ -136,7 +136,53 @@ const applianceSpace = {
decor: [],
};
const runtime = (includeAppliance = false) => {
// #780: the LED strip reference scene — the designer's four strips on a
// synthetic plan with the reference floor colour #868D94: a free straight
// strip, a strip on the inner face of the bottom wall, a polyline along the
// wall faces around a corner and a closed rectangle. The colours are scene
// values of the four light sources, not a product palette.
const ledRooms = [
{ id: 'led-living', name: 'LED living', area: 'golden_led_living',
poly: [[0.06, 0.08], [0.56, 0.08], [0.56, 0.62], [0.06, 0.62]] },
{ id: 'led-studio', name: 'LED studio', area: 'golden_led_studio',
poly: [[0.56, 0.08], [0.94, 0.08], [0.94, 0.62], [0.56, 0.62]] },
];
// Inner faces of the 20 cm walls (half thickness = 10 cm = 2 cells of 1/240).
const LED_FACE = 2 / 240;
export const LED_REFERENCE_STRIPS = Object.freeze([
{ id: 'led-free', marker: 'demo-led-strip', color: [128, 213, 255],
points: [[0.14, 0.26], [0.44, 0.26]] },
{ id: 'led-wall', marker: 'demo-led-strip-wall', color: [230, 128, 255],
points: [[0.12, round(0.62 - LED_FACE)], [0.50, round(0.62 - LED_FACE)]] },
{ id: 'led-corner', marker: 'demo-led-strip-corner', color: [255, 234, 128],
points: [[0.64, round(0.08 + LED_FACE)], [round(0.94 - LED_FACE), round(0.08 + LED_FACE)],
[round(0.94 - LED_FACE), 0.42]] },
{ id: 'led-loop', marker: 'demo-led-strip-loop', color: [88, 255, 88],
points: [[0.66, 0.30], [0.84, 0.30], [0.84, 0.52], [0.66, 0.52], [0.66, 0.30]] },
]);
const ledSpace = {
id: 'golden-led',
title: 'LED strips',
plan_url: null,
view_box: [0, 0, 1, 0.7],
cell_cm: 5,
settings: {
fill_mode: 'custom', custom_fill: { c: '#868D94', a: 1 }, glow_enabled: true,
show_borders: true, show_names: false, sun_rays: false, bg_mode: 'static',
},
rooms: ledRooms,
walls: wallsFor('led', ledRooms, 20),
openings: [
{ id: 'led-door', type: 'door', x: 0.56, y: 0.53, angle: 90, length: 0.10 },
],
partitions: [],
wall_columns: [],
decor: [],
led_strips: LED_REFERENCE_STRIPS.map(({ id, marker, points }) => ({ id, marker, points })),
};
const runtime = (includeAppliance = false, includeLed = false) => {
const devices = {};
const entities = {};
const states = {
@@ -199,6 +245,15 @@ const runtime = (includeAppliance = false) => {
unit_of_measurement: 'lqi',
});
if (includeLed) {
for (const room of ledRooms) areas[room.area] = { area_id: room.area, name: room.name };
for (const strip of LED_REFERENCE_STRIPS) {
add(strip.marker, 'light', ledRooms[strip.id === 'led-free' || strip.id === 'led-wall' ? 0 : 1].area,
0.5, 0.5, 'on', { rgb_color: strip.color, brightness: 255 });
layout[strip.marker] = { s: 'golden-led', x: 0.5, y: 0.35 };
}
}
if (includeAppliance) {
const washerId = 'golden-washer';
devices[washerId] = {
@@ -247,16 +302,23 @@ export const VISUAL_MATRIX_COUNTS = Object.freeze({
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
});
export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) => ({
export const makeVisualMatrixFixture = ({ applianceLifecycle = false, ledStrips = false } = {}) => ({
config: {
spaces: [
structuredClone(geometrySpace), structuredClone(lightingSpace),
...(applianceLifecycle ? [structuredClone(applianceSpace)] : []),
...(ledStrips ? [structuredClone(ledSpace)] : []),
],
// A persisted marker is part of the fixture contract for scenarios that
// override per-source Glow controls. The device/layout alone are not a
// saved marker configuration and must not be silently treated as one.
markers: [{ id: 'golden-light-two', binding: 'device:golden-light-two' }],
markers: [
{ id: 'golden-light-two', binding: 'device:golden-light-two' },
// #780: a strip link writes the marker's explicit space (ТЗ §5).
...(ledStrips ? LED_REFERENCE_STRIPS.map(({ marker }) => ({
id: marker, binding: `device:${marker}`, space: 'golden-led',
})) : []),
],
settings: {
glow_radius_cm: 360,
north_deg: 0,
@@ -269,7 +331,7 @@ export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) =>
},
},
},
...runtime(applianceLifecycle),
...runtime(applianceLifecycle, ledStrips),
counts: applianceLifecycle ? {
...VISUAL_MATRIX_COUNTS,
spaces: VISUAL_MATRIX_COUNTS.spaces + 1,
+3 -1
View File
@@ -43,7 +43,9 @@ const VERSION_RELOAD_ATTEMPT_KEY = 'houseplan-card:version-reload-target:v1';
const fixtureFor = (scenario) => scenario.fixture === 'large'
? makeLargeHouseFixture({ includeStairs: false })
: makeVisualMatrixFixture({ applianceLifecycle: !!scenario.applianceLifecycle });
: makeVisualMatrixFixture({
applianceLifecycle: !!scenario.applianceLifecycle, ledStrips: !!scenario.ledStrips,
});
const themeVars = {
dark: {
+20 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 70;
export const GOLDEN_MATRIX_VERSION = 71;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -266,6 +266,8 @@ export const STAGE6_ACCEPTANCE_SCENARIOS = Object.freeze([
},
]);
const LED_STRIPS_OFF = Object.fromEntries(['light.demo_led_strip', 'light.demo_led_strip_wall', 'light.demo_led_strip_corner', 'light.demo_led_strip_loop'].map((id) => [id, { state: 'off' }]));
export const GOLDEN_SCENARIOS = Object.freeze([
...STAGE6_ACCEPTANCE_SCENARIOS,
// #663 AC3/AC10/AC12: both stair geometries, both rise directions and
@@ -1314,4 +1316,21 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'card-editor-invalid-default-floor-dark-ru', fixture: 'visual',
cardEditorInvalidDefaultFloor: 'removed-floor', language: 'ru', theme: 'dark',
viewport: { width: 900, height: 760 }, ...page },
// #780: LED strips. The two design references keep all four strips of the
// designer's composition at once (free, wall face, corner polyline, closed
// loop) on the reference floor #868D94; the others pin the dark Glow field,
// the off stripe in the light theme and the raised 2.5D stripe.
{ id: 'led-strip-design-reference-off-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', stateOverrides: LED_STRIPS_OFF, language: 'en', theme: 'light',
viewport: { width: 1000, height: 760 }, ...stage },
{ id: 'led-strip-design-reference-on-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 }, ...stage },
{ id: 'lighting-led-strip-glow-dark', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', fillMode: 'none', language: 'en', theme: 'dark', viewport: { width: 1000, height: 760 }, ...stage },
{ id: 'led-strip-off-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', fillMode: 'none', stateOverrides: LED_STRIPS_OFF, language: 'en', theme: 'light',
viewport: { width: 1000, height: 760 }, ...stage },
{ id: 'iso-led-strip-dark', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', projection: 'iso', fillMode: 'none', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 760 }, ...stage },
]);
+23
View File
@@ -424,3 +424,26 @@ they must be reviewed against the first paired Ubuntu artifacts before the
feature is promoted from beta. Same-runner relative checks in the full workflow
remain the primary regression signal; the candidate-only smoke only guards
against catastrophic failures.
## `led-strips-v1` (#780)
`demo/benchmark_led_strips.mjs` (`npm run benchmark:led-strips -- --size=…`)
measures the LED strips on the `large-house-v1` fixture: on every floor 10 or 50
existing devices become lights shown as strips that are on — `10x5` (10 strips
× 5 points) or `50x50` — without adding a device or an icon
(`performance/led-strips-fixture.mjs`); `none` is the same build and plan
without strips. Glow on, strip radius 50 cm, viewport 1440×1000, DPR 1,
reduced motion; every sample is cold (a new browser and card), seven samples
after one warm-up. The runner fails on its own against
`budgets-led-strips.json` — the ТЗ table, median and p95: `firstStableRenderMs`
3400/5000, `warmSpaceReadyMs` 1500, `stateUpdateMs` 1000/1500, `panZoomMs` 500,
the longest pan/zoom Long Task 150 ms, retained heap after 20 A→B→C→A cycles
64 MiB — and on exact counters: zero field geometry recomputes over 100
unrelated HA ticks, 100 camera steps and a colour-only change, at most 50 cache
entries, no cache growth over the cycles, the LED chunks loaded with strips
and none without them, never the editor chunk in the View. Base predates the
strips, so there is no relative comparison; the zero-LED View remains judged
by the relative `large-house-interaction-v1` profile. Exact-SHA Linux output
of the full performance workflow (`led-strips` matrix entry) is the gate
evidence; a local report is diagnostic.
+25
View File
@@ -0,0 +1,25 @@
{
"profile": "led-strips-v1",
"issue": 780,
"note": "ТЗ #780 §13.2: absolute limits applied to median and p95; 10x5 rests on the interaction profile's absolute ceilings, 50x50 are the acceptance bounds set in the ТЗ. The no-strip control is judged by the existing interaction profile against base.",
"cacheEntries": 50,
"sizes": {
"10x5": {
"firstStableRenderMs": 3400,
"warmSpaceReadyMs": 1500,
"stateUpdateMs": 1000,
"panZoomMs": 500,
"panZoomLongTaskMaxMs": 150,
"retainedHeapBytes": 67108864
},
"50x50": {
"firstStableRenderMs": 5000,
"warmSpaceReadyMs": 1500,
"stateUpdateMs": 1500,
"panZoomMs": 500,
"panZoomLongTaskMaxMs": 150,
"retainedHeapBytes": 67108864
},
"none": {}
}
}
+44
View File
@@ -0,0 +1,44 @@
/**
* #780 `led-strips-v1`: the `large-house-v1` fixture with N existing devices
* of every floor shown as LED strips of K points that are on. No device is
* added: a converted device keeps its id and layout; its entity becomes a
* light (`light.perf_led_<floor>_<k>`) and its marker gets the strip's space.
*/
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
const round = (value) => Number(value.toFixed(6));
export function makeLedStripsFixture(strips, points) {
const fixture = makeLargeHouseFixture();
if (!strips) return fixture;
const floors = fixture.config.spaces;
for (const [floorIndex, space] of floors.entries()) {
const deviceIds = Object.keys(fixture.layout).filter((id) => fixture.layout[id].s === space.id).slice(0, strips);
if (deviceIds.length < strips) throw new Error(`${space.id} has only ${deviceIds.length} devices`);
space.led_strips = deviceIds.map((deviceId, k) => {
const at = fixture.layout[deviceId];
const w = 0.05, h = 0.016;
const pts = Array.from({ length: points }, (_, i) => [
round(Math.min(0.995, Math.max(0.005, at.x - w / 2 + (w * i) / (points - 1)))),
round(Math.min(0.995, Math.max(0.005, at.y + (i % 2 ? h : -h) / 2 + (k % 3) * 0.004))),
]);
return { id: `perf-led-${floorIndex}-${k}`, points: pts, marker: deviceId };
});
for (const [k, deviceId] of deviceIds.entries()) {
for (const [entityId, entity] of Object.entries(fixture.entities)) {
if (entity.device_id !== deviceId) continue;
delete fixture.entities[entityId];
delete fixture.states[entityId];
}
const entityId = `light.perf_led_${floorIndex}_${k}`;
fixture.entities[entityId] = { entity_id: entityId, device_id: deviceId, platform: 'houseplan_perf',
config_entry_id: 'perf_entry', disabled_by: null };
fixture.states[entityId] = { entity_id: entityId, state: 'on',
attributes: { friendly_name: `LED ${floorIndex}.${k}`, rgb_color: [128, 213, 255], brightness: 255 } };
fixture.config.markers = fixture.config.markers.filter((marker) => marker.id !== deviceId);
fixture.config.markers.push({ id: deviceId, binding: `device:${deviceId}`, space: space.id, is_light: true });
}
}
return fixture;
}
+138
View File
@@ -0,0 +1,138 @@
/**
* #780 AC6/AC19/AC20 (ТЗ §5): binding and the «icon ↔ LED strip» switch.
* The picker binds a light (lights first): the live marker is created with
* the strip's explicit space, the ordinary icon disappears and comes back on
* «Show as icon», whose shape stays hidden and is restored exactly by «Show as
* LED strip» — no drawing, no new record, no HA service. An ordinary icon
* clicked while a strip is selected opens its own dialog without the LED tray.
* Deleting the bound marker leaves an unbound strip. A failed write rolls back
* and records no command; Undo/Redo of the switch is one command each.
*/
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 820 }, 1);
const evaluate = (fn, arg) => page.evaluate(fn, arg);
await evaluate(async () => {
const c = window.__card;
window.__ledSaves = 0; window.__ledFail = false; window.__ledServices = [];
const orig = c.hass.callWS;
c.hass.callWS = async (m) => {
if (m.type === 'houseplan/config/set') {
if (window.__ledFail) throw new Error('offline');
window.__ledSaves++;
return { ok: true, rev: (c._cfgRev || 0) + 1 };
}
return orig.call(c.hass, m);
};
const service = c.hass.callService;
c.hass.callService = async (...args) => { window.__ledServices.push(args.slice(0, 2).join('.')); return service(...args); };
// One unbound strip in the Living room of the demo floor.
c._serverCfg.spaces[0].led_strips = [{ id: 'led-a', points: [[0.12, 0.22], [0.40, 0.22], [0.40, 0.40]], marker: null }];
c._modelCache = null; c._frame = null; c._cfgEpoch++;
c._setMode('devices'); await c.updateComplete;
});
await page.waitForTimeout(800);
check('an editable strip loads the tool for the Devices editor', await evaluate(() => !!window.__card._ledEditor));
const icon = (id) => evaluate((id) => !!window.__card.shadowRoot.querySelector(`.dev[data-id="${id}"], [data-hp="device"][data-id="${id}"]:not(.led-hit)`), id);
check('the light starts as an ordinary icon', await icon('d_bedlight'));
await evaluate(() => { window.__card._ledEditor.select('led-a'); window.__card._ledEditor.picker = 'led-a'; window.__card.requestUpdate(); });
await page.waitForTimeout(200);
check('lights first in the picker', await evaluate(() => {
const picks = [...window.__card.shadowRoot.querySelectorAll('[data-led-pick]')].map((b) => b.dataset.ledPick);
const firstOther = picks.findIndex((id) => !['d_light1', 'd_lamp', 'd_bedlight'].includes(id));
return picks.slice(0, 3).every((id) => ['d_light1', 'd_lamp', 'd_bedlight'].includes(id)) && firstOther === 3;
}));
await page.click('[data-led-pick="d_bedlight"]');
await page.waitForTimeout(500);
const bound = await evaluate(() => {
const c = window.__card;
return { strip: c._serverCfg.spaces[0].led_strips[0], marker: c._serverCfg.markers.find((m) => m.id === 'd_bedlight') };
});
check('bound to the light', bound.strip.marker, 'd_bedlight');
check('the live marker carries the explicit strip space', bound.marker?.space, 'f1');
check('the ordinary icon is gone', await icon('d_bedlight'), false);
check('the device keeps its catalogue place', await evaluate(() => window.__card._devices.some((d) => d.id === 'd_bedlight' && d.space === 'f1')));
// An ordinary icon while the strip is selected: its own dialog, no LED tray.
await evaluate(() => window.__card._ledEditor.select('led-a'));
await page.waitForTimeout(100);
await evaluate(() => window.__card._editorRuntime._openMarkerDialog(window.__card._devices.find((d) => d.id === 'd_lamp')));
await page.waitForTimeout(200);
check('an icon dialog drops the strip selection', await evaluate(() => window.__card._ledEditor.sel), null);
await evaluate(() => window.__card._editorRuntime._closeMarkerDialog());
await page.waitForTimeout(150);
check('closing that dialog shows no LED tray', await evaluate(() => !/Delete strip/.test(window.__card.shadowRoot.querySelector('.editor-secondary')?.textContent || '')));
// Tray: «Show as icon» hides the shape and returns the icon.
await evaluate(() => window.__card._ledEditor.select('led-a'));
await page.waitForTimeout(150);
await page.click('[data-led-action="icon"]');
await page.waitForTimeout(500);
check('«Show as icon» keeps the exact shape hidden', await evaluate(() => {
const s = window.__card._serverCfg.spaces[0].led_strips[0];
return s.active === false && s.marker === 'd_bedlight' && JSON.stringify(s.points) === JSON.stringify([[0.12, 0.22], [0.4, 0.22], [0.4, 0.4]]);
}));
check('the icon is back and nothing of the strip is drawn', await icon('d_bedlight')
&& await evaluate(() => !window.__card.shadowRoot.querySelector('[data-led-strip], [data-led-select]')));
check('the switch is one command', await evaluate(() => window.__card._devicePositionHistory.undoName), 'Device representation');
await page.keyboard.press('Control+z'); await page.waitForTimeout(500);
check('Undo shows the strip again', await evaluate(() => window.__card._serverCfg.spaces[0].led_strips[0].active !== false));
await page.keyboard.press('Control+Shift+z'); await page.waitForTimeout(500);
check('Redo hides it again', await evaluate(() => window.__card._serverCfg.spaces[0].led_strips[0].active), false);
// Device dialog: «Show as LED strip» restores the hidden shape at once.
await evaluate(() => window.__card._editorRuntime._openMarkerDialog(window.__card._devices.find((d) => d.id === 'd_bedlight')));
await page.waitForTimeout(300);
check('the dialog offers unbind/delete for a hidden shape', await evaluate(() => [...window.__card.shadowRoot
.querySelectorAll('[data-led-representation] [data-led-action]')].map((b) => b.dataset.ledAction).join()), 'show-strip,unbind,delete');
await page.click('[data-led-representation] [data-led-action="show-strip"]');
await page.waitForTimeout(500);
check('restored without drawing, same record', await evaluate(() => {
const strips = window.__card._serverCfg.spaces[0].led_strips;
return strips.length === 1 && strips[0].id === 'led-a' && strips[0].active === true && !window.__card._ledEditor.tool;
}));
check('no HA service on any representation change', await evaluate(() => window.__ledServices.length), 0);
// A failed write rolls back and records no command.
const undoBefore = await evaluate(() => window.__card._devicePositionHistory.undoName);
await evaluate(() => { window.__ledFail = true; });
await evaluate(() => window.__card._ledEditor.setActive('led-a', false, 'f1'));
await page.waitForTimeout(300);
check('failed write: state unchanged', await evaluate(() => window.__card._serverCfg.spaces[0].led_strips[0].active), true);
check('failed write: no new command', await evaluate(() => window.__card._devicePositionHistory.undoName), undoBefore);
await evaluate(() => { window.__ledFail = false; });
// Converting a plain icon by drawing: one write, the same marker.
await evaluate(() => window.__card._editorRuntime._openMarkerDialog(window.__card._devices.find((d) => d.id === 'd_lamp')));
await page.waitForTimeout(300);
await page.click('[data-led-representation] [data-led-action="show-strip"]');
await page.waitForTimeout(400);
const toScreen = (x, y) => evaluate(([x, y]) => {
const c = window.__card; const r = c.shadowRoot.querySelector('.stage').getBoundingClientRect();
const a = c._screenToVb(0, 0), b = c._screenToVb(1000, 1000);
return [r.left + (x - a[0]) / (b[0] - a[0]) * 1000, r.top + (y - a[1]) / (b[1] - a[1]) * 1000];
}, [x, y]);
for (const [x, y] of [[150, 480], [380, 480]]) { const [sx, sy] = await toScreen(x, y); await page.mouse.click(sx, sy); await page.waitForTimeout(80); }
await page.keyboard.press('Escape'); await page.waitForTimeout(500);
check('drawing converted the lamp in one write, no picker', await evaluate(() => {
const c = window.__card; const strip = c._serverCfg.spaces[0].led_strips.find((s) => s.marker === 'd_lamp');
return !!strip && strip.active === true && !c._ledEditor.picker && c._serverCfg.markers.filter((m) => m.id === 'd_lamp').length === 1;
}));
// Deleting the bound marker leaves an unbound strip with its geometry.
await evaluate(async () => {
const c = window.__card;
c._confirmDanger = async () => true;
c._editorRuntime._openMarkerDialog(c._devices.find((d) => d.id === 'd_bedlight'));
await c.updateComplete;
await c._editorRuntime._deleteMarker();
});
await page.waitForTimeout(500);
check('deleting the marker unbinds the strip in the same write', await evaluate(() => {
const s = window.__card._serverCfg.spaces[0].led_strips.find((x) => x.id === 'led-a');
return s.marker === null && s.active === true && s.points.length === 3;
}));
await finish(browser);
+171
View File
@@ -0,0 +1,171 @@
/**
* #780 AC3/AC4/AC5/AC20 (ТЗ §4, §6): the LED strip tool of the Devices editor.
* Clean clicks draw a chain; a mouse pan, a second finger and a cancelled
* pointer add nothing; Ctrl+Z removes the chain's own point; Esc finishes and
* opens the device picker; a click on the first point closes the loop; leaving
* the tool finishes, never loses the chain. A new segment stops at the first
* face of a thick wall, a fast vertex drag cannot jump it. The selection lives
* in the session only, its tray closes on Esc and on a clean background click.
* Plan/Background have no LED tool, handles or targets.
*/
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 820 }, 1);
const evaluate = (fn, arg) => page.evaluate(fn, arg);
await evaluate(async () => {
const c = window.__card;
window.__ledSaves = [];
const orig = c.hass.callWS;
c.hass.callWS = async (m) => {
if (m.type === 'houseplan/config/set') {
window.__ledSaves.push(JSON.parse(JSON.stringify(m.config)));
return { ok: true, rev: (c._cfgRev || 0) + 1 };
}
return orig.call(c.hass, m);
};
// A thick partition across a plain room: the placement body of AC5.
c._serverCfg = { model_version: 10, spaces: [{ id: 'led', title: 'LED', cell_cm: 5, view_box: [0, 0, 1, 0.7],
rooms: [{ id: 'room', name: 'Room', area: null, poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.6], [0.1, 0.6]] }],
wall_segments: [], partitions: [{ id: 'p1', a: [0.5, 0.15], b: [0.5, 0.55], cm: 40 }] }], markers: [], settings: {} };
c._layout = {}; c._space = 'led'; c._modelCache = null; c._frame = null; c._cfgEpoch++;
c._setMode('plan'); await c.updateComplete;
});
await page.waitForTimeout(300);
check('plan editor has no LED tool', await evaluate(() => !window.__card.shadowRoot.querySelector('[data-tool="led-strip"]')));
await evaluate(async () => { window.__card._setMode('devices'); await window.__card.updateComplete; });
await page.waitForTimeout(300);
check('devices editor has the LED tool next to Add', await evaluate(() => {
const tools = [...window.__card.shadowRoot.querySelectorAll('.devbar [data-hp="tool"]')].map((b) => b.dataset.tool);
return tools.indexOf('led-strip') === tools.indexOf('add-device') + 1;
}));
check('no LED chunk before the tool', await evaluate(() => window.__card._ledEditor === null));
const toScreen = (x, y) => evaluate(([x, y]) => {
const c = window.__card; const r = c.shadowRoot.querySelector('.stage').getBoundingClientRect();
const a = c._screenToVb(0, 0), b = c._screenToVb(1000, 1000);
return [r.left + (x - a[0]) / (b[0] - a[0]) * 1000, r.top + (y - a[1]) / (b[1] - a[1]) * 1000];
}, [x, y]);
const clickAt = async (x, y) => { const [sx, sy] = await toScreen(x, y); await page.mouse.click(sx, sy); await page.waitForTimeout(80); };
const chain = () => evaluate(() => window.__card._ledEditor?.chain?.points.length ?? null);
const strips = () => evaluate(() => window.__card._serverCfg.spaces[0].led_strips || []);
await page.click('[data-tool="led-strip"]');
await page.waitForTimeout(500);
check('tool armed, capture layer over icons', await evaluate(() => !!window.__card._ledEditor?.tool
&& !!window.__card.shadowRoot.querySelector('[data-hp-led-capture]')));
check('touch-safe start hint in the tray', await evaluate(() => /Click to start the strip/.test(
window.__card.shadowRoot.querySelector('.editor-secondary')?.textContent || '')));
await clickAt(200, 200);
await clickAt(400, 200);
check('two clean clicks, two points', await chain(), 2);
// A mouse pan: press, travel past the slop, release.
{
const [sx, sy] = await toScreen(400, 300);
await page.mouse.move(sx, sy); await page.mouse.down(); await page.mouse.move(sx + 60, sy + 10, { steps: 6 }); await page.mouse.up();
await page.waitForTimeout(100);
}
check('a pan adds no point', await chain(), 2);
// A pinch: a second finger joins, the release adds nothing.
await evaluate(() => {
const layer = window.__card.shadowRoot.querySelector('[data-hp-led-capture]');
const ev = (type, id, x) => layer.dispatchEvent(new PointerEvent(type, { pointerId: id, pointerType: 'touch', clientX: x, clientY: 300, bubbles: true, button: 0 }));
ev('pointerdown', 11, 300); ev('pointerdown', 12, 340); ev('pointerup', 11, 300); ev('pointerup', 12, 340);
ev('pointerdown', 13, 360); ev('pointercancel', 13, 360); ev('pointerup', 13, 360);
});
check('pinch and cancel add no point', await chain(), 2);
await clickAt(400, 400);
check('third point', await chain(), 3);
await page.keyboard.press('Control+z');
check('Ctrl+Z removes the chain’s own point', await chain(), 2);
check('nothing written while drawing', await evaluate(() => window.__ledSaves.length), 0);
await page.keyboard.press('Escape');
await page.waitForTimeout(400);
const first = await strips();
check('Esc finished one unbound strip', first.length === 1 && first[0].marker === null
&& JSON.stringify(first[0].points) === JSON.stringify([[0.2, 0.2], [0.4, 0.2]]));
check('the device picker opened', await evaluate(() => !!window.__card.shadowRoot.querySelector('hp-dialog[data-kind="led-picker"]')));
await page.click('[data-led-action="later"]');
await page.waitForTimeout(200);
check('Later keeps the unbound strip, selected with its tray', await evaluate(() => {
const c = window.__card;
return c._serverCfg.spaces[0].led_strips.length === 1 && !!c._ledEditor.sel
&& /Strip is not bound to a device/.test(c.shadowRoot.querySelector('.editor-secondary')?.textContent || '');
}));
check('unbound strip is a grey dashed mark in Devices', await evaluate(() => !!window.__card.shadowRoot.querySelector('[data-led-unbound]')));
// AC5: a new segment stops at the first face of the 40 cm partition.
await page.click('[data-tool="led-strip"]'); await page.waitForTimeout(200);
await clickAt(300, 450);
await clickAt(700, 450);
const clamp = await evaluate(() => window.__card._ledEditor.chain.points.map((p) => p.map((v) => Math.round(v * 10) / 10)));
check('segment stopped at the partition face', clamp[1][0] < 500 && clamp[1][0] > 470 && clamp[1][1] === 450);
// Leaving the tool finishes the chain without an extra segment.
await page.click('[data-tool="led-strip"]'); await page.waitForTimeout(400);
check('leaving the tool finished the chain', (await strips()).length, 2);
await page.keyboard.press('Escape'); await page.waitForTimeout(100);
// A click on the first point (≥3 vertices) closes the strip.
await page.click('[data-tool="led-strip"]'); await page.waitForTimeout(200);
await clickAt(150, 150); await clickAt(250, 150); await clickAt(250, 250); await clickAt(150, 152);
await page.waitForTimeout(400);
const loop = (await strips())[2];
check('closed by a click on the first point', !!loop && JSON.stringify(loop.points[0]) === JSON.stringify(loop.points.at(-1)) && loop.points.length === 4);
await page.keyboard.press('Escape'); await page.waitForTimeout(100);
// AC5: a fast vertex drag across the partition keeps the last safe position.
await evaluate(() => window.__card._ledEditor.select(window.__card._serverCfg.spaces[0].led_strips[0].id));
await page.waitForTimeout(200);
const handle = await evaluate(() => { const r = window.__card.shadowRoot.querySelector('[data-led-handle="1"]').getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; });
const far = await toScreen(800, 200);
await page.mouse.move(handle[0], handle[1]); await page.mouse.down(); await page.mouse.move(far[0], far[1], { steps: 2 }); await page.mouse.up();
await page.waitForTimeout(400);
const dragged = (await strips())[0].points[1];
check('vertex drag cannot jump the wall', dragged[0] < 0.5 && dragged[0] > 0.45);
check('the drag is one LED history command', await evaluate(() => window.__card._devicePositionHistory.undoName), 'LED strip shape');
await page.keyboard.press('Control+z'); await page.waitForTimeout(400);
check('Undo restores the shape', JSON.stringify((await strips())[0].points), JSON.stringify([[0.2, 0.2], [0.4, 0.2]]));
// The selection is session state: Esc and a clean background click close the tray.
await evaluate(() => window.__card._ledEditor.select(window.__card._serverCfg.spaces[0].led_strips[0].id));
const savesBefore = await evaluate(() => window.__ledSaves.length);
await page.keyboard.press('Escape'); await page.waitForTimeout(100);
check('Esc drops the selection', await evaluate(() => window.__card._ledEditor.sel), null);
await evaluate(() => window.__card._ledEditor.select(window.__card._serverCfg.spaces[0].led_strips[0].id));
await page.waitForTimeout(100);
await clickAt(700, 550);
check('a clean background click drops the selection', await evaluate(() => window.__card._ledEditor.sel), null);
check('dropping the selection writes nothing', await evaluate(() => window.__ledSaves.length), savesBefore);
// «Optimize plans» reports a strip that passes through the partition and changes nothing in it.
const optimizeNote = await evaluate(async () => {
const c = window.__card;
const space = c._serverCfg.spaces[0];
space.led_strips = [...space.led_strips, { id: 'through', points: [[0.3, 0.3], [0.7, 0.3]], marker: null }];
c._modelCache = null; c._frame = null; c._cfgEpoch++;
await c.updateComplete;
c._editorRuntime.optimizePlans.open();
await c.updateComplete;
for (let i = 0; i < 40 && !c.shadowRoot.querySelector('[data-led-walls]'); i++) {
await new Promise((r) => setTimeout(r, 50));
c.requestUpdate(); await c.updateComplete;
}
const note = c.shadowRoot.querySelector('[data-led-walls]');
const kept = JSON.stringify(c._alignDialog?.config?.spaces?.[0]?.led_strips?.find((s) => s.id === 'through')?.points);
c._alignDialog = null; await c.updateComplete;
return { n: note?.dataset.ledWalls, text: note?.textContent || '', kept };
});
check('optimize reports strips through walls', optimizeNote.n, '1');
check('the report names the space', /Strips passing through walls: 1 \(LED\)/.test(optimizeNote.text));
check('optimize keeps the strip as drawn', optimizeNote.kept, JSON.stringify([[0.3, 0.3], [0.7, 0.3]]));
// Plan/Background: passive translucent marks only, no handles, no targets.
await evaluate(async () => { window.__card._setMode('decor'); await window.__card.updateComplete; });
await page.waitForTimeout(300);
check('Background editor: no LED editor layer or tool', await evaluate(() => {
const r = window.__card.shadowRoot;
return !r.querySelector('[data-hp-led-editor]') && !r.querySelector('[data-tool="led-strip"]') && !r.querySelector('[data-led-handle]');
}));
await finish(browser);
+135
View File
@@ -0,0 +1,135 @@
/**
* #780 AC4/AC7/AC12/AC14/AC17 (ТЗ §3, §5, §7, §8, §13.1): the LED strip in
* the View and on the static card. No LED chunk without a displayed active
* strip (a hidden shape or an unbound strip loads nothing); an active bound
* strip replaces the icon, paints the two-stroke stripe and — with Glow — the
* linear field; off/unavailable have no field, unavailable is dashed; without
* Glow the core takes the source colour. The whole length is one target: a
* click toggles once, a touch pan calls nothing. The static card is passive
* and computes no field with `light_pools: false`.
*/
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 820 }, 1);
const ledRequests = [];
page.on('request', (request) => { if (/led-strip-(runtime|field|editor)-/.test(request.url())) ledRequests.push(request.url().replace(/.*\//, '')); });
const evaluate = (fn, arg) => page.evaluate(fn, arg);
const settle = () => page.waitForTimeout(500);
const setStrips = (strips, settings) => evaluate(async ([strips, settings]) => {
await window.__hpTest.setServerConfig((cfg) => {
cfg.spaces[0].led_strips = strips;
cfg.spaces[0].settings = { ...(cfg.spaces[0].settings || {}), ...settings };
cfg.markers = [
...(cfg.markers || []).filter((m) => !['d_light1', 'd_lamp'].includes(m.id)),
{ id: 'd_light1', binding: 'device:d_light1', space: 'f1' },
{ id: 'd_lamp', binding: 'device:d_lamp', space: 'f1' },
];
});
}, [strips, settings]);
const setState = (entity, state, attributes = {}) => evaluate(async ([entity, state, attributes]) => {
const c = window.__card; const st = c.hass.states[entity];
c.hass = { ...c.hass, states: { ...c.hass.states, [entity]: { ...st, state, attributes: { ...st.attributes, ...attributes } } } };
await c.updateComplete;
}, [entity, state, attributes]);
const stripe = (marker) => evaluate((marker) => {
const g = window.__card.shadowRoot.querySelector(`[data-led-strip][data-marker="${marker}"]`);
return g ? { state: g.dataset.state, core: g.querySelector('.led-core')?.getAttribute('stroke'),
dash: g.querySelector('.led-core')?.getAttribute('stroke-dasharray') || null } : null;
}, marker);
const field = () => evaluate(() => window.__card.shadowRoot.querySelectorAll('[data-led-field]').length);
await settle();
check('no strips: no LED chunk requested', ledRequests.length, 0);
await setStrips([{ id: 'hidden', points: [[0.12, 0.40], [0.40, 0.40]], marker: 'd_lamp', active: false },
{ id: 'loose', points: [[0.12, 0.45], [0.40, 0.45]], marker: null }], { glow_enabled: true });
await settle();
check('hidden shape and unbound strip: still no LED chunk', ledRequests.length, 0);
check('a hidden shape leaves the ordinary icon', await evaluate(() => !!window.__card.shadowRoot.querySelector('.dev[data-id="d_lamp"]')));
await setStrips([{ id: 'ceiling', points: [[0.12, 0.30], [0.40, 0.30], [0.40, 0.45]], marker: 'd_light1' },
{ id: 'loose', points: [[0.12, 0.45], [0.30, 0.45]], marker: null }], { glow_enabled: true });
await settle();
check('a displayed active strip loads the runtime', ledRequests.some((url) => url.startsWith('led-strip-runtime-')));
check('the editor chunk is not loaded in the View', ledRequests.some((url) => url.startsWith('led-strip-editor-')), false);
check('the icon is replaced by the strip', await evaluate(() => !window.__card.shadowRoot.querySelector('.dev[data-id="d_light1"]')));
check('unbound strip is absent from the View', await evaluate(() => window.__card.shadowRoot.querySelectorAll('[data-led-strip]').length), 1);
check('no round pool at the anchor: the strip is the source', await evaluate(() =>
!window.__card.shadowRoot.querySelector('[data-glow-source="light.ceiling"]')));
check('no auto-slot reserved for the strip’s marker', await evaluate(() => !('d_light1' in (window.__card._defPos || {}))));
check('on with Glow: white core and a field', JSON.stringify([await stripe('d_light1'), await field() > 0]),
JSON.stringify([{ state: 'on', core: '#FFFFFF', dash: null }, true]));
// One target over the whole length: a click toggles exactly once.
const calls = await evaluate(async () => {
const c = window.__card; const log = [];
const service = c.hass.callService;
window.__ledToggle = log;
c.hass = { ...c.hass, callService: async (d, s, data) => { log.push(`${d}.${s}:${data?.entity_id}`); return service(d, s, data); } };
await c.updateComplete;
return log.length;
});
const hit = await evaluate(() => { const r = window.__card.shadowRoot.querySelector('.led-hit').getBoundingClientRect(); return [r.x + r.width * 0.25, r.y + 2]; });
const box = await evaluate(() => { const p = window.__card.shadowRoot.querySelector('.led-hit'); const len = p.getTotalLength(); const pt = p.getPointAtLength(len * 0.3); const m = p.getScreenCTM(); return [pt.x * m.a + m.e, pt.y * m.d + m.f]; });
await page.mouse.click(box[0], box[1]);
await settle();
check('a click on the stripe toggles once', await evaluate(() => window.__ledToggle.length), calls + 1);
void hit;
check('off: no field, white core', JSON.stringify([await stripe('d_light1'), await field()]),
JSON.stringify([{ state: 'off', core: '#FFFFFF', dash: null }, 0]));
// A pan along the stripe calls nothing; the next clean click works at once.
const along = (k) => evaluate((k) => {
const el = window.__card.shadowRoot.querySelector('.led-hit');
const p = el.getPointAtLength(el.getTotalLength() * k), m = el.getScreenCTM();
return [p.x * m.a + m.e, p.y * m.d + m.f];
}, k);
{
const [x0, y0] = await along(0.3), [x1, y1] = await along(0.45);
await page.mouse.move(x0, y0); await page.mouse.down();
await page.mouse.move(x1, y1, { steps: 8 }); await page.mouse.up();
}
await settle();
const ceiling = () => evaluate(() => window.__card.hass.states['light.ceiling'].state);
check('a pan along the stripe calls no service', await ceiling(), 'off');
{
const [x, y] = await along(0.6);
await page.mouse.click(x, y);
}
await settle();
check('the next clean click works at once', await ceiling(), 'on');
{
const [x, y] = await along(0.6);
await page.mouse.click(x, y);
}
await settle();
await setState('light.ceiling', 'unavailable');
await settle();
const unavailable = await stripe('d_light1');
check('unavailable: dashed, no field', JSON.stringify([unavailable?.state, !!unavailable?.dash, await field()]),
JSON.stringify(['unavailable', true, 0]));
await setState('light.ceiling', 'on', { rgb_color: [128, 213, 255] });
await setStrips(await evaluate(() => window.__card._serverCfg.spaces[0].led_strips), { glow_enabled: false });
await settle();
const colored = await stripe('d_light1');
check('on without Glow: coloured core, no field', JSON.stringify([colored?.state, colored?.core !== '#FFFFFF', await field()]),
JSON.stringify(['on', true, 0]));
// The static card: passive; the field only with light_pools.
await setStrips(await evaluate(() => window.__card._serverCfg.spaces[0].led_strips), { glow_enabled: true });
const staticCards = await evaluate(async () => {
await customElements.whenDefined('houseplan-space-card');
const host = document.createElement('div'); host.style.width = '700px'; document.body.appendChild(host);
const make = (pools) => { const el = document.createElement('houseplan-space-card'); el.setConfig({ type: 'custom:houseplan-space-card', space: 'f1', light_pools: pools }); el.hass = window.__card.hass; host.appendChild(el); return el; };
const plain = make(false), pools = make(true);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
for (let i = 0; i < 40; i++) {
await wait(50);
if (plain.shadowRoot?.querySelector('[data-led-strip]') && pools.shadowRoot?.querySelector('[data-led-field]')) break;
}
const read = (el) => ({ stripe: !!el.shadowRoot?.querySelector('[data-led-strip]'),
field: !!el.shadowRoot?.querySelector('[data-led-field]'), hit: !!el.shadowRoot?.querySelector('.led-hit') });
return { plain: read(plain), pools: read(pools) };
});
check('static card without light_pools: passive stripe, no field', JSON.stringify(staticCards.plain), JSON.stringify({ stripe: true, field: false, hit: false }));
check('static card with light_pools: the field', JSON.stringify(staticCards.pools), JSON.stringify({ stripe: true, field: true, hit: false }));
await finish(browser);
+10
View File
@@ -87,6 +87,16 @@ Everything is in English; the demo user's language is Auto, so the interface
follows the browser. **Smart Plug 2** is deactivated in the HA registry on
purpose — it is the ready-made example for the disabled-devices behaviour.
**LED strips (#780).** The synthetic light `light.demo_led_strip` (device
`demo-led-strip`) and its three siblings `light.demo_led_strip_wall`,
`light.demo_led_strip_corner` and `light.demo_led_strip_loop` carry the
designer's four reference strips — free, on a wall face, along a corner,
closed loop — in the `golden-led` space of the visual fixture
(`demo/fixtures/visual-matrix.mjs`, `makeVisualMatrixFixture({ ledStrips: true })`),
which the LED golden scenes and the design acceptance use. On the stand seed
the same entity id is the place for a strip drawn with the Devices editor's
«LED strip» tool; the hourly reset removes whatever a visitor draws.
The public demo user is an administrator, so the full registry scenario can be
checked on the stand; a limited/read-only user needs the local harness or a
separate unprivileged user.
+19
View File
@@ -689,3 +689,22 @@ compatibility export surface over `mutation-registry.mjs` (declarations),
runs); dependencies never point back to the CLI. Guard-input caching is
invocation-scoped (one resolver, one tracked-file snapshot); persisted success
exists only in the explicit caught-witness ledger. Usage: [TESTING](TESTING.md).
## LED strips: lazy boundaries (#780)
`space.led_strips` belongs to the space; the link is one-way strip → marker
and the device model stays the only owner of state and services. Four modules:
| Module | Graph | Holds |
|---|---|---|
| `led-strip-gate.ts` | initial | which markers a space shows as a strip, the anchor, the page-wide loaders of both chunks |
| `led-strip-card.ts` | initial | delegation only: the Devices toolbar button, the device-dialog section, notes, the LED branch of the device history |
| `led-strip-runtime.ts` (+ `led-strip-geometry.ts`) | lazy `led` | frame, stripe, hit/focus, 2.5D, static card |
| `led-strip-field.ts` | lazy `led-field` | the linear field, loaded by the runtime only for an on strip in a Glow room |
| `led-strip-editor.ts` (+ `i18n/led`) | lazy `led-editor` | the Devices tool, tray, picker, representation switch, LED history commands |
A View without a displayed active strip, and the Devices editor without the
tool or an editable strip, load none of them. Each chunk checks the entry
build fingerprint; a failed load is fail-dark for the strips only and retried
on the next explicit entry. Budgets: `LAZY_LED_GZIP_CEILING` and
`LAZY_LED_EDITOR_GZIP_CEILING` in `scripts/bundle-budget.mjs`.
+15
View File
@@ -2,6 +2,21 @@
## Unreleased
- LED strips: in the Devices editor, the new «LED strip» tool next to «Add»
draws a strip on the plan point by point — it snaps to the grid and to wall
faces and stops at walls, windows and columns, passing doors and passages.
Bind it to a light (or any device) and the strip replaces the device's icon:
a two-stroke stripe that, when the light is on with Glow, casts a soft
continuous band of its colour (50 cm by default) that does not pass through
walls or closed doors; without Glow the stripe itself takes the light's
colour. In the View the whole strip is one tap target, also in 2.5D and on
the space card (light only with `light_pools`). Any device can switch between
«Show as LED strip» and «Show as icon» in its settings; the shape is kept
and comes back without redrawing. Drawing, editing vertices and switching are
part of Undo/Redo; full and space exports keep the strips, a deleted device
leaves an unbound strip, and «Optimize plans» reports strips passing through
walls ([#780](https://github.com/Matysh/houseplan-card/issues/780)).
## v1.79.0-beta.2 — 2026-10-02
- Recreating the card in Home Assistant now keeps the editor camera steady and the
+16
View File
@@ -8,6 +8,22 @@
## Не выпущено
- LED-ленты: в редакторе устройств рядом с «Добавить» появился инструмент
«LED-лента» — лента рисуется на плане по точкам, притягивается к сетке и к
граням стен и упирается в стены, окна и колонны, проходя через двери и
проёмы. Привяжите её к светильнику (или любому устройству) — и лента заменит
значок устройства: полоса из двух штрихов, а при включённом свете с Glow —
мягкая непрерывная полоса света его цвета (по умолчанию 50 см), которая не
проходит сквозь стены и закрытые двери; без Glow цвет света принимает сама
полоса. В просмотре вся лента — одна цель нажатия, в том числе в 2.5D и в
карточке пространства (свет — с `light_pools`). Любое устройство
переключается между «Показывать LED-лентой» и «Показывать значком» в своих
настройках; форма сохраняется и возвращается без перерисовки. Рисование,
правка вершин и переключение входят в Отменить/Повторить; полный экспорт и
перенос пространства сохраняют ленты, удаление устройства оставляет ленту
непривязанной, а «Оптимизировать планы» сообщает число лент, проходящих
сквозь стены ([#780](https://github.com/Matysh/houseplan-card/issues/780)).
## v1.79.0-beta.2 — 2026-10-02
- При техническом пересоздании карточки Home Assistant сохраняются положение плана в редакторе
+32
View File
@@ -1117,3 +1117,35 @@ authentication. Plans and marker files are served only by the authenticated
Stored configs may still hold older `/houseplan_files/plans|files/…` URLs:
`contentUrl()` rewrites them on every read and portable import accepts both
prefixes, so there is no storage migration.
## LED strips: `space.led_strips` (#780)
```ts
led_strips?: Array<{ id: string; points: [number, number][]; marker: string | null; active?: boolean }>;
```
- Optional; absent = no strips; no `model_version` bump. Coordinates are the
space's normalised coordinates (scalar JSON-noise cleanup only, no lattice
snap — a strip lies on real wall faces).
- 2–50 points (a closed strip repeats its first point and needs three distinct
vertices), non-zero length, finite coordinates; at most 50 records per space
**including** hidden shapes; unique `id` per space; at most one record per
marker in the whole config. `active` is a strict boolean; `false` needs a
marker.
- **Write-path normalisation** (`led_strips.py`, after structure/duplicate
checks, before referential validation, one transaction): a link to a marker
that is missing (deleted, tombstoned) becomes `marker: null, active: true`
with id and points kept; an existing live marker with an empty `space`
adopts the strip's space. A non-empty foreign `space` or a second link
still rejects the whole write. `config/set` reports the counts
`{led_strips: {unbound, space_adopted}}`; older clients may ignore them.
- **Older clients** keep the unknown array on an ordinary save; deleting a
bound marker succeeds and leaves an unbound strip.
- **Transfer.** Full export/import keeps geometry, links and `active`. A
space import remaps links through the same marker-id map as the devices;
a strip whose marker did not travel (skipped duplicate, absent) arrives
unbound — the count is in the import summary. «Plan only» export drops every
link (`marker: null, active: true`).
- **Diagnostics** count strips, unbound and hidden ones only — no ids,
coordinates, room names or HA identifiers.
- **Optimize plans** reports strips passing through walls and never edits them.
+16
View File
@@ -110,6 +110,22 @@ cover → light sources → device role, шторы и медиаплееры)
только тогда, когда меняет победившее решение или наблюдаемый результат; полное
декартово произведение binding × source × display × activity запрещено.
## LED strip representation (#780)
A marker may be shown as an LED strip instead of an icon (`space.led_strips`,
`active` ≠ `false`). It is the same device: name, `hidden`, light source and
state, `tap_*`, confirmation, `controls`, light colour/brightness/radius and
the value badge keep working; the individual `display`, `size`, `angle`,
`ripple_*` neither shape the strip nor get erased, and apply again on «Show as
icon». While shown as a strip the icon is not drawn, its saved position and
auto-grid slot are not used; the anchor — the point at half the polyline
length — replaces the position for the room, tooltip, label and badge (an
explicit valid `room_id` wins). A hidden shape (`active: false`) changes
nothing: the icon works normally. No pulses and no alarm red on the stripe.
`hidden` / HA-disabled suppress the stripe, its target and its light like an
icon; temporarily unavailable keeps it as a dashed grey stripe. In the device
catalogue the marker stays «On the plan».
## Implementation notes
- `device-value-badge.ts` owns candidate discovery, source keys, HA formatting,
+10
View File
@@ -292,6 +292,16 @@ Room names remain screen-facing and lose stroke, text shadow, drop shadow and
halo. Iso uses `#303936` on a light presentation and `#f2f0e8` on a dark one;
contrast comes from colour, never an outline.
## LED strips in 2.5D (#780)
An LED stripe is projected with the plan like a device tile: the body is
raised by `ISO_TILE.lift` (0.075 D), its edge is swept `ISO_TILE.depth`
(0.1 D) below in `isoEdgeColor`, and an inert blurred floor shadow from
`isoTileShadow` follows theme and floor; D takes the shared `ISO_ICON_SCALE`.
The linear field stays on the floor plane; the hit path moves with the raised
body. Everything is in plan units, so zoom never detaches vertices or the
target. Editors and `houseplan-space-card` keep Flat.
## Stage 6: public mode, tiles, sun and materials (#649)
The visual language and the numbers come from the designer lab (sketch 07,
+39
View File
@@ -409,3 +409,42 @@ slower and remains a performance watch item. During pinch/pan and the bounded
500 ms source fade, the whole-layer blur is bypassed and its parameters stay
frozen; the final screen-space feather is restored once after the transition
instead of rebuilding and evaluating the filter for every animation frame.
## LED strips: a linear source (#780)
A marker shown as an LED strip is one source whose emitter is the whole
polyline (`space.led_strips`, ТЗ #780). It never also paints a round pool from
its anchor. Colour, brightness, role and availability come from the same
`resolveGlowCandidates` / `resolveGlowAppearance` path as every other source;
only the geometry differs:
- **Radius.** 50 cm by default, independent of `settings.glow_radius_cm`; the
marker's personal `glow_radius_cm` wins.
- **Field.** A continuous band along every segment with round free ends: grey
luminance bands of the shared `GLOW_FALLOFF`, one piece per stretch, blended
with `lighten` inside one isolated group, so corners and the closing of a
loop neither seam nor double the brightness. Intensity and the 500 ms fade
are the shared `glowAlpha` / `GLOW_FADE_MS`.
- **Visibility.** The strip is cut into consecutive pieces no longer than the
radius; each piece is clipped to the visibility fans of its own emitters
(the shared `visibilityPolygon` over the same barrier scene as pools; the
fans are separate paths of one clipPath, no boolean pass per piece), and the
whole field layer is clipped once to the floor. A piece with no occluder
within the radius needs no fan at all. Windows, columns,
thick walls and Solid zero walls block; doors/gates pass by their actual
opening; Dashed zero walls are transparent. Emitters on a thick face sit
`epsilonGeom` (0.001 cm) outward into free floor; a part buried in a body
emits nothing; a strip entirely inside a wall has no field.
- **Core.** With effective Glow (space `glow_enabled` + room `glow`) the core
stays white and the colour is the field; without Glow the core takes the
source colour and there is no field. Off: white core, no field.
Unavailable/unknown: dashed grey stripe, no field — the link is kept.
- **Surfaces.** The full card renders field and stripe in the View; the
Devices editor shows active strips (unbound ones as grey dashes);
Plan/Background show a passive translucent stripe. `houseplan-space-card`
draws the passive stripe always and the field only with `light_pools: true`
— with the option off no barrier, visibility or timer is created.
- **Laziness.** The stripe/hit/2.5D code (`led-strip-runtime`) and the field
(`led-strip-field`) are separate lazy chunks; the initial graph holds only
the presence check and the loader (`led-strip-gate`). Caches are bounded per
space (50 shapes, 50 visibilities) and released on space change.
+10
View File
@@ -178,6 +178,16 @@ cannot operate stale geometry; leaving the editor is always a single safe
action. The decorative smoothness of the editor chrome remains best effort on
coarse-pointer devices, while the correct final View frame is release-blocking.
### LED strip tool (#780)
The LED tool applies the same safety floor as the wall chain: only a clean
tap adds a point; pan, pinch, a second finger, `pointercancel`, a lost
capture and the synthetic click after navigation add nothing, finish nothing
and open no picker. The touch hint does not require keyboard modifiers:
switching the tool off finishes the chain. In the View the whole stripe is
one target with `max(22 CSS px, t/2)` hit radius; a pan or pinch over it calls
no action, long press or more-info, and the next clean tap works at once.
## Deliberate degradation rule
When an editor change would be expensive to implement correctly for touch, the
+30
View File
@@ -1041,6 +1041,35 @@ binding to **Available again**. A disabled or missing binding keeps its saved
category and receives a separate Home Assistant status instead of silently
moving to another tab.
### LED strip (#780)
An LED strip is drawn on the plan as a polyline and can stand in for any
device instead of its round icon. Everything happens in the **Devices
editor** — there is no need to switch to the Plan editor.
| Action | Result |
|---|---|
| **LED strip** (next to «Add») | Starts drawing. A clean click adds a point; the grid and wall faces attract it, a zero-thickness wall its axis; `Shift` gives 45° steps. A new segment stops at the first face of a thick wall, partition, column or window («The strip stopped at a wall»); doors, gates and passages let it through |
| `Ctrl/Cmd+Z` while drawing | Removes the chain's last point, not another history step |
| `Esc`, a double click on the last point, pressing **LED strip** again, leaving the editor | Finish the strip; a chain of fewer than two distinct points is not saved |
| A click on the first point (at least three vertices) | Closes the strip |
| After finishing | The device picker opens: lights first, devices bound to another strip are disabled with an explanation, **New device…** creates one and binds it in the same write, **Later** keeps an unbound strip (grey dashes, visible in the Devices editor only) |
| A click on a strip | Selects it and shows its vertices; they can be dragged, and walls stop them even on a fast drag. The tray offers **Device settings**, **Bind / Change device**, **Unbind**, **Show as icon**, **Delete strip**. `Esc` or a clean click on free space drops the selection |
| In the device dialog | **Show as LED strip** draws a shape for the same marker (or brings a saved one back at once); **Show as icon** returns the ordinary icon, the shape is kept hidden and comes back without redrawing. The usual unsaved-changes question comes first |
Binding creates no new Home Assistant device: the same marker, light
settings, tap actions and badge. The strip glows when the device does: with
Glow on, a white core and a soft band of colour 50 cm wide by default (or the
device's own radius); without Glow the core takes the light's colour. Light
does not pass through walls or closed doors. In View the whole strip is one
tap target, like an icon. Every finished shape change and every switch of the
representation is one **Undo/Redo** step.
«Unbind» keeps an unbound shape, «Delete strip» deletes the shape but not the
device. Deleting the device leaves the strip unbound. Up to 50 points per
strip and 50 strips per space (hidden shapes included). «Optimize plans»
reports strips passing through walls and changes nothing in them.
### Marker basics
The dialog has five cards: **Basics**, a compact tap-action card without a
@@ -2154,6 +2183,7 @@ Marker attachments accept PDF/PNG/JPG/WebP/TXT up to 50 MB, with a
| Room details use hover in View | A touch-only user may need an editor or another visible metric |
| Sun has no exterior 3D model | It cannot know shadows from trees, awnings or neighbouring structures |
| Icon rules use regular expressions | First matching rule wins and invalid expressions are rejected |
| LED strips are not cut by walls | A thickened wall does not edit a strip: parts inside the wall do not glow, «Optimize plans» shows how many strips pass through walls |
Storage guards allow up to 50 spaces, 400 rooms per space, 2,000 markers, 500
openings, 1,000 Background items and bounded wall/physical-object catalogues
+30
View File
@@ -1137,6 +1137,35 @@ binding tombstone не вырезает её из живого устройст
![Живой предпросмотр выбранного отображения устройства](images/06-device-display-preview.png)
### LED-лента (#780)
Светодиодную ленту можно нарисовать на плане как ломаную и показать ею любое
устройство вместо круглого значка. Всё делается в **редакторе устройств** —
в редактор плана переходить не нужно.
| Действие | Результат |
|---|---|
| **LED-лента** (рядом с «Добавить») | Включает рисование. Чистый клик ставит точку; сетка и грани стен притягивают точку, у стены нулевой толщины — её ось; `Shift` даёт шаг 45°. Новый отрезок упирается в первую грань толстой стены, перегородки, колонны или окна («Лента упёрлась в стену»); двери, ворота и проходы пропускают |
| `Ctrl/Cmd+Z` во время рисования | Убирает последнюю точку цепочки, а не чужое действие истории |
| `Esc`, двойной клик по последней точке, повторное нажатие **LED-лента**, выход из редактора | Завершают ленту; цепочка меньше двух различных точек не сохраняется |
| Клик по первой точке (не меньше трёх вершин) | Замыкает ленту |
| После завершения | Открывается выбор устройства: сначала светильники, занятые другой лентой недоступны с пояснением, **Новое устройство…** создаёт его и привязывает в той же записи, **Позже** оставляет непривязанную ленту (серый пунктир, видна только в редакторе устройств) |
| Клик по ленте | Выделяет её и показывает вершины; их можно перетаскивать, упор в стены действует и при быстром перетаскивании. Внизу — лоток: **Настройки устройства**, **Привязать / Сменить устройство**, **Отвязать**, **Показывать значком**, **Удалить ленту**. `Esc` или чистый клик по свободному месту снимают выделение |
| В диалоге устройства | **Показывать LED-лентой** — нарисовать форму для этого же маркера (или сразу вернуть сохранённую); **Показывать значком** — вернуть обычный значок, форма сохраняется скрыто и восстанавливается без перерисовки. Перед этим действует обычный вопрос о несохранённых изменениях |
Привязка не создаёт нового устройства в Home Assistant: тот же маркер, те же
настройки света, действия по нажатию и бейдж. Лента светится, когда светится
устройство: при включённом Glow — белое ядро и мягкая цветная полоса света
радиусом 50 см (или персональный радиус устройства), без Glow — ядро цвета
источника. Свет не проходит сквозь стены и закрытые двери. В просмотре лента
— одна цель нажатия по всей длине, как значок. Каждое законченное изменение
формы и смена представления — один шаг **Отменить/Повторить**.
«Отвязать» оставляет непривязанную форму, «Удалить ленту» удаляет форму, но не
устройство. Удаление устройства оставляет ленту непривязанной. Не более 50 точек
в ленте и 50 лент в пространстве (включая скрытые формы). «Оптимизировать
планы» сообщает число лент, проходящих сквозь стены, и ничего в них не меняет.
### Основные настройки маркера
Диалог устройства собран в пять карточек: **Основное**, компактная карточка
@@ -2406,6 +2435,7 @@ cycle: 0
| Редакторы desktop-first | На touch-устройствах функции редактирования могут быть неудобны, ограничены или отсутствовать; для создания и обслуживания плана используйте компьютер |
| Солнце не знает внешнюю 3D-геометрию | Нет теней от соседних крыльев, навесов и деревьев |
| Иконки по регулярным выражениям | Правила мощные, но требуют знания regex; первая совпавшая строка побеждает |
| LED-лента не режется по стенам | После утолщения стены форма не правится сама: участки внутри стены не светятся, «Оптимизировать планы» показывает число таких лент |
Технические пределы защиты хранилища: до 50 пространств, 400 комнат на пространство, 2000 маркеров, 500 проёмов, 1000 элементов декора и ограниченные текущей схемой каталоги стен/физических объектов. Конфигурационный пакет ограничен 2 МБ.
+16
View File
@@ -249,6 +249,22 @@ layer you cannot see is a layer you cannot edit.
👁 "Show hidden" (local editor tool; replaced the shared show-all toggle),
⬡ icon rules.
- **LED strip** (#780) — the only place a strip is drawn or edited. The tool
sits next to «Add»; it is a temporary drawing mode inside Devices, not a
separate editor. Clean clicks add points (pan, pinch, a second finger,
cancel and the synthetic click after navigation never do), snapping to the
grid and to physical wall faces / zero-wall axes; a new segment stops at
the first face of masonry, a partition, a column or a window. `Ctrl/Cmd+Z`
removes the chain's own point first, `Esc` finishes. A finished strip opens
the device picker. A clicked strip is selected (session state only) and
shows vertex handles and the LED branch of the secondary tray. An ordinary
icon clicked while a strip is selected still opens its own dialog. The
device dialog switches the representation («Show as LED strip» / «Show as
icon»). Geometry and representation changes are LED commands of the same
device history; the stack still never absorbs marker settings or
Plan/Background edits. Plan and Background show strips as a passive
translucent mark only — no tool, handles or targets.
## Background — the decor underlay
- Toolbar tools: Select / optional Plan backdrop / Line / Rectangle / Oval /
+72
View File
@@ -0,0 +1,72 @@
# #780 · LED strips — acceptance against the designer frames
AC8/AC18 of [#780](https://github.com/Matysh/houseplan-card/issues/780). The
product reproduces the designer's four strips — a free straight strip (blue),
a strip on the inner face of the bottom wall (violet), a polyline along the
wall faces around a corner (yellow) and a closed rectangle (green) — on a
synthetic plan with the reference floor `#868D94`. Scene colours
`#80D5FF`, `#E680FF`, `#FFEA80`, `#58FF58` are values of the four sources, not
a product palette.
## How the frames were made
- **Designer** — `source/previews/Led-On.png`, `source/previews/Led-Off.png`
(2543 × 1572, unchanged).
- **Product** — golden scenes `led-strip-design-reference-on-light` and
`led-strip-design-reference-off-light` (`demo/golden/matrix.mjs`, fixture
`makeVisualMatrixFixture({ ledStrips: true })` in
`demo/fixtures/visual-matrix.mjs`: viewport 1000 × 760, DPR 1, English, light
theme, `icon_size` 3.4, 20 cm walls, cell 5 cm). Captured with
`node demo/golden/run.mjs --mode=capture --scenario=<id>` on the built bundle;
the reviewed baselines are accepted from the Linux CI artifact of the task
(label `ci:golden`).
The plans differ (the designer frame is a fragment of a real floor with
furniture and icons), so the comparison is of the visual language and the four
behaviours, not of positions.
## On
![Designer Led On (left) and product (right)](pairs/pair-on.png)
## Off
![Designer Led Off (left) and product (right)](pairs/pair-off.png)
## ТЗ §3 — visual contract
| Requirement | Product | Evidence |
|---|---|---|
| `D = icon_size/100 × iconUnit(space)`, not `marker.size`; 2.5D uses the shared scale | `ledFrame` takes `iconPct` from the full card's or the space card's own `icon_size`; `ISO_ICON_SCALE` in 2.5D | golden `iso-led-strip-dark`, reference pair |
| Thickness 0.08 D off / 0.12 D on; outline `#383838` t, core t/2, round joins and caps | `renderLedStripes` | `led-strip-off-light`, reference pair |
| Off: white core, no field, both themes | stripe state `off` | smoke `smoke_led_strip_glow.mjs`, `led-strip-off-light` |
| On with Glow: white core + coloured field; without Glow: core in the source colour, no field | `ledStripView` + `resolveGlowAppearance` | smoke `smoke_led_strip_glow.mjs` |
| Glow is the space/room switch, independent of `fill_mode` | `glowFor(room)` | `lighting-led-strip-glow-dark` uses `fill_mode: none` |
| Per-piece offset: t/2 on a thick face into free floor, 0 on free floor and zero walls; continuous transition | `visibleStripPath` | unit `test/led-strip-geometry.test.mjs` (AC8) |
| Field 50 cm by default, own `glow_radius_cm` wins; round free ends; no seams or doubled brightness at corners/closure | `ledFrame`, `led-strip-field` (`lighten` in one group) | unit `test/led-strip-runtime.test.mjs`; reference pair |
| Shared `glowAlpha` / `GLOW_FALLOFF` / `GLOW_FADE_MS` | field bands from `falloffAt` | unit `test/led-strip-runtime.test.mjs` |
| Field under icons, badges and labels; icons not tinted | glow layer below the device layer | reference pair (designer tinting deliberately not reproduced) |
## Designer §11 — ten visual criteria
| # | Criterion | Result |
|---|---|---|
| 1 | Same geometry on/off; only colour/thickness/field change | Stored points never change; only `t` and the derived offset follow the state — pairs on/off |
| 2 | White core and dark outline keep contrast on grey floor, near hatched walls, over the field | Visible in both pairs; `#383838` outline, opaque core |
| 3 | Straight parts are not a chain of circles | One stroked path per strip; the field is bands of stroked paths, not discs |
| 4 | No hard rectangular cut of light at the ends | Round caps of the field bands (blue strip in the pair) |
| 5 | No dark gaps or bright spots at corners | Round joins; `lighten` blend of pieces (yellow corner, green loop) |
| 6 | A wall strip lights into the room, not behind the wall | Visibility clip from emitters `epsilonGeom` outward (violet, yellow) |
| 7 | A closed rectangle gives a continuous field along the perimeter | Green loop in the On pair |
| 8 | Colour changes with the entity without changing shape/radius/edges | Colour from `resolveGlowAppearance`; the stored points and the radius do not depend on state (four colours of one fixture in the On pair) |
| 9 | When off the field fades smoothly and the thin stripe stays | Shared 500 ms fade; Off pair keeps the stripe |
| 10 | Zoom keeps the proportions of thickness, outline, hit area and 2.5D lift | All sizes in plan units of D; hit radius `max(22 px, t/2)` in screen px (unit AC12) |
## Accepted differences
- Pixel sizes and the mockup blur (22.2–30 px) are not product filters: the
product uses 0.08/0.12 D and the shared falloff, so its stripe is thinner at
the default `icon_size` and the band edge is the shared Glow edge.
- Icons and labels are not tinted by the field.
- The mockup's "half the shared radius" and "always coloured core" are replaced
by 50 cm and the white core under Glow (owner's decision in #780).
+22
View File
@@ -0,0 +1,22 @@
# LED strips — designer materials (#780)
The designer's archive for the LED strips, kept **unchanged** under
[`source/`](source/): `Issue-662-LED-light-spec-2026-10-01.zip` attached to
[#662 (comment)](https://github.com/Matysh/houseplan-card/issues/662#issuecomment-5935038397)
(archive SHA-256 of every member in [`source/MANIFEST-SHA256.txt`](source/MANIFEST-SHA256.txt);
all six checksums verified when the files were copied). The Figma section is
[Led Light, 626:34](https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan?node-id=626-34)
with the frames Led On (626:35) and Led Off (626:53).
**The contract is the ТЗ in the body of
[#780](https://github.com/Matysh/houseplan-card/issues/780)**, not the archived
`source/TZ-issue-662-LED-strips.md`. Where they differ the issue wins — notably
the 50 cm default radius, the white core under Glow, editing in the Devices
editor and the product thicknesses 0.08/0.12 D instead of the mockup's pixel
sizes and blur. These files are documentation only; nothing here reaches the
runtime bundle.
- [`ACCEPTANCE.md`](ACCEPTANCE.md) — the two paired frames and the acceptance
table against §3 of the ТЗ and the ten visual criteria of the designer's §11.
- [`pairs/`](pairs/) — designer frame (left) next to the product scene (right),
256-colour diagnostic copies.
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

@@ -0,0 +1,3 @@
[InternetShortcut]
URL=https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan?node-id=626-34
@@ -0,0 +1,3 @@
[InternetShortcut]
URL=https://github.com/Matysh/houseplan-card/issues/662
@@ -0,0 +1,6 @@
A0E61CCF1416CE8F92332CD6B855C8131628E32C27ADDD77EDBEE3A6A2B3F37A README.md
1A98198905F147E3D1B8720F45481B6D5295FBDB94F4AD407F92FAC044B7A1DA TZ-issue-662-LED-strips.md
7AD21EE0042384443B2E41AE337ED31416127E183A72765EBB493AD808FCB651 FIGMA-LINK.url
16317687BA78439DA6052B384825E5FEF480A71EB3F46FCDDC4B180EDD09760C ISSUE-LINK.url
EBAC64901AAA055DD74A2D04E2EE18093AA59FE65E152CB699BECD8C5C9A82FB previews/Led-On.png
8DCDD5F2FE784EE9C9DAB98D092A89457CA171554481586927E23E0DF7FFC18D previews/Led-Off.png
+22
View File
@@ -0,0 +1,22 @@
# Issue #662 — LED-ленты
Архив содержит техническое задание и визуальные референсы для задачи [Matysh/houseplan-card#662](https://github.com/Matysh/houseplan-card/issues/662).
## Состав
- [`TZ-issue-662-LED-strips.md`](./TZ-issue-662-LED-strips.md) — сводное ТЗ: поведение из issue и визуальные требования из Figma.
- [`previews/Led-Off.png`](./previews/Led-Off.png) — четыре примера выключенных LED-лент.
- [`previews/Led-On.png`](./previews/Led-On.png) — четыре примера включённых LED-лент и рассеивания света.
- [`FIGMA-LINK.url`](./FIGMA-LINK.url) — ссылка на исходный макет.
- [`ISSUE-LINK.url`](./ISSUE-LINK.url) — ссылка на исходную задачу.
- [`MANIFEST-SHA256.txt`](./MANIFEST-SHA256.txt) — контрольные суммы файлов архива.
## Основные ссылки
- Issue: <https://github.com/Matysh/houseplan-card/issues/662>
- Figma, секция `Led Light`: <https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan?node-id=626-34>
- Фрейм `Led On`: <https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan?node-id=626-35>
- Фрейм `Led Off`: <https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan?node-id=626-53>
Дата подготовки: 1 октября 2026 года.
@@ -0,0 +1,210 @@
# Техническое задание: LED-ленты на плане
Связанная задача: [Matysh/houseplan-card#662](https://github.com/Matysh/houseplan-card/issues/662)
Визуальный макет: [Figma — `Led Light`](https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan?node-id=626-34)
Фреймы: [`Led On`](https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan?node-id=626-35) и [`Led Off`](https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan?node-id=626-53)
Дата фиксации: 01.10.2026
## 1. Назначение
Добавить в House Plan протяжённое представление устройств `light.*`: LED-лента отображается не точечным значком, а ломаной линией в реальном месте установки. Пользователь должен сразу понимать положение, форму, состояние и цвет ленты, а также включать и выключать её нажатием по любой части линии.
Поведенческий контракт определяется issue #662. Figma определяет визуальное направление, форму выключенной полосы, характер свечения, работу света у стен, в углах и на свободном участке пола. При возможном расхождении логика и размеры в единицах плана берутся из issue, внешний вид — из Figma.
## 2. Визуальные референсы
### 2.1. Выключенное состояние
![Led Off — четыре выключенные ленты](./previews/Led-Off.png)
Во фрейме `Led Off` показаны четыре варианта геометрии и размещения:
1. прямая лента на свободном участке плана;
2. прямая лента вплотную к стене;
3. Г-образная лента с поворотом в углу;
4. замкнутая прямоугольная лента по периметру зоны.
Требования к выключенной ленте:
- световое пятно отсутствует;
- ядро белое `#FFFFFF`;
- внешняя обводка тёмная `#383838`;
- концы и стыки визуально скруглены;
- линия остаётся различимой на светлом и тёмном фоне плана;
- в исходном Figma-фрейме номинальная высота белого ядра — 6 px, внешняя обводка — 3 px, радиус скругления — 5 px;
- в продукте итоговая толщина масштабируется с планом и подчиняется C6 issue: `0,08 D` для выключенного состояния, где `D` — диаметр значка маркера.
### 2.2. Включённое состояние
![Led On — четыре включённые ленты](./previews/Led-On.png)
Во фрейме `Led On` показаны четыре сценария:
1. синяя прямая лента на расстоянии от стены — свет расходится по обе стороны;
2. фиолетовая лента вдоль нижней стены — свет направлен в комнату;
3. жёлтая Г-образная лента вдоль верхней и правой стен — свет корректно продолжается через угол и ограничивается стенами;
4. зелёная лента по периметру помещения — свет соединяется на углах без разрывов и не выходит за стены.
Требования к включённой ленте:
- сама полоса остаётся читаемой поверх свечения;
- цвет свечения поступает от устройства: live RGB/цветовая температура либо `glow_color`;
- показанные в макете синий, фиолетовый, жёлтый и зелёный цвета являются примерами, а не фиксированной палитрой;
- свет распространяется непрерывно вдоль каждого сегмента;
- на свободных концах должен быть мягкий округлый спад без прямоугольного обрыва;
- в точках поворота световые поля сегментов соединяются без тёмного шва и без заметного удвоения яркости;
- свет, направленный к толстой стене, обрезается стеной; лента на грани стены освещает только сторону комнаты;
- в продукте толщина включённой линии — `0,12 D`; свечение не входит в эту толщину;
- появление и исчезновение поля света происходит через существующий `GLOW_FADE_MS`.
## 3. Измеренные параметры макета Figma
Параметры ниже документируют исходник 2543×1572 px и нужны для визуального сравнения. Они не заменяют масштабируемые величины `D` из issue.
| Элемент | Параметр в Figma |
|---|---|
| Ядро выключенной полосы | `#FFFFFF`, высота 6 px |
| Обводка | `#383838`, 3 px, Outside |
| Скругление | 5 px |
| Синий пример | `#80D5FF` → прозрачный |
| Фиолетовый пример | `#E680FF` → прозрачный |
| Жёлтый пример | `#FFEA80` → прозрачный |
| Зелёный пример | `#58FF58`, мягкие внутренние и внешние тени |
| Линейное поле | ориентировочная глубина 86–102 px в исходном фрейме |
| Радиальные окончания | диаметр 188–206 px |
| Blur радиальных окончаний | 22,2 px |
| Blur протяжённого поля | до 30 px в примерах |
В рабочем рендерере мягкость должна строиться градиентами по `GLOW_FALLOFF`; CSS/SVG `filter` не является обязательной частью реализации и не должен ухудшать производительность.
## 4. Модель данных
В каждом пространстве допускается необязательный массив:
```ts
space.led_strips: {
id: string;
points: number[][];
marker: string | null;
}[];
```
- `points` содержит от 2 до 50 точек в канонических координатах пространства;
- не более 50 лент в одном пространстве;
- `id` уникален в пространстве;
- один маркер может быть привязан не более чем к одной ленте во всех пространствах;
- версия модели не меняется, миграция не требуется;
- полный и per-space экспорт/импорт сохраняют геометрию; отсутствующий при импорте маркер превращает ленту в непривязанную.
Якорь ленты — точка на половине суммарной длины ломаной. Он используется для определения комнаты, подписи, бейджа, подсказки и агрегатов.
## 5. Рисование и редактирование
- В редакторе плана после инструмента «Перегородка» появляется кнопка `LED-лента` с иконкой `mdi:led-strip-variant`.
- Лента рисуется цепочкой точек по контракту инструмента стен.
- Клик или тап добавляет вершину; Shift ограничивает направление шагом 45°; Ctrl+Z удаляет последнюю добавленную точку.
- Завершение: Esc, двойной клик по последней точке, смена инструмента или выход из редактора.
- Цепочка короче двух точек отбрасывается.
- Панорамирование, pinch, второй палец и `pointercancel` не добавляют точки.
- Точки прилипают к сетке и к физическим граням стен с порогом магнита мебели.
- В v1 можно перетаскивать вершины и удалить ленту целиком. Вставка и удаление отдельной вершины не входят в задачу.
## 6. Стены и проёмы
- Лента не может пересекать тело стены с толщиной.
- При попытке пересечения новый сегмент останавливается на ближайшей грани тела стены.
- Касание стены и движение точно вдоль её грани допустимы.
- Через `door`, `gate` и `passage` лента проходит по геометрическому проёму независимо от текущего состояния двери.
- Окно остаётся частью тела стены и блокирует ленту.
- Стены нулевой толщины, Solid и Dashed, не являются телом и не блокируют рисование.
- Перетаскивание вершины проверяет оба соседних сегмента по тем же правилам.
- Если стена была утолщена или добавлена позже, геометрия ленты автоматически не исправляется; Optimize сообщает число проблемных лент.
## 7. Привязка устройства
- После завершения ломаной открывается существующий диалог добавления устройства; сущности `light.*` показываются первыми.
- Сохранение создаёт или переиспользует live-маркер и связывает его с лентой атомарно.
- Закрытие или «Позже» сохраняет непривязанную ленту.
- Непривязанная лента отображается серым пунктиром только в редакторах и не видна во View, киоске и space-card.
- После привязки обычный значок маркера исчезает, но его `layout` сохраняется.
- В лотке выбранной ленты доступны: привязать, сменить устройство, отвязать и удалить ленту.
- После отвязки или удаления маркер снова отображается значком на сохранённой позиции либо в авторасстановке.
## 8. Представление и состояния
| Состояние | Представление |
|---|---|
| On | линия `0,12 D`, полный цвет `resolveGlowAppearance`, поле света при активной заливке «Свечение источников света» |
| Off | линия `0,08 D`, белое/приглушённое ядро с тёмной обводкой по визуальному референсу, без поля света |
| Unavailable | серый пунктир, без поля света |
| Hidden | не отображается |
| Unbound | серый пунктир только в редакторах |
- Линия строится SVG `path` со скруглёнными концами и соединениями.
- Подпись и `value_badge` располагаются относительно якоря.
- Hover, long press, контекстное меню и клавиатурный фокус работают как у обычного маркера.
- Enter и Space запускают тот же `_clickDevice()`.
- У ленты нет пульсации и тревожного красного состояния.
## 9. Свечение
- Световой источник линейный: по сегментам строятся капсулы радиуса `glow_radius_cm`, а при отсутствии персонального значения — половины общего радиуса.
- Точки выборки: все вершины плюс промежуточные точки с шагом около 100 см, не более восьми точек на ленту.
- Капсулы одной ленты смешиваются через `lighten` внутри изолированной группы; с остальными пятнами группа смешивается через `screen`.
- Поле пересекается с полом и с объединением полигонов видимости; за стеной свет отсутствует.
- Для ленты на грани толстой стены точки выборки сдвигаются на эпсилон в сторону комнаты.
- Лента на стене нулевой толщины освещает обе стороны.
- Точка выборки внутри тела стены не создаёт веер; если внутри все точки, светового кандидата нет.
- В остальных режимах заливки световое поле не отображается.
## 10. Взаимодействие и 2.5D
- Активная зона проходит по всей ломаной: расстояние до неё не более `max(22 px экрана, половина толщины)`.
- Точка в 20 px от ленты должна попадать в неё, в 30 px — нет.
- При наложении с окрашенной капсулой значка приоритет у значка; между лентами выбирается ближайшая, затем стабильный порядок по `id`.
- В 2.5D лента поднимается на `0,075 D`, получает торец `0,1 D` и тень на полу; само световое поле остаётся на полу.
- `houseplan-space-card` отображает ленту и свет, но остаётся read-only.
## 11. Критерии визуальной приёмки по Figma
1. Во включённом и выключенном состоянии геометрия совпадает: изменяются цвет/толщина и наличие свечения, а не траектория.
2. Белое ядро и тёмная обводка сохраняют контраст на сером полу, возле штрихованных стен и поверх светового поля.
3. Прямые участки не выглядят как цепочка отдельных кругов.
4. На концах нет резкого прямоугольного обрыва света.
5. На углах отсутствуют тёмные разрывы и чрезмерно яркие круглые пятна.
6. Свечение ленты у стены направлено в помещение и не окрашивает пол за стеной.
7. Замкнутая прямоугольная лента даёт непрерывное поле по периметру.
8. Цвет меняется от сущности без изменения формы, радиуса и поведения границ.
9. При выключении поле плавно исчезает, а тонкая полоса остаётся видимой.
10. Масштабирование плана сохраняет пропорции толщины, обводки, активной зоны и 2.5D-подъёма.
## 12. Проверки
Обязательные группы приёмки из issue #662:
- backend: лимиты, уникальность, конечность координат, ссылки на маркеры, экспорт/импорт;
- unit: якорь, рендер состояний, hit-test, линейные кандидаты света, стены, auto-grid, space-card;
- smoke: рисование, привязка/отвязка, свечение и clip-path, 2.5D, упор в стену;
- golden: `lighting-led-strip-glow-dark`, `led-strip-off-light`, `iso-led-strip-dark`;
- i18n: паритет ru/en/de/fr;
- performance: benchmark десяти лент по пять точек.
## 13. Вне скоупа
- эффекты и адресные сегменты WLED;
- вставка или удаление отдельных вершин;
- отдельная высота крепления и дополнительная 3D-модель света от стены;
- кривые Безье и произвольные гирлянды;
- интерактивность в `houseplan-space-card`;
- автоматическая починка лент, оказавшихся внутри стены.
## 14. Комплект поставки
- реализация модели, редактора, представления, света, 2.5D, импорта/экспорта и валидации;
- сущность `light.demo_led_strip` в demo/golden/smoke fixtures;
- четыре словаря i18n;
- unit, backend, smoke, golden и mutant-проверки по issue;
- документация и changelog RU/EN;
- замер производительности в handoff;
- визуальное сравнение с двумя PNG из этого архива.
Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 KiB

+18 -11
View File
@@ -14,11 +14,11 @@ to prove that the Node witness actually kills it.
| --- | ---: | --- |
| Performance threshold | 4 | The witness measures real browser wall-time or frame work; a pure assertion cannot prove the budget. |
| Browser harness integrity | 4 | The mutation breaks page-error, round-trip or page-registration observation in the browser harness itself. |
| Paint, cascade and layer composition | 26 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
| Pointer geometry and trusted interaction | 45 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
| Paint, cascade and layer composition | 30 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
| Pointer geometry and trusted interaction | 47 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
| Responsive DOM layout | 38 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. |
| Custom-element and HA browser lifecycle | 99 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **216 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
| Custom-element and HA browser lifecycle | 100 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **223 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
## Measured effect
@@ -91,6 +91,10 @@ The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels p
- `iso-sun-card-drops-occluders`
- `iso-sun-flat-wedges-remain`
- `iso-theme-dark-wall-rule-returns`
- `led-core-coloured-under-glow`
- `led-icon-not-suppressed`
- `led-source-stays-round-at-anchor`
- `led-unbound-in-view`
- `stage3-w4-device-target-loses-44px-floor`
- `stage3-w5-runtime-nudge-writes-storage`
- `stage3-w6-no-borders-keeps-raised-plates`
@@ -119,6 +123,8 @@ The invariant depends on hit testing, pointer capture, touch/keyboard dispatch o
- `furniture-shift-listeners-not-attached`
- `furniture-wall-runtime-drops-drag-side`
- `furniture-wall-runtime-drops-raw-intent`
- `led-pan-adds-point`
- `led-pinch-calls-action`
- `live-pinch-compositor-demoted-on-active-lit-commit`
- `opening-dimension-overlay-hidden`
- `opening-search-hides-none`
@@ -228,8 +234,6 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy
- `device-focus-tooltip-handler-removed`
- `device-focus-tooltip-room-hover-overwrites`
- `device-inbox-batch-rollback`
- `household-enter-stops-acting`
- `household-marker-drops-keyboard-reach`
- `device-markers-rendered-without-keys`
- `device-pointer-leave-clears-focus-fallback`
- `device-position-cancel-routed-to-commit`
@@ -240,10 +244,13 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy
- `floor-geometry-key-global-epoch`
- `floor-geometry-key-ignores-content`
- `french-locale-wrong-dictionary`
- `household-enter-stops-acting`
- `household-marker-drops-keyboard-reach`
- `hp-dialog-escape-does-not-close`
- `junction-limit-baseline-cache-stale`
- `junction-limit-candidate-fail-open`
- `junction-limit-write-gate-removed`
- `led-auto-slot-reserved`
- `locale-failure-toast-dropped`
- `marker-reject-keeps-optimistic-candidate`
- `marker-rollback-keeps-enqueue-time-revision`
@@ -278,16 +285,16 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy
- `support-stale-preview-response-revives-consent`
- `support-timeout-claims-success`
- `vacuum-overlay-back-to-the-dock-space-filter`
- `volumetric-kiosk-ignores-setting`
- `view-current-space-aria-removed`
- `volumetric-kiosk-ignores-setting`
- `wall-face-apply-skips-overlap-guard`
- `wallthick-hit-narrowed`
- `warm-pending-mode-leaves-revive-waiting`
- `warm-resume-overwrites-view-return-camera`
- `warm-resume-camera-depends-on-dialog`
- `warm-pan-during-runtime-keeps-refit-blocked`
- `warm-late-resume-beats-user-mode`
- `warm-late-resume-crosses-space`
- `warm-pan-during-runtime-keeps-refit-blocked`
- `warm-pending-mode-leaves-revive-waiting`
- `warm-resume-camera-depends-on-dialog`
- `warm-resume-overwrites-view-return-camera`
- `writer-history-skips-finished-chain-normalization`
- `zigbee-topology-endpoint-cleanup-skipped`
- `zigbee-topology-endpoint-elevation-removed`
+1
View File
@@ -30,6 +30,7 @@
"benchmark:isometric-stage3-dense": "node demo/benchmark_large_house.mjs --profile=isometric-stage3-dense-v1",
"benchmark:large-house-plan-snap": "node demo/benchmark_large_house.mjs --profile=large-house-plan-snap-v1",
"benchmark:large-house-interaction": "node demo/benchmark_large_house.mjs --profile=large-house-interaction-v1",
"benchmark:led-strips": "node demo/benchmark_led_strips.mjs",
"benchmark:glow": "node demo/benchmark_glow.mjs",
"benchmark:compare": "node demo/performance/compare.mjs",
"benchmark:optimize-geometry-preflight": "node demo/benchmark_optimize_geometry_preflight.mjs",
+29
View File
@@ -668,6 +668,16 @@ export const LAZY_GRAPH_CEILING_BAND = 2_000;
*/
export const LAZY_ONBOARDING_GZIP_CEILING = 28_396;
/**
* #780 ТЗ §13.1: the LED chunks are a new feature's own budget, not a raise of
* an existing ceiling. Measured on the production build at the hand-off
* (runtime + linear field + shared geometry 8 698 B; Devices-editor tool +
* its English dictionary + shared geometry 9 913 B), plus 10 %, rounded up to
* a KiB. Absolute walls: a View without strips loads neither graph.
*/
export const LAZY_LED_GZIP_CEILING = 10 * 1024;
export const LAZY_LED_EDITOR_GZIP_CEILING = 11 * 1024;
/**
* Потолок ленивого графа: `null`, пока значение внутри полосы.
*
@@ -749,6 +759,21 @@ export function assertBundleBudget(
if (manifest.lazyEditorFiles.some((path) => manifest.lazyMoonFiles.includes(path))) {
throw new Error('lazy editor graph overlaps lazy moon graph');
}
// #780 ТЗ §13.1: the LED runtime/field and the LED tool are lazy, own-budget
// graphs; neither enters the first frame or the static editor graph.
for (const [files, bytes, ceiling, label] of [
[manifest.lazyLedFiles, manifest.lazyLedGzipBytes, LAZY_LED_GZIP_CEILING, 'lazy LED graph'],
[manifest.lazyLedEditorFiles, manifest.lazyLedEditorGzipBytes, LAZY_LED_EDITOR_GZIP_CEILING, 'lazy LED editor graph'],
]) {
if (!files?.length) throw new Error(`bundle has no ${label}`);
if (manifest.initialViewFiles.some((path) => files.includes(path))) {
throw new Error(`initial View graph overlaps ${label}`);
}
if (manifest.lazyEditorFiles.some((path) => /\/led-strip-/.test(path) && files.includes(path))) {
throw new Error(`lazy editor graph overlaps ${label}`);
}
if (bytes > ceiling) throw new Error(`${label} ${bytes} B gzip exceeds its ${ceiling} B ceiling`);
}
// #474: designer furniture artwork is lazy; a static import anywhere in the
// View graph would pull ~10 KB gzip back into the initial graph silently.
if (!manifest.lazyFurnitureArtFiles?.length) {
@@ -822,6 +847,8 @@ export function assertBundleBudget(
lazyFurnitureArtGzipBytes: manifest.lazyFurnitureArtGzipBytes,
lazyPdfGzipBytes: manifest.lazyPdfGzipBytes,
lazyMoonGzipBytes: manifest.lazyMoonGzipBytes,
lazyLedGzipBytes: manifest.lazyLedGzipBytes,
lazyLedEditorGzipBytes: manifest.lazyLedEditorGzipBytes,
};
}
@@ -857,6 +884,8 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
`lazy isometric: ${result.lazyIsometricGzipBytes} B gzip`,
`lazy PDF: ${result.lazyPdfGzipBytes} B gzip`,
`lazy moon: ${result.lazyMoonGzipBytes} B gzip`,
`lazy LED: ${result.lazyLedGzipBytes} B gzip (потолок ${LAZY_LED_GZIP_CEILING} B)`,
`lazy LED editor: ${result.lazyLedEditorGzipBytes} B gzip (потолок ${LAZY_LED_EDITOR_GZIP_CEILING} B)`,
];
for (const line of lines) console.log(line);
const warning = lowHeadroomWarning(headroom);
+14
View File
@@ -545,6 +545,20 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([
migration: 'v9 migration resolves opening-vs-zero-wall conflicts (#316)',
compatibility: 'v8 configs without partition hosts stay valid',
},
{
id: 'spaces[].led_strips',
selector: { path: ['spaces', '*', 'led_strips'] },
storage: 'House Plan server config',
type: "Array<{ id, points: [x, y][], marker: string | null, active?: boolean }>",
default: 'absent = no strips',
level: 'space',
ui: 'Devices editor: LED strip tool, LED tray, device dialog «Show as LED strip / icon» (#780)',
runtime: 'LED stripe and linear Glow field (lazy chunks), anchor of the bound marker, static card',
introduced: 'v1.79.0',
status: 'current',
migration: 'none: optional array, no model_version bump; orphan links normalise to unbound geometry on write',
compatibility: 'older clients keep the unknown array; deleting a bound marker unbinds the strip instead of failing',
},
]);
export const CONFIG_FIELD_STATUSES = Object.freeze([
+2 -2
View File
@@ -1,8 +1,8 @@
{
"delegates": 154,
"portMembers": 349,
"hostRefs": 5050,
"hostRefs": 5057,
"portPrivates": 103,
"harnessPrivates": 100,
"bundleBytes": 2664132
"bundleBytes": 2667879
}
+142 -3
View File
@@ -13741,9 +13741,8 @@ const MUTANT_DEFINITIONS = [
+ 'Перевёрнутое условие закрывает «грязный» диалог молча и спрашивает у чистого',
patches: [{
file: 'src/editors/marker-dialog.ts',
// #780: the guard is the dialog's shared `leave` (close and the LED representation switch).
find: " if (edit && dirty && !await this.host._confirmDanger({",
replace: " if ((!edit || !dirty) && !await this.host._confirmDanger({",
find: " if (!edit || !dirty) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); return; }",
replace: " if (!edit || dirty) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); return; }",
}],
},
{
@@ -14235,6 +14234,146 @@ const MUTANT_DEFINITIONS = [
replace: " ? body.slice(0, at).replace(/\\s+$/, '')",
}],
},
{
id: 'led-icon-not-suppressed',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: "#780 ТЗ §5: a marker shown as an LED strip draws no ordinary icon; the old icon must not stay under the stripe",
patches: [{
file: 'src/houseplan-card.ts',
find: "(d) => d.space === space.id && (!d.hidden || showGhosts) && !leds.has(d.id));",
replace: "(d) => d.space === space.id && (!d.hidden || showGhosts));",
}],
},
{
id: 'led-source-stays-round-at-anchor',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: "#780 ТЗ §3: the strip is a linear source — no second round pool from its anchor",
patches: [{
file: 'src/houseplan-card.ts',
find: " if (leds.has(key.slice(space.id.length + 1))) continue;\n",
replace: "\n",
}],
},
{
id: 'led-auto-slot-reserved',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: "#780 ТЗ §5: a marker represented by a strip reserves no auto-grid slot",
patches: [{
file: 'src/houseplan-card.ts',
find: "d.space === s.id && !leds.has(d.id));",
replace: "d.space === s.id);",
}],
},
{
id: 'led-default-radius-shared',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/led-strip-runtime.test.mjs',
because: "#780 ТЗ §3: the field radius is 50 cm by default, independent of the shared radius of ordinary sources",
patches: [{
file: 'src/led-strip-geometry.ts',
find: "export const LED_DEFAULT_RADIUS_CM = 50;",
replace: "export const LED_DEFAULT_RADIUS_CM = 360;",
}],
},
{
id: 'led-core-coloured-under-glow',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: "#780 ТЗ §3: with Glow the core stays white and the colour is the field; only without Glow the core takes the source colour",
patches: [{
file: 'src/led-strip-runtime.ts',
find: ": view.state === 'on' && !view.glow && view.appearance ? view.appearance.c : CORE_IDLE;",
replace: ": view.state === 'on' && view.appearance ? view.appearance.c : CORE_IDLE;",
}],
},
{
id: 'led-wall-offset-removed',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/led-strip-geometry.test.mjs',
because: "#780 AC8: on a thick face the visible stripe moves t/2 into the free floor",
patches: [{
file: 'src/led-strip-geometry.ts',
find: " const shift = piece.free ? [piece.free[0] * offset, piece.free[1] * offset] : [0, 0];",
replace: " const shift = [0, 0];",
}],
},
{
id: 'led-emits-from-body',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/led-strip-geometry.test.mjs',
because: "#780 ТЗ §6: a part of the strip inside a body emits nothing",
patches: [{
file: 'src/led-strip-geometry.ts',
find: " if (ctx && ctx.inside(s)) continue;\n",
replace: "\n",
}],
},
{
id: 'led-long-polyline-loses-vertices',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/led-strip-geometry.test.mjs',
because: "#780 AC10: every segment of a long polyline emits — no vertex beyond the first eight is dropped",
patches: [{
file: 'src/led-strip-geometry.ts',
find: " for (const piece of stripPieces(points, ctx)) {\n const len = dist(piece.a, piece.b);",
replace: " for (const piece of stripPieces(points, ctx).slice(0, 8)) {\n const len = dist(piece.a, piece.b);",
}],
},
{
id: 'led-pan-adds-point',
guard: 'node demo/smoke_led_strip_draw.mjs',
because: "#780 AC4: a pan or pinch is no clean click and adds no chain point",
patches: [{
file: 'src/led-strip-editor.ts',
find: " const clean = !!start && !this.gesture && e.button === 0",
replace: " const clean = !!start && e.button === 0",
}],
},
{
id: 'led-pinch-calls-action',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: "#780 AC4: the stripe shares the card gesture owner, so a pan over it calls no service",
patches: [{
file: 'src/led-strip-runtime.ts',
find: " @pointerdown=${(e: PointerEvent) => h.pointerdown(e, own(e))}\n"
+ " @pointermove=${(e: PointerEvent) => h.pointermove(e, own(e))}\n"
+ " @pointerup=${(e: PointerEvent) => h.pointerup(e, own(e))}",
replace: " @pointerdown=${(e: PointerEvent) => { e.stopPropagation(); h.pointerdown(e, own(e)); }}\n"
+ " @pointermove=${(e: PointerEvent) => e.stopPropagation()}\n"
+ " @pointerup=${(e: PointerEvent) => { e.stopPropagation(); h.pointerup(e, own(e)); }}",
}],
},
{
id: 'led-binding-not-unique',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/led-strip-editor.test.mjs',
because: "#780 ТЗ §5: a marker bound to another strip cannot be taken silently",
patches: [{
file: 'src/led-strip-editor.ts',
find: " if (owner && owner.strip.id !== stripId) { this.host._showToast(this.t('led.taken')); return; }",
replace: " void owner;",
}],
},
{
id: 'led-unbound-in-view',
guard: 'node demo/smoke_led_strip_glow.mjs',
because: "#780 ТЗ §5: an unbound strip has no View representation, light or target",
patches: [{
file: 'src/led-strip-runtime.ts',
find: " if (!device || device.space !== input.space.id) continue;",
replace: " if (device && device.space !== input.space.id) continue;",
}],
},
{
id: 'led-transfer-remap-ignored',
guard: 'node scripts/backend-test-guard.mjs issue_780_space_import_remaps '
+ 'tests_backend/test_ha_import_export.py',
because: '#780 ТЗ §9: a transferred strip follows the marker-id remap; a coinciding old id never binds it',
patches: [{
file: 'custom_components/houseplan/import_export.py',
find: ' unbound_led_strips = unbind_strips(space, remap={\n old_id: new_id for old_id, new_id in marker_map.items()',
replace: ' unbound_led_strips = unbind_strips(space, remap={\n old_id: old_id for old_id, new_id in marker_map.items()',
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
+13
View File
@@ -600,6 +600,19 @@ export const SMOKE_LINKS = [
+ 'isometric smoke inspects door/window/gate bases from the same helper, but neither '
+ 'browser bundle exposes the helper name in its test steps',
},
{
// #780: the LED chunks are lazy modules reached only through the bundle;
// the smokes drive the Devices editor, the View and the static card by
// DOM (`data-led-*`) and never name a helper of these files.
files: [
'src/led-strip-gate.ts', 'src/led-strip-card.ts', 'src/led-strip-geometry.ts',
'src/led-strip-runtime.ts', 'src/led-strip-field.ts', 'src/led-strip-editor.ts',
],
smokes: ['smoke_led_strip_draw.mjs', 'smoke_led_strip_bind.mjs', 'smoke_led_strip_glow.mjs'],
because: '#780: drawing, placement against walls, binding, the icon ↔ strip switch, the LED '
+ 'history, the View target, the field states and the static card are observed only as '
+ 'rendered strips and saved configs; no smoke names the lazy modules’ functions',
},
{
symbols: ['cachedStairMarkup', 'stairTreadPath', 'StairMarkup'],
smokes: ['smoke_stairs.mjs'],
+7 -11
View File
@@ -168,21 +168,17 @@ export function renderMarkerDialog(this: HouseplanEditorRuntime): TemplateResult
&& (edit ? dirty : true);
const saveTitle = problems[0] ? st(problems[0].message)
: bindingUnverified && !!d.binding && d.binding !== 'virtual' ? t('marker.ha_registry_limited') : '';
// #780: the same save/discard guard also runs before a representation change.
const leave = async () => {
if (edit && dirty && !await this.host._confirmDanger({
const requestClose = async (event: Event) => {
if (!edit || !dirty) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); return; }
const dialog = event.currentTarget as { rejectClose?: () => void } | null;
const discard = await this.host._confirmDanger({
key: 'discard-marker-dialog', kind: 'warning',
title: st('dialog.discard_title'), message: st('dialog.discard_message'),
objectName: d.name.trim() || previewDevice?.name || undefined,
confirmLabel: st('dialog.discard_confirm'), cancelLabel: st('dialog.discard_keep'),
icon: 'mdi:content-save-off-outline', confirmIcon: 'mdi:content-save-off-outline',
})) return false;
forgetMarkerBaseline(this.host); this._closeMarkerDialog();
return true;
};
const requestClose = async (event: Event) => {
const dialog = event.currentTarget as { rejectClose?: () => void } | null;
if (!await leave()) dialog?.rejectClose?.();
});
if (discard) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); } else dialog?.rejectClose?.();
};
const reviewFirst = () => {
const first = problems[0];
@@ -864,7 +860,7 @@ export function renderMarkerDialog(this: HouseplanEditorRuntime): TemplateResult
.badge=${previewDevice ? this.host._spaceModelById(previewDevice.space)?.title ?? '' : this.host._spaceModel()?.title ?? ''}
icon="mdi:shape-plus" wide @hp-close=${requestClose}>
<div class="body hpf-form">
${basics}${d.devId ? this.host._ledSection(d.devId, leave) : nothing}${tap}${light}${appearance}${details}
${basics}${this.host._ledSection(d.devId)}${tap}${light}${appearance}${details}
</div>
<div class="row dialog-action-footer hpf-footer" slot="footer">
<div class="dialog-action-group markeractions">
+5 -3
View File
@@ -7,7 +7,7 @@
* The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage.
*/
import { ledAnchor, ledRuntime, ledStripsByMarker } from './led-strip-gate';
import { ledButton, ledEditorFor, ledHistory, ledSection } from './led-strip-card';
import { ledButton, ledEditorFor, ledHistory, ledImportNote, ledSection, ledWallsNote } from './led-strip-card';
import { LitElement, html, svg, nothing, noChange, TemplateResult, PropertyValues, type PropertyDeclaration } from 'lit';
import { cache as litCache } from 'lit/directives/cache.js';
import { keyed } from 'lit/directives/keyed.js';
@@ -5232,7 +5232,8 @@ export class HouseplanCard extends LitElement {
// HA-disabled saved markers stay in the roster: they render nowhere
// outside the service ghost, but reserve their old auto-grid slot so
// temporary deactivation cannot shuffle visible neighbours.
const leds = ledStripsByMarker(s);
// #780: read the stored strips (the model may lag one adoption behind).
const leds = ledStripsByMarker(this._serverCfg?.spaces.find((x) => x.id === s.id) ?? s);
const ds = this._devices.filter((d) => d.area === r.area && d.space === s.id && !leds.has(d.id));
if (!ds.length) continue;
const b = this._roomBounds(r);
@@ -10515,7 +10516,8 @@ export class HouseplanCard extends LitElement {
public _ledEditor: import('./led-strip-editor').LedStripEditor | null = null;
private _ledEd(entry: string) { return ledEditorFor(this as never, entry); }
public _ledButton() { return ledButton(this as never); }
public _ledSection(devId: string, leave: () => Promise<boolean>) { return ledSection(this as never, devId, leave); }
public _ledSection(devId?: string) { return devId ? ledSection(this as never, devId) : nothing; }
public _ledNote(preview?: { unbound_led_strips?: number }) { return preview ? ledImportNote(this as never, preview.unbound_led_strips) : ledWallsNote(this as never); }
private _renderSettingsDialog(): TemplateResult {
return this._editorRuntimeOrThrow()._renderSettingsDialog();
+6 -2
View File
@@ -440,7 +440,9 @@ export interface HouseplanEditorHostPort {
/** #780: the LED tool lives in its own lazy chunk; the card owns its loader. */
_ledEditor: import('./led-strip-editor').LedStripEditor | null;
_ledButton(): TemplateResult;
_ledSection(devId: string, leave: () => Promise<boolean>): TemplateResult | typeof nothing;
_ledSection(devId?: string): TemplateResult | typeof nothing;
/** No argument: strips through walls for «Optimize plans»; an import preview: its unbound-strip line. */
_ledNote(importPreview?: { unbound_led_strips?: number }): TemplateResult | typeof nothing;
_setMode(mode: 'view' | 'plan' | 'devices' | 'decor', animate?: boolean, warm?: boolean): void;
_ackNewDevice: (id: string) => void;
_activeWallChainId: string | null;
@@ -881,6 +883,7 @@ export class HouseplanEditorRuntime {
clearGeometryGesture: () => this._clearGeometryGesture(),
commit: (config, layout) => commitPlanOptimization(host, config, layout),
reloadAfterConflict: () => Promise.all([host._reloadConfigOnly(true), host._reloadLayoutOnly()]),
ledNote: () => host._ledNote(),
});
host._editorSecondary = new EditorSecondaryController({
root: () => host.renderRoot as ShadowRoot,
@@ -2247,7 +2250,7 @@ public _canAppendWallPoint(): boolean {
public _markupClick(ev: MouseEvent): void {
if (this.host._vacFit) return; // the fit overlay owns all pointer input
if (this.host._mode === 'devices') this.host._ledEditor?.backgroundClick(ev);
this.host._ledEditor?.backgroundClick(ev);
if (!this.host._markup) return;
const space = this.host._spaceModel();
if (!space) {
@@ -9423,6 +9426,7 @@ public _renderBackupImportDialog(): TemplateResult {
${p.dropped_marker_links ? html`<div class="backupwarn">${this.host._t('backup.dropped_marker_links', {
n: String(p.dropped_marker_links),
})}</div>` : nothing}
${this.host._ledNote(p)}
${draftMigration.room_drafts || draftMigration.room_draft_segments ? html`
<div class="rhint">${this.host._t('backup.room_drafts_migrated', {
drafts: String(draftMigration.room_drafts || 0),
+1
View File
@@ -1245,6 +1245,7 @@
"backup.binding_status": "Zielstatus — aktiv: {active}, deaktiviert: {disabled}, fehlt: {missing}",
"backup.missing_areas": "Auf dem Ziel fehlende Bereiche: {areas}",
"backup.dropped_marker_links": "Planlicht-Verknüpfungen außerhalb dieses übertragenen Bereichs wurden ausgelassen: {n}.",
"backup.unbound_led_strips": "LED-Streifen ohne Gerät nach dem Import: {n}",
"backup.room_drafts_migrated": "Alte unvollständige Konturen wurden in normale Wände umgewandelt: {drafts}; Wandsegmente: {segments}.",
"backup.repaired_target_refs": "Durch den Import dieses Bereichs wiederhergestellte vorhandene Referenzen: {n}.",
"backup.preserved_unresolved_refs": "Referenzen, die nicht eindeutig wiederhergestellt werden konnten, wurden beibehalten: {n}.",
+1
View File
@@ -1245,6 +1245,7 @@
"backup.binding_status": "Target status — active: {active}, disabled: {disabled}, missing: {missing}",
"backup.missing_areas": "Areas missing on the target: {areas}",
"backup.dropped_marker_links": "Plan-light links outside this transferred space were omitted: {n}.",
"backup.unbound_led_strips": "Strips left unbound after import: {n}",
"backup.room_drafts_migrated": "Legacy unfinished contours converted to ordinary walls: {drafts}; wall segments: {segments}.",
"backup.repaired_target_refs": "Existing references restored by this space import: {n}.",
"backup.preserved_unresolved_refs": "References that could not be restored unambiguously were preserved: {n}.",
+1
View File
@@ -1245,6 +1245,7 @@
"backup.binding_status": "État des cibles — actives : {active}, désactivées : {disabled}, manquantes : {missing}",
"backup.missing_areas": "Zones absentes sur la cible : {areas}",
"backup.dropped_marker_links": "Liens de lumières du plan hors de cet espace transféré omis : {n}.",
"backup.unbound_led_strips": "Rubans LED sans appareil après l’import : {n}",
"backup.room_drafts_migrated": "Anciens contours inachevés convertis en murs ordinaires : {drafts} ; segments : {segments}.",
"backup.repaired_target_refs": "Références existantes restaurées par cet import d’espace : {n}.",
"backup.preserved_unresolved_refs": "Références qui n’ont pas pu être restaurées sans ambiguïté conservées : {n}.",
-1
View File
@@ -30,6 +30,5 @@
"led.history_draw": "LED-Streifen",
"led.history_shape": "Form des LED-Streifens",
"led.history_view": "Darstellung des Geräts",
"led.import_unbound": "Streifen ohne Gerät nach dem Import: {n}",
"led.optimize_walls": "Streifen durch Wände: {n} ({spaces})"
}
-1
View File
@@ -30,6 +30,5 @@
"led.history_draw": "LED strip",
"led.history_shape": "LED strip shape",
"led.history_view": "Device representation",
"led.import_unbound": "Strips left unbound after import: {n}",
"led.optimize_walls": "Strips passing through walls: {n} ({spaces})"
}
-1
View File
@@ -30,6 +30,5 @@
"led.history_draw": "Ruban LED",
"led.history_shape": "Forme du ruban LED",
"led.history_view": "Représentation de l’appareil",
"led.import_unbound": "Rubans sans appareil après l’import : {n}",
"led.optimize_walls": "Rubans traversant des murs : {n} ({spaces})"
}
-1
View File
@@ -30,6 +30,5 @@
"led.history_draw": "LED-лента",
"led.history_shape": "Форма LED-ленты",
"led.history_view": "Вид устройства",
"led.import_unbound": "Лент без устройства после импорта: {n}",
"led.optimize_walls": "Лент, проходящих сквозь стены: {n} ({spaces})"
}
+1
View File
@@ -1245,6 +1245,7 @@
"backup.binding_status": "Статус на целевом экземпляре — активных: {active}, деактивированных: {disabled}, отсутствующих: {missing}",
"backup.missing_areas": "На целевом экземпляре отсутствуют зоны: {areas}",
"backup.dropped_marker_links": "Связи с источниками света вне переносимого пространства пропущены: {n}.",
"backup.unbound_led_strips": "Лент без устройства после импорта: {n}",
"backup.room_drafts_migrated": "Старые незавершённые контуры преобразованы в обычные стены: {drafts}; отрезков: {segments}.",
"backup.repaired_target_refs": "Существующих ссылок восстановлено этим импортом: {n}.",
"backup.preserved_unresolved_refs": "Неоднозначные ссылки сохранены без изменений: {n}.",
+17 -4
View File
@@ -14,6 +14,7 @@ interface LedCardPort {
_ledEditor: LedStripEditor | null;
_mode: string;
_serverCfg: ServerConfig | null;
_alignDialog: { config: ServerConfig } | null;
_devicePositionBusy: boolean;
_devicePositionHistory: { undo(): unknown; redo(): unknown; clear(): void };
_t(key: string, vars?: Record<string, string | number>): string;
@@ -43,14 +44,26 @@ export function ledButton(card: LedCardPort): TemplateResult {
* only (ТЗ §5). A strip's dialog elsewhere still loads the tool: its save
* renames the link on a rebinding.
*/
export function ledSection(
card: LedCardPort, devId: string, leave: () => Promise<boolean>,
): TemplateResult | typeof nothing {
export function ledSection(card: LedCardPort, devId: string): TemplateResult | typeof nothing {
const devices = card._mode === 'devices';
if (!devices && !card._serverCfg?.spaces.some((space: { led_strips?: Array<{ marker: string | null }> }) =>
space.led_strips?.some((strip) => strip.marker === devId))) return nothing;
const led = ledEditorFor(card, 'dialog');
return devices ? led?.markerSection(devId, leave) ?? nothing : nothing;
return devices ? led?.markerSection(devId) ?? nothing : nothing;
}
const hasStrips = (config: ServerConfig | null | undefined) => !!config?.spaces.some(
(space: { led_strips?: unknown[] }) => space.led_strips?.length);
/** «Optimize plans»: strips through walls of the optimised result, counted by the tool (AC16). */
export function ledWallsNote(card: LedCardPort): TemplateResult | typeof nothing {
const config = card._alignDialog?.config;
return config && hasStrips(config) ? ledEditorFor(card, 'optimize')?.wallsNote(config) ?? nothing : nothing;
}
/** The import summary: strips whose device did not travel are left unbound (§9). */
export function ledImportNote(card: LedCardPort, n: unknown): TemplateResult | typeof nothing {
return n ? html`<div class="backupwarn" data-led-unbound-import=${String(n)}>${card._t('backup.unbound_led_strips', { n: String(n) })}</div>` : nothing;
}
/** Undo/Redo of an LED command (ТЗ §4 п.9): its own strip record, never a newer foreign change. */
+75 -15
View File
@@ -55,6 +55,8 @@ export interface LedEditorHost {
_devices: DevItem[];
_suppressClick: boolean;
_markerDialog: { devId?: string } | null;
_dangerConfirm: unknown;
renderRoot: ParentNode;
_editorRuntime: LedEditorRuntime | null;
_cellCm: number;
_gridPitch: number;
@@ -74,6 +76,8 @@ export interface LedEditorHost {
_openCuts(): number[][];
_roomWallOpeningInputs(openings: Opening[], space: SpaceModel): Array<{ x: number; y: number; angle: number; length: number }>;
_partitionOpeningCuts(space: SpaceModel, accept: (opening: OpeningCfg) => boolean): unknown[];
_checkSpacePhysicalGeometry(config: ServerConfig, spaceId: string,
capture: (geometry: { geom: unknown; components: ReadonlyArray<{ geom: unknown }> }) => void): unknown;
_rollbackOptimistic(attempt: OptimisticAttempt<ServerConfig>): boolean;
_maybeRebuildDevices(): void;
_showToast(message: string): void;
@@ -119,6 +123,26 @@ function ownerOf(cfg: ServerConfig | null | undefined, markerId: string) {
return null;
}
/** Opaque bodies from polygon geometries; a point on a face is outside (touching and sliding work). */
function bodiesOf(geoms: unknown[], eps: number): PlacementBodies {
const rings = geoms.flatMap((geom) => geometryAllRings(geom)).map((ring) => ring.map((p) => [p[0], p[1]] as Pt));
const nearFace = (p: Pt) => rings.some((ring) => ring.some((a, i) => {
const q = nearestOnSegment(p, a, ring[(i + 1) % ring.length]);
return Math.hypot(p[0] - q[0], p[1] - q[1]) <= eps;
}));
return {
rings,
inside: (p) => geoms.some((geom) => pointInPhysicalGeometry([p[0], p[1]], geom)) && !nearFace(p),
};
}
/** A stored strip passes through a body: a point inside, or a segment that enters one. */
export function stripCrossesBodies(points: readonly number[][], bodies: PlacementBodies): boolean {
const pts = points.map(scaleIn);
return pts.some((p) => bodies.inside(p))
|| pts.slice(1).some((p, i) => !!clampToBodies(pts[i], p, bodies)?.stopped);
}
function snap45(from: Pt, to: Pt): Pt {
const dx = to[0] - from[0], dy = to[1] - from[1];
const len = Math.hypot(dx, dy);
@@ -198,19 +222,11 @@ export class LedStripEditor {
} catch {
geoms = []; // fail-open for placement only: light keeps its own barriers
}
const rings = geoms.flatMap((geom) => geometryAllRings(geom)).map((ring) => ring.map((p) => [p[0], p[1]] as Pt));
const eps = host._gridPitch * 1e-4;
const nearFace = (p: Pt) => rings.some((ring) => ring.some((a, i) => {
const q = nearestOnSegment(p, a, ring[(i + 1) % ring.length]);
return Math.hypot(p[0] - q[0], p[1] - q[1]) <= eps;
}));
// Strict interior: a point on a face is outside, so touching and sliding along work.
const inside = (p: Pt) => geoms.some((geom) => pointInPhysicalGeometry([p[0], p[1]], geom)) && !nearFace(p);
const guides = space.rooms.flatMap((room) => {
const poly = roomPoly(room);
return poly ? [poly.map((p) => [p[0], p[1]] as Pt)] : [];
});
this.bodyCache = { key, bodies: { rings, inside }, guides };
this.bodyCache = { key, bodies: bodiesOf(geoms, host._gridPitch * 1e-4), guides };
return this.bodyCache;
}
@@ -295,7 +311,7 @@ export class LedStripEditor {
/** A clean click on the free background drops the selection (ТЗ §4 п.8). */
backgroundClick(ev: MouseEvent): void {
if (!this.sel || this.tool || this.host._suppressClick) return;
if (!this.sel || this.tool || this.host._suppressClick || this.host._mode !== 'devices') return;
const path = (ev.composedPath?.() || []) as Element[];
// Only the plan itself is background: not the tray, a dialog, a strip or a handle.
if (!path.some((node) => node?.classList?.contains?.('zoomwrap'))
@@ -454,6 +470,8 @@ export class LedStripEditor {
if (marker && !marker.space) marker.space = spaceId;
}
candidate = this.rt._prepareConfigCandidate(candidate);
// The command keeps the record as stored (after canonicalisation), so Undo can recognise it.
const stored = clone(stripsOf(candidate, spaceId).find((item) => item.id === stripId) || null);
const attempt = this.host._adoption.beginOptimistic(cfg, candidate);
this.host._adoption.stageLocalConfig(candidate);
if (this.host._saveConfigDebounced.pending()) this.host._saveConfigDebounced.cancel();
@@ -467,7 +485,7 @@ export class LedStripEditor {
this.host._devicePositionHistory.push({
name,
before: { kind: 'led', spaceId, stripId, strip: before } satisfies LedHistoryState,
after: { kind: 'led', spaceId, stripId, strip: after } satisfies LedHistoryState,
after: { kind: 'led', spaceId, stripId, strip: stored } satisfies LedHistoryState,
});
}
return true;
@@ -779,16 +797,58 @@ export class LedStripEditor {
</hp-dialog>`;
}
/** Close the device dialog through its own `hp-close` path (save/discard guard). */
private async leaveDialog(): Promise<boolean> {
const dialog = this.host.renderRoot.querySelector('#marker-dialog');
dialog?.dispatchEvent(new CustomEvent('hp-close'));
const tick = () => new Promise((resolve) => setTimeout(resolve, 30));
await tick();
while (this.host._dangerConfirm) await tick();
await tick();
return !this.host._markerDialog;
}
private crossings = new WeakMap<object, { n: number; spaces: string[] }>();
/**
* «Optimize plans» reports strips passing through walls (ТЗ §6, AC16) and
* changes none of them: each space of the optimised result is measured
* against its own wall bodies (doors cut; windows, partitions, columns solid).
*/
wallsNote(config: ServerConfig): TemplateResult | typeof nothing {
let found = this.crossings.get(config);
if (!found) {
found = { n: 0, spaces: [] };
for (const space of config.spaces as Array<Strips & { title?: string }>) {
const strips = (space.led_strips || []).filter((strip) => validStripPoints(strip.points));
if (!strips.length) continue;
let geoms: unknown[] = [];
try {
this.host._checkSpacePhysicalGeometry(config, space.id, (geometry) => {
geoms = [geometry.geom, ...geometry.components.map((c) => c.geom)];
});
} catch { continue; }
const bodies = bodiesOf(geoms, this.host._gridPitch * 1e-4);
const n = strips.filter((strip) => stripCrossesBodies(strip.points, bodies)).length;
if (n) { found.n += n; found.spaces.push(String(space.title || space.id)); }
}
this.crossings.set(config, found);
}
return found.n ? html`<div class="rhint" data-led-walls=${found.n}>${this.t('led.optimize_walls',
{ n: found.n, spaces: found.spaces.join(', ') })}</div>` : nothing;
}
/**
* The representation section of the device dialog (ТЗ §5): "Show as LED
* strip" (restoring a hidden shape at once, or drawing one) or "Show as
* icon"; for a hidden shape also unbind/delete. `leave` runs the dialog's
* own save/discard guard and closes it; false keeps the dialog.
* icon"; for a hidden shape also unbind/delete. The dialog is left through
* its own close path first — the shared save/discard guard decides, and a
* kept dialog cancels the action.
*/
markerSection(devId: string, leave: () => Promise<boolean>): TemplateResult {
markerSection(devId: string): TemplateResult {
const owner = ownerOf(this.host._serverCfg, devId);
const active = !!owner && owner.strip.active !== false;
const then = (action: () => unknown) => async () => { if (await leave()) await action(); };
const then = (action: () => unknown) => async () => { if (await this.leaveDialog()) await action(); };
const button = (name: string, icon: string, text: string, run: () => unknown, cls = 'ghost') =>
html`<button class="btn ${cls}" type="button" data-led-action=${name} ?disabled=${this.busy}
@click=${then(run)}><ha-icon icon=${icon}></ha-icon>${text}</button>`;
+131 -60
View File
@@ -13,16 +13,35 @@
* lights through another part's visibility, a closed strip has no seam and a
* corner no double brightness.
*/
import { svg, type TemplateResult } from 'lit';
import { noChange, nothing, svg, type TemplateResult } from 'lit';
import { Directive, directive, type PartInfo } from 'lit/directive.js';
import { repeat } from 'lit/directives/repeat.js';
import {
GLOW_FALLOFF, buildGlowClipGeometry, type LightBarrierScene, type LightRoomPolygon,
GLOW_FALLOFF, type LightBarrierScene, type LightRoomPolygon,
} from './glow-scene';
import { visibilityPolygon } from './light-visibility';
import {
compactPoints, emitterSamples, isClosedStrip, type FaceContext, type Pt,
} from './led-strip-geometry';
import type { LedStripView } from './led-strip-runtime';
/**
* `guard` without its own shared chunk: an unchanged field (same cached
* geometry, state and colour) is not diffed again on a camera move or an
* unrelated HA tick.
*/
class Memo extends Directive {
private deps: readonly unknown[] | null = null;
constructor(part: PartInfo) { super(part); }
render(_deps: readonly unknown[], build: () => unknown): unknown { return build(); }
update(_part: unknown, [deps, build]: [readonly unknown[], () => unknown]): unknown {
if (this.deps && this.deps.length === deps.length && deps.every((dep, i) => dep === this.deps![i])) return noChange;
this.deps = deps;
return build();
}
}
const memo = directive(Memo);
export const LED_FIELD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
/** Bands of the luminance field: 16 steps keep a band under 7 % of the range. */
@@ -32,7 +51,8 @@ const pts = (points: readonly number[][]): Pt[] => points.map((p) => [p[0], p[1]
interface FieldPiece {
d: string;
clip: string[];
/** Visibility fans of the piece's emitters; `null` = nothing blocks within the radius. */
clip: string[] | null;
}
interface FieldGeometry {
@@ -48,8 +68,15 @@ export class LedFieldCache {
get size(): number { return this.entries.size; }
/** Recompute counter for the performance witness: geometry, not paint. */
recomputes = 0;
/** Stable DOM ids per strip of the shown space: a re-render rebuilds no masks. */
private ids = new Map<string, number>();
id(stripId: string): number {
let id = this.ids.get(stripId);
if (id == null) { id = this.ids.size + 1; this.ids.set(stripId, id); }
return id;
}
forSpace(spaceId: string): void {
if (spaceId !== this.space) { this.entries.clear(); this.space = spaceId; }
if (spaceId !== this.space) { this.entries.clear(); this.ids.clear(); this.space = spaceId; }
}
read(key: string, build: () => FieldGeometry | null): FieldGeometry | null {
if (this.entries.has(key)) {
@@ -68,17 +95,62 @@ export class LedFieldCache {
}
return value;
}
clear(): void { this.entries.clear(); this.space = ''; }
clear(): void { this.entries.clear(); this.ids.clear(); this.space = ''; }
}
const pointsKey = (points: readonly number[][]): string =>
points.map((p) => `${p[0].toFixed(5)},${p[1].toFixed(5)}`).join(';');
/** Fans of the field: a coarser arc than pools — the far rim sits where the falloff is 0. */
const LED_ARC_STEPS = 32;
const segmentDistance = (p: Pt, s: readonly number[]): number => {
const dx = s[2] - s[0], dy = s[3] - s[1];
const len2 = dx * dx + dy * dy;
const t = len2 ? Math.max(0, Math.min(1, ((p[0] - s[0]) * dx + (p[1] - s[1]) * dy) / len2)) : 0;
return Math.hypot(p[0] - s[0] - t * dx, p[1] - s[1] - t * dy);
};
const ringPath = (ring: readonly number[][]): string =>
`${ring.map((p, k) => `${k ? 'L' : 'M'}${p[0]} ${p[1]}`).join(' ')} Z`;
/**
* Pieces of a strip for the field: stored segments cut to at most the radius,
* each with the union of what its own emitters see (ТЗ §6). A piece whose
* emitters are all inside a body emits nothing; a failed clip makes that piece
* dark (fail-dark), never an unclipped field.
* What a piece's emitters can see (ТЗ §6): the visibility fans of the shared
* `visibilityPolygon` with the scene's occluders, kept as separate paths of
* one clipPath (SVG unions the children) — no boolean pass per piece. `null`
* means no occluder is within the radius of any emitter: every fan is a full
* disc and the 2r-wide bands already bound the light. The floor itself is one
* clip of the whole field layer (`fieldFloor`).
*/
function fans(emitters: readonly Pt[], radius: number, scene: LightBarrierScene): string[] | null {
const reach = radius * 1.01;
if (!scene.occluders.some((seg) => seg?.length >= 4
&& emitters.some((p) => segmentDistance(p, seg) < reach))) return null;
return emitters
.map((p) => visibilityPolygon([p[0], p[1]], radius, scene.occluders, LED_ARC_STEPS))
.filter((fan) => fan.length >= 3)
.map(ringPath);
}
/** The floor clip of the whole field layer, built once per scene. */
const floorPaths = new WeakMap<LightBarrierScene, string[]>();
function fieldFloor(scene: LightBarrierScene): string[] {
let paths = floorPaths.get(scene);
if (!paths) {
paths = scene.floor.filter((ring) => ring.length >= 3).map(ringPath);
floorPaths.set(scene, paths);
}
return paths;
}
/**
* Pieces of a strip for the field (ТЗ §6, §13.2): the stored polyline is cut
* into consecutive pieces no longer than the radius — short segments of a
* dense strip share one piece, a long one is split — and every piece is
* clipped to the union of what its own emitters see. Emitters keep every
* vertex and the radius/4 spacing of `emitterSamples`, thinned to radius/4 on
* dense strips; a piece whose emitters are all inside a body emits nothing; a
* failed clip makes that piece dark, never an unclipped field.
*/
export function buildFieldGeometry(input: {
points: readonly number[][];
@@ -91,38 +163,44 @@ export function buildFieldGeometry(input: {
const path = compactPoints(pts(input.points));
if (path.length < 2 || !(input.radius > 0)) return null;
const r = input.radius;
// Consecutive pieces of at most r along the polyline.
const runs: Pt[][] = [];
let run: Pt[] = [path[0]];
let left = r;
for (let i = 1; i < path.length; i++) {
let a = path[i - 1];
const b = path[i];
let len = Math.hypot(b[0] - a[0], b[1] - a[1]);
while (len > left + 1e-12) {
const t = left / len;
const cut: Pt = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
run.push(cut);
runs.push(run);
run = [cut];
a = cut;
len -= left;
left = r;
}
run.push(b);
left -= len;
}
if (run.length > 1) runs.push(run);
const pieces: FieldPiece[] = [];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let i = 1; i < path.length; i++) {
const a = path[i - 1], b = path[i];
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
const count = Math.max(1, Math.ceil(len / r));
for (let k = 0; k < count; k++) {
const p0: Pt = [a[0] + ((b[0] - a[0]) * k) / count, a[1] + ((b[1] - a[1]) * k) / count];
const p1: Pt = [a[0] + ((b[0] - a[0]) * (k + 1)) / count, a[1] + ((b[1] - a[1]) * (k + 1)) / count];
const samples = emitterSamples([p0, p1], input.faces, r / 4);
if (!samples.length) continue;
const clip: string[] = [];
let failed = false;
for (const source of samples) {
try {
const lit = buildGlowClipGeometry({
spaceId: input.spaceId,
source: { x: source[0], y: source[1] },
radius: r,
scene: input.scene,
polygons: input.polygons,
onBoundsFailure: () => { failed = true; },
}).lit;
clip.push(...lit);
} catch {
failed = true;
}
}
if (failed || !clip.length) continue;
pieces.push({ d: `M${p0[0]} ${p0[1]} L${p1[0]} ${p1[1]}`, clip });
minX = Math.min(minX, p0[0], p1[0]); minY = Math.min(minY, p0[1], p1[1]);
maxX = Math.max(maxX, p0[0], p1[0]); maxY = Math.max(maxY, p0[1], p1[1]);
for (const piece of runs) {
const emitters: Pt[] = [];
for (const p of emitterSamples(piece, input.faces, r / 4)) {
const last = emitters[emitters.length - 1];
if (!last || Math.hypot(p[0] - last[0], p[1] - last[1]) >= r / 4) emitters.push(p);
}
if (!emitters.length) continue;
let clip: string[] | null;
try { clip = fans(emitters, r, input.scene); } catch { continue; } // fail-dark for this piece
if (clip && !clip.length) continue;
pieces.push({ d: piece.map((p, k) => `${k ? 'L' : 'M'}${p[0]} ${p[1]}`).join(' '), clip });
for (const p of piece) {
minX = Math.min(minX, p[0]); minY = Math.min(minY, p[1]);
maxX = Math.max(maxX, p[0]); maxY = Math.max(maxY, p[1]);
}
}
if (!pieces.length) return null;
@@ -145,18 +223,6 @@ const grey = (value: number): string => {
return `rgb(${level},${level},${level})`;
};
/** Stable DOM ids per strip: a re-render must not rebuild masks and clips. */
const fieldIds = new Map<string, number>();
const fieldId = (spaceId: string, stripId: string): number => {
const key = `${spaceId}|${stripId}`;
let id = fieldIds.get(key);
if (id == null) {
id = fieldIds.size + 1;
fieldIds.set(key, id);
}
return id;
};
export interface LedFieldInput {
views: readonly LedStripView[];
scene: LightBarrierScene | null;
@@ -207,26 +273,30 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
const inner = 1 - (k + 1) / LED_FIELD_BANDS;
return { half: outer, value: falloffAt((outer + inner) / 2) };
});
return svg`<g class="led-fields" pointer-events="none" aria-hidden="true">
// The performance witness (led-strips-v1) reads the bounded cache from the DOM.
return svg`<g class="led-fields" pointer-events="none" aria-hidden="true"
data-led-cache="${cache.size}" data-led-recomputes="${cache.recomputes}">
<defs><clipPath id="hp-led-floor">${fieldFloor(scene).map((d) => svg`<path d="${d}"></path>`)}</clipPath></defs>
<g clip-path="url(#hp-led-floor)">
${repeat(fields, ({ view }) => view.strip.id, ({ view, geometry }) => {
const id = `${fieldId(input.spaceId, view.strip.id)}`;
const id = `${cache.id(view.strip.id)}`;
const r = view.radius;
const on = view.state === 'on' && !!view.appearance;
const box = geometry.box;
const closed = isClosedStrip(pts(view.strip.points));
return svg`<g class="glow-spot led-field ${on ? '' : 'is-leaving'}" data-led-field="${view.strip.id}"
return memo([geometry, on, view.appearance?.c, view.appearance?.alpha, r, id], () => svg`<g class="glow-spot led-field ${on ? '' : 'is-leaving'}" data-led-field="${view.strip.id}"
data-pieces="${geometry.pieces.length}" data-closed="${closed ? 'true' : 'false'}">
<defs>
${geometry.pieces.map((piece, k) => svg`<clipPath id="hp-led-clip-${id}-${k}">
${piece.clip.map((d) => svg`<path d="${d}" clip-rule="evenodd"></path>`)}
</clipPath>`)}
${geometry.pieces.map((piece, k) => piece.clip ? svg`<clipPath id="hp-led-clip-${id}-${k}">
${piece.clip.map((d) => svg`<path d="${d}"></path>`)}
</clipPath>` : nothing)}
<mask id="hp-led-mask-${id}" maskUnits="userSpaceOnUse"
x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}"
color-interpolation="sRGB" style="mask-type:luminance">
<rect x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" fill="black"></rect>
<g style="isolation:isolate">
${geometry.pieces.map((piece, k) => svg`<g clip-path="url(#hp-led-clip-${id}-${k})"
style="mix-blend-mode:lighten">
${geometry.pieces.map((piece, k) => svg`<g style="mix-blend-mode:lighten"
clip-path=${piece.clip ? `url(#hp-led-clip-${id}-${k})` : nothing}>
${bands.map((band) => svg`<path d="${piece.d}" fill="none" stroke="${grey(band.value)}"
stroke-width="${2 * band.half * r}" stroke-linecap="round" stroke-linejoin="round"></path>`)}
</g>`)}
@@ -236,8 +306,9 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
<rect class="glow-pool led-pool" x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}"
fill="${view.appearance?.c ?? 'transparent'}" fill-opacity="${(view.appearance?.alpha ?? 0).toFixed(4)}"
mask="url(#hp-led-mask-${id})"></rect>
</g>`;
</g>`);
})}
</g>
</g>` as unknown as TemplateResult;
}
+8 -1
View File
@@ -118,6 +118,8 @@ export interface FaceContext {
inside: (point: Pt) => boolean;
/** `LED_EPSILON_CM` in plan units. */
epsilon: number;
/** Optional spatial index: the faces whose box meets `[minX, minY, maxX, maxY]`. */
near?: (box: readonly number[]) => readonly BodyFace[];
}
/** One piece of a stored segment: on a face (side = unit normal into free floor) or free. */
@@ -179,11 +181,16 @@ function freeNormal(a: Pt, b: Pt, at: Pt, ctx: FaceContext): [number, number] |
export function stripPieces(points: readonly Pt[], ctx: FaceContext | null): StripPiece[] {
const path = compactPoints(points);
const pieces: StripPiece[] = [];
let faces = ctx?.faces ?? [];
if (ctx?.near && path.length) {
const xs = path.map((p) => p[0]), ys = path.map((p) => p[1]), e = ctx.epsilon * 2;
faces = ctx.near([Math.min(...xs) - e, Math.min(...ys) - e, Math.max(...xs) + e, Math.max(...ys) + e]);
}
for (let i = 1; i < path.length; i++) {
const a = path[i - 1], b = path[i];
const intervals: Array<[number, number]> = [];
if (ctx) {
for (const face of ctx.faces) {
for (const face of faces) {
const hit = overlapOnFace(a, b, face, ctx.epsilon);
if (hit) intervals.push(hit);
}
+37 -6
View File
@@ -26,7 +26,7 @@ import { roomGlowOf, roomPoly } from './logic';
import { ISO_ICON_SCALE, ISO_TILE, isoEdgeColor, isoTileShadow } from './iso-tiles';
import { deviceThemeClass } from './device-face';
import type { VirtualLightSnapshot } from './virtual-light-state';
import { geometryAllRings, pointInOpaquePlanBody } from './physical-geometry';
import { geometryAllRings, pointInPhysicalBody, pointInPhysicalGeometry } from './physical-geometry';
import {
LED_DEFAULT_RADIUS_CM, LED_EPSILON_CM, LED_THICKNESS_OFF_D, LED_THICKNESS_ON_D,
pathD, stripAnchor, stripHitOwner,
@@ -88,10 +88,8 @@ export interface LedStripView {
/** Faces and the inside test of the opaque bodies, from the shared light scene. */
export function faceContext(scene: LightBarrierScene | null, epsilon: number): FaceContext | null {
if (!scene) return null;
const rings = [
...geometryAllRings(scene.masonryGeometry),
...scene.opaqueBodies,
];
const masonry: number[][][][] = Array.isArray(scene.masonryGeometry) ? scene.masonryGeometry as number[][][][] : [];
const rings = [...geometryAllRings(scene.masonryGeometry), ...scene.opaqueBodies];
const faces: BodyFace[] = [];
for (const ring of rings) {
for (let i = 0; i < ring.length; i++) {
@@ -99,10 +97,43 @@ export function faceContext(scene: LightBarrierScene | null, epsilon: number): F
if (a && b) faces.push({ a: [a[0], a[1]], b: [b[0], b[1]] });
}
}
// A uniform grid over the faces and boxes of the bodies (ТЗ §13.2): a strip
// or an emitter only ever looks at what is near it, never at the whole space.
const CELL = 25;
const grid = new Map<string, number[]>();
faces.forEach((face, index) => {
const x0 = Math.floor(Math.min(face.a[0], face.b[0]) / CELL), x1 = Math.floor(Math.max(face.a[0], face.b[0]) / CELL);
const y0 = Math.floor(Math.min(face.a[1], face.b[1]) / CELL), y1 = Math.floor(Math.max(face.a[1], face.b[1]) / CELL);
for (let x = x0; x <= x1; x++) {
for (let y = y0; y <= y1; y++) {
const key = `${x},${y}`;
const list = grid.get(key);
if (list) list.push(index); else grid.set(key, [index]);
}
}
});
const boxOf = (ring: readonly number[][]) => {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of ring) { minX = Math.min(minX, p[0]); minY = Math.min(minY, p[1]); maxX = Math.max(maxX, p[0]); maxY = Math.max(maxY, p[1]); }
return [minX, minY, maxX, maxY];
};
const inBox = (p: Pt, box: number[]) => p[0] >= box[0] && p[0] <= box[2] && p[1] >= box[1] && p[1] <= box[3];
const polygons = masonry.filter((poly) => poly?.[0]?.length).map((poly) => ({ box: boxOf(poly[0]), poly }));
const bodies = scene.opaqueBodies.map((body) => ({ box: boxOf(body), body }));
return {
faces,
inside: (point) => pointInOpaquePlanBody([point[0], point[1]], scene.masonryGeometry, scene.opaqueBodies),
inside: (point) => polygons.some(({ box, poly }) => inBox(point, box) && pointInPhysicalGeometry([point[0], point[1]], [poly]))
|| bodies.some(({ box, body }) => inBox(point, box) && pointInPhysicalBody([point[0], point[1]], body)),
epsilon,
near: (box) => {
const seen = new Set<number>();
for (let x = Math.floor(box[0] / CELL); x <= Math.floor(box[2] / CELL); x++) {
for (let y = Math.floor(box[1] / CELL); y <= Math.floor(box[3] / CELL); y++) {
for (const index of grid.get(`${x},${y}`) || []) seen.add(index);
}
}
return [...seen].map((index) => faces[index]);
},
};
}
+3
View File
@@ -65,6 +65,8 @@ export interface OptimizePlansDialogPort {
commit(config: ServerConfig, layout: DeviceLayout): Promise<void>;
/** a write conflict reloads both stores before the error toast */
reloadAfterConflict(): Promise<unknown>;
/** #780: strips passing through walls of the optimised result (lazy LED tool). */
ledNote(): TemplateResult | typeof nothing;
}
export function preflightDiagnostics(
@@ -462,6 +464,7 @@ export class OptimizePlansDialog {
n: String(r.nestedRefsUnresolved),
})}</div>`
: nothing}
${this.port.ledNote()}
${referenceDetails.length
? html`<details class="optimize-details">
<summary>${this.port.t('gs.optimize_details')}</summary>
+26
View File
@@ -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,
+2 -2
View File
@@ -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',
+31 -2
View File
@@ -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}`);
});
+1 -1
View File
@@ -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,',
+2
View File
@@ -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) };
+30 -1
View File
@@ -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);
});