mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
@@ -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
Vendored
+17
-7
File diff suppressed because one or more lines are too long
+51
-7
@@ -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
@@ -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
@@ -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
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user