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 13:35:11 +03:00
parent 1511996c5e
commit 9825819cd4
64 changed files with 2075 additions and 134 deletions
+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);