diff --git a/demo/golden/matrix.mjs b/demo/golden/matrix.mjs index 2935915b..24a64a73 100644 --- a/demo/golden/matrix.mjs +++ b/demo/golden/matrix.mjs @@ -1333,6 +1333,12 @@ export const GOLDEN_SCENARIOS = Object.freeze([ // #785 the active visibility clip kept only the wall fans and dropped both // free diagonals because SVG clip paths ignore open-path strokes. ledStripOverride: { points: [[0.12, 0.15], [0.551667, 0.35], [0.551667, 0.55], [0.16, 0.45]] }, ...stage }, + { id: 'led-strip-endcaps-zoom-light', fixture: 'visual', ledStrips: true, space: 'golden-led', + mode: 'view', fillMode: 'none', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 }, + // #786: at 4× the free end and the end inside one Glow radius of masonry + // must keep the same smooth circular rim; the wall still clips its side. + zoom: 4, zoomCenter: [500, 380], + ledStripOverride: { points: [[0.43, 0.35], [0.535, 0.35]] }, ...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 }, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ede8ab75..a82f57ef 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Fixed LED-strip Glow end caps so free ends stay smoothly round at high zoom + instead of exposing polygon facets depending on nearby walls + ([#786](https://github.com/Matysh/houseplan-card/issues/786)). + ## v1.79.0-beta.5 — 2026-10-03 - Fixed Glow for LED strips with mixed geometry: long free segments no longer diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 767e0f88..261e1223 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,11 @@ ## Не выпущено +- Исправлены торцы Glow у LED-лент: свободные концы остаются + плавно круглыми при сильном приближении и больше не показывают грани + многоугольника в зависимости от соседних стен + ([#786](https://github.com/Matysh/houseplan-card/issues/786)). + ## v1.79.0-beta.5 — 2026-10-03 - Исправлено свечение LED-лент со смешанной геометрией: длинные свободные diff --git a/docs/LIGHT.md b/docs/LIGHT.md index 0a632c63..4bfa7ff0 100644 --- a/docs/LIGHT.md +++ b/docs/LIGHT.md @@ -429,8 +429,10 @@ only the geometry differs: 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, + whole field layer is clipped once to the floor. An unobstructed emitter uses + an exact SVG disc; a blocked fan keeps hard obstacle edges and exact circular + arcs between them, so free ends cannot expose angular-sweep facets. 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 diff --git a/docs/design/led-strips/ACCEPTANCE.md b/docs/design/led-strips/ACCEPTANCE.md index 7fa22eb8..587d0420 100644 --- a/docs/design/led-strips/ACCEPTANCE.md +++ b/docs/design/led-strips/ACCEPTANCE.md @@ -43,7 +43,7 @@ behaviours, not of positions. | 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 30 cm by default, own `glow_radius_cm` wins; round free ends; no seams, bands, missing free runs or doubled brightness at corners/closure | `ledFrame`, one continuous path through the filled visibility fans in `led-strip-field` | unit `test/led-strip-runtime.test.mjs`; golden `led-strip-long-zigzag-glow-light`; reference pair | +| Field 30 cm by default, own `glow_radius_cm` wins; round free ends; no seams, bands, missing free runs or doubled brightness at corners/closure | `ledFrame`, one continuous path through exact circular free fans and obstacle-clipped visibility fans in `led-strip-field` | unit `test/led-strip-runtime.test.mjs`; golden `led-strip-long-zigzag-glow-light`, `led-strip-endcaps-zoom-light`; 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) | diff --git a/src/led-strip-field.ts b/src/led-strip-field.ts index 78ea0ff7..f783d5d7 100644 --- a/src/led-strip-field.ts +++ b/src/led-strip-field.ts @@ -114,10 +114,9 @@ const pointsKey = (points: readonly number[][]): string => points.map((p) => `${p[0].toFixed(5)},${p[1].toFixed(5)}`).join(';'); /** - * Fans of the field only bound the zero-alpha outer rim. A 12-gon keeps its - * maximum radial error below 3.5%; every visible acceptance point at r/2 stays - * well inside it, while the heavy 50×50 scene carries fewer clip segments - * through every camera rasterization. + * Barrier visibility still needs angular samples, but an unobstructed emitter + * is represented by SVG arcs rather than by that polygon. This keeps a free + * end truly round at every zoom without increasing the retained fan count. */ const LED_ARC_STEPS = 12; @@ -137,6 +136,33 @@ const coord = (value: number): string => { const ringPath = (ring: readonly number[][]): string => `${ring.map((p, k) => `${k ? 'L' : 'M'}${coord(p[0])} ${coord(p[1])}`).join(' ')} Z`; +/** Exact disc in one path: two half-circle arcs avoid a polygonal free end. */ +const discPath = (center: Pt, radius: number): string => { + const left = coord(center[0] - radius), right = coord(center[0] + radius); + const cy = coord(center[1]), r = coord(radius); + return `M${left} ${cy} A${r} ${r} 0 1 0 ${right} ${cy} A${r} ${r} 0 1 0 ${left} ${cy} Z`; +}; + +/** + * Preserve hard obstacle edges, but join consecutive points on the radius by + * exact circular arcs. The visibility sweep is angle-sorted, so sweep=1 also + * covers the final 2π → 0 seam without a chord. + */ +const visibilityPath = (center: Pt, radius: number, ring: readonly number[][]): string => { + if (ring.length < 3) return ''; + const tolerance = Math.max(1e-9, radius * 1e-7); + const onRadius = (p: readonly number[]): boolean => + Math.abs(Math.hypot(p[0] - center[0], p[1] - center[1]) - radius) <= tolerance; + let d = `M${coord(ring[0][0])} ${coord(ring[0][1])}`; + for (let i = 1; i <= ring.length; i++) { + const previous = ring[i - 1], point = ring[i % ring.length]; + d += onRadius(previous) && onRadius(point) + ? ` A${coord(radius)} ${coord(radius)} 0 0 1 ${coord(point[0])} ${coord(point[1])}` + : ` L${coord(point[0])} ${coord(point[1])}`; + } + return `${d} Z`; +}; + /** * What a piece's emitters can see (ТЗ §6): the visibility fans of the shared * `visibilityPolygon` with the scene's occluders. When no occluder is close, @@ -148,17 +174,14 @@ const ringPath = (ring: readonly number[][]): string => */ function fans(emitters: readonly Pt[], radius: number, scene: LightBarrierScene): string[] { const reach = radius * 1.01; - if (!scene.occluders.some((seg) => seg?.length >= 4 - && emitters.some((p) => segmentDistance(p, seg) < reach))) { - return emitters.map((p) => ringPath(Array.from({ length: LED_ARC_STEPS }, (_, k) => { - const angle = (k / LED_ARC_STEPS) * Math.PI * 2; - return [p[0] + Math.cos(angle) * radius, p[1] + Math.sin(angle) * radius]; - }))); - } - return emitters - .map((p) => visibilityPolygon([p[0], p[1]], radius, scene.occluders, LED_ARC_STEPS)) - .filter((fan) => fan.length >= 3) - .map(ringPath); + return emitters.flatMap((p) => { + const blocked = scene.occluders.some((seg) => seg?.length >= 4 + && segmentDistance(p, seg) < reach); + if (!blocked) return [discPath(p, radius)]; + const fan = visibilityPolygon([p[0], p[1]], radius, scene.occluders, LED_ARC_STEPS); + const path = visibilityPath(p, radius, fan); + return path ? [path] : []; + }); } /** The floor clip of the whole field layer, built once per scene. */ diff --git a/test/led-strip-runtime.test.mjs b/test/led-strip-runtime.test.mjs index 3b344ad7..458dd96e 100644 --- a/test/led-strip-runtime.test.mjs +++ b/test/led-strip-runtime.test.mjs @@ -105,13 +105,17 @@ test('ТЗ §6: every piece is clipped to what its own emitters see; a buried st 'visibility/cache pieces do not split the painted path'); for (const piece of geometry.pieces) { assert.ok(piece.clip.length >= 2, 'free pieces retain filled visibility fans for the shared clip'); + assert.ok(piece.clip.every((d) => /\bA2 2\b/.test(d) && !/\bL/.test(d)), + 'an unobstructed fan is an exact SVG disc, not a visible polygon'); } // 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.some((d) => (d.match(/[ML]/g) || []).length > 12)); + const clipped = near.pieces.filter((piece) => piece.clip.some((d) => /\bL/.test(d))); 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); + assert.ok(piece.clip.some((d) => /\bA1 1\b/.test(d)), + 'unblocked parts of a clipped fan retain exact circular arcs'); // 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)) { @@ -148,6 +152,21 @@ test('#785: a mixed free/wall polyline keeps visibility for every piece', () => 'the long free run retains several overlapping visibility discs'); }); +test('#786: reversing a free strip keeps two equally smooth circular end fans', () => { + const freeScene = { ...scene, occluders: [], fingerprint: 'free' }; + const forward = buildFieldGeometry({ points: [[1, 2], [9, 3]], radius: 2, + scene: freeScene, polygons, faces: null, spaceId: 's' }); + const reverse = buildFieldGeometry({ points: [[9, 3], [1, 2]], radius: 2, + scene: freeScene, polygons, faces: null, spaceId: 's' }); + for (const geometry of [forward, reverse]) { + assert.ok(geometry); + const fans = geometry.pieces.flatMap((piece) => piece.clip); + assert.ok(fans.length >= 2); + assert.ok(fans.every((d) => (d.match(/\bA2 2\b/g) || []).length === 2)); + assert.ok(fans.every((d) => !/\bL/.test(d)), 'no order-dependent polygon chord at either end'); + } +}); + test('AC17: the field cache is bounded, per space, and counts geometry rebuilds', () => { const cache = new LedFieldCache(3); cache.forSpace('a');