perf: упростить SVG-поле LED-лент (#780)
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s

Issue: #780
User-Visible: no
This commit is contained in:
Matysh
2026-10-02 23:38:35 +03:00
parent e9e6eba5d1
commit 6607a34973
2 changed files with 41 additions and 12 deletions
+31 -11
View File
@@ -44,8 +44,12 @@ 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. */
export const LED_FIELD_BANDS = 16;
/**
* Bands of the luminance field. Twelve keep the midpoint error at the r/2
* visual acceptance point below 10%, without multiplying every visibility
* piece into sixteen SVG paint nodes.
*/
export const LED_FIELD_BANDS = 12;
const pts = (points: readonly number[][]): Pt[] => points.map((p) => [p[0], p[1]] as Pt);
@@ -107,8 +111,13 @@ export class LedFieldCache {
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;
/**
* Fans of the field only bound the zero-alpha outer rim. A 16-gon keeps its
* maximum radial error below 2%; every visible acceptance point at r/2 stays
* well inside it, while the heavy 50×50 scene carries half as many clip
* segments through every camera rasterization.
*/
const LED_ARC_STEPS = 16;
const segmentDistance = (p: Pt, s: readonly number[]): number => {
const dx = s[2] - s[0], dy = s[3] - s[1];
@@ -117,8 +126,14 @@ const segmentDistance = (p: Pt, s: readonly number[]): number => {
return Math.hypot(p[0] - s[0] - t * dx, p[1] - s[1] - t * dy);
};
/** SVG does not gain visible precision from JS's full decimal expansion. */
const coord = (value: number): string => {
const rounded = Math.round(value * 10_000) / 10_000;
return Object.is(rounded, -0) ? '0' : String(rounded);
};
const ringPath = (ring: readonly number[][]): string =>
`${ring.map((p, k) => `${k ? 'L' : 'M'}${p[0]} ${p[1]}`).join(' ')} Z`;
`${ring.map((p, k) => `${k ? 'L' : 'M'}${coord(p[0])} ${coord(p[1])}`).join(' ')} Z`;
/**
* What a piece's emitters can see (ТЗ §6): the visibility fans of the shared
@@ -300,19 +315,25 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
const on = view.state === 'on' && !!view.appearance;
const box = geometry.box;
const closed = isClosedStrip(pts(view.strip.points));
const clipped = geometry.pieces.flatMap((piece, k): Array<FieldPiece & { clip: string[]; clipId: number }> => piece.clip
? [{ ...piece, clip: piece.clip, clipId: k }] : []);
const free = geometry.pieces.filter((piece) => !piece.clip).map((piece) => piece.d).join(' ');
const paint = [...clipped, ...(free ? [{ d: free, clip: null, clipId: -1 }] : [])];
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) => piece.clip ? svg`<clipPath id="hp-led-clip-${id}-${k}">
${piece.clip.map((d) => svg`<path d="${d}"></path>`)}
</clipPath>` : nothing)}
${clipped.map((piece) => svg`<clipPath id="hp-led-clip-${id}-${piece.clipId}">
${''/* Subpaths of one path have the same union semantics in a clipPath,
without one DOM node per emitter. */}
<path d="${piece.clip.join(' ')}"></path>
</clipPath>`)}
<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 style="mix-blend-mode:lighten"
clip-path=${piece.clip ? `url(#hp-led-clip-${id}-${k})` : nothing}>
${paint.map((piece) => svg`<g style="mix-blend-mode:lighten"
clip-path=${piece.clip ? `url(#hp-led-clip-${id}-${piece.clipId})` : 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>`)}
@@ -327,4 +348,3 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
</g>
</g>` as unknown as TemplateResult;
}
+10 -1
View File
@@ -4,7 +4,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ledAnchor, ledStripsByMarker } from '../test-build/led-strip-gate.js';
import { faceContext, ledFrame, ledStripView, stripRoom } from '../test-build/led-strip-runtime.js';
import { LedFieldCache, buildFieldGeometry, falloffAt } from '../test-build/led-strip-field.js';
import { LED_FIELD_BANDS, 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';
@@ -53,6 +53,15 @@ test('ТЗ §3: the linear falloff is the shared GLOW_FALLOFF', () => {
}
});
test('AC10/AC17: the compact field bands stay within the r/2 visual tolerance', () => {
const fraction = 0.5;
const band = Math.floor((1 - fraction) * LED_FIELD_BANDS);
const midpoint = 1 - (band + 0.5) / LED_FIELD_BANDS;
const exact = falloffAt(fraction);
assert.ok(Math.abs(falloffAt(midpoint) - exact) / exact <= 0.1,
`${LED_FIELD_BANDS} bands must approximate r/2 within 10%`);
});
const device = (extra = {}) => ({ id: 'm1', name: 'Kitchen LED', primary: 'light.led', space: 's', ...extra });
const strip = { id: 'a', points: [[0, 0], [1, 0]], marker: 'm1' };