diff --git a/src/led-strip-field.ts b/src/led-strip-field.ts index f796744c..a6415ace 100644 --- a/src/led-strip-field.ts +++ b/src/led-strip-field.ts @@ -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 => 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` - ${geometry.pieces.map((piece, k) => piece.clip ? svg` - ${piece.clip.map((d) => svg``)} - ` : nothing)} + ${clipped.map((piece) => svg` + ${''/* Subpaths of one path have the same union semantics in a clipPath, + without one DOM node per emitter. */} + + `)} - ${geometry.pieces.map((piece, k) => svg` + ${paint.map((piece) => svg` ${bands.map((band) => svg``)} `)} @@ -327,4 +348,3 @@ export function renderLedField(input: LedFieldInput): TemplateResult { ` as unknown as TemplateResult; } - diff --git a/test/led-strip-runtime.test.mjs b/test/led-strip-runtime.test.mjs index e10e8e8d..8d94c46a 100644 --- a/test/led-strip-runtime.test.mjs +++ b/test/led-strip-runtime.test.mjs @@ -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' };