mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 13:48:57 +00:00
fix(led): сгладить торцы поля свечения (#786)
Issue: #786 User-Visible: yes
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user