diff --git a/demo/golden/matrix.mjs b/demo/golden/matrix.mjs index 24a64a73..46d8e749 100644 --- a/demo/golden/matrix.mjs +++ b/demo/golden/matrix.mjs @@ -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 }, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a82f57ef..d7d37c0c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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)). diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 261e1223..6b88511c 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,11 @@ ## Не выпущено +- Исправлены прямоугольные LED-ленты на гранях стен: углы теперь остаются + точными стыками, а двери, ворота и проходы больше не возвращают ленту и + излучатели Glow на ось стены, создавая ступеньки и изгибы + ([#787](https://github.com/Matysh/houseplan-card/issues/787)). + - Исправлены торцы Glow у LED-лент: свободные концы остаются плавно круглыми при сильном приближении и больше не показывают грани многоугольника в зависимости от соседних стен diff --git a/docs/LIGHT.md b/docs/LIGHT.md index 4bfa7ff0..43b55cbf 100644 --- a/docs/LIGHT.md +++ b/docs/LIGHT.md @@ -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. diff --git a/docs/design/led-strips/ACCEPTANCE.md b/docs/design/led-strips/ACCEPTANCE.md index 587d0420..4ed6dce1 100644 --- a/docs/design/led-strips/ACCEPTANCE.md +++ b/docs/design/led-strips/ACCEPTANCE.md @@ -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) | diff --git a/src/led-strip-geometry.ts b/src/led-strip-geometry.ts index 33ca30f7..11651ce7 100755 --- a/src/led-strip-geometry.ts +++ b/src/led-strip-geometry.ts @@ -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 = []; 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; closed: boolean }): string { diff --git a/test/led-strip-geometry.test.mjs b/test/led-strip-geometry.test.mjs index 291c8a8a..601155a6 100755 --- a/test/led-strip-geometry.test.mjs +++ b/test/led-strip-geometry.test.mjs @@ -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');