Files
houseplan-card/demo/smoke_sun_soft.mjs
Matysh 02ae7f9588 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.
2026-08-04 11:59:11 +03:00

217 lines
11 KiB
JavaScript

// 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.
// 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.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const cfg = c._serverCfg;
const sp = cfg.spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
];
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'static', sun_rays: true };
c._cfgEpoch++;
const setSun = async (az, el) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: az, elevation: el } } } };
c.requestUpdate(); await c.updateComplete;
};
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
const oldK = (e) => 0.8 + 1.7 * Math.pow(1 - e / 90, 1.6);
const winLen = 0.08 * 1000;
const low = c._sunRaysCache.rays[0].len;
out.lowSunIs70Percent = Math.abs(low - oldK(5) * 0.7 * winLen) < 1e-6;
await setSun(180, 60);
const high = c._sunRaysCache.rays[0].len;
out.highSunIs70Percent = Math.abs(high - oldK(60) * 0.7 * winLen) < 1e-6;
out.lowStillReachesFurther = low > high;
// ---- 2) the shaft always dissolves BEFORE its own far edge -------------
await setSun(270, 5);
const gs = grads();
out.gradientsDrawn = gs.length > 0;
// 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);
const firstZero = st.find(([, a]) => a === 0);
return !!firstZero && firstZero[0] <= 85.001; // %
});
out.lastStopIsZero = gs.every((g) => stopsOf(g).slice(-1)[0][1] === 0);
out.brightAtTheGlass = gs.every((g) => stopsOf(g)[0][1] > 0.2);
out.neverBrightensInward = gs.every((g) => {
const st = stopsOf(g);
return st.every(([, a], i) => i === 0 || a <= st[i - 1][1] + 1e-9);
});
// ---- 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.
// 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) * 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;
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;
// ---- 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));