fix(led): сгладить торцы поля свечения (#786)

Issue: #786
User-Visible: yes
This commit is contained in:
Matysh
2026-10-03 16:41:10 +03:00
parent aa1105468b
commit 9598768167
7 changed files with 78 additions and 19 deletions
+6
View File
@@ -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 },
+4
View File
@@ -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
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- Исправлены торцы Glow у LED-лент: свободные концы остаются
плавно круглыми при сильном приближении и больше не показывают грани
многоугольника в зависимости от соседних стен
([#786](https://github.com/Matysh/houseplan-card/issues/786)).
## v1.79.0-beta.5 — 2026-10-03
- Исправлено свечение LED-лент со смешанной геометрией: длинные свободные
+4 -2
View File
@@ -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
+1 -1
View File
@@ -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) |
+38 -15
View File
@@ -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. */
+20 -1
View File
@@ -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');