Sunlight has hard sides again and fades only along the ray

Owner, 2026-08-04, on yesterday's attempt: «с лучами солнца ты сделал фигню —
не надо размывать их боковые грани».

They are right. 22b588e answered "the shafts run into something invisible" with
a Gaussian blur of the WHOLE wedge (`raySoftness`, filter `hp-sunsoft`), which
feathered the sides as well as the tip. A shaft of light through a window has
crisp sides; only its reach fades. The blur turned every wedge into a smudge.

GONE. `raySoftness()`, the `<filter>`/`feGaussianBlur` in <defs>, the `<g
filter clip-path>` wrapper, and with it the `hp-sunclip` clipPath — that clip
existed only so the blur could not bleed through a wall. The polygons come out
of `computeSunRays()` already intersected with the room, so a wall still stops
the light by geometry (demo/smoke_sun.mjs, wedgeClippedToRoom). The sun layer
is plain `<polygon fill="url(#hp-sun-i)">` again.

THE KERB DID NOT COME BACK, and not by luck. The old bright edge floating in
mid-floor was never about softness: the gradient's iso-alpha lines are square
to the SUN, while a parallelogram's far edge is parallel to the WALL. Head-on
they coincide; at any other angle one far corner sits at offset `1 − 0.5/k` —
0.71 of the way at a low sun, 0.11 at a high one — i.e. still lit when the
polygon ends. So `rayQuad()` no longer builds a parallelogram: each side is
extruded until it reaches the same distance `len` ALONG `dir`, which puts the
far edge on one iso-alpha line of the gradient. Combined with the untouched
`RAY_FADE_END` = 85 %, the last 15 % of every wedge is empty and its outline
has nothing left to draw. The sides stay razor-sharp on purpose.

Length (×0.7) and the live sky catch-up are untouched.

Tests: unit — `rayQuad` at six sun angles (sides exactly parallel to the ray,
both far corners at offset 1, far edge ⊥ ray, nothing past the gradient) plus
the head-on parallelogram pinned; the `raySoftness` test is gone with the
function. Smoke — demo/smoke_sun_soft.mjs keeps the reach and the "dead at
85 %" checks and flips the feather assert into its opposite: no filter on any
wedge, no `feGaussianBlur` in the tree, and at an OBLIQUE sun (230°/8° and
225°/55°) no vertex is drawn past the end of the gradient. Verified to fail on
the previous bundle on exactly those four. All 247 unit tests and all 97 smokes
green. Stills: sun_sharp_low / sun_sharp_high (demo/shot_sun_short.mjs now
takes a file prefix).
This commit is contained in:
Matysh
2026-08-04 10:30:22 +03:00
parent 0af50a74ae
commit eb17396006
9 changed files with 849 additions and 782 deletions
File diff suppressed because one or more lines are too long
+8 -6
View File
@@ -1,9 +1,11 @@
// Before/after shots for the 2026-08-04 ray report: 30% shorter shafts that
// always dissolve to nothing (docs/SUN.md). Usage:
// node demo/shot_sun_short.mjs <outdir> <suffix>
// Stills for the 2026-08-04 ray report: 30 % shorter shafts with SHARP sides
// that dissolve only along the ray (docs/SUN.md). The low-sun frame is the
// interesting one — the north and south windows get a nearly wall-grazing sun,
// the case that used to hang a bright kerb across the floor. Usage:
// node demo/shot_sun_short.mjs <outdir> <prefix>
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const tag = process.argv[3] || 'short';
const tag = process.argv[3] || 'sun_rays_short';
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no 45 s sky glide
@@ -35,12 +37,12 @@ const setup = async (az, el) => {
// low sun in the west — the long shafts, the case that hit the far walls
await setup(265, 5);
let stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/sun_rays_${tag}_low.png` });
await stage.asElement().screenshot({ path: `${outDir}/${tag}_low.png` });
// high southern sun — the short noon pools
await setup(180, 55);
stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/sun_rays_${tag}_high.png` });
await stage.asElement().screenshot({ path: `${outDir}/${tag}_high.png` });
await browser.close();
console.log('shots written to ' + outDir);
+63 -15
View File
@@ -1,8 +1,16 @@
// Owner 2026-08-04: «лучи от солнца сделать короче на 30%, проверить, чтобы они
// всегда плавно рассеивались (сейчас есть ощущение, что они упираются во что-то
// невидимое)». The wedge must be 70 % of the old reach AND must never show a
// straight kerb: the gradient dies before the far edge, the shaft is feathered
// and only then clipped by the room (docs/SUN.md).
// Owner 2026-08-04, on the first attempt: «с лучами солнца ты сделал фигню —
// не надо размывать их боковые грани». A shaft of sunlight has HARD sides; it
// fades only with distance, along the ray, from the glass inward. So:
// * the reach is still 70 % of the v1.56 curve;
// * the gradient still spans the FULL wedge and is dead from RAY_FADE_END;
// * the sides are SHARP — no blur filter on the wedge, none defined at all;
// * and because the sides are sharp, the wedge must end ON an iso-alpha line
// of that gradient: nothing is ever drawn past its end, so the old bright
// kerb (a far edge parallel to the wall, cut while still lit) cannot come
// back at an oblique sun.
// The "light never crosses a wall" clip is asserted in demo/smoke_sun.mjs
// (wedgeClippedToRoom) — the polygons arrive from computeSunRays() already
// intersected with the room, which is why no clip-path is needed here.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
@@ -61,16 +69,56 @@ const res = await page.evaluate(async () => {
return st.every(([, a], i) => i === 0 || a <= st[i - 1][1] + 1e-9);
});
// ---- 3) feathered edges, and still no light through a wall -------------
const wraps = [...sr().querySelectorAll('.sunlayer > g')];
out.everyWedgeFeathered = wraps.length > 0
&& wraps.every((g) => /hp-sunsoft-/.test(g.getAttribute('filter') || ''));
out.everyWedgeClippedToItsRoom = wraps.every((g) => /hp-sunclip-/.test(g.getAttribute('clip-path') || ''));
out.blurIsProportional = [...sr().querySelectorAll('filter[id^=hp-sunsoft-] feGaussianBlur')]
.every((b) => { const s = Number(b.getAttribute('stdDeviation')); return s >= 3 && s <= 18; });
// the clip really is the room outline, so a wall still stops the light
out.clipIsTheRoomOutline = [...sr().querySelectorAll('clipPath[id^=hp-sunclip-] polygon')]
.every((p) => (p.getAttribute('points') || '').split(' ').length >= 3);
// ---- 3) SHARP sides: no blur on the wedge, and none defined anywhere ----
const wedges = () => [...sr().querySelectorAll('.sunlayer polygon')];
// walk the polygon and its ancestors up to and including .sunlayer — the
// day/night `brightness` filter lives further up, on the zoomwrap, and is
// none of this test's business
const blurredChain = (el) => {
for (let n = el; n; n = n.parentElement) {
const attr = n.getAttribute && n.getAttribute('filter');
if (attr && attr !== 'none') return true;
const cs = getComputedStyle(n).filter;
if (cs && cs !== 'none' && cs !== '') return true;
if (n.classList && n.classList.contains('sunlayer')) break;
}
return false;
};
out.wedgesDrawn = wedges().length > 0;
out.everyWedgeHasSharpSides = wedges().length > 0 && wedges().every((p) => !blurredChain(p));
out.noSoftFilterDefined = sr().querySelectorAll('filter[id^=hp-sunsoft-]').length === 0;
out.noGaussianBlurAtAll = sr().querySelectorAll('feGaussianBlur').length === 0;
// ---- 4) an OBLIQUE sun: the shaft still dies of its gradient -----------
// The sides are hard again, so the only thing that may end the wedge is the
// gradient. That holds ONLY if the far edge is square to the RAY: with the
// old wall-parallel edge one far corner sat at offset ~0.7 (low sun) or
// ~0.11 (high sun) — i.e. still lit — which is exactly the bright kerb.
const tOf = (r, p) => {
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
return ((p[0] - mx) * r.dir[0] + (p[1] - my) * r.dir[1]) / r.len;
};
const skew = (r) => {
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
return Math.abs((r.a[0] - mx) * r.dir[0] + (r.a[1] - my) * r.dir[1]) / r.len;
};
out.obliqueChecked = [];
out.sunIsReallyOblique = true;
out.nothingDrawnPastTheGradient = true;
for (const [az, el] of [[230, 8], [225, 55]]) {
await setSun(az, el);
const rays = c._sunRaysCache.rays;
out.obliqueChecked.push(rays.length);
if (!rays.length) { out.sunIsReallyOblique = false; continue; }
if (!rays.some((r) => skew(r) > 1e-3)) out.sunIsReallyOblique = false;
for (const r of rays) {
for (const poly of r.polys) {
for (const p of poly) if (tOf(r, p) > 1 + 1e-6) out.nothingDrawnPastTheGradient = false;
}
}
}
out.obliqueSunHasWedges = out.obliqueChecked.every((n) => n > 0);
delete out.obliqueChecked;
return out;
});
await finish(browser, checkAll(res));
File diff suppressed because one or more lines are too long
+217 -227
View File
File diff suppressed because one or more lines are too long
+40 -31
View File
@@ -127,7 +127,8 @@ when BOTH hold:
toward the sun is positive (the sun actually faces this window).
The wedge is a quadrilateral cast from the window's span along the
direction AWAY from the sun (light falls inward), clipped by the
direction AWAY from the sun (light falls inward) and cut off
PERPENDICULAR to the ray (see «Dissolving» below), clipped by the
room's polygon (`polyclip` intersection, the same dependency
`src/resize.ts` already uses). Its length is `k(elevation)` in window
lengths: ~1.75 at sunrise/sunset tapering to ~0.56 at the zenith
@@ -141,41 +142,47 @@ opacity is `RAY_MAX_ALPHA` = 0.30 (owner 2026-08-03: «лучи поярче,
still stay under a readable ceiling on white paper and on the dark glow
canvas alike).
### Dissolving — never a kerb (owner 2026-08-04)
### Dissolving — along the ray only (owner 2026-08-04)
«Проверить, чтобы они всегда плавно рассеивались (сейчас есть
ощущение, что они упираются во что-то невидимое)». A wedge is a
polygon, and until v1.56 nothing hid that:
Two rounds with the owner on the same day:
- the gradient's iso-alpha lines run PERPENDICULAR to the sun, while
the wedge's far edge is parallel to the WALL. For any sun that does
not hit the glass head-on the two are not the same line, so half of
that far edge was cut while it still carried colour — a straight
bright kerb hanging in the middle of the floor;
- the wedge's two SIDES had no falloff at all — two razor lines running
from the window into the room;
- where the room outline clips the wedge (the opposite wall, the inner
corner of an L, and above all an OPEN boundary, which has no wall
drawn at all) the shaft was chopped at whatever alpha it still had.
1. «Проверить, чтобы они всегда плавно рассеивались (сейчас есть
ощущение, что они упираются во что-то невидимое)» — the wedge was
ending on a visible line;
2. «С лучами солнца ты сделал фигню — не надо размывать их боковые
грани» — the first answer to (1) was a Gaussian blur over the whole
wedge, which feathered the SIDES too. Wrong: a shaft of sunlight
through a window has crisp sides. Only its reach fades.
The contract now:
So the falloff is one-dimensional: **along the ray, from the glass
inward, and nothing else.** The contract:
- the gradient still spans the FULL wedge length (`x1,y1` at the glass,
- the gradient spans the FULL wedge length (`x1,y1` at the glass,
`x2,y2` exactly `len` away), so geometry and gradient always describe
the same shaft — but its stops (`rayStops()`) ease out to **zero at
`RAY_FADE_END` = 85 %** of that length: `1 → .86 → .60 → .32 → .10
→ 0`. The last 15 % of every wedge is guaranteed empty, so a shaft
that ends in mid-air has nothing left to draw an edge with;
- each wedge is drawn inside `<g filter="…" clip-path="…">`. SVG applies
the filter FIRST and the clip SECOND, so a small Gaussian blur
(`raySoftness(len)` = 7 % of the length, clamped to 3…18 render units)
feathers the sides and the tip, and the room outline then cuts the
feather off — light still never crosses a wall, but where the shaft
does reach one the kerb is a soft ramp that reads as light landing ON
the wall instead of a cut-out shape.
- `rayQuad()` therefore ends the wedge ON an iso-alpha line of that
gradient: both sides are extruded until they reach the same distance
`len` ALONG `dir`, so the far edge is perpendicular to the RAY, not
parallel to the wall. This is what killed the old bright kerb. A
parallelogram (equal extrusion of both ends) has its far edge
parallel to the WALL, while the gradient's iso-alpha lines are square
to the sun; for any sun that does not face the glass head-on the two
disagree and one far corner sits at offset `1 − 0.5/k` — still lit
(~0.71 at a low sun, ~0.11 at a high one). That corner was the
straight bright kerb hanging in mid-floor;
- the two SIDES carry no falloff at all, on purpose. They are hard
lines, because that is what light through a window looks like. There
is **no filter, no `feGaussianBlur`, no `clip-path`** anywhere in the
sun layer — the polygons arrive from `computeSunRays()` already
intersected with the room, so a wall stops the light by geometry;
- where the room outline does cut a still-lit shaft (the opposite wall,
the inner corner of an L, an OPEN boundary) the edge stays crisp:
that is light landing on a wall, and blurring it was the mistake.
Clipping by the room stays exactly as before; only its visible edge
changed.
Clipping by the room is unchanged; only the visible edge changed.
### The 3° threshold and the 2-second fade
@@ -250,11 +257,13 @@ Backend validation: string or null.
- `custom_components/houseplan/validation.py` — the four settings at
both levels; tests in `tests_backend/test_validation.py`.
- `demo/smoke_sun.mjs` — end-to-end behaviour against the demo rig.
- `demo/smoke_sun_soft.mjs` — the −30 % reach and the "always
dissolves" contract (the gradient spans the wedge and dies at 85 %,
the feather filter, the room clip).
- `demo/smoke_sun_soft.mjs` — the −30 % reach and the "dissolves along
the ray only" contract: the gradient spans the wedge and dies at
85 %, the sides are sharp (no filter on the wedge, no
`feGaussianBlur` at all), and at an oblique sun nothing is drawn past
the end of the gradient — the kerb cannot come back.
- `demo/smoke_sun_live_bg.mjs` — the sky follows `sun.sun` on a plain
`hass` tick with no reload, asserted on the COMPUTED background of the
stage; small steps still glide, big ones catch up at once.
- `demo/shot_sun_short.mjs` — before/after stills at a low and a high
sun.
- `demo/shot_sun_short.mjs` — stills at a low and a high sun
(`node demo/shot_sun_short.mjs <outdir> <prefix>`).
+12 -24
View File
@@ -32,7 +32,7 @@ import {
import {
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, weatherEntityOf,
sunStateOf, cloudFactor, rayPeakAlpha, raysVisible, rayColor, RAY_FADE_MS, type SunRay,
rayStops, raySoftness, skyElevation, skyNeedsSnap,
rayStops, skyElevation, skyNeedsSnap,
} from './sun';
import { ContentSigner } from './signing';
import { mdiHomeCityOutline } from '@mdi/js';
@@ -4969,14 +4969,14 @@ class HouseplanCard extends LitElement {
if (!rays.length) return empty;
const color = rayColor(dayPhase(sun.elevation).warmth);
const stops = rayStops();
// Room outlines for the clip: the wedge is blurred FIRST and clipped
// SECOND (SVG applies filter → clip-path in that order), so the soft kerb
// never leaks through a wall while the wedge itself has no razor edges.
const roomPolys = new Map<string, string>();
for (const r of space.rooms) {
const poly = r.id ? roomPoly(r) : null;
if (r.id && poly) roomPolys.set(r.id, poly.map((q) => q[0] + ',' + q[1]).join(' '));
}
// NO filter here, and none in <defs>. Owner 2026-08-04: «не надо размывать
// их боковые грани» — the shaft keeps the crisp sides real light has, and
// the only falloff is the gradient running ALONG the ray. The tip needs no
// blur either: `rayStops()` is already at zero from RAY_FADE_END on, and
// `rayQuad()` ends the wedge on that same iso-alpha line, so the far edge
// has nothing left to draw. The polygons come out of `computeSunRays()`
// already intersected with the room, so no clip-path is needed to keep the
// light off the far side of a wall.
return svg`<defs>
${rays.map((r, i) => {
const mx = (r.a[0] + r.b[0]) / 2;
@@ -4985,24 +4985,12 @@ class HouseplanCard extends LitElement {
x1="${mx}" y1="${my}" x2="${mx + r.dir[0] * r.len}" y2="${my + r.dir[1] * r.len}">
${stops.map(([off, k]) => svg`<stop offset="${(off * 100).toFixed(1)}%"
stop-color="${color}" stop-opacity="${(alpha * k).toFixed(4)}"></stop>`)}
</linearGradient>
<filter id="hp-sunsoft-${i}" x="-30%" y="-30%" width="160%" height="160%"
color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="${raySoftness(r.len).toFixed(2)}"></feGaussianBlur>
</filter>
${roomPolys.has(r.roomId)
? svg`<clipPath id="hp-sunclip-${i}" clipPathUnits="userSpaceOnUse">
<polygon points="${roomPolys.get(r.roomId)}"></polygon>
</clipPath>`
: nothing}`;
</linearGradient>`;
})}
</defs>
<g class="sunlayer ${this._sunOut ? 'out' : ''}">
${rays.map((r, i) => svg`<g filter="url(#hp-sunsoft-${i})"
clip-path="${roomPolys.has(r.roomId) ? `url(#hp-sunclip-${i})` : 'none'}">
${r.polys.map((p) => svg`<polygon
points="${p.map((q) => q[0] + ',' + q[1]).join(' ')}" fill="url(#hp-sun-${i})"></polygon>`)}
</g>`)}
${rays.map((r, i) => r.polys.map((p) => svg`<polygon
points="${p.map((q) => q[0] + ',' + q[1]).join(' ')}" fill="url(#hp-sun-${i})"></polygon>`))}
</g>` as unknown as TemplateResult;
}
+32 -16
View File
@@ -143,13 +143,37 @@ export function rayLength(elevation: number): number {
return RAY_LENGTH_K * (0.8 + 1.7 * Math.pow(1 - e / 90, 1.6));
}
/** The unclipped wedge: window span a-b extruded by `len` along `dir`. */
/**
* The unclipped wedge: the window span a-b extruded along `dir` and cut off
* PERPENDICULAR to the ray, `len` from the span's midpoint.
*
* Not the parallelogram an equal extrusion of both ends would give. The fade
* is a linear gradient running ALONG `dir`, so its iso-alpha lines are
* perpendicular to `dir`, while a parallelogram's far edge stays parallel to
* the WALL. For any sun that does not face the glass head-on the two are
* different lines: one half of that far edge got cut while it still carried
* colour — the straight bright kerb hanging in mid-floor. Ending both sides on
* the SAME iso-alpha line makes the geometry and the gradient describe one
* shaft, so a wedge dies of its gradient (empty from RAY_FADE_END on) and
* never of its own outline.
*
* The two SIDES stay razor-sharp on purpose — owner 2026-08-04: «с лучами
* солнца ты сделал фигню — не надо размывать их боковые грани». A shaft of
* light through a window HAS crisp sides; only its reach fades.
*/
export function rayQuad(a: number[], b: number[], dir: number[], len: number): number[][] {
const mx = (a[0] + b[0]) / 2;
const my = (a[1] + b[1]) / 2;
// how far along `dir` each end of the span already sits, from the midpoint
const pa = (a[0] - mx) * dir[0] + (a[1] - my) * dir[1];
const pb = (b[0] - mx) * dir[0] + (b[1] - my) * dir[1];
const ea = Math.max(0, len - pa); // the trailing end travels further
const eb = Math.max(0, len - pb);
return [
[a[0], a[1]],
[b[0], b[1]],
[b[0] + dir[0] * len, b[1] + dir[1] * len],
[a[0] + dir[0] * len, a[1] + dir[1] * len],
[b[0] + dir[0] * eb, b[1] + dir[1] * eb],
[a[0] + dir[0] * ea, a[1] + dir[1] * ea],
];
}
@@ -284,6 +308,11 @@ export const RAY_FADE_END = 0.85;
* a whisper at two thirds, nothing from RAY_FADE_END on. Consumed by the card
* as SVG <stop>s over the FULL wedge length, so the geometry and the gradient
* always describe the same shaft (docs/SUN.md).
*
* This gradient is the ONLY thing that dissolves a wedge: the falloff runs
* along the ray, from the glass inward, and the sides of the shaft keep the
* hard edge light actually has (owner 2026-08-04: «не надо размывать их
* боковые грани»). No blur is involved anywhere.
*/
export function rayStops(): [number, number][] {
return [
@@ -297,19 +326,6 @@ export function rayStops(): [number, number][] {
];
}
/**
* Blur radius (render units) that feathers a wedge's own edges. The wedge is a
* polygon: without this its SIDES are razor-sharp lines across the floor, and
* where the room outline clips it — an opposite wall, an L-corner, an OPEN
* boundary with no wall drawn at all — the shaft is chopped at whatever alpha
* it still had. Blurring the wedge and only then clipping it to the room keeps
* the light inside the walls (it never leaks through them) while the visible
* kerb becomes a soft ramp: light landing ON the wall, not a cut-out shape.
*/
export function raySoftness(len: number): number {
return Math.max(3, Math.min(18, len * 0.07));
}
/**
* Day/night sky: how far the painted sky may drift from the real sun before
* the card stops gliding and simply JUMPS to the right colour.
+43 -9
View File
@@ -6,7 +6,7 @@ import {
rayLength, rayQuad, clipToRoom, computeSunRays,
rayAlpha, rayColor, cloudFactor, RAY_MAX_ALPHA,
raysVisible, rayPeakAlpha, RAY_ELEVATION_MIN, RAY_FADE_MS,
RAY_LENGTH_K, RAY_FADE_END, rayStops, raySoftness,
RAY_LENGTH_K, RAY_FADE_END, rayStops,
SKY_SNAP_DEG, skyNeedsSnap, skyElevation,
northDegOf, bgModeOf, sunRaysOn, weatherEntityOf, sunStateOf,
} from '../test-build/sun.js';
@@ -138,14 +138,6 @@ test('rayStops: the shaft is fully dissolved BEFORE its own far edge', () => {
assert.ok(half[0] <= 0.65, 'past half-dark by two thirds of the way');
});
test('raySoftness: a feather proportional to the shaft, clamped both ends', () => {
assert.equal(raySoftness(0), 3); // a stub of a wedge still gets a kerb
assert.ok(near(raySoftness(100), 7, 1e-9));
assert.ok(near(raySoftness(200), 14, 1e-9));
assert.equal(raySoftness(1e6), 18); // never a smear across the plan
assert.ok(raySoftness(200) > raySoftness(100));
});
test('skyNeedsSnap / skyElevation: glide with the sun, jump when we were away', () => {
assert.equal(SKY_SNAP_DEG, 3);
assert.equal(skyNeedsSnap(null, 12), true); // nothing painted yet
@@ -160,6 +152,48 @@ test('skyNeedsSnap / skyElevation: glide with the sun, jump when we were away',
assert.equal(skyElevation('nonsense'), 0);
});
test('rayQuad: sharp sides, far edge square to the RAY (owner 2026-08-04)', () => {
// «не надо размывать их боковые грани» — the shaft's sides are hard lines,
// so the only thing that may dissolve it is the gradient along the ray. That
// works only if the wedge ends exactly ON an iso-alpha line: the far edge is
// perpendicular to `dir`, not parallel to the wall.
const a = [100, 100];
const b = [100, 200]; // a window along +y
const len = 300;
for (const deg of [0, 20, 45, 70, -35, -60]) {
const rad = (deg * Math.PI) / 180;
const dir = [Math.cos(rad), Math.sin(rad)]; // oblique sun in most cases
const q = rayQuad(a, b, dir, len);
assert.equal(q.length, 4);
// the near edge is still the window itself
assert.deepEqual(q[0], [100, 100]);
assert.deepEqual(q[1], [100, 200]);
// both sides run exactly along the ray — razor-sharp, never splayed
for (const [near0, far] of [[q[0], q[3]], [q[1], q[2]]]) {
const ex = far[0] - near0[0];
const ey = far[1] - near0[1];
const cross = ex * dir[1] - ey * dir[0];
assert.ok(Math.abs(cross) < 1e-9, 'side parallel to the ray at ' + deg);
assert.ok(ex * dir[0] + ey * dir[1] > 0, 'side runs away from the glass');
}
// ...and both far corners sit at the SAME distance along the ray, i.e. on
// one iso-alpha line of the gradient. This is what kills the bright kerb.
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const t = (p) => ((p[0] - mid[0]) * dir[0] + (p[1] - mid[1]) * dir[1]) / len;
assert.ok(near(t(q[2]), 1, 1e-9), 'far corner B at offset 1 at ' + deg);
assert.ok(near(t(q[3]), 1, 1e-9), 'far corner A at offset 1 at ' + deg);
// nothing is drawn past the end of the gradient
for (const p of q) assert.ok(t(p) <= 1 + 1e-9, 'no vertex past the gradient');
// the far edge really is square to the ray
const fx = q[2][0] - q[3][0];
const fy = q[2][1] - q[3][1];
assert.ok(Math.abs(fx * dir[0] + fy * dir[1]) < 1e-9, 'far edge ⊥ ray at ' + deg);
}
// head-on sun: the classic parallelogram, unchanged
const straight = rayQuad(a, b, [1, 0], len);
assert.deepEqual(straight, [[100, 100], [100, 200], [400, 200], [400, 100]]);
});
test('rayQuad + clipToRoom: the wedge is cut by the room outline', () => {
const quad = rayQuad([100, 270], [100, 330], [1, 0], 1000); // way past the wall
const clipped = clipToRoom(quad, ROOMS[0].poly);