mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
DEV-EB173-01: a shaft of light fades along the wall's normal
Audit finding P2. At a grazing sun the wedge lost the two invariants it was supposed to keep: one end of the GLASS started at opacity 0, and the two sides of one shaft came out 5.41 and 84.19 long — the long one 31 % LONGER than the pre-cut 64, not 30 % shorter. The cause was the axis. The gradient ran along `dir` from the middle of the window span, so the geometry had to be skewed (each end extruded by a different amount) to make both far corners land on the same offset. That buys the iso-alpha far edge with the other two requirements. The light is a bundle of PARALLEL rays: the distance a point has travelled from the glass is depth/cos, an affine function of the point, whose level sets are lines PARALLEL TO THE WALL. So the correct linear gradient runs along the wall's INWARD NORMAL, starts on the window line and is `len·cos(incidence)` long — SunRay.normal / SunRay.depth. A point `source + dir·u` then lands on offset u/len, whichever ray it rode in on. All three invariants hold at once: * the whole pane of glass is at depth 0 → peak alpha end to end; * alpha depends only on how far that point's own ray has run; * rayQuad() is an honest parallelogram again (both ends extruded by the same `len`), and its far edge — parallel to the wall — IS the gradient's last iso-alpha line, so a bright kerb is impossible by construction and the −30 % holds for every side of every wedge. windowLit() gets a real threshold instead of the 1e-9 epsilon: RAY_MIN_COS = 0.05, i.e. the sun must clear the plane of the wall by ~2.9°. Below it glass reflects nearly everything and the shaft would be a sliver thinner than the wall it came through — nothing is drawn, and the gradient axis can never degenerate to a point. Tests: rayQuad now asserts equal, full-length sides and a wall-parallel far edge; new unit tests replay the auditor's repro with his numbers (both sides 44.8, offsets 0 at both ends of the glass, offset = travel / len for arbitrary rays) and the RAY_MIN_COS cut-off. smoke_sun_soft measures the same facts off the DOM gradient end to end and fails by name on the old bundle (9 named failures). docs/SUN.md carries the new contract and the finding.
This commit is contained in:
+99
-7
@@ -8,6 +8,14 @@
|
||||
// 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.
|
||||
// DEV-EB173-01 turned the last of those into a contract of its own: the fade
|
||||
// runs along the wall's INWARD NORMAL, not along the ray. For parallel rays
|
||||
// the distance travelled from the glass is an affine function of the point, so
|
||||
// its iso-alpha lines are parallel to the WALL — which is where an honest
|
||||
// parallelogram puts its far edge. All three invariants then hold at once:
|
||||
// peak alpha across the whole pane, the same fade distance along every ray,
|
||||
// and a far edge exactly on the gradient's end. The auditor's own grazing
|
||||
// repro is re-run below with his numbers.
|
||||
// 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.
|
||||
@@ -34,6 +42,32 @@ const res = await page.evaluate(async () => {
|
||||
const grads = () => [...sr().querySelectorAll('linearGradient[id^=hp-sun-]')];
|
||||
const stopsOf = (g) => [...g.querySelectorAll('stop')].map((s) => [
|
||||
parseFloat(s.getAttribute('offset')), Number(s.getAttribute('stop-opacity'))]);
|
||||
// everything below is measured off the DOM gradient, exactly like the audit
|
||||
// probe: axis, the offset a point lands on, and the alpha there
|
||||
const axisOf = (g) => {
|
||||
const x1 = +g.getAttribute('x1'), y1 = +g.getAttribute('y1');
|
||||
const x2 = +g.getAttribute('x2'), y2 = +g.getAttribute('y2');
|
||||
const len = Math.hypot(x2 - x1, y2 - y1);
|
||||
return { x1, y1, dx: x2 - x1, dy: y2 - y1, len, ux: (x2 - x1) / len, uy: (y2 - y1) / len };
|
||||
};
|
||||
const offsetOf = (g, p) => {
|
||||
const a = axisOf(g);
|
||||
return ((p[0] - a.x1) * a.dx + (p[1] - a.y1) * a.dy) / (a.len * a.len);
|
||||
};
|
||||
// a bundle without the normal-axis fade must FAIL these by name, not blow up
|
||||
const nrm = (r) => r.normal || [NaN, NaN];
|
||||
const dep = (r) => (r.depth === undefined ? NaN : r.depth);
|
||||
const alphaAt = (g, off) => {
|
||||
const st = stopsOf(g).map(([o, a]) => [o / 100, a]);
|
||||
if (off <= st[0][0]) return st[0][1];
|
||||
for (let i = 1; i < st.length; i++) {
|
||||
if (off <= st[i][0]) {
|
||||
const t = (off - st[i - 1][0]) / (st[i][0] - st[i - 1][0] || 1);
|
||||
return st[i - 1][1] + t * (st[i][1] - st[i - 1][1]);
|
||||
}
|
||||
}
|
||||
return st[st.length - 1][1];
|
||||
};
|
||||
|
||||
// ---- 1) 30 % shorter: the wedge reach in window lengths ----------------
|
||||
await setSun(270, 5); // low western sun into the west window
|
||||
@@ -50,12 +84,28 @@ const res = await page.evaluate(async () => {
|
||||
await setSun(270, 5);
|
||||
const gs = grads();
|
||||
out.gradientsDrawn = gs.length > 0;
|
||||
out.gradientSpansWholeWedge = gs.every((g) => {
|
||||
const st = stopsOf(g);
|
||||
// the gradient axis is the FULL wedge length (x1,y1 → x2,y2 = len away)
|
||||
const dx = +g.getAttribute('x2') - +g.getAttribute('x1');
|
||||
const dy = +g.getAttribute('y2') - +g.getAttribute('y1');
|
||||
return Math.abs(Math.hypot(dx, dy) - c._sunRaysCache.rays[0].len) < 1e-6 && st.length > 2;
|
||||
// the axis is the wall's inward normal, `len · cos(incidence)` long — the
|
||||
// perpendicular depth a ray reaches after running the FULL wedge length
|
||||
out.gradientRunsAlongTheWallNormal = gs.every((g, i) => {
|
||||
const r = c._sunRaysCache.rays[i];
|
||||
const a = axisOf(g);
|
||||
return Math.abs(a.ux - nrm(r)[0]) < 1e-6 && Math.abs(a.uy - nrm(r)[1]) < 1e-6;
|
||||
});
|
||||
out.gradientSpansWholeWedge = gs.every((g, i) => {
|
||||
const r = c._sunRaysCache.rays[i];
|
||||
const cos = r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1];
|
||||
return Math.abs(axisOf(g).len - r.len * cos) < 1e-6
|
||||
&& Math.abs(dep(r) - r.len * cos) < 1e-9
|
||||
&& stopsOf(g).length > 2;
|
||||
});
|
||||
// the ONLY thing that matters about that axis: a point `source + dir·u`
|
||||
// lands on offset `u / len`, whichever ray it rode in on
|
||||
out.offsetIsDistanceAlongTheRay = gs.every((g, i) => {
|
||||
const r = c._sunRaysCache.rays[i];
|
||||
return [0, 0.3, 0.85, 1].every((u) => [r.a, r.b].every((src) => {
|
||||
const p = [src[0] + r.dir[0] * r.len * u, src[1] + r.dir[1] * r.len * u];
|
||||
return Math.abs(offsetOf(g, p) - u) < 1e-6;
|
||||
}));
|
||||
});
|
||||
out.deadWellBeforeTheEnd = gs.every((g) => {
|
||||
const st = stopsOf(g);
|
||||
@@ -94,9 +144,10 @@ const res = await page.evaluate(async () => {
|
||||
// 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.
|
||||
// offset ALONG THE GRADIENT, i.e. depth under the wall over `len · cos`
|
||||
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;
|
||||
return ((p[0] - mx) * nrm(r)[0] + (p[1] - my) * nrm(r)[1]) / dep(r);
|
||||
};
|
||||
const skew = (r) => {
|
||||
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
|
||||
@@ -119,6 +170,47 @@ const res = await page.evaluate(async () => {
|
||||
}
|
||||
out.obliqueSunHasWedges = out.obliqueChecked.every((n) => n > 0);
|
||||
delete out.obliqueChecked;
|
||||
|
||||
// ---- 5) DEV-EB173-01: the auditor's own grazing repro ------------------
|
||||
// West window 80 render units long, elevation 90 (nominal reach 0.56 · 80 =
|
||||
// 44.8, i.e. 70 % of the old 64), azimuth 190 — the light enters the glass
|
||||
// and travels 10° off the wall's own direction. The probe on the broken
|
||||
// build read sides 5.408 / 84.192 (ratio 15.57) and source offsets ±0.879
|
||||
// with opacity 0 at one end of the pane.
|
||||
sp.openings = [{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 }];
|
||||
c._cfgEpoch++;
|
||||
await setSun(190, 90);
|
||||
const gr = c._sunRaysCache.rays;
|
||||
out.grazingRayDrawn = gr.length === 1;
|
||||
if (gr.length === 1) {
|
||||
const r = gr[0];
|
||||
const g = grads()[0];
|
||||
out.grazingIsReallyGrazing = Math.abs(r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1] - 0.17365) < 1e-4;
|
||||
out.grazingLengthIs70Percent = Math.abs(r.len - 0.7 * (0.8 * 80)) < 1e-6
|
||||
&& Math.abs(r.len - 44.8) < 1e-6;
|
||||
// both sides of the shaft, measured off the DRAWN polygon: the depth of a
|
||||
// vertex divided by cos is how far its ray ran
|
||||
const cos = r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1];
|
||||
const ran = (p) => ((p[0] - r.a[0]) * nrm(r)[0] + (p[1] - r.a[1]) * nrm(r)[1]) / cos;
|
||||
const far = r.polys[0].map(ran).filter((u) => u > 1e-6);
|
||||
out.grazingHasTwoFarCorners = far.length === 2;
|
||||
out.grazingSidesEqualWithin1Percent = far.length === 2
|
||||
&& Math.abs(far[0] - far[1]) <= 0.01 * r.len;
|
||||
out.grazingBothSidesAreTheNominalLength = far.every((u) => Math.abs(u - r.len) <= 0.01 * r.len);
|
||||
// the whole pane of glass at peak alpha (was 0 at one end)
|
||||
const peak = stopsOf(g)[0][1];
|
||||
out.grazingGlassAtOffsetZero = [r.a, r.b].every((p) => Math.abs(offsetOf(g, p)) < 1e-6);
|
||||
out.grazingGlassAtPeakAlpha = [r.a, r.b].every((p) => Math.abs(alphaAt(g, offsetOf(g, p)) - peak) < 1e-9);
|
||||
out.grazingPeakIsTheRealPeak = peak > 0.2;
|
||||
// and nothing is drawn past the gradient
|
||||
out.grazingInsideTheGradient = r.polys.every((poly) =>
|
||||
poly.every((p) => offsetOf(g, p) >= -1e-6 && offsetOf(g, p) <= 1 + 1e-6));
|
||||
}
|
||||
// a sun 2° off the wall's plane (cos 0.035 < RAY_MIN_COS) casts nothing
|
||||
await setSun(182, 90);
|
||||
out.sunAlongTheWallCastsNothing = c._sunRaysCache.rays.length === 0;
|
||||
await setSun(186, 90);
|
||||
out.sunJustClearOfTheWallStillCasts = c._sunRaysCache.rays.length === 1;
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+26
-25
File diff suppressed because one or more lines are too long
+63
-24
@@ -124,11 +124,16 @@ when BOTH hold:
|
||||
|
||||
- the sun is above the horizon (`elevation > 0`), and
|
||||
- the dot product of the wall's outward normal with the direction
|
||||
toward the sun is positive (the sun actually faces this window).
|
||||
toward the sun — the cosine of the angle of incidence — is above
|
||||
`RAY_MIN_COS` = 0.05, i.e. the sun faces this window AND clears the
|
||||
plane of its wall by ~2.9° (~87.1° of incidence). Below that there is
|
||||
nothing to paint: glass reflects almost all of it, and the shaft's
|
||||
perpendicular depth (`len · cos`, see «Dissolving») would be thinner
|
||||
than the wall it came through.
|
||||
|
||||
The wedge is a quadrilateral cast from the window's span along the
|
||||
direction AWAY from the sun (light falls inward) and cut off
|
||||
PERPENDICULAR to the ray (see «Dissolving» below), clipped by the
|
||||
The wedge is a PARALLELOGRAM: the window's span extruded by the same
|
||||
length along the direction AWAY from the sun (light falls inward), so
|
||||
its far edge is parallel to the wall, 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
|
||||
@@ -155,24 +160,54 @@ Two rounds with the owner on the same day:
|
||||
through a window has crisp sides. Only its reach fades.
|
||||
|
||||
So the falloff is one-dimensional: **along the ray, from the glass
|
||||
inward, and nothing else.** The contract:
|
||||
inward, and nothing else.** Three invariants have to hold at once:
|
||||
|
||||
1. the whole pane of glass is at peak alpha — light does not start out
|
||||
half-dark at one end of the window;
|
||||
2. every ray fades over the same distance, its own `len`;
|
||||
3. the wedge's far edge lies exactly on an iso-alpha line, so the shaft
|
||||
dies of its gradient and never of its own outline (that visible
|
||||
straight «bright kerb» hanging in mid-floor).
|
||||
|
||||
**The axis of the fade is the wall's INWARD NORMAL, not the ray.**
|
||||
The light is a bundle of PARALLEL rays, so the distance a point has
|
||||
travelled from the glass is `depth / cos`, where `depth` is its
|
||||
perpendicular distance from the wall and `cos = dir·normal` is fixed
|
||||
for the whole wedge. That is an affine function of the point, and its
|
||||
level sets are straight lines PARALLEL TO THE WALL. A linear gradient
|
||||
whose axis is the normal therefore describes the travelled distance
|
||||
exactly:
|
||||
|
||||
- `x1,y1` = the middle of the window span (any point of the glass —
|
||||
they all have depth 0);
|
||||
- `x2,y2` = that point plus `normal · len · cos` — `SunRay.depth`, the
|
||||
perpendicular depth a ray reaches after running the full `len`;
|
||||
- a point `source + dir·u` lands on offset `u / len`, whichever ray it
|
||||
rode in on.
|
||||
|
||||
Hence: the glass is all at offset 0 (invariant 1), the alpha at any
|
||||
point is a function of how far its own ray has run (invariant 2), and
|
||||
the parallelogram's far edge — parallel to the wall — IS the gradient's
|
||||
last iso-alpha line (invariant 3). The «30 % shorter» reach is then a
|
||||
fact about every SIDE of every wedge, at any sun angle.
|
||||
|
||||
The stops (`rayStops()`) ease out to **zero at `RAY_FADE_END` = 85 %**
|
||||
of the axis: `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.
|
||||
|
||||
> **DEV-EB173-01 (fixed).** The previous attempt kept the gradient along
|
||||
> `dir` from the span's midpoint and bent the GEOMETRY to match,
|
||||
> extruding the two ends of the window by different amounts so both far
|
||||
> corners projected onto the same point of that axis. It bought
|
||||
> invariant 3 with the other two: at a grazing sun the ends of the glass
|
||||
> themselves sat at offsets ±0.879 — one of them fully transparent
|
||||
> before the shaft even started — and the two sides came out 5.41 and
|
||||
> 84.19 long (ratio 15.6), the long one 31 % LONGER than the pre-cut 64
|
||||
> rather than 30 % shorter. One linear gradient along the ray cannot
|
||||
> satisfy all three; along the normal it satisfies all three by
|
||||
> construction.
|
||||
|
||||
- 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;
|
||||
- `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
|
||||
@@ -258,10 +293,14 @@ Backend validation: string or null.
|
||||
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 "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
|
||||
the ray only" contract: the gradient axis is the wall normal and is
|
||||
`len · cos` long, an offset is exactly how far a ray has run, the
|
||||
stops die 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.
|
||||
the end of the gradient — the kerb cannot come back. It re-runs the
|
||||
DEV-EB173-01 grazing repro end to end (west window 80, elevation 90,
|
||||
azimuth 190): equal sides of the nominal length, peak alpha at BOTH
|
||||
ends of the glass, and no wedge at all below `RAY_MIN_COS`.
|
||||
- `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.
|
||||
|
||||
+16
-7
@@ -4971,18 +4971,27 @@ class HouseplanCard extends LitElement {
|
||||
const stops = rayStops();
|
||||
// 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.
|
||||
// the only falloff is the gradient. The tip needs no blur either:
|
||||
// `rayStops()` is already at zero from RAY_FADE_END on, and the wedge's far
|
||||
// edge IS the gradient's last iso-alpha line, so it 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.
|
||||
//
|
||||
// DEV-EB173-01: the axis runs along the wall's INWARD NORMAL, from the
|
||||
// window line inward, and is `r.depth` = `len·cos` long — NOT along the
|
||||
// ray. For parallel rays the distance travelled from the glass is an
|
||||
// affine function of the point, so its iso-alpha lines are parallel to the
|
||||
// wall; with this axis every point `source + dir·u` lands at offset
|
||||
// `u/len`. Whole pane at peak alpha, identical fade distance along every
|
||||
// ray, and the parallelogram's far edge exactly on the gradient's end.
|
||||
return svg`<defs>
|
||||
${rays.map((r, i) => {
|
||||
const mx = (r.a[0] + r.b[0]) / 2;
|
||||
const my = (r.a[1] + r.b[1]) / 2;
|
||||
return svg`<linearGradient id="hp-sun-${i}" gradientUnits="userSpaceOnUse"
|
||||
x1="${mx}" y1="${my}" x2="${mx + r.dir[0] * r.len}" y2="${my + r.dir[1] * r.len}">
|
||||
x1="${mx}" y1="${my}"
|
||||
x2="${mx + r.normal[0] * r.depth}" y2="${my + r.normal[1] * r.depth}">
|
||||
${stops.map(([off, k]) => svg`<stop offset="${(off * 100).toFixed(1)}%"
|
||||
stop-color="${color}" stop-opacity="${(alpha * k).toFixed(4)}"></stop>`)}
|
||||
</linearGradient>`;
|
||||
|
||||
+59
-26
@@ -119,11 +119,24 @@ export function windowWallInfo(
|
||||
: { normal: n, roomId: minus!.id };
|
||||
}
|
||||
|
||||
/** Does the sun actually shine INTO this window right now? (A grazing sun
|
||||
* exactly along the wall does not count — hence the epsilon, which also
|
||||
* swallows the sin/cos float dust of the right-angle directions.) */
|
||||
/**
|
||||
* How square the sun has to be to a wall before that wall's windows cast
|
||||
* anything: the cosine of the angle of incidence, i.e. `outward normal · dir
|
||||
* to the sun`. 0.05 is ~87.1°, so the sun has to clear the plane of the wall
|
||||
* by ~2.9° — the same order as the 3° elevation threshold, and for the same
|
||||
* reason: below it there is no light worth painting. Glass agrees (Fresnel
|
||||
* reflects almost everything at that incidence), and so does the geometry —
|
||||
* the shaft's perpendicular depth is `len · cos`, so under this threshold the
|
||||
* whole wedge is a sliver thinner than the wall it came through, drawn with a
|
||||
* gradient axis shorter than a pixel (DEV-EB173-01).
|
||||
*/
|
||||
export const RAY_MIN_COS = 0.05;
|
||||
|
||||
/** Does the sun actually shine INTO this window right now? (A sun grazing
|
||||
* along the wall does not count — see RAY_MIN_COS, which also swallows the
|
||||
* sin/cos float dust of the right-angle directions.) */
|
||||
export function windowLit(normal: number[], sunDir: number[], elevation: number): boolean {
|
||||
return elevation > 0 && normal[0] * sunDir[0] + normal[1] * sunDir[1] > 1e-9;
|
||||
return elevation > 0 && normal[0] * sunDir[0] + normal[1] * sunDir[1] > RAY_MIN_COS;
|
||||
}
|
||||
|
||||
// ---------------- wedge geometry ----------------
|
||||
@@ -144,36 +157,38 @@ export function rayLength(elevation: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* The unclipped wedge: the window span a-b extruded along `dir` and cut off
|
||||
* PERPENDICULAR to the ray, `len` from the span's midpoint.
|
||||
* The unclipped wedge: the window span a-b extruded along `dir` by the SAME
|
||||
* `len` at both ends. An honest parallelogram — every ray through the glass
|
||||
* travels exactly the wedge's reach, so the promised "30 % shorter" holds for
|
||||
* each side of every wedge, at any sun angle.
|
||||
*
|
||||
* 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.
|
||||
* Its far edge is parallel to the WALL, and that is not a compromise: it is
|
||||
* the iso-alpha line of the gradient the card actually draws. For parallel
|
||||
* rays the distance travelled from the glass is `depth / cos`, an affine
|
||||
* function of the point whose level sets are lines PARALLEL TO THE WALL, so
|
||||
* the fade must run along the wall's NORMAL (see `SunRay.normal/depth` and
|
||||
* docs/SUN.md), not along `dir`. With that axis all three invariants hold at
|
||||
* once: the whole pane of glass sits at offset 0 (peak alpha end to end), the
|
||||
* alpha at any point depends only on how far its own ray has travelled, and
|
||||
* the wedge's far edge coincides with the gradient's end — a bright kerb is
|
||||
* impossible by construction.
|
||||
*
|
||||
* The previous attempt (DEV-EB173-01) kept the gradient along `dir` from the
|
||||
* span's midpoint and bent the GEOMETRY to match, extruding the two ends by
|
||||
* different amounts. At a grazing sun that put one end of the glass itself at
|
||||
* offset 0.88 — fully transparent before the shaft even started — and made
|
||||
* the long side 88 % longer than the nominal reach instead of 30 % shorter.
|
||||
*
|
||||
* 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] * eb, b[1] + dir[1] * eb],
|
||||
[a[0] + dir[0] * ea, a[1] + dir[1] * ea],
|
||||
[b[0] + dir[0] * len, b[1] + dir[1] * len],
|
||||
[a[0] + dir[0] * len, a[1] + dir[1] * len],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -206,8 +221,21 @@ export interface SunRay {
|
||||
b: number[];
|
||||
/** Direction the light travels (AWAY from the sun), unit vector. */
|
||||
dir: [number, number];
|
||||
/** Wedge reach in render units (the gradient's fade distance). */
|
||||
/** Wedge reach in render units: how far along `dir` every ray travels. */
|
||||
len: number;
|
||||
/**
|
||||
* INWARD wall normal (unit) — the axis of the fade. The distance a point
|
||||
* has travelled from the glass is the same affine function of the point as
|
||||
* its depth under the wall, so the gradient's iso-alpha lines are parallel
|
||||
* to the wall and its axis is this normal (docs/SUN.md, DEV-EB173-01).
|
||||
*/
|
||||
normal: [number, number];
|
||||
/**
|
||||
* Length of that axis: `len · (dir·normal)` — the perpendicular depth a ray
|
||||
* reaches after travelling `len`. A point `source + dir·u` therefore lands
|
||||
* at offset `u/len`: the glass is all at 0, the far edge all at 1.
|
||||
*/
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,7 +270,12 @@ export function computeSunRays(
|
||||
const len = k * w.length;
|
||||
const polys = clipToRoom(rayQuad(a, b, away, len), room.poly);
|
||||
if (!polys.length) continue;
|
||||
out.push({ openingId: w.id, roomId: info.roomId, polys, a, b, dir: away, len });
|
||||
// inward normal + how deep the ray gets: cos of the incidence angle,
|
||||
// which windowLit() has already found to be above RAY_MIN_COS
|
||||
const normal: [number, number] = [-info.normal[0], -info.normal[1]];
|
||||
const cos = away[0] * normal[0] + away[1] * normal[1];
|
||||
out.push({ openingId: w.id, roomId: info.roomId, polys, a, b, dir: away, len,
|
||||
normal, depth: len * cos });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+87
-20
@@ -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,
|
||||
RAY_LENGTH_K, RAY_FADE_END, rayStops, RAY_MIN_COS,
|
||||
SKY_SNAP_DEG, skyNeedsSnap, skyElevation,
|
||||
northDegOf, bgModeOf, sunRaysOn, weatherEntityOf, sunStateOf,
|
||||
} from '../test-build/sun.js';
|
||||
@@ -96,12 +96,20 @@ test('isExteriorWall probes the outer side', () => {
|
||||
assert.ok(!isExteriorWall([500, 300], [1, 0], ROOMS)); // r2 is outside r1 here
|
||||
});
|
||||
|
||||
test('windowLit: above the horizon AND facing the sun', () => {
|
||||
test('windowLit: above the horizon, facing the sun, and NOT along the wall', () => {
|
||||
const east = [1, 0];
|
||||
assert.ok(windowLit(east, sunDirOnPlan(90, 0), 10));
|
||||
assert.ok(!windowLit(east, sunDirOnPlan(270, 0), 10)); // sun behind the house
|
||||
assert.ok(!windowLit(east, sunDirOnPlan(90, 0), 0)); // sunset moment
|
||||
assert.ok(!windowLit(east, sunDirOnPlan(90, 0), -5)); // night
|
||||
// DEV-EB173-01: a sun sliding ALONG the wall lights nothing. The dot product
|
||||
// is the cosine of the incidence angle: for this wall it is exactly sin(az).
|
||||
assert.equal(RAY_MIN_COS, 0.05);
|
||||
const cos = (az) => Math.sin((az * Math.PI) / 180);
|
||||
assert.ok(cos(2) < RAY_MIN_COS && !windowLit(east, sunDirOnPlan(2, 0), 40));
|
||||
assert.ok(cos(4) > RAY_MIN_COS && windowLit(east, sunDirOnPlan(4, 0), 40));
|
||||
// ~87.1° of incidence, i.e. the sun ~2.9° clear of the wall's own plane
|
||||
assert.ok(near((Math.acos(RAY_MIN_COS) * 180) / Math.PI, 87.13, 0.01));
|
||||
});
|
||||
|
||||
test('rayLength: 30% shorter than v1.56 (owner 2026-08-04), same shape', () => {
|
||||
@@ -152,11 +160,13 @@ 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.
|
||||
test('rayQuad: an honest parallelogram, both sides exactly `len` (DEV-EB173-01)', () => {
|
||||
// «Не надо размывать их боковые грани» — the sides are hard lines, so the
|
||||
// only thing that may dissolve a shaft is the gradient. That gradient runs
|
||||
// along the wall's NORMAL (see SunRay.normal/depth), and ITS iso-alpha lines
|
||||
// are parallel to the wall — which is exactly where an equal extrusion of
|
||||
// both ends puts the far edge. So the wedge is a plain parallelogram again
|
||||
// and every side is the full, promised reach.
|
||||
const a = [100, 100];
|
||||
const b = [100, 200]; // a window along +y
|
||||
const len = 300;
|
||||
@@ -168,26 +178,22 @@ test('rayQuad: sharp sides, far edge square to the RAY (owner 2026-08-04)', () =
|
||||
// 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);
|
||||
// both sides run exactly along the ray — razor-sharp, never splayed
|
||||
assert.ok(Math.abs(ex * dir[1] - ey * dir[0]) < 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 each is the FULL reach: the 30 % cut is a fact on every side,
|
||||
// at every sun angle (the old skewed quad made one side 88 % longer)
|
||||
assert.ok(near(Math.hypot(ex, ey), len, 1e-9), 'side is exactly len at ' + deg);
|
||||
}
|
||||
// ...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
|
||||
// the far edge is parallel to the wall — the gradient's last iso-alpha line
|
||||
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);
|
||||
const sx = b[0] - a[0];
|
||||
const sy = b[1] - a[1];
|
||||
assert.ok(Math.abs(fx * sy - fy * sx) < 1e-6, 'far edge parallel to the wall at ' + deg);
|
||||
}
|
||||
// head-on sun: the classic parallelogram, unchanged
|
||||
const straight = rayQuad(a, b, [1, 0], len);
|
||||
@@ -230,6 +236,67 @@ test('computeSunRays: evening west sun → west window', () => {
|
||||
assert.deepEqual(rays.map((r) => r.openingId), ['wW']);
|
||||
});
|
||||
|
||||
test('grazing sun: the auditor\'s repro, fixed by a normal-axis fade (DEV-EB173-01)', () => {
|
||||
// The report's browser probe: a WEST window 80 render units long, elevation
|
||||
// 90 (so the nominal reach is 0.56 · 80 = 44.8 — «на 30 % короче»), azimuth
|
||||
// 190 at north_deg 0, i.e. the light enters the glass but travels only 10°
|
||||
// off the wall's own direction. It measured sides of 5.408 and 84.192
|
||||
// (ratio 15.57, the long one 31 % LONGER than the pre-cut 64) and source
|
||||
// offsets of ±0.879 — one end of the glass already fully transparent,
|
||||
// because rayStops() is dead from 0.85 on.
|
||||
const win = { id: 'wW', x: 100, y: 300, angle: 90, length: 80 };
|
||||
const rays = computeSunRays(ROOMS, [win], 190, 90, 0);
|
||||
assert.equal(rays.length, 1);
|
||||
const r = rays[0];
|
||||
assert.ok(near(r.dir[0], 0.17365, 1e-5) && near(r.dir[1], -0.98481, 1e-5));
|
||||
assert.ok(near(r.len, 44.8, 1e-9), 'nominal reach is the 70 % one');
|
||||
|
||||
// 1) EQUAL sides, each exactly the nominal reach
|
||||
const q = rayQuad([r.a[0], r.a[1]], [r.b[0], r.b[1]], r.dir, r.len);
|
||||
const side = (p0, p1) => Math.hypot(p1[0] - p0[0], p1[1] - p0[1]);
|
||||
const sides = [side(q[0], q[3]), side(q[1], q[2])];
|
||||
assert.ok(near(sides[0], sides[1], 1e-9), 'sides equal (was a ratio of 15.57)');
|
||||
for (const l of sides) assert.ok(near(l, 44.8, 1e-9), 'each side is 44.8 (was 5.41 / 84.19)');
|
||||
|
||||
// 2) the fade axis is the INWARD wall normal, len · cos(incidence) long
|
||||
assert.ok(near(r.normal[0], 1, 1e-12) && near(r.normal[1], 0, 1e-12));
|
||||
const cos = r.dir[0] * r.normal[0] + r.dir[1] * r.normal[1];
|
||||
assert.ok(near(cos, 0.17365, 1e-5), 'a 10°-off-the-wall sun');
|
||||
assert.ok(near(r.depth, 44.8 * cos, 1e-9));
|
||||
assert.ok(near(r.depth, 7.7794, 1e-4));
|
||||
|
||||
// 3) offsets along THAT axis: the whole pane of glass at 0 (peak alpha at
|
||||
// BOTH ends — the probe's ±0.879 is gone), the far edge exactly at 1
|
||||
const mx = (r.a[0] + r.b[0]) / 2;
|
||||
const my = (r.a[1] + r.b[1]) / 2;
|
||||
const off = (p) => ((p[0] - mx) * r.normal[0] + (p[1] - my) * r.normal[1]) / r.depth;
|
||||
assert.ok(near(off(r.a), 0, 1e-12) && near(off(r.b), 0, 1e-12), 'glass all at peak alpha');
|
||||
assert.ok(near(off(q[2]), 1, 1e-12) && near(off(q[3]), 1, 1e-12), 'far edge on the last iso-alpha line');
|
||||
|
||||
// 4) ...and the offset of any point is exactly how far ITS ray has run
|
||||
for (const u of [0, 0.25, 0.5, 0.85, 1]) {
|
||||
for (const src of [r.a, r.b, [r.a[0], r.a[1] + 17]]) {
|
||||
const p = [src[0] + r.dir[0] * r.len * u, src[1] + r.dir[1] * r.len * u];
|
||||
assert.ok(near(off(p), u, 1e-9), 'offset = travelled / len at u=' + u);
|
||||
}
|
||||
}
|
||||
// 5) nothing drawn past the gradient, on the clipped geometry too
|
||||
for (const poly of r.polys) for (const p of poly) {
|
||||
assert.ok(off(p) >= -1e-6 && off(p) <= 1 + 1e-6, 'inside the gradient');
|
||||
}
|
||||
});
|
||||
|
||||
test('grazing sun: below RAY_MIN_COS a window casts nothing at all', () => {
|
||||
// azimuth 182° at north_deg 0 puts the sun 2° off the west wall's plane:
|
||||
// cos = sin(2°) = 0.035 < RAY_MIN_COS. 186° (0.105) still lights it.
|
||||
const win = { id: 'wW', x: 100, y: 300, angle: 90, length: 80 };
|
||||
assert.deepEqual(computeSunRays(ROOMS, [win], 182, 90, 0), []);
|
||||
assert.equal(computeSunRays(ROOMS, [win], 186, 90, 0).length, 1);
|
||||
// the surviving wedge is never thinner than 5 % of its own reach
|
||||
const r = computeSunRays(ROOMS, [win], 186, 90, 0)[0];
|
||||
assert.ok(r.depth >= r.len * RAY_MIN_COS);
|
||||
});
|
||||
|
||||
test('computeSunRays: night → nothing at all', () => {
|
||||
assert.deepEqual(computeSunRays(ROOMS, ALL, 90, 0, 0), []);
|
||||
assert.deepEqual(computeSunRays(ROOMS, ALL, 90, -10, 0), []);
|
||||
|
||||
Reference in New Issue
Block a user