fix(led): сохранить прямоугольник через проёмы (#787)

Issue: #787
User-Visible: yes
This commit is contained in:
Matysh
2026-10-03 17:17:50 +03:00
parent fab28af26f
commit 35ef231191
7 changed files with 152 additions and 12 deletions
+8
View File
@@ -1339,6 +1339,14 @@ export const GOLDEN_SCENARIOS = Object.freeze([
// 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-rectangle-door-zoom-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', fillMode: 'none', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 },
// #787: four stored corners must remain four straight visible sides. The
// right side crosses led-door: the opening is optically open, but neither
// the stripe nor its emitter line may step back to the wall axis there.
zoom: 1.15, zoomCenter: [310, 350],
ledStripOverride: { points: [[0.068333, 0.088333], [0.551667, 0.088333],
[0.551667, 0.611667], [0.068333, 0.611667], [0.068333, 0.088333]] }, ...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 },
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Fixed rectangular LED strips on wall faces: corners remain exact miters and
doors, gates and passages no longer pull the stripe or its Glow emitters
back to the wall axis, causing steps and bends
([#787](https://github.com/Matysh/houseplan-card/issues/787)).
- 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)).
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- Исправлены прямоугольные LED-ленты на гранях стен: углы теперь остаются
точными стыками, а двери, ворота и проходы больше не возвращают ленту и
излучатели Glow на ось стены, создавая ступеньки и изгибы
([#787](https://github.com/Matysh/houseplan-card/issues/787)).
- Исправлены торцы Glow у LED-лент: свободные концы остаются
плавно круглыми при сильном приближении и больше не показывают грани
многоугольника в зависимости от соседних стен
+8
View File
@@ -437,6 +437,14 @@ only the geometry differs:
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
emits nothing; a strip entirely inside a wall has no field.
- **Wall openings and corners.** A door, gate or passage remains optically
open, but an internal gap between two collinear pieces of the same wall face
inherits their free-side normal. The visible stripe and its emitters
therefore stay on one straight line through the opening. At a genuine turn,
safely intersecting shifted sides use that bounded intersection as their
single miter; unsafe acute angles retain the short connector. A stored
four-corner loop consequently stays a four-corner rectangle without steps
at openings or diagonal corner inserts (#787).
- **Core.** With effective Glow (space `glow_enabled` + room `glow`) the core
stays white and the colour is the field; without Glow the core takes the
source colour and there is no field. Off: white core, no field.
+1 -1
View File
@@ -42,7 +42,7 @@ behaviours, not of positions.
| 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) |
| Per-piece offset: t/2 on a thick face into free floor, 0 on free floor and zero walls; continuous transition; a collinear door gap keeps the flanking face normal and shifted sides meet at one bounded miter | `stripPieces` + `visibleStripPath` | unit `test/led-strip-geometry.test.mjs` (AC8, #787); golden `led-strip-rectangle-door-zoom-light` |
| 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) |
+91 -11
View File
@@ -176,7 +176,11 @@ function freeNormal(a: Pt, b: Pt, at: Pt, ctx: FaceContext): [number, number] |
return plusInside ? [-nx + 0, -ny + 0] : [nx + 0, ny + 0];
}
/** Split every stored segment into face pieces and free pieces (ТЗ §3). */
/**
* Split every stored segment into face pieces and free pieces (ТЗ §3).
* An internal opening between collinear pieces of the same body face inherits
* their free side: a door is optically open, but it does not bend the strip.
*/
export function stripPieces(points: readonly Pt[], ctx: FaceContext | null): StripPiece[] {
const path = compactPoints(points);
const pieces: StripPiece[] = [];
@@ -187,6 +191,7 @@ export function stripPieces(points: readonly Pt[], ctx: FaceContext | null): Str
}
for (let i = 1; i < path.length; i++) {
const a = path[i - 1], b = path[i];
const local: Array<StripPiece & { matched: boolean }> = [];
const intervals: Array<[number, number]> = [];
if (ctx) {
for (const face of faces) {
@@ -203,21 +208,82 @@ export function stripPieces(points: readonly Pt[], ctx: FaceContext | null): Str
}
let cursor = 0;
for (const [t0, t1] of merged) {
if (t0 > cursor) pieces.push({ a: lerp(a, b, cursor), b: lerp(a, b, t0), free: null });
if (t0 > cursor) local.push({ a: lerp(a, b, cursor), b: lerp(a, b, t0), free: null, matched: false });
const pa = lerp(a, b, t0), pb = lerp(a, b, t1);
const mid = lerp(a, b, (t0 + t1) / 2);
pieces.push({ a: pa, b: pb, free: ctx ? freeNormal(a, b, mid, ctx) : null });
local.push({ a: pa, b: pb, free: ctx ? freeNormal(a, b, mid, ctx) : null, matched: true });
cursor = t1;
}
if (cursor < 1) pieces.push({ a: lerp(a, b, cursor), b: [b[0], b[1]], free: null });
if (cursor < 1) local.push({ a: lerp(a, b, cursor), b: [b[0], b[1]], free: null, matched: false });
// A door/gate/passage cuts a gap out of a physical face. The stored strip
// still follows one straight wall side through that gap, so keep the free
// side selected by the two flanking face pieces. Leading/trailing free
// pieces are deliberately not inherited: a strip that actually leaves a
// wall must still return to its stored path.
for (let j = 1; j + 1 < local.length; j++) {
const prev = local[j - 1], gap = local[j], next = local[j + 1];
if (gap.matched || !prev.matched || !next.matched || !prev.free || !next.free) continue;
if (Math.hypot(prev.free[0] - next.free[0], prev.free[1] - next.free[1]) <= 1e-9) {
gap.free = [prev.free[0], prev.free[1]];
}
}
pieces.push(...local.map(({ a: pa, b: pb, free }) => ({ a: pa, b: pb, free })));
}
return pieces.filter((piece) => dist(piece.a, piece.b) > 0);
}
function shiftedLineIntersection(
prev: StripPiece, next: StripPiece, offset: number,
): [number, number] | null {
if (!prev.free || !next.free || !same(prev.b, next.a)) return null;
const pa: [number, number] = [prev.a[0] + prev.free[0] * offset, prev.a[1] + prev.free[1] * offset];
const pb: [number, number] = [prev.b[0] + prev.free[0] * offset, prev.b[1] + prev.free[1] * offset];
const qa: [number, number] = [next.a[0] + next.free[0] * offset, next.a[1] + next.free[1] * offset];
const qb: [number, number] = [next.b[0] + next.free[0] * offset, next.b[1] + next.free[1] * offset];
const r = sub(pb, pa), s = sub(qb, qa);
const den = r[0] * s[1] - r[1] * s[0];
if (Math.abs(den) <= 1e-12) return null;
const qmp = sub(qa, pa);
const t = (qmp[0] * s[1] - qmp[1] * s[0]) / den;
const hit: [number, number] = [pa[0] + r[0] * t, pa[1] + r[1] * t];
// Acute angles can put an infinite-line intersection far away. Keep the
// existing short connector there instead of producing a long miter spike.
const maxMiter = Math.max(Math.abs(offset) * 4, 1e-9);
return dist(hit, pb) <= maxMiter && dist(hit, qa) <= maxMiter ? hit : null;
}
function redundantCollinear(a: Pt, b: Pt, c: Pt): boolean {
const ab = sub(b, a), bc = sub(c, b);
const scale = Math.max(1, Math.hypot(...ab) * Math.hypot(...bc));
const cross = ab[0] * bc[1] - ab[1] * bc[0];
const forward = ab[0] * bc[0] + ab[1] * bc[1];
return Math.abs(cross) <= 1e-12 * scale && forward >= -1e-12;
}
function simplifyVisiblePoints(points: Array<[number, number]>, closed: boolean): Array<[number, number]> {
const out = [...points];
let changed = true;
while (changed && out.length > (closed ? 3 : 2)) {
changed = false;
const first = closed ? 0 : 1, last = closed ? out.length : out.length - 1;
for (let i = first; i < last; i++) {
const prev = out[(i - 1 + out.length) % out.length];
const next = out[(i + 1) % out.length];
if (!redundantCollinear(prev, out[i], next)) continue;
out.splice(i, 1);
changed = true;
break;
}
}
return out;
}
/**
* The derived visible path (ТЗ §3): a face piece shifted `offset` along its
* free normal, a free piece unshifted, consecutive pieces joined by a short
* connector (drawn with round joins) — no gap, no square patch, no long miter.
* free normal, a free piece unshifted. Shifted sides meeting at a real corner
* meet at their bounded line intersection; wall/free transitions and unsafe
* acute angles retain the short connector — no gap and no long miter spike.
* Closed strips close through the same rule. Shared by both strokes, the hit
* path, focus and 2.5D: one derivation, never a stored position.
*/
@@ -226,21 +292,35 @@ export function visibleStripPath(
): { points: Array<[number, number]>; closed: boolean } {
const closed = isClosedStrip(compactPoints(points));
const pieces = stripPieces(points, ctx);
const shifted = pieces.map((piece) => {
const shift = piece.free ? [piece.free[0] * offset, piece.free[1] * offset] : [0, 0];
return {
a: [piece.a[0] + shift[0], piece.a[1] + shift[1]] as [number, number],
b: [piece.b[0] + shift[0], piece.b[1] + shift[1]] as [number, number],
};
});
const joins: Array<[number, number] | null> = pieces.map(() => null);
for (let i = 0; i + 1 < pieces.length; i++) {
joins[i] = shiftedLineIntersection(pieces[i], pieces[i + 1], offset);
}
if (closed && pieces.length > 1) {
joins[pieces.length - 1] = shiftedLineIntersection(pieces[pieces.length - 1], pieces[0], offset);
}
const out: Array<[number, number]> = [];
const push = (p: [number, number]) => {
const last = out[out.length - 1];
if (!last || Math.hypot(last[0] - p[0], last[1] - p[1]) > 1e-12) out.push(p);
};
for (const piece of pieces) {
const shift = piece.free ? [piece.free[0] * offset, piece.free[1] * offset] : [0, 0];
push([piece.a[0] + shift[0], piece.a[1] + shift[1]]);
push([piece.b[0] + shift[0], piece.b[1] + shift[1]]);
for (let i = 0; i < pieces.length; i++) {
const previousJoin = i > 0 ? joins[i - 1] : closed ? joins[pieces.length - 1] : null;
push(previousJoin ?? shifted[i].a);
push(joins[i] ?? shifted[i].b);
}
if (closed && out.length > 2) {
const first = out[0], last = out[out.length - 1];
if (Math.hypot(first[0] - last[0], first[1] - last[1]) <= 1e-12) out.pop();
}
return { points: out, closed };
return { points: simplifyVisiblePoints(out, closed), closed };
}
export function pathD(path: { points: ReadonlyArray<Pt>; closed: boolean }): string {
+34
View File
@@ -93,6 +93,40 @@ test('AC8: a closed strip closes through the same rule without a seam point', ()
assert.deepEqual(path.points, [[2, 3], [6, 3], [6, 6], [2, 6]]);
});
test('#787: a four-corner strip stays rectangular across a door opening', () => {
// Opaque body faces around a door gap y=3..5 on the rectangle's right side.
const faces = [
{ a: [0, 0], b: [10, 0] }, { a: [10, 0], b: [10, 3] },
{ a: [10, 5], b: [10, 10] }, { a: [10, 10], b: [0, 10] },
{ a: [0, 10], b: [0, 0] },
];
const openingCtx = {
faces,
inside: ([x, y]) => (y > -1 && y < 0 && x > 0 && x < 10)
|| (x > 10 && x < 11 && ((y > 0 && y < 3) || (y > 5 && y < 10)))
|| (y > 10 && y < 11 && x > 0 && x < 10)
|| (x > -1 && x < 0 && y > 0 && y < 10),
epsilon: 1e-5,
};
const stored = [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]];
const before = JSON.stringify(stored);
const right = stripPieces([[10, 0], [10, 10]], openingCtx);
assert.equal(right.length, 3, 'the opening remains a separate derived piece');
assert.ok(right.every((piece) => JSON.stringify(piece.free) === JSON.stringify([-1, 0])),
'the internal opening inherits the same free side from both wall faces');
assert.deepEqual(visibleStripPath(stored, openingCtx, 0.25), {
points: [[0.25, 0.25], [9.75, 0.25], [9.75, 9.75], [0.25, 9.75]],
closed: true,
}, 'shifted wall sides meet at exact miters without steps or diagonal inserts');
assert.equal(JSON.stringify(stored), before, 'the stored four-corner contour is untouched');
const emitters = emitterSamples([[10, 0], [10, 10]], openingCtx, 0.5);
assert.ok(emitters.length > 0);
for (const point of emitters) close(point[0], 10 - openingCtx.epsilon, 1e-12,
'Glow stays on one side through the optically open doorway');
});
test('ТЗ §6: emitters sit epsilon outward on a face, cover the length, skip buried parts', () => {
const onFace = emitterSamples([[2, 1], [8, 1]], ctx, 1);
assert.equal(onFace.length, 7, 'every vertex plus spacing ≤ 1');