mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
244 lines
12 KiB
JavaScript
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));
|