Sunlight is 30% shorter and always dissolves into nothing

Owner, 2026-08-04: «лучи от солнца сделать короче на 30%, проверить, чтобы они
всегда плавно рассеивались (сейчас есть ощущение, что они упираются во что-то
невидимое)».

SHORTER. `rayLength` is now the v1.56 curve times RAY_LENGTH_K = 0.7 — 1.75
window lengths at sunrise, 0.56 at the zenith. Scaling the whole curve instead
of re-picking the constants keeps the shape the owner approved: a low sun still
reaches three times further than a high one.

WHAT THEY WERE BUMPING INTO. Nothing invisible — the wedge's own outline, in
three places at once.

1. The gradient runs ALONG the sun, so its iso-alpha lines are perpendicular to
   the sun, while the wedge's far edge is parallel to the WALL. The two
   coincide only for a sun hitting the glass dead-on; at any other angle one
   half of that far edge was cut while it still carried colour — a straight
   bright kerb hanging in the middle of the floor. The single `100% → alpha 0`
   stop hid this from the reader of the code and from nobody else.
2. The two SIDES of the wedge had no falloff at all: two razor lines from the
   window into the room, brightest exactly where they are most visible.
3. Where the room outline clips the wedge — the opposite wall, the inner corner
   of an L, and above all an OPEN (virtual) boundary, which has no wall drawn
   at all — the shaft was chopped at whatever alpha it still had.

WHAT IT IS NOW. The gradient still spans the FULL wedge (geometry and gradient
must describe the same shaft), but `rayStops()` eases it to a hard zero at
RAY_FADE_END = 85% of the length, so the last 15% of every wedge is guaranteed
empty and a shaft ending in mid-air has nothing left to draw an edge with. Each
wedge is then drawn inside `<g filter clip-path>`: SVG applies the filter FIRST
and the clip SECOND, so a Gaussian blur of `raySoftness(len)` (7% of the shaft,
clamped 3…18 render units) feathers the sides and the tip and the room outline
cuts that feather off. Light still never crosses a wall — but where it reaches
one, the kerb is a soft ramp that reads as light landing ON the wall.

Clipping by the room is untouched; only its visible edge changed.

Tests: unit — rayLength pinned at exactly 70% of the old curve at ten
elevations, rayStops (monotone, dead at/after 85%, bright at the glass),
raySoftness clamps. Smoke — demo/smoke_sun_soft.mjs, which fails on the
previous tip (lowSunIs70Percent, highSunIs70Percent, gradientSpansWholeWedge,
deadWellBeforeTheEnd, everyWedgeFeathered). Stills: demo/shot_sun_short.mjs.
This commit is contained in:
Matysh
2026-08-04 09:47:34 +03:00
parent 1da1aba625
commit 22b588e116
9 changed files with 342 additions and 41 deletions
+46
View File
@@ -0,0 +1,46 @@
// Before/after shots for the 2026-08-04 ray report: 30% shorter shafts that
// always dissolve to nothing (docs/SUN.md). Usage:
// node demo/shot_sun_short.mjs <outdir> <suffix>
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const tag = process.argv[3] || 'short';
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no 45 s sky glide
const setup = async (az, el) => {
await page.evaluate(async ([azimuth, elevation]) => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.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: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
];
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
c.hass = { ...c.hass, states: { ...c.hass.states,
'light.ceiling': { ...c.hass.states['light.ceiling'], state: 'off' },
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth, elevation } } } };
c.requestUpdate();
await c.updateComplete;
}, [az, el]);
await page.waitForTimeout(350);
};
// low sun in the west — the long shafts, the case that hit the far walls
await setup(265, 5);
let stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/sun_rays_${tag}_low.png` });
// high southern sun — the short noon pools
await setup(180, 55);
stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/sun_rays_${tag}_high.png` });
await browser.close();
console.log('shots written to ' + outDir);
+76
View File
@@ -0,0 +1,76 @@
// Owner 2026-08-04: «лучи от солнца сделать короче на 30%, проверить, чтобы они
// всегда плавно рассеивались (сейчас есть ощущение, что они упираются во что-то
// невидимое)». The wedge must be 70 % of the old reach AND must never show a
// straight kerb: the gradient dies before the far edge, the shaft is feathered
// and only then clipped by the room (docs/SUN.md).
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'))]);
// ---- 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;
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;
});
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) feathered edges, and still no light through a wall -------------
const wraps = [...sr().querySelectorAll('.sunlayer > g')];
out.everyWedgeFeathered = wraps.length > 0
&& wraps.every((g) => /hp-sunsoft-/.test(g.getAttribute('filter') || ''));
out.everyWedgeClippedToItsRoom = wraps.every((g) => /hp-sunclip-/.test(g.getAttribute('clip-path') || ''));
out.blurIsProportional = [...sr().querySelectorAll('filter[id^=hp-sunsoft-] feGaussianBlur')]
.every((b) => { const s = Number(b.getAttribute('stdDeviation')); return s >= 3 && s <= 18; });
// the clip really is the room outline, so a wall still stops the light
out.clipIsTheRoomOutline = [...sr().querySelectorAll('clipPath[id^=hp-sunclip-] polygon')]
.every((p) => (p.getAttribute('points') || '').split(' ').length >= 3);
return out;
});
await finish(browser, checkAll(res));
File diff suppressed because one or more lines are too long