mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 13:48:57 +00:00
fix(led): сохранить непрерывный Glow и толщину ленты (#785)
Issue: #785 User-Visible: yes
This commit is contained in:
@@ -628,6 +628,17 @@ export function prepareGoldenFixture(scenario) {
|
||||
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
|
||||
return space;
|
||||
};
|
||||
if (scenario.ledStripOverride) {
|
||||
const space = requireSpace();
|
||||
if (space.id !== 'golden-led' || !Array.isArray(scenario.ledStripOverride.points)
|
||||
|| scenario.ledStripOverride.points.length < 2) {
|
||||
throw new Error(`invalid golden ledStripOverride: ${scenario.id}`);
|
||||
}
|
||||
space.led_strips = [{
|
||||
id: 'led-owner-repro', marker: 'demo-led-strip-loop',
|
||||
points: structuredClone(scenario.ledStripOverride.points),
|
||||
}];
|
||||
}
|
||||
if (scenario.stage3Fixture) {
|
||||
const contract = scenario.stage3Fixture;
|
||||
const space = requireSpace();
|
||||
|
||||
@@ -1327,6 +1327,12 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
mode: 'view', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 }, ...stage },
|
||||
{ id: 'lighting-led-strip-glow-dark', fixture: 'visual', ledStrips: true, space: 'golden-led',
|
||||
mode: 'view', fillMode: 'none', language: 'en', theme: 'dark', viewport: { width: 1000, height: 760 }, ...stage },
|
||||
{ id: 'led-strip-long-zigzag-glow-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
|
||||
mode: 'view', fillMode: 'none', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 },
|
||||
// A free diagonal joins a wall-face run and returns to free floor. Before
|
||||
// #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-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 },
|
||||
|
||||
@@ -43,7 +43,9 @@ const setState = (entity, state, attributes = {}) => evaluate(async ([entity, st
|
||||
const stripe = (marker) => evaluate((marker) => {
|
||||
const g = window.__card.shadowRoot.querySelector(`[data-led-strip][data-marker="${marker}"]`);
|
||||
return g ? { state: g.dataset.state, core: g.querySelector('.led-core')?.getAttribute('stroke'),
|
||||
dash: g.querySelector('.led-core')?.getAttribute('stroke-dasharray') || null } : null;
|
||||
dash: g.querySelector('.led-core')?.getAttribute('stroke-dasharray') || null,
|
||||
outlineWidth: Number(g.querySelector('.led-outline')?.getAttribute('stroke-width')),
|
||||
coreWidth: Number(g.querySelector('.led-core')?.getAttribute('stroke-width')) } : null;
|
||||
}, marker);
|
||||
const field = () => evaluate(() => window.__card.shadowRoot.querySelectorAll('[data-led-field]').length);
|
||||
|
||||
@@ -80,8 +82,9 @@ check('unbound strip is absent from the View', await evaluate(() => window.__car
|
||||
check('no round pool at the anchor: the strip is the source', await evaluate(() =>
|
||||
!window.__card.shadowRoot.querySelector('[data-glow-source="light.ceiling"]')));
|
||||
check('no auto-slot reserved for the strip’s marker', await evaluate(() => !('d_light1' in (window.__card._defPos || {}))));
|
||||
check('on with Glow: white core and a field', JSON.stringify([await stripe('d_light1'), await field() > 0]),
|
||||
JSON.stringify([{ state: 'on', core: '#FFFFFF', dash: null }, true]));
|
||||
const onStripe = await stripe('d_light1');
|
||||
check('on with Glow: white core and a field', JSON.stringify([onStripe?.state, onStripe?.core, onStripe?.dash, await field() > 0]),
|
||||
JSON.stringify(['on', '#FFFFFF', null, true]));
|
||||
|
||||
// r1 M1 (ТЗ §5): the strip keeps the device's value badge, passive, at the
|
||||
// half-length anchor — the icon core, pulse and slot stay suppressed.
|
||||
@@ -141,8 +144,12 @@ check('a pointer click leaves no selection outline', await evaluate(() => {
|
||||
}), true);
|
||||
await page.waitForTimeout(600);
|
||||
void hit;
|
||||
check('off: no field, white core', JSON.stringify([await stripe('d_light1'), await field()]),
|
||||
JSON.stringify([{ state: 'off', core: '#FFFFFF', dash: null }, 0]));
|
||||
const offStripe = await stripe('d_light1');
|
||||
check('off: no field, white core', JSON.stringify([offStripe?.state, offStripe?.core, offStripe?.dash, await field()]),
|
||||
JSON.stringify(['off', '#FFFFFF', null, 0]));
|
||||
check('#785: switching does not change the physical stripe thickness',
|
||||
JSON.stringify([offStripe?.outlineWidth, offStripe?.coreWidth]),
|
||||
JSON.stringify([onStripe?.outlineWidth, onStripe?.coreWidth]));
|
||||
// A pan along the stripe calls nothing; the next clean click works at once.
|
||||
const along = (k) => evaluate((k) => {
|
||||
const el = window.__card.shadowRoot.querySelector('.led-hit');
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fixed Glow for LED strips with mixed geometry: long free segments no longer
|
||||
lose their light when another part of the same strip follows a wall, and
|
||||
turns no longer leave isolated polygonal patches. Switching the entity now
|
||||
changes only its light and state colour, not the physical strip thickness
|
||||
([#785](https://github.com/Matysh/houseplan-card/issues/785)).
|
||||
|
||||
## v1.79.0-beta.4 — 2026-10-03
|
||||
|
||||
- LED strips now cast a smooth, continuous Glow field without gradient bands
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Исправлено свечение LED-лент со смешанной геометрией: длинные свободные
|
||||
отрезки больше не теряют Glow, когда другая часть той же ленты идёт вдоль
|
||||
стены; у поворотов не остаются отдельные многоугольные островки. Включение и
|
||||
выключение теперь меняет только свет и цвет состояния, но не толщину самой
|
||||
ленты ([#785](https://github.com/Matysh/houseplan-card/issues/785)).
|
||||
|
||||
## v1.79.0-beta.4 — 2026-10-03
|
||||
|
||||
- LED-ленты теперь дают мягкое непрерывное свечение без полос градиента и
|
||||
|
||||
@@ -38,12 +38,12 @@ behaviours, not of positions.
|
||||
| Requirement | Product | Evidence |
|
||||
|---|---|---|
|
||||
| `D = icon_size/100 × iconUnit(space)`, not `marker.size`; 2.5D uses the shared scale | `ledFrame` takes `iconPct` from the full card's or the space card's own `icon_size`; `ISO_ICON_SCALE` in 2.5D | golden `iso-led-strip-dark`, reference pair |
|
||||
| Thickness 0.08 D off / 0.12 D on; outline `#383838` t, core t/2, round joins and caps | `renderLedStripes` | `led-strip-off-light`, reference pair |
|
||||
| State-invariant thickness 0.12 D; outline `#383838` t, core t/2, round joins and caps | `renderLedStripes` | smoke `smoke_led_strip_glow.mjs`, reference pair |
|
||||
| Off: white core, no field, both themes | stripe state `off` | smoke `smoke_led_strip_glow.mjs`, `led-strip-off-light` |
|
||||
| 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 or doubled brightness at corners/closure | `ledFrame`, one continuous path through the unioned visibility clip in `led-strip-field` | unit `test/led-strip-runtime.test.mjs`; 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 the filled visibility fans in `led-strip-field` | unit `test/led-strip-runtime.test.mjs`; golden `led-strip-long-zigzag-glow-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) |
|
||||
|
||||
@@ -51,7 +51,7 @@ behaviours, not of positions.
|
||||
|
||||
| # | Criterion | Result |
|
||||
|---|---|---|
|
||||
| 1 | Same geometry on/off; only colour/thickness/field change | Stored points never change; only `t` and the derived offset follow the state — pairs on/off |
|
||||
| 1 | Same geometry on/off; only colour/field change | Stored points, thickness and the derived offset do not follow the state — smoke and pairs on/off |
|
||||
| 2 | White core and dark outline keep contrast on grey floor, near hatched walls, over the field | Visible in both pairs; `#383838` outline, opaque core |
|
||||
| 3 | Straight parts are not a chain of circles | One stroked path per strip; the field is bands of stroked paths, not discs |
|
||||
| 4 | No hard rectangular cut of light at the ends | Round caps of the field bands (blue strip in the pair) |
|
||||
@@ -65,7 +65,7 @@ behaviours, not of positions.
|
||||
## Accepted differences
|
||||
|
||||
- Pixel sizes and the mockup blur (22.2–30 px) are not product filters: the
|
||||
product uses 0.08/0.12 D and the shared falloff, so its stripe is thinner at
|
||||
product uses a state-invariant 0.12 D and the shared falloff, so its stripe is thinner at
|
||||
the default `icon_size` and the band edge is the shared Glow edge.
|
||||
- Icons and labels are not tinted by the field.
|
||||
- The mockup's "half the shared radius" and "always coloured core" are replaced
|
||||
|
||||
@@ -12,7 +12,7 @@ with the frames Led On (626:35) and Led Off (626:53).
|
||||
[#780](https://github.com/Matysh/houseplan-card/issues/780)**, not the archived
|
||||
`source/TZ-issue-662-LED-strips.md`. Where they differ the issue wins — notably
|
||||
the 30 cm default radius (#784), the white core under Glow, editing in the Devices
|
||||
editor and the product thicknesses 0.08/0.12 D instead of the mockup's pixel
|
||||
editor and the product's state-invariant thickness 0.12 D instead of the mockup's pixel
|
||||
sizes and blur. These files are documentation only; nothing here reaches the
|
||||
runtime bundle.
|
||||
|
||||
|
||||
+22
-21
@@ -11,7 +11,7 @@
|
||||
* That separation keeps walls opaque without exposing piece boundaries in the
|
||||
* gradient at straight cuts or corners.
|
||||
*/
|
||||
import { noChange, nothing, svg, type TemplateResult } from 'lit';
|
||||
import { noChange, svg, type TemplateResult } from 'lit';
|
||||
import { Directive, directive, type PartInfo } from 'lit/directive.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import {
|
||||
@@ -55,8 +55,8 @@ const pts = (points: readonly number[][]): Pt[] => points.map((p) => [p[0], p[1]
|
||||
|
||||
interface FieldPiece {
|
||||
d: string;
|
||||
/** Visibility fans of the piece's emitters; `null` = nothing blocks within the radius. */
|
||||
clip: string[] | null;
|
||||
/** Visibility fans of the piece's emitters, including full discs when nothing blocks them. */
|
||||
clip: string[];
|
||||
}
|
||||
|
||||
interface FieldGeometry {
|
||||
@@ -139,16 +139,22 @@ const ringPath = (ring: readonly number[][]): string =>
|
||||
|
||||
/**
|
||||
* What a piece's emitters can see (ТЗ §6): the visibility fans of the shared
|
||||
* `visibilityPolygon` with the scene's occluders, kept as separate paths of
|
||||
* one clipPath (SVG unions the children) — no boolean pass per piece. `null`
|
||||
* means no occluder is within the radius of any emitter: every fan is a full
|
||||
* disc and the 2r-wide bands already bound the light. The floor itself is one
|
||||
* clip of the whole field layer (`fieldFloor`).
|
||||
* `visibilityPolygon` with the scene's occluders. When no occluder is close,
|
||||
* explicit full-disc fans still enter the shared clip. SVG clip paths ignore
|
||||
* strokes, so an open stroked path cannot stand in for that free-space region:
|
||||
* doing so dropped every free part as soon as one wall-following part made the
|
||||
* clip active (#785). The floor itself is one clip of the whole field layer
|
||||
* (`fieldFloor`).
|
||||
*/
|
||||
function fans(emitters: readonly Pt[], radius: number, scene: LightBarrierScene): string[] | null {
|
||||
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 null;
|
||||
&& 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)
|
||||
@@ -220,9 +226,9 @@ export function buildFieldGeometry(input: {
|
||||
if (!last || Math.hypot(p[0] - last[0], p[1] - last[1]) >= r / 4) emitters.push(p);
|
||||
}
|
||||
if (!emitters.length) continue;
|
||||
let clip: string[] | null;
|
||||
let clip: string[];
|
||||
try { clip = fans(emitters, r, input.scene); } catch { continue; } // fail-dark for this piece
|
||||
if (clip && !clip.length) continue;
|
||||
if (!clip.length) continue;
|
||||
pieces.push({ d: piece.map((p, k) => `${k ? 'L' : 'M'}${coord(p[0])} ${coord(p[1])}`).join(' '), clip });
|
||||
for (const p of piece) {
|
||||
minX = Math.min(minX, p[0]); minY = Math.min(minY, p[1]);
|
||||
@@ -377,9 +383,6 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
|
||||
const r = view.radius;
|
||||
const box = geometry.box;
|
||||
const closed = isClosedStrip(pts(view.strip.points));
|
||||
const clipped = geometry.pieces.filter((piece): piece is FieldPiece & { clip: string[] } => !!piece.clip);
|
||||
const free = geometry.pieces.filter((piece) => !piece.clip);
|
||||
const visibilityClip = clipped.length > 0;
|
||||
return memo([geometry, transition.entering, transition.leaving, view.appearance.c,
|
||||
view.appearance.alpha, r, id], () => svg`<g
|
||||
class="glow-spot led-field ${transition.entering ? 'is-entering' : ''} ${transition.leaving ? 'is-leaving' : ''}"
|
||||
@@ -387,16 +390,14 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
|
||||
data-pieces="${geometry.pieces.length}" data-bands="${LED_FIELD_BANDS}"
|
||||
data-closed="${closed ? 'true' : 'false'}">
|
||||
<defs>
|
||||
${visibilityClip ? svg`<clipPath id="hp-led-visible-${id}">
|
||||
${free.map((piece) => svg`<path d="${piece.d}" fill="none" stroke="white"
|
||||
stroke-width="${2 * r}" stroke-linecap="round" stroke-linejoin="round"></path>`)}
|
||||
${clipped.map((piece) => svg`<path d="${piece.clip.join(' ')}"></path>`)}
|
||||
</clipPath>` : nothing}
|
||||
<clipPath id="hp-led-visible-${id}">
|
||||
${geometry.pieces.map((piece) => svg`<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 clip-path=${visibilityClip ? `url(#hp-led-visible-${id})` : nothing}>
|
||||
<g clip-path="url(#hp-led-visible-${id})">
|
||||
${bands.map((band) => svg`<path d="${geometry.d}" fill="none" stroke="${grey(band.value)}"
|
||||
stroke-width="${2 * band.half * r}" stroke-linecap="round" stroke-linejoin="round"></path>`)}
|
||||
</g>
|
||||
|
||||
@@ -27,9 +27,8 @@ export const LED_MAX_POINTS = 50;
|
||||
export const LED_EPSILON_CM = 0.001;
|
||||
/** Default linear field radius (#784): 30 cm, independent of the shared one. */
|
||||
export const LED_DEFAULT_RADIUS_CM = 30;
|
||||
/** Total stripe thickness in base device diameters (ТЗ §3). */
|
||||
export const LED_THICKNESS_OFF_D = 0.08;
|
||||
export const LED_THICKNESS_ON_D = 0.12;
|
||||
/** Total stripe thickness in base device diameters (#785): physical geometry is state-invariant. */
|
||||
export const LED_THICKNESS_D = 0.12;
|
||||
/** Minimum touch radius across the visible stripe (ТЗ §7). */
|
||||
export const LED_HIT_MIN_CSS_PX = 22;
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { deviceThemeClass } from './device-face';
|
||||
import type { VirtualLightSnapshot } from './virtual-light-state';
|
||||
import { geometryAllRings, pointInPhysicalBody, pointInPhysicalGeometry } from './physical-geometry';
|
||||
import {
|
||||
LED_DEFAULT_RADIUS_CM, LED_EPSILON_CM, LED_THICKNESS_OFF_D, LED_THICKNESS_ON_D,
|
||||
LED_DEFAULT_RADIUS_CM, LED_EPSILON_CM, LED_THICKNESS_D,
|
||||
pathD, stripAnchor, stripHitOwner,
|
||||
stripHitRadiusPx, validStripPoints, visibleStripPath,
|
||||
type BodyFace, type FaceContext, type Pt,
|
||||
@@ -207,12 +207,12 @@ export interface LedStripeInput {
|
||||
handlers?: LedHandlers | null;
|
||||
}
|
||||
|
||||
function stripeThickness(view: LedStripView, d: number): number {
|
||||
return (view.state === 'on' ? LED_THICKNESS_ON_D : LED_THICKNESS_OFF_D) * d;
|
||||
function stripeThickness(d: number): number {
|
||||
return LED_THICKNESS_D * d;
|
||||
}
|
||||
|
||||
function stripePath(view: LedStripView, input: LedStripeInput) {
|
||||
const t = stripeThickness(view, input.d);
|
||||
const t = stripeThickness(input.d);
|
||||
return { t, path: visibleStripPath(pts(view.strip.points), input.faces, t / 2) };
|
||||
}
|
||||
|
||||
|
||||
@@ -103,10 +103,12 @@ test('ТЗ §6: every piece is clipped to what its own emitters see; a buried st
|
||||
assert.equal(geometry.pieces.length, 4, 'an 8-unit segment with r = 2 makes four pieces');
|
||||
assert.equal((geometry.d.match(/M/g) || []).length, 1,
|
||||
'visibility/cache pieces do not split the painted path');
|
||||
for (const piece of geometry.pieces) assert.equal(piece.clip, null, 'nothing within r: the bands are the bound');
|
||||
for (const piece of geometry.pieces) {
|
||||
assert.ok(piece.clip.length >= 2, 'free pieces retain filled visibility fans for the shared clip');
|
||||
}
|
||||
// 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);
|
||||
const clipped = near.pieces.filter((piece) => piece.clip.some((d) => (d.match(/[ML]/g) || []).length > 12));
|
||||
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);
|
||||
@@ -135,6 +137,17 @@ test('#784: a corner and a closed strip remain one painted path', () => {
|
||||
assert.match(closed.d, / Z$/);
|
||||
});
|
||||
|
||||
test('#785: a mixed free/wall polyline keeps visibility for every piece', () => {
|
||||
const faces = faceContext(scene, 1e-6);
|
||||
const mixed = buildFieldGeometry({ points: [[1, 1], [8, 1], [10, 1], [10, 7]], radius: 2,
|
||||
scene, polygons, faces, spaceId: 's' });
|
||||
assert.ok(mixed && mixed.pieces.length > 3);
|
||||
assert.equal(mixed.pieces.every((piece) => piece.clip.length > 0), true,
|
||||
'free pieces use filled discs and blocked pieces use visibility polygons');
|
||||
assert.equal(mixed.pieces.some((piece) => piece.clip.length >= 4), true,
|
||||
'the long free run retains several overlapping visibility discs');
|
||||
});
|
||||
|
||||
test('AC17: the field cache is bounded, per space, and counts geometry rebuilds', () => {
|
||||
const cache = new LedFieldCache(3);
|
||||
cache.forSpace('a');
|
||||
@@ -190,7 +203,7 @@ test('AC17/r1 M5: a released owner retains nothing; the stats count visibility e
|
||||
const owner = {};
|
||||
const cache = ledFieldCache(owner);
|
||||
cache.forSpace('a');
|
||||
cache.read('k1', () => ({ pieces: [{ d: 'M0 0', clip: ['M0 0 Z', 'M1 1 Z'] }, { d: 'M1 1', clip: null }], box: { x: 0, y: 0, w: 1, h: 1 } }));
|
||||
cache.read('k1', () => ({ pieces: [{ d: 'M0 0', clip: ['M0 0 Z', 'M1 1 Z'] }, { d: 'M1 1', clip: [] }], box: { x: 0, y: 0, w: 1, h: 1 } }));
|
||||
cache.read('k2', () => null);
|
||||
assert.deepEqual(ledFieldStats(owner), { visibility: 2, sources: 2, recomputes: 2 });
|
||||
releaseLedField(owner);
|
||||
|
||||
Reference in New Issue
Block a user