Files
houseplan-card/demo/smoke_sun_rim.mjs
T

244 lines
12 KiB
JavaScript

// THE RIM (owner 2026-08-04): «тонкая (1px) чёрная граница по бокам
// светящегося сектора, которая также плавно уходит в ноль вместе с самим
// градиентом». The owner rejected the «shade instead of light» model of
// legacy/docs/SUN-CONTRAST.md and asked for this one line instead — because a wedge
// of added luminance simply cannot read on white paper, while its BOUNDARY
// can (docs/SUN.md, «The rim»).
//
// Everything this smoke pins down:
// * exactly TWO rim lines per wedge, on the two SIDE edges — the ones that
// run from the ends of the glass along the ray. Never the glass edge,
// never the far edge; the coordinates are checked against the wedge's own
// vertices, so a rim drawn around the whole polygon fails here;
// * the stroke is `url(#hp-sunrim-N)`, a gradient with BLACK stops on the
// SAME axis and the SAME offsets as the fill's `hp-sun-N`, monotone,
// dead from 85 % on — the rim cannot outlive the light it outlines;
// * `vector-effect: non-scaling-stroke` at width 1 — one screen pixel at
// any zoom;
// * no rim below the 3° threshold (after the 2 s layer fade) and none in an
// editor;
// * and the point of the whole change: on WHITE paper the pixels along the
// wedge's side are measurably darker than the paper on either hand.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' }); // no 2 s layer fade in the way
const RAY_FADE_MS = 2000;
const res = await page.evaluate(async (fadeMs) => {
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');
// r1 is [[0.04,0.14],[0.55,0.14],[0.55,0.58],[0.04,0.58]] — a west window
// at y = 0.30 casts a wedge that stays well inside it, so the unclipped
// side edges are the whole story; the second window is deliberately on a
// wall the same sun cannot reach, to prove no rim appears without a wedge.
sp.openings = [
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
];
sp.plan_url = null; // plain white paper under the wedge
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'static', sun_rays: true };
c._cfgEpoch++;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
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 } } } };
await upd();
};
const rimsOf = () => [...sr().querySelectorAll('.sunlayer line.sunrim')];
const gradOf = (id) => sr().querySelector('linearGradient[id="' + id + '"]');
const stopsOf = (g) => [...g.querySelectorAll('stop')].map((s) => ({
off: parseFloat(s.getAttribute('offset')),
color: s.getAttribute('stop-color'),
a: Number(s.getAttribute('stop-opacity')),
}));
const near = (a, b, eps = 1e-4) => Math.abs(a - b) < eps;
await setSun(270, 60); // a western sun, square into the west window
const rays = c._sunRaysCache.rays;
out.oneWedgeDrawn = rays.length === 1;
out.wedgeIsUnclipped = rays.length === 1 && rays[0].polys.length === 1
&& rays[0].polys[0].length === 4;
// ---- 1) exactly two rim lines, on the two SIDE edges -------------------
const rims = rimsOf();
out.rimDrawn = rims.length > 0;
out.twoRimsPerWedge = rims.length === rays.length * 2;
out.rimStrokeIsAGradient = rims.length > 0
&& rims.every((l) => /^url\(#hp-sunrim-\d+\)$/.test(l.getAttribute('stroke') || ''));
out.rimIsOnePixelAtAnyZoom = rims.length > 0 && rims.every((l) =>
l.getAttribute('vector-effect') === 'non-scaling-stroke'
&& Number(l.getAttribute('stroke-width')) === 1);
// every rim line must BE a side edge: its two ends are vertices of the
// wedge polygon, it runs along `dir`, and it starts at an end of the glass
out.rimSidesMatchTheWedge = true;
out.rimIsNeverTheGlassOrTheFarEdge = true;
for (let i = 0; i < rays.length; i++) {
const r = rays[i];
const mine = rims.filter((l) => l.getAttribute('stroke') === `url(#hp-sunrim-${i})`);
if (mine.length !== 2) { out.rimSidesMatchTheWedge = false; continue; }
const verts = r.polys.flat();
const isVertex = (p) => verts.some((v) => near(v[0], p[0], 1e-3) && near(v[1], p[1], 1e-3));
const ends = mine.map((l) => [
[+l.getAttribute('x1'), +l.getAttribute('y1')],
[+l.getAttribute('x2'), +l.getAttribute('y2')],
]);
// the four expected corners: the glass ends and where their rays stop
const far = (s) => [s[0] + r.dir[0] * r.len, s[1] + r.dir[1] * r.len];
const want = [[r.a, far(r.a)], [r.b, far(r.b)]];
for (const [src, tip] of want) {
const hit = ends.some(([p, q]) =>
(near(p[0], src[0], 1e-3) && near(p[1], src[1], 1e-3)
&& near(q[0], tip[0], 1e-3) && near(q[1], tip[1], 1e-3))
|| (near(q[0], src[0], 1e-3) && near(q[1], src[1], 1e-3)
&& near(p[0], tip[0], 1e-3) && near(p[1], tip[1], 1e-3)));
if (!hit) out.rimSidesMatchTheWedge = false;
}
for (const [p, q] of ends) {
if (!isVertex(p) || !isVertex(q)) out.rimSidesMatchTheWedge = false;
// parallel to the ray (the glass edge and the far edge are not)
const dx = q[0] - p[0], dy = q[1] - p[1];
const L = Math.hypot(dx, dy) || 1;
if (Math.abs((dx / L) * r.dir[1] - (dy / L) * r.dir[0]) > 1e-6)
out.rimIsNeverTheGlassOrTheFarEdge = false;
// and it must not be the glass: both ends on the a-b line would mean it
const onGlass = (z) => Math.abs((z[0] - r.a[0]) * r.dir[0] + (z[1] - r.a[1]) * r.dir[1]) < 1e-6;
if (onGlass(p) && onGlass(q)) out.rimIsNeverTheGlassOrTheFarEdge = false;
}
}
// ---- 2) the rim gradient: same axis, same curve, black ------------------
const fills = [...sr().querySelectorAll('linearGradient[id^=hp-sun-]')];
out.rimGradientBlack = true;
out.rimGradientSharesTheAxis = true;
out.rimGradientSharesTheCurve = true;
out.rimGradientMonotone = true;
out.rimGradientDeadAt85 = true;
out.rimGradientBrightAtTheGlass = true;
for (let i = 0; i < rays.length; i++) {
const rim = gradOf(`hp-sunrim-${i}`);
const fill = fills[i];
if (!rim || !fill) { out.rimGradientBlack = false; continue; }
for (const k of ['x1', 'y1', 'x2', 'y2']) {
if (!near(+rim.getAttribute(k), +fill.getAttribute(k), 1e-9)) out.rimGradientSharesTheAxis = false;
}
const rs = stopsOf(rim);
const fs = stopsOf(fill);
if (rs.length !== fs.length) out.rimGradientSharesTheCurve = false;
const peak = rs[0].a;
rs.forEach((s, j) => {
const black = /^#0{3,8}$/i.test(s.color || '') || /rgb\(0,\s*0,\s*0\)/.test(s.color || '');
if (!black) out.rimGradientBlack = false;
if (!fs[j] || !near(s.off, fs[j].off, 1e-9)) out.rimGradientSharesTheCurve = false;
// the same normalised curve as the fill, only scaled to its own peak
if (fs[j] && !near(s.a / (peak || 1), fs[j].a / (fs[0].a || 1), 1e-3))
out.rimGradientSharesTheCurve = false;
if (j > 0 && s.a > rs[j - 1].a + 1e-9) out.rimGradientMonotone = false;
if (s.off >= 85 - 1e-9 && s.a !== 0) out.rimGradientDeadAt85 = false;
if (s.off < 85 - 1e-9 && !(s.a > 0)) out.rimGradientDeadAt85 = false;
});
if (!(peak >= 0.3 && peak <= 0.55)) out.rimGradientBrightAtTheGlass = false;
}
// ---- 3) screen coords for the pixel probe, off the real geometry --------
// sample ACROSS the a-side edge at 22 % of the reach, where both the fill
// and the rim are still strong
const svgEl = sr().querySelector('.sunlayer')?.ownerSVGElement || sr().querySelector('.stage svg');
const ctm = svgEl.getScreenCTM();
const toScr = (x, y) => {
const q = svgEl.createSVGPoint(); q.x = x; q.y = y;
const p = q.matrixTransform(ctm); return [p.x, p.y];
};
if (rays.length) {
const r = rays[0];
const u = 0.22 * r.len;
const on = toScr(r.a[0] + r.dir[0] * u, r.a[1] + r.dir[1] * u);
// one render unit INTO the wedge (toward b) → the screen-space normal
const inw = [r.b[0] - r.a[0], r.b[1] - r.a[1]];
const iL = Math.hypot(inw[0], inw[1]) || 1;
const q = toScr(r.a[0] + r.dir[0] * u + (inw[0] / iL) * 10, r.a[1] + r.dir[1] * u + (inw[1] / iL) * 10);
const vx = q[0] - on[0], vy = q[1] - on[1];
const vL = Math.hypot(vx, vy) || 1;
out.probe = { on, perp: [vx / vL, vy / vL], dpr: window.devicePixelRatio };
}
// ---- 4) below the threshold and in an editor there is no rim -----------
await setSun(270, 2); // under RAY_ELEVATION_MIN
await new Promise((rs) => setTimeout(rs, fadeMs + 300)); // let the layer leave
await upd();
out.noRimBelowThreshold = rimsOf().length === 0;
out.noWedgeBelowThreshold = sr().querySelectorAll('.sunlayer polygon').length === 0;
await setSun(270, 60);
out.rimIsBackAboveThreshold = rimsOf().length === 2;
c._mode = 'plan';
await upd();
out.noRimInTheEditor = rimsOf().length === 0;
c._mode = 'view';
await upd();
out.rimIsBackInViewMode = rimsOf().length === 2;
// ---- 5) a legacy weather setting cannot hide the rim -------------------
cfg.settings = { ...cfg.settings, weather_entity: 'weather.home' };
c.hass = { ...c.hass, states: { ...c.hass.states,
'weather.home': { entity_id: 'weather.home', state: 'pouring', attributes: {} } } };
c._cfgEpoch++;
await upd();
out.rimIgnoresRain = rimsOf().length === 2;
delete cfg.settings.weather_entity;
c._cfgEpoch++;
await upd();
out.rimStaysAfterLegacyCleanup = rimsOf().length === 2;
return out;
}, RAY_FADE_MS);
// ---- 6) the whole point: on WHITE paper the side of the wedge is DARK ----
const probe = res.probe;
delete res.probe;
if (probe) {
const shot = (await page.screenshot()).toString('base64');
const px = await page.evaluate(async ({ shot, probe }) => {
const img = new Image();
img.src = 'data:image/png;base64,' + shot;
await img.decode();
const cv = document.createElement('canvas');
cv.width = img.width; cv.height = img.height;
const ctx = cv.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0);
const d = probe.dpr || 1;
const lum = (t) => {
const x = Math.round((probe.on[0] + probe.perp[0] * t) * d);
const y = Math.round((probe.on[1] + probe.perp[1] * t) * d);
const [r, g, b] = ctx.getImageData(x, y, 1, 1).data;
return 0.299 * r + 0.587 * g + 0.114 * b;
};
// t > 0 goes INTO the wedge, t < 0 onto the untouched paper beside it
const band = [];
for (let t = -2.5; t <= 2.5; t += 0.25) band.push(lum(t));
const darkest = Math.min(...band);
const inside = Math.min(lum(7), lum(9), lum(11)); // lit floor, still bright
const outside = Math.min(lum(-7), lum(-9), lum(-11)); // plain white paper
return {
paperIsLight: outside > 200,
wedgeInteriorIsLight: inside > 200,
rimIsDarkOnTheEdge: darkest < Math.min(inside, outside) - 25,
rimProfile: [Math.round(darkest), Math.round(inside), Math.round(outside)],
};
}, { shot, probe });
res.rimProfile = px.rimProfile;
delete px.rimProfile;
Object.assign(res, px);
} else {
res.paperIsLight = false;
res.wedgeInteriorIsLight = false;
res.rimIsDarkOnTheEdge = false;
}
const profile = res.rimProfile;
delete res.rimProfile;
console.log('rim/inside/outside luminance:', JSON.stringify(profile));
await finish(browser, checkAll(res));