diff --git a/README.md b/README.md index 6386623..dd9e742 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,9 @@ window, a `blind` → a slider, a `garage` or `shutter` → a roll-up); adjust a exactly that. That means the two-panel sliders above, and any hinged double — a casement window (`sash: double`, the window default) or a double door. Leave it empty and both leaves follow the first entity, as they always have. The opening's own invert - switch covers both, and a tap still acts on the first. + switch covers both, and a tap still acts on the first. A lamp's pool follows the leaves + too: with one open, the light comes through *that* leaf's half of the doorway rather + than the middle. - **Orientation** — **Hinge** (left / right) and **Opens** (this side / other side) face a swing door any of four ways; they're pure mirrors (`flipH` / `flipV`), so the animation follows. diff --git a/src/editor.ts b/src/editor.ts index ff61f57..550a56d 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -67,7 +67,7 @@ import { resolveOpeningAmount, openingIsActive, wallsLightPassesThrough, - glowClearFraction, + glowClearSpan, openingHasTwoLeaves, secondLeafOf, renderGlowMask, @@ -2839,7 +2839,7 @@ export class FloorplanCardEditor extends LitElement { // Same reading as the card, second leaf included (issue #145), // glass admitted whole regardless of sash, and a shutter overriding // that — all same as the card. - return glowClearFraction( + return glowClearSpan( o, amt(o.entity), o.secondaryEntity && openingHasTwoLeaves(o) diff --git a/src/floorplan-card.ts b/src/floorplan-card.ts index 1a0101a..7bb78b0 100644 --- a/src/floorplan-card.ts +++ b/src/floorplan-card.ts @@ -75,7 +75,7 @@ import { renderSunDimMask, wallsLightPassesThrough, openingClearFraction, - glowClearFraction, + glowClearSpan, polygonCentroid, trackerSensorReading, entityIsActive, @@ -789,7 +789,7 @@ export class FloorplanCard extends LitElement { // of sash — a closed window is not a hole, but light still gets // through it. A shutter rolled down overrides that, same as it // does for sunlight. - glowClearFraction( + glowClearSpan( o, this._openingAmount(o), this._openingSecond(o)?.amount, diff --git a/src/render.opening.test.ts b/src/render.opening.test.ts index a7b2644..26ed8b0 100644 --- a/src/render.opening.test.ts +++ b/src/render.opening.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { renderOpening, renderSunlight, SUN_REACH } from "./render"; +import { renderOpening, renderSunlight, SUN_REACH, SUN_ACROSS } from "./render"; import type { OpeningStyle } from "./render"; import type { Opening, Wall } from "./types"; import { nothing } from "lit"; @@ -141,6 +141,55 @@ describe("renderSunlight — the markup, not just the geometry", () => { } }); + it("does not cut the beam's flanks while the light is still on them (#206)", () => { + // The far corners were already checked above; these are the near two — the + // gap's own ends, where the outline is at its narrowest. The falloff is + // scaled by *semi*-axes, and it was being handed the beam's whole width + // instead of half of it, so the ellipse ran nearly twice as wide as the + // polygon that carried it. The straight edge then sliced through light at + // roughly half strength, all the way up both sides: the hard diagonal + // boundary reported in #206. + for (const dir of [sun, { x: 0.72, y: 0.69 }, { x: -0.5, y: 0.87 }]) { + const markup = serialize( + renderSunlight([wall], [win], 400, 400, "sun", { + dir, + openAmount: () => 0, + shutterOpen: () => undefined, + }) + ); + const f = falloff(markup)!; + const pts = markup.match(/class="fp-sunbeam" points=([-\d., ]+)/)![1]!.trim().split(" ") + .map((q) => q.split(",").map(Number)); + const rad = (-f.angle * Math.PI) / 180; + // Every corner, not just the far pair: the polygon may only ever clip + // the ellipse where the ellipse has already reached zero. + for (const i of [0, 1, 2, 3]) { + const dx = pts[i]![0]! - f.cx; + const dy = pts[i]![1]! - f.cy; + const u = (dx * Math.cos(rad) - dy * Math.sin(rad)) / f.along; + const v = (dx * Math.sin(rad) + dy * Math.cos(rad)) / f.across; + expect(Math.hypot(u, v)).toBeGreaterThanOrEqual(1); + } + } + }); + + it("fits the falloff to half the beam, since a gradient is scaled by semi-axes", () => { + // The arithmetic behind the test above, stated once so the next person + // changing SUN_ACROSS can see what it is a fraction *of*. + const markup = serialize( + renderSunlight([wall], [win], 400, 400, "sun", { + dir: sun, + openAmount: () => 0, + shutterOpen: () => undefined, + }) + ); + const f = falloff(markup)!; + // The window is 60 wide, square-on to a straight-down sun, so the beam is + // 60 across and the ellipse may reach at most 30 to either side of it. + expect(f.across).toBeCloseTo(30 * SUN_ACROSS, 6); + expect(f.across).toBeLessThanOrEqual(30); + }); + it("a door ajar throws a narrower patch than one standing open", () => { // The end of the boolean, seen in the markup: the width of the polygon is // the width of the gap that is actually clear, and the wall keeps the diff --git a/src/render.test.ts b/src/render.test.ts index cc9e820..94cec43 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -135,6 +135,8 @@ import { glowReach, wallsLightPassesThrough, openingClearFraction, + openingClearSpan, + glowClearSpan, glowClearFraction, renderGlowMask, renderOpening, @@ -4453,6 +4455,72 @@ describe("glowClearFraction — glass admits a lamp's pool shut or open", () => }); }); +describe("openingClearSpan — where the gap is, not just how wide (#219)", () => { + const dbl = (extra: Partial = {}) => + ({ id: "d", type: "door", sash: "double", x: 500, y: 100, length: 100, angle: 0, ...extra }) as Opening; + const width = ([a, b]: [number, number]) => b - a; + + it("puts a double door's gap in the half whose leaf is open", () => { + // The report: a sensor per leaf, one leaf open, and the light came + // through the middle — half of it through the leaf that was still shut. + expect(openingClearSpan(dbl(), 1, 0)).toEqual([0, 0.5]); + expect(openingClearSpan(dbl(), 0, 1)).toEqual([0.5, 1]); + // Ajar, each leaf clears outward from the middle as it swings. + expect(openingClearSpan(dbl(), 0.5, 0)).toEqual([0.25, 0.5]); + }); + + it("leaves a double door with both leaves alike exactly where it was", () => { + // Which is why a single-sensor double door sees no change at all: the + // span it produces *is* the centred one. + for (const a of [0.25, 0.5, 1]) { + const [s0, s1] = openingClearSpan(dbl(), a, a); + expect(s0 + s1).toBeCloseTo(1, 10); // centred + expect(width([s0, s1])).toBeCloseTo(a, 10); + } + expect(openingClearSpan(dbl(), 0.6)).toEqual(openingClearSpan(dbl(), 0.6, 0.6)); + }); + + it("mirrors with flipH, because the leaves swap jambs with it", () => { + expect(openingClearSpan(dbl({ flipH: true }), 1, 0)).toEqual([0.5, 1]); + expect(openingClearSpan(dbl({ flipH: true }), 0, 1)).toEqual([0, 0.5]); + }); + + it("never disagrees with openingClearFraction about the amount", () => { + // The two answer different halves of one question, so a span that was + // wider or narrower than the fraction would leak light or lose it. + const cases: Array<[Opening, number, number | undefined]> = [ + [dbl(), 1, 0], + [dbl(), 0.3, 0.9], + [dbl({ flipH: true }), 0.2, 1], + [{ id: "s", type: "door", x: 0, y: 0, length: 90, angle: 0 } as Opening, 0.4, undefined], + [{ id: "w", type: "window", x: 0, y: 0, length: 90, angle: 0 } as Opening, 0.7, undefined], + [ + { id: "c", type: "door", motion: "slide", sliderStyle: "converging", + x: 0, y: 0, length: 200, angle: 0 } as Opening, + 1, 1, + ], + ]; + for (const [o, a1, a2] of cases) { + expect(width(openingClearSpan(o, a1, a2))).toBeCloseTo(openingClearFraction(o, a1, a2), 10); + } + }); + + it("centres everything that is not a double door, as it always did", () => { + const slider = { id: "s", type: "door", motion: "slide", x: 0, y: 0, length: 100, angle: 0 } as Opening; + expect(openingClearSpan(slider, 0.5)).toEqual([0.25, 0.75]); + const roll = { id: "r", type: "window", motion: "roll", x: 0, y: 0, length: 100, angle: 0 } as Opening; + expect(openingClearSpan(roll, 0.4)).toEqual([0.3, 0.7]); + }); + + it("glowClearSpan keeps glass and a shut shutter as whole-opening answers", () => { + const glass = dbl({ glazed: true }); + expect(glowClearSpan(glass, 1, 0)).toEqual([0, 1]); // all of it, leaves irrelevant + expect(glowClearSpan(dbl(), 1, 0, 0)).toEqual([0, 0]); // shutter down: none of it + // …and defers to the placed span otherwise. + expect(glowClearSpan(dbl(), 1, 0)).toEqual([0, 0.5]); + }); +}); + describe("wallsLightPassesThrough (#143)", () => { const wall = (x1: number, y1: number, x2: number, y2: number, id = "w") => ({ id, x1, y1, x2, y2 }); // A door centred on a horizontal wall at y=100, spanning x 480..520. @@ -4492,6 +4560,41 @@ describe("wallsLightPassesThrough (#143)", () => { expect(asked).toBe(openings.length); }); + it("cuts the gap where the span says, not always in the middle (#219)", () => { + // The end-to-end shape of the fix: a 40-wide double door centred at 500 on + // a horizontal wall, first leaf open. The clear half is 480..500, and that + // is where the wall must be cut — centring it left 490..510, so a lamp + // next door lit half of the leaf that was still shut. + const walls = [wall(0, 100, 1000, 100)]; + const dbl = door({ sash: "double" } as Partial); + expect(spans(wallsLightPassesThrough(walls, [dbl], () => 0.5))).toEqual([ + [0, 490], + [510, 1000], + ]); + expect(spans(wallsLightPassesThrough(walls, [dbl], (o) => openingClearSpan(o, 1, 0)))).toEqual([ + [0, 480], + [500, 1000], + ]); + expect(spans(wallsLightPassesThrough(walls, [dbl], (o) => openingClearSpan(o, 0, 1)))).toEqual([ + [0, 500], + [520, 1000], + ]); + }); + + it("places the span against the wall's own direction, not the canvas's", () => { + // A wall drawn right-to-left runs backwards under the same opening, so a + // span read straight off would land mirrored — and only ever show up on a + // door with unequal leaves, which is the one case this is for. + const forward = [wall(0, 100, 1000, 100)]; + const backward = [wall(1000, 100, 0, 100, "wb")]; + const dbl = door({ sash: "double" } as Partial); + const cut = (walls: ReturnType[]) => + spans(wallsLightPassesThrough(walls, [dbl], (o) => openingClearSpan(o, 1, 0))) + .map(([a, b]) => [Math.min(a, b), Math.max(a, b)]) + .sort((p, q) => p[0]! - q[0]!); + expect(cut(forward)).toEqual(cut(backward)); + }); + it("hands back the very same array when nothing is open", () => { // Lets a caller compare identity to know the light sees the walls it // always did — and skips the whole scan on the common case. diff --git a/src/render.ts b/src/render.ts index 16fda8e..e2613e8 100644 --- a/src/render.ts +++ b/src/render.ts @@ -683,6 +683,70 @@ export function glowClearFraction( return openingClearFraction(o, amount, secondAmount); } +/** + * The same answer as {@link glowClearFraction}, placed: which part of the + * opening the pool comes through, for {@link wallsLightPassesThrough}. + * + * Glass and a shut shutter are whole-opening answers and need no placing — + * all of it or none of it. Everything else defers to {@link + * openingClearSpan}, which is where a double door's open leaf stops being + * drawn as a gap in the middle (issue #219). + */ +export function glowClearSpan( + o: Opening, + amount: number, + secondAmount?: number, + shutter?: number, +): [number, number] { + if (shutter !== undefined && shutter <= 0) return [0, 0]; + if (openingIsGlazed(o) && openingMotion(o) !== "roll") return [0, 1]; + return openingClearSpan(o, amount, secondAmount); +} + +/** + * Where the clear part of an opening actually is, as a `[start, end]` pair of + * fractions along its own length — 0 at the jamb the symbol is drawn from, 1 + * at the other, mirrored by `flipH` exactly as the drawing is. + * + * {@link openingClearFraction} says how *much* is clear; this says *where*, + * and the two always agree on the amount (`end - start` is that fraction). + * + * It exists for the double door with a sensor on each leaf (issue #219). Each + * leaf covers its own half and swings out of it from the middle, so one leaf + * open clears the half that leaf was covering — not the middle. Placed + * centrally, as the wall gap always was, a lamp in the next room threw its + * pool through the shut half of the doorway and half of the open half, which + * is what the reporter saw. + * + * Everything else keeps the centred gap it has always had. That is still an + * approximation for a slider — a single panel really clears the side it slid + * away from — but it is one this function is not being asked to fix, and + * moving those would change how every existing plan lights up. A double door + * with unequal leaves is the case where centring is not close: it is off by a + * quarter of the opening, and the door is drawn plainly showing which half is + * open. + */ +export function openingClearSpan( + o: Opening, + amount: number, + secondAmount?: number, +): [number, number] { + const clear = openingClearFraction(o, amount, secondAmount); + const centred: [number, number] = [(1 - clear) / 2, (1 + clear) / 2]; + if (openingMotion(o) !== "swing" || openingSash(o) !== "double") return centred; + // Each leaf is hinged at its own jamb and covers its own half, so its + // projection on the wall shrinks toward that jamb as it swings: the first + // leaf clears outward from the middle to `0.5 - a1/2`, the second to + // `0.5 + a2/2`. Both open by the same amount and this *is* the centred + // span, which is why a single-sensor double door is untouched. + const a1 = Math.max(0, Math.min(1, amount)); + const a2 = Math.max(0, Math.min(1, secondAmount ?? amount)); + const span: [number, number] = [0.5 - a1 / 2, 0.5 + a2 / 2]; + // `flipH` swaps which jamb each leaf hangs on, so the clear half swaps with + // it — the same mirror {@link openingMirror} applies to the symbol. + return o.flipH ? [1 - span[1], 1 - span[0]] : span; +} + /** * The walls as **light** meets them (issue #143): the plan's walls with a gap * cut wherever an opening is currently open. @@ -709,29 +773,42 @@ export function glowClearFraction( * {@link openingClearFraction} and {@link glowClearFraction}, which is where a * two-panel slider's two sensors are reconciled into one number. * - * The gap is `length * fraction`, **centred**, and that is an approximation - * worth naming. A half-open slider really clears one side rather than the - * middle; `converging` is the sharpest case, since its two leaves stack in the - * centre and what actually clears is a quarter at each jamb — so the gap is - * the right *size* and the wrong *place*. The amount of light through the wall - * is right, where it lands is approximate, and the pool is a soft radial wash - * that hides most of the difference. Fixing it properly means letting one - * opening contribute several intervals rather than one, which is a change to - * this function's contract rather than to its arithmetic. + * It may supply **where** instead: a `[start, end]` pair of fractions along + * the opening places the gap exactly (see {@link openingClearSpan}), which is + * what a double door with one leaf open needs — the clear half is that leaf's + * half, not the middle (issue #219). A plain number is still centred, which is + * what every caller that has no better answer wants. + * + * Centring is an approximation worth naming wherever it is still used. A + * half-open slider really clears the side it slid away from; `converging` is + * the sharpest case, since its two leaves stack in the centre and what + * actually clears is a quarter at each jamb — so the gap is the right *size* + * and the wrong *place*. Those want more than one interval per opening, which + * this function's one-gap-per-opening shape still cannot say; the amount of + * light through the wall is right and where it lands is approximate, which a + * soft radial pool hides most of. */ export function wallsLightPassesThrough( walls: readonly Wall[], openings: readonly Opening[], - openAmount: (o: Opening) => number, + openAmount: (o: Opening) => number | readonly [number, number], ): Wall[] { // Resolve each opening once, not once per wall. `openAmount` reads hass on // every call, and asking it inside the wall loop made that walls × openings // state lookups per render — hundreds, on a plan of any size, to answer the // same handful of questions. - const open: Array<{ o: Opening; amount: number }> = []; + const open: Array<{ o: Opening; span: [number, number] }> = []; for (const o of openings) { - const amount = Math.max(0, Math.min(1, openAmount(o))); - if (amount > 0) open.push({ o, amount }); + const answer = openAmount(o); + // A number is a width with no opinion about placement, so it centres; a + // pair is a placement already worked out. Both are clamped here rather + // than trusted, since either can arrive from a caller's own arithmetic. + const clamp = (v: number) => Math.max(0, Math.min(1, v)); + const span: [number, number] = + typeof answer === "number" + ? [(1 - clamp(answer)) / 2, (1 + clamp(answer)) / 2] + : [clamp(Math.min(answer[0], answer[1])), clamp(Math.max(answer[0], answer[1]))]; + if (span[1] > span[0]) open.push({ o, span }); } // Nothing open is the common case — a plan of shut doors, or one with no // openings at all. Hand back the same array, so a caller can compare @@ -751,14 +828,28 @@ export function wallsLightPassesThrough( // Where each open opening sits along this wall, as a [0,1] interval. const gaps: Array<[number, number]> = []; - for (const { o, amount } of open) { + for (const { o, span } of open) { // Openings snap onto walls, but they are stored free of them, so an // opening belongs to this wall only if it actually lies on it. if (pointWallDist(o.x, o.y, w) > OPENING_ON_WALL_EPS) continue; const tc = ((o.x - w.x1) * dx + (o.y - w.y1) * dy) / len2; - const half = (o.length * amount) / 2 / len; - const a = Math.max(0, tc - half); - const b = Math.min(1, tc + half); + // The span runs along the opening's own axis, and the wall may run the + // other way: a doorway drawn at 180° has its first jamb at the wall's + // far end. Project the opening's +x onto the wall to find out which, + // or a placed gap lands mirrored — invisible until one leaf opens. + const dirSign = + Math.cos((o.angle * Math.PI) / 180) * dx + Math.sin((o.angle * Math.PI) / 180) * dy >= 0 + ? 1 + : -1; + const off = (s: number) => (dirSign * (s - 0.5) * o.length) / len; + // Running the wall backwards flips the span's ends past each other, so + // order them after projecting rather than before: taken as given, `b > + // a` fails and the gap is silently dropped — a doorway that stops + // letting light through because of the direction its wall was drawn. + const t0 = tc + off(span[0]); + const t1 = tc + off(span[1]); + const a = Math.max(0, Math.min(t0, t1)); + const b = Math.min(1, Math.max(t0, t1)); if (b > a) gaps.push([a, b]); } if (!gaps.length) { @@ -3326,8 +3417,15 @@ export const SUN_REACH = 0.34; */ export const SUN_REACH_REF = 30; /** - * How far the falloff spreads ACROSS the beam, as a multiple of the gap's - * width — the other half of the ellipse the light dies into. + * How far the falloff spreads ACROSS the beam, as a multiple of **half** the + * gap's width — the other half of the ellipse the light dies into. + * + * Half, because a gradient's `gradientTransform` scales its *semi*-axes: the + * ellipse reaches this far from the beam's middle, and the beam only has half + * its width to give on each side. Handed the whole width it ran nearly twice + * as wide as the polygon carrying it, so the outline sliced through light at + * about half strength all the way up both flanks — the hard diagonal edge in + * issue #206. Anything above 1 puts that edge back. * * The falloff is an ellipse fitted to the beam, not a circle. That is the * whole difference between a rounded tip and a flat one, and it took four @@ -3337,7 +3435,7 @@ export const SUN_REACH_REF = 30; * window, 10% of the width. An ellipse squashed to the beam's own proportions * curves on the scale of its WIDTH instead, which is what reads as round. * - * A shade under 1 means the light is already dimming at the gap's own edges, + * A shade under 1 means the light has died just inside the gap's own edges, * so the patch has soft flanks as well as a soft tip. */ export const SUN_ACROSS = 0.95; @@ -3742,6 +3840,10 @@ export function renderSunlight( const gx = gb.x - ga.x; const gy = gb.y - ga.y; const width = Math.abs(gx * dir.y - gy * dir.x) * clear(o); + // Half of it, because the gradient below is scaled by *semi*-axes: the + // ellipse reaches `across` from the beam's middle, and the beam only has + // half its width to give on each side (issue #206). + const halfWidth = width / 2; return { // The outline runs past the falloff, so the ellipse is what bounds the // patch and never the polygon's flat far edge. @@ -3749,7 +3851,7 @@ export function renderSunlight( cx: o.x, cy: o.y, along, - across: Math.max(1, width * SUN_ACROSS), + across: Math.max(1, halfWidth * SUN_ACROSS), angle: (Math.atan2(dir.y, dir.x) * 180) / Math.PI, lightId: `${id}-b${i}`, shadeId: `${id}-s${i}`,