mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
CODE-REVIEW-649-r1 M1: the 2.5D beam's cut by physical bodies (AC7) had no witness. Unit: a column in the beam leaves the floor between window and column lit and shades it behind the column; smoke: a Solid partition across the south beam removes floor from the rendered wash. Mutants: occluders extruded toward the sun, occluders ignored, card drops occluders. Lows: L1 stale alpha wording (DEVELOPMENT, ARCHITECTURE, card comment, benchmark); L3 dark-theme state edges computed per theme; L4 Alert beats a plain Hover and a plain Selected; L5 a virtual marker has no dashed ring; L6 tiles and shadows stay without walls (show_borders off). Issue: #649 User-Visible: no
171 lines
8.3 KiB
JavaScript
171 lines
8.3 KiB
JavaScript
// #649 п.2 (docs/SUN.md «2.5D»): in the 2.5D View the Flat window wedges give
|
||
// way to a soft wash along the real sun — the same windows and gates as
|
||
// `sun_rays`, length from the elevation, a parallelogram along the sun vector,
|
||
// tone and streaks by floor lightness, a sill line. Flat keeps its wedges
|
||
// byte for byte; editors and night draw nothing.
|
||
import { launch, checkAll, finish } from './serve.mjs';
|
||
|
||
const { page, browser } = await launch({ width: 900, height: 900 });
|
||
const res = await page.evaluate(async () => {
|
||
const out = {};
|
||
const c = window.__card;
|
||
const t = window.__hpTest;
|
||
const sr = () => c.renderRoot;
|
||
const setSun = async (azimuth, elevation) => {
|
||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
|
||
attributes: { azimuth, elevation, rising: azimuth <= 180 },
|
||
} } };
|
||
await t.settled();
|
||
};
|
||
const setFloor = (color) => t.setServerConfig((cfg) => {
|
||
const space = cfg.spaces.find((item) => item.id === 'f1');
|
||
space.settings = { ...(space.settings || {}), fill_mode: 'custom', custom_fill: { c: color, a: 1 } };
|
||
return cfg;
|
||
});
|
||
const flatWedges = () => [...sr().querySelectorAll('.sunlayer:not(.iso-sunwash) polygon')];
|
||
const wash = () => sr().querySelector('.sunlayer.iso-sunwash');
|
||
const beams = () => [...sr().querySelectorAll('.iso-sunwash .iso-sunbeam')];
|
||
const litIds = () => beams().map((b) => b.dataset.opening).sort();
|
||
const gradientOf = (beam) => {
|
||
if (!beam) return null;
|
||
const fill = beam.querySelector('.iso-sun-fill')?.getAttribute('fill') || '';
|
||
const id = fill.match(/url\(#([^)]+)\)/)?.[1];
|
||
return id ? sr().getElementById?.(id) ?? sr().querySelector(`#${id}`) : null;
|
||
};
|
||
const depthOf = (beam) => {
|
||
const g = gradientOf(beam);
|
||
if (!g) return NaN;
|
||
const n = (name) => Number(g.getAttribute(name));
|
||
return Math.hypot(n('x2') - n('x1'), n('y2') - n('y1'));
|
||
};
|
||
const firstStop = (beam) => {
|
||
const stop = gradientOf(beam)?.querySelector('stop');
|
||
return stop ? [stop.getAttribute('stop-color'), Number(stop.getAttribute('stop-opacity'))] : null;
|
||
};
|
||
const shiftSign = (beam) => {
|
||
if (!beam) return 0;
|
||
// Far edge of the parallelogram relative to its base, along the base line.
|
||
const pts = (beam.querySelector('.iso-sun-fill')?.getAttribute('points') || '')
|
||
.trim().split(/\s+/).map((p) => p.split(',').map(Number));
|
||
const sill = beam.querySelector('.iso-sun-sill');
|
||
if (!sill) return 0;
|
||
const ax = Number(sill.getAttribute('x1')), bx = Number(sill.getAttribute('x2'));
|
||
const cx = pts.reduce((sum, p) => sum + p[0], 0) / (pts.length || 1);
|
||
return Math.sign(cx - (ax + bx) / 2);
|
||
};
|
||
|
||
await t.setServerConfig((cfg) => {
|
||
const space = cfg.spaces.find((item) => item.id === 'f1');
|
||
space.openings = [
|
||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||
{ id: 'wI', type: 'window', x: 0.55, y: 0.30, angle: 90, length: 0.08 },
|
||
{ id: 'dS', type: 'door', x: 0.20, y: 0.86, angle: 0, length: 0.1 },
|
||
];
|
||
space.settings = { ...(space.settings || {}), show_borders: true };
|
||
cfg.settings = { ...(cfg.settings || {}), sun_rays: true, north_deg: 0 };
|
||
return cfg;
|
||
});
|
||
await setFloor('#737777');
|
||
|
||
// Flat reference: the wedges and their exact markup.
|
||
await setSun(180, 30);
|
||
const flatLit = (c._sunRaysCache?.rays || []).map((r) => r.openingId).sort();
|
||
const flatMarkup = sr().querySelector('.sunlayer')?.outerHTML ?? '';
|
||
out.flatHasWedges = flatWedges().length > 0 && !wash();
|
||
|
||
await t.setVolumetricView(true);
|
||
await setSun(180, 30);
|
||
out.washReplacesWedges = !!wash() && flatWedges().length === 0;
|
||
out.sameWindowsAsSunRays = JSON.stringify(litIds()) === JSON.stringify(flatLit) && flatLit.length > 0;
|
||
out.interiorWindowDark = !litIds().includes('wI');
|
||
|
||
// Length from the elevation: L(e) ∝ clamp(1.865·(0.55 + 1.4·(1 − e/90)^1.6), 0.98, 4.16).
|
||
const L = (e) => Math.min(4.16, Math.max(0.98, 1.865 * (0.55 + 1.4 * (1 - e / 90) ** 1.6)));
|
||
await setSun(180, 10);
|
||
const low = depthOf(beams().find((b) => b.dataset.opening === 'wS'));
|
||
await setSun(180, 60);
|
||
const high = depthOf(beams().find((b) => b.dataset.opening === 'wS'));
|
||
out.lengthFollowsElevation = Math.abs(low / high - L(10) / L(60)) < 0.01;
|
||
|
||
// Parallelogram along the sun: the far edge slides the other way when the sun crosses south.
|
||
await setSun(150, 30);
|
||
const east = shiftSign(beams().find((b) => b.dataset.opening === 'wS'));
|
||
await setSun(210, 30);
|
||
const west = shiftSign(beams().find((b) => b.dataset.opening === 'wS'));
|
||
out.shearFollowsSun = east !== 0 && west === -east;
|
||
out.clippedToRoom = beams().every((b) => /url\(#hp-iso-sun-clip-/.test(b.getAttribute('clip-path') || ''));
|
||
|
||
// The same physical bodies as Flat cut the beam (CODE-REVIEW-649-r1 M1): a
|
||
// Solid partition across the south beam removes the floor behind it.
|
||
const litArea = (id) => {
|
||
const beam = beams().find((b) => b.dataset.opening === id);
|
||
return !beam ? 0 : [...beam.querySelectorAll('.iso-sun-fill')].reduce((sum, poly) => {
|
||
const pts = poly.getAttribute('points').trim().split(/\s+/).map((q) => q.split(',').map(Number));
|
||
return sum + Math.abs(pts.reduce((acc, point, i) => {
|
||
const next = pts[(i + 1) % pts.length];
|
||
return acc + point[0] * next[1] - next[0] * point[1];
|
||
}, 0)) / 2;
|
||
}, 0);
|
||
};
|
||
await setSun(180, 30);
|
||
const openArea = litArea('wS');
|
||
await t.setServerConfig((cfg) => {
|
||
const space = cfg.spaces.find((item) => item.id === 'f1');
|
||
space.partitions = [...(space.partitions || []), { id: 'sun-cut', a: [0.22, 0.76], b: [0.38, 0.76], cm: 15 }];
|
||
return cfg;
|
||
});
|
||
await setSun(180, 31);
|
||
const cutArea = litArea('wS');
|
||
out.bodyCutsTheBeam = openArea > 0 && cutArea > 0 && cutArea < openArea * 0.9;
|
||
await t.setServerConfig((cfg) => {
|
||
const space = cfg.spaces.find((item) => item.id === 'f1');
|
||
space.partitions = (space.partitions || []).filter((item) => item.id !== 'sun-cut');
|
||
return cfg;
|
||
});
|
||
await setSun(180, 30);
|
||
out.beamBackWithoutBody = Math.abs(litArea('wS') - openArea) < 1;
|
||
|
||
// Tone by the floor: dark floor warm-white, no streaks; light floor amber with two streaks.
|
||
const dark = beams()[0];
|
||
out.darkFloorStops = !!dark && JSON.stringify(firstStop(dark)) === JSON.stringify(['#ffe9b4', 0.62])
|
||
&& dark.dataset.floor === 'dark';
|
||
out.darkFloorNoStreaks = beams().every((b) => b.querySelectorAll('.iso-sun-streak').length === 0);
|
||
out.darkFloorSill = beams().every((b) => b.querySelector('.iso-sun-sill')?.getAttribute('stroke') === '#fff3cf');
|
||
await setFloor('#eee8de');
|
||
await setSun(210, 30);
|
||
const light = beams()[0];
|
||
out.lightFloorStops = !!light && JSON.stringify(firstStop(light)) === JSON.stringify(['#e2b95e', 0.46])
|
||
&& light.dataset.floor === 'light';
|
||
out.lightFloorTwoStreaks = beams().length > 0 && beams().every((b) => b.querySelectorAll('.iso-sun-streak').length === 2);
|
||
out.lightFloorSill = beams().every((b) => b.querySelector('.iso-sun-sill')?.getAttribute('stroke') === '#efd493');
|
||
|
||
// Gates of sun_rays: below 3° nothing, night nothing, feature off nothing.
|
||
await setSun(180, 2);
|
||
out.lowSunHidden = !wash() || wash().classList.contains('out') || beams().length === 0;
|
||
await setSun(180, -8);
|
||
out.nightEmpty = beams().length === 0 && flatWedges().length === 0;
|
||
await t.setServerConfig((cfg) => { cfg.settings = { ...cfg.settings, sun_rays: false }; return cfg; });
|
||
await setSun(180, 30);
|
||
out.featureOffEmpty = beams().length === 0;
|
||
await t.setServerConfig((cfg) => { cfg.settings = { ...cfg.settings, sun_rays: true }; return cfg; });
|
||
await setSun(180, 30);
|
||
|
||
// Editors draw no light.
|
||
await t.setMode('plan');
|
||
out.editorNoLight = beams().length === 0 && flatWedges().length === 0;
|
||
await t.setMode('view');
|
||
|
||
// Back to Flat: the same wedges, byte for byte.
|
||
await setFloor('#737777');
|
||
await t.setVolumetricView(false);
|
||
await setSun(180, 30);
|
||
out.flatWedgesUnchanged = !wash() && (sr().querySelector('.sunlayer')?.outerHTML ?? '') === flatMarkup;
|
||
return out;
|
||
});
|
||
checkAll(res);
|
||
await finish(browser, res);
|