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
File diff suppressed because one or more lines are too long
+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
+17 -7
View File
File diff suppressed because one or more lines are too long
+51 -7
View File
@@ -108,13 +108,52 @@ The wedge is a quadrilateral cast from the window's span along the
direction AWAY from the sun (light falls inward), clipped by the
room's polygon (`polyclip` intersection, the same dependency
`src/resize.ts` already uses). Its length is `k(elevation)` in window
lengths: ~2.5 at sunrise/sunset tapering to ~0.8 at the zenith
(`0.8 + 1.7·(1 − elevation/90)^1.6`). A linear gradient runs bright at
the window and dissolves inward; the color is warm orange while
`elevation < 10°` and neutral by day; peak opacity is `RAY_MAX_ALPHA`
= 0.30 (owner 2026-08-03: «лучи поярче, иногда плохо видны» — raised
from 0.18; two overlapping wedges still stay under a readable ceiling
on white paper and on the dark glow canvas alike).
lengths: ~1.75 at sunrise/sunset tapering to ~0.56 at the zenith
(`0.56 + 1.19·(1 − elevation/90)^1.6` — the v1.56 curve
`0.8 + 1.7·(1 − elevation/90)^1.6` times `RAY_LENGTH_K` = 0.7, owner
2026-08-04: «лучи от солнца сделать короче на 30%»; scaling the whole
curve keeps the "a low sun reaches much further" shape intact). The
color is warm orange while `elevation < 10°` and neutral by day; peak
opacity is `RAY_MAX_ALPHA` = 0.30 (owner 2026-08-03: «лучи поярче,
иногда плохо видны» — raised from 0.18; two overlapping wedges
still stay under a readable ceiling on white paper and on the dark glow
canvas alike).
### Dissolving — never a kerb (owner 2026-08-04)
«Проверить, чтобы они всегда плавно рассеивались (сейчас есть
ощущение, что они упираются во что-то невидимое)». A wedge is a
polygon, and until v1.56 nothing hid that:
- the gradient's iso-alpha lines run PERPENDICULAR to the sun, while
the wedge's far edge is parallel to the WALL. For any sun that does
not hit the glass head-on the two are not the same line, so half of
that far edge was cut while it still carried colour — a straight
bright kerb hanging in the middle of the floor;
- the wedge's two SIDES had no falloff at all — two razor lines running
from the window into the room;
- where the room outline clips the wedge (the opposite wall, the inner
corner of an L, and above all an OPEN boundary, which has no wall
drawn at all) the shaft was chopped at whatever alpha it still had.
The contract now:
- the gradient still spans the FULL wedge length (`x1,y1` at the glass,
`x2,y2` exactly `len` away), so geometry and gradient always describe
the same shaft — but its stops (`rayStops()`) ease out to **zero at
`RAY_FADE_END` = 85 %** of that length: `1 → .86 → .60 → .32 → .10
→ 0`. The last 15 % of every wedge is guaranteed empty, so a shaft
that ends in mid-air has nothing left to draw an edge with;
- each wedge is drawn inside `<g filter="…" clip-path="…">`. SVG applies
the filter FIRST and the clip SECOND, so a small Gaussian blur
(`raySoftness(len)` = 7 % of the length, clamped to 3…18 render units)
feathers the sides and the tip, and the room outline then cuts the
feather off — light still never crosses a wall, but where the shaft
does reach one the kerb is a soft ramp that reads as light landing ON
the wall instead of a cut-out shape.
Clipping by the room stays exactly as before; only its visible edge
changed.
### The 3° threshold and the 2-second fade
@@ -189,3 +228,8 @@ Backend validation: string or null.
- `custom_components/houseplan/validation.py` — the four settings at
both levels; tests in `tests_backend/test_validation.py`.
- `demo/smoke_sun.mjs` — end-to-end behaviour against the demo rig.
- `demo/smoke_sun_soft.mjs` — the −30 % reach and the "always
dissolves" contract (the gradient spans the wedge and dies at 85 %,
the feather filter, the room clip).
- `demo/shot_sun_short.mjs` — before/after stills at a low and a high
sun.
+27 -5
View File
@@ -32,6 +32,7 @@ import {
import {
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, weatherEntityOf,
sunStateOf, cloudFactor, rayPeakAlpha, raysVisible, rayColor, RAY_FADE_MS, type SunRay,
rayStops, raySoftness,
} from './sun';
import { ContentSigner } from './signing';
import { mdiHomeCityOutline } from '@mdi/js';
@@ -4923,20 +4924,41 @@ class HouseplanCard extends LitElement {
const rays = this._sunRaysCache.rays;
if (!rays.length) return empty;
const color = rayColor(dayPhase(sun.elevation).warmth);
const stops = rayStops();
// Room outlines for the clip: the wedge is blurred FIRST and clipped
// SECOND (SVG applies filter → clip-path in that order), so the soft kerb
// never leaks through a wall while the wedge itself has no razor edges.
const roomPolys = new Map<string, string>();
for (const r of space.rooms) {
const poly = r.id ? roomPoly(r) : null;
if (r.id && poly) roomPolys.set(r.id, poly.map((q) => q[0] + ',' + q[1]).join(' '));
}
return svg`<defs>
${rays.map((r, i) => {
const mx = (r.a[0] + r.b[0]) / 2;
const my = (r.a[1] + r.b[1]) / 2;
return svg`<linearGradient id="hp-sun-${i}" gradientUnits="userSpaceOnUse"
x1="${mx}" y1="${my}" x2="${mx + r.dir[0] * r.len}" y2="${my + r.dir[1] * r.len}">
<stop offset="0%" stop-color="${color}" stop-opacity="${alpha.toFixed(3)}"></stop>
<stop offset="100%" stop-color="${color}" stop-opacity="0"></stop>
</linearGradient>`;
${stops.map(([off, k]) => svg`<stop offset="${(off * 100).toFixed(1)}%"
stop-color="${color}" stop-opacity="${(alpha * k).toFixed(4)}"></stop>`)}
</linearGradient>
<filter id="hp-sunsoft-${i}" x="-30%" y="-30%" width="160%" height="160%"
color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="${raySoftness(r.len).toFixed(2)}"></feGaussianBlur>
</filter>
${roomPolys.has(r.roomId)
? svg`<clipPath id="hp-sunclip-${i}" clipPathUnits="userSpaceOnUse">
<polygon points="${roomPolys.get(r.roomId)}"></polygon>
</clipPath>`
: nothing}`;
})}
</defs>
<g class="sunlayer ${this._sunOut ? 'out' : ''}">
${rays.map((r, i) => r.polys.map((p) => svg`<polygon
points="${p.map((q) => q[0] + ',' + q[1]).join(' ')}" fill="url(#hp-sun-${i})"></polygon>`))}
${rays.map((r, i) => svg`<g filter="url(#hp-sunsoft-${i})"
clip-path="${roomPolys.has(r.roomId) ? `url(#hp-sunclip-${i})` : 'none'}">
${r.polys.map((p) => svg`<polygon
points="${p.map((q) => q[0] + ',' + q[1]).join(' ')}" fill="url(#hp-sun-${i})"></polygon>`)}
</g>`)}
</g>` as unknown as TemplateResult;
}
+52 -4
View File
@@ -129,13 +129,18 @@ export function windowLit(normal: number[], sunDir: number[], elevation: number)
// ---------------- wedge geometry ----------------
/**
* Wedge length in WINDOW LENGTHS: longest (~2.5) at sunrise/sunset, shortest
* (~0.8) at the zenith. `0.8 + 1.7·(1 − e/90)^1.6` — long low shafts, short
* noon pools, smooth in between (docs/SUN.md).
* Wedge length in WINDOW LENGTHS: longest (~1.75) at sunrise/sunset, shortest
* (~0.56) at the zenith. `0.56 + 1.19·(1 − e/90)^1.6` — long low shafts, short
* noon pools, smooth in between (docs/SUN.md). Owner 2026-08-04: «лучи от
* солнца сделать короче на 30%» — the whole curve is the old
* `0.8 + 1.7·(1 − e/90)^1.6` scaled by RAY_LENGTH_K, so the "low sun reaches
* further" shape is untouched.
*/
export const RAY_LENGTH_K = 0.7;
export function rayLength(elevation: number): number {
const e = Math.min(90, Math.max(0, elevation));
return 0.8 + 1.7 * Math.pow(1 - e / 90, 1.6);
return RAY_LENGTH_K * (0.8 + 1.7 * Math.pow(1 - e / 90, 1.6));
}
/** The unclipped wedge: window span a-b extruded by `len` along `dir`. */
@@ -262,6 +267,49 @@ export function rayColor(warmth: number): string {
return lerpColor('#ffe9c2', '#ff9a45', clamp01(warmth));
}
/**
* Where the shaft is already fully dissolved, as a fraction of its own length.
* Owner 2026-08-04: «проверить, чтобы они всегда плавно рассеивались (сейчас
* есть ощущение, что они упираются во что-то невидимое)». The old gradient ran
* to alpha 0 exactly AT the far edge, so any wedge that ended in mid-air still
* carried a sliver of colour up to its last pixel — and the eye reads the
* straight line of a polygon edge long before the alpha reaches zero. The last
* visible light now sits at 85 % of the length; the remaining 15 % is empty.
*/
export const RAY_FADE_END = 0.85;
/**
* Gradient stops along the shaft: `[offset 0..1, share of the peak alpha]`.
* Convex ease-out — bright at the glass, half gone by a third of the way,
* a whisper at two thirds, nothing from RAY_FADE_END on. Consumed by the card
* as SVG <stop>s over the FULL wedge length, so the geometry and the gradient
* always describe the same shaft (docs/SUN.md).
*/
export function rayStops(): [number, number][] {
return [
[0, 1],
[0.26, 0.86],
[0.46, 0.6],
[0.64, 0.32],
[0.77, 0.1],
[RAY_FADE_END, 0],
[1, 0],
];
}
/**
* Blur radius (render units) that feathers a wedge's own edges. The wedge is a
* polygon: without this its SIDES are razor-sharp lines across the floor, and
* where the room outline clips it — an opposite wall, an L-corner, an OPEN
* boundary with no wall drawn at all — the shaft is chopped at whatever alpha
* it still had. Blurring the wedge and only then clipping it to the room keeps
* the light inside the walls (it never leaks through them) while the visible
* kerb becomes a soft ramp: light landing ON the wall, not a cut-out shape.
*/
export function raySoftness(len: number): number {
return Math.max(3, Math.min(18, len * 0.07));
}
// ---------------- cloud cover ----------------
/** weather.* state → wedge opacity multiplier (docs/SUN.md table). */
+39 -4
View File
@@ -6,6 +6,7 @@ import {
rayLength, rayQuad, clipToRoom, computeSunRays,
rayAlpha, rayColor, cloudFactor, RAY_MAX_ALPHA,
raysVisible, rayPeakAlpha, RAY_ELEVATION_MIN, RAY_FADE_MS,
RAY_LENGTH_K, RAY_FADE_END, rayStops, raySoftness,
northDegOf, bgModeOf, sunRaysOn, weatherEntityOf, sunStateOf,
} from '../test-build/sun.js';
@@ -102,12 +103,46 @@ test('windowLit: above the horizon AND facing the sun', () => {
assert.ok(!windowLit(east, sunDirOnPlan(90, 0), -5)); // night
});
test('rayLength: longest at the horizon, shortest at noon, monotonic', () => {
assert.ok(near(rayLength(0), 2.5, 1e-9));
assert.ok(near(rayLength(90), 0.8, 1e-9));
test('rayLength: 30% shorter than v1.56 (owner 2026-08-04), same shape', () => {
// the old curve, kept here so the -30% stays a fact and not a memory
const before = (e) => 0.8 + 1.7 * Math.pow(1 - Math.min(90, Math.max(0, e)) / 90, 1.6);
assert.equal(RAY_LENGTH_K, 0.7);
assert.ok(near(rayLength(0), 1.75, 1e-9)); // was 2.5
assert.ok(near(rayLength(90), 0.56, 1e-9)); // was 0.8
for (const e of [-5, 0, 3, 10, 30, 45, 60, 89, 90, 120]) {
assert.ok(near(rayLength(e), before(e) * 0.7, 1e-12), 'exactly 70% at ' + e);
}
// the shape survives: a low sun still reaches much further than a high one
assert.ok(rayLength(10) > rayLength(30));
assert.ok(rayLength(30) > rayLength(60));
assert.ok(near(rayLength(-5), 2.5, 1e-9)); // clamped
assert.ok(near(rayLength(-5), 1.75, 1e-9)); // clamped
});
test('rayStops: the shaft is fully dissolved BEFORE its own far edge', () => {
const stops = rayStops();
assert.ok(near(stops[0][0], 0) && near(stops[0][1], 1), 'brightest at the glass');
assert.equal(RAY_FADE_END, 0.85);
// offsets are sorted, alphas never rise, and the tail is a hard zero
for (let i = 1; i < stops.length; i++) {
assert.ok(stops[i][0] > stops[i - 1][0] || stops[i][0] === 1, 'offsets ascend');
assert.ok(stops[i][1] <= stops[i - 1][1], 'alpha never brightens inward');
}
assert.ok(near(stops[stops.length - 1][0], 1), 'the gradient spans the FULL wedge');
for (const [off, k] of stops) {
if (off >= RAY_FADE_END) assert.equal(k, 0, 'nothing left at/after ' + RAY_FADE_END);
else assert.ok(k > 0, 'still lit at ' + off);
}
// half gone well before the middle — the eye must not find a straight edge
const half = stops.find(([, k]) => k <= 0.5);
assert.ok(half[0] <= 0.65, 'past half-dark by two thirds of the way');
});
test('raySoftness: a feather proportional to the shaft, clamped both ends', () => {
assert.equal(raySoftness(0), 3); // a stub of a wedge still gets a kerb
assert.ok(near(raySoftness(100), 7, 1e-9));
assert.ok(near(raySoftness(200), 14, 1e-9));
assert.equal(raySoftness(1e6), 18); // never a smear across the plan
assert.ok(raySoftness(200) > raySoftness(100));
});
test('rayQuad + clipToRoom: the wedge is cut by the room outline', () => {