Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ import {
resolveOpeningAmount,
openingIsActive,
wallsLightPassesThrough,
glowClearFraction,
glowClearSpan,
openingHasTwoLeaves,
secondLeafOf,
renderGlowMask,
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/floorplan-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ import {
renderSunDimMask,
wallsLightPassesThrough,
openingClearFraction,
glowClearFraction,
glowClearSpan,
polygonCentroid,
trackerSensorReading,
entityIsActive,
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 50 additions & 1 deletion src/render.opening.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions src/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ import {
glowReach,
wallsLightPassesThrough,
openingClearFraction,
openingClearSpan,
glowClearSpan,
glowClearFraction,
renderGlowMask,
renderOpening,
Expand Down Expand Up @@ -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<Opening> = {}) =>
({ 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.
Expand Down Expand Up @@ -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<Opening>);
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<Opening>);
const cut = (walls: ReturnType<typeof wall>[]) =>
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.
Expand Down
Loading
Loading