mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
Sunlight has hard sides again and fades only along the ray
Owner, 2026-08-04, on yesterday's attempt: «с лучами солнца ты сделал фигню —
не надо размывать их боковые грани».
They are right. 22b588e answered "the shafts run into something invisible" with
a Gaussian blur of the WHOLE wedge (`raySoftness`, filter `hp-sunsoft`), which
feathered the sides as well as the tip. A shaft of light through a window has
crisp sides; only its reach fades. The blur turned every wedge into a smudge.
GONE. `raySoftness()`, the `<filter>`/`feGaussianBlur` in <defs>, the `<g
filter clip-path>` wrapper, and with it the `hp-sunclip` clipPath — that clip
existed only so the blur could not bleed through a wall. The polygons come out
of `computeSunRays()` already intersected with the room, so a wall still stops
the light by geometry (demo/smoke_sun.mjs, wedgeClippedToRoom). The sun layer
is plain `<polygon fill="url(#hp-sun-i)">` again.
THE KERB DID NOT COME BACK, and not by luck. The old bright edge floating in
mid-floor was never about softness: the gradient's iso-alpha lines are square
to the SUN, while a parallelogram's far edge is parallel to the WALL. Head-on
they coincide; at any other angle one far corner sits at offset `1 − 0.5/k` —
0.71 of the way at a low sun, 0.11 at a high one — i.e. still lit when the
polygon ends. So `rayQuad()` no longer builds a parallelogram: each side is
extruded until it reaches the same distance `len` ALONG `dir`, which puts the
far edge on one iso-alpha line of the gradient. Combined with the untouched
`RAY_FADE_END` = 85 %, the last 15 % of every wedge is empty and its outline
has nothing left to draw. The sides stay razor-sharp on purpose.
Length (×0.7) and the live sky catch-up are untouched.
Tests: unit — `rayQuad` at six sun angles (sides exactly parallel to the ray,
both far corners at offset 1, far edge ⊥ ray, nothing past the gradient) plus
the head-on parallelogram pinned; the `raySoftness` test is gone with the
function. Smoke — demo/smoke_sun_soft.mjs keeps the reach and the "dead at
85 %" checks and flips the feather assert into its opposite: no filter on any
wedge, no `feGaussianBlur` in the tree, and at an OBLIQUE sun (230°/8° and
225°/55°) no vertex is drawn past the end of the gradient. Verified to fail on
the previous bundle on exactly those four. All 247 unit tests and all 97 smokes
green. Stills: sun_sharp_low / sun_sharp_high (demo/shot_sun_short.mjs now
takes a file prefix).
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,9 +1,11 @@
|
||||
// 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>
|
||||
// Stills for the 2026-08-04 ray report: 30 % shorter shafts with SHARP sides
|
||||
// that dissolve only along the ray (docs/SUN.md). The low-sun frame is the
|
||||
// interesting one — the north and south windows get a nearly wall-grazing sun,
|
||||
// the case that used to hang a bright kerb across the floor. Usage:
|
||||
// node demo/shot_sun_short.mjs <outdir> <prefix>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const tag = process.argv[3] || 'short';
|
||||
const tag = process.argv[3] || 'sun_rays_short';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no 45 s sky glide
|
||||
|
||||
@@ -35,12 +37,12 @@ const setup = async (az, el) => {
|
||||
// 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` });
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${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 stage.asElement().screenshot({ path: `${outDir}/${tag}_high.png` });
|
||||
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
|
||||
+63
-15
@@ -1,8 +1,16 @@
|
||||
// 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).
|
||||
// Owner 2026-08-04, on the first attempt: «с лучами солнца ты сделал фигню —
|
||||
// не надо размывать их боковые грани». A shaft of sunlight has HARD sides; it
|
||||
// fades only with distance, along the ray, from the glass inward. So:
|
||||
// * the reach is still 70 % of the v1.56 curve;
|
||||
// * the gradient still spans the FULL wedge and is dead from RAY_FADE_END;
|
||||
// * the sides are SHARP — no blur filter on the wedge, none defined at all;
|
||||
// * and because the sides are sharp, the wedge must end ON an iso-alpha line
|
||||
// of that gradient: nothing is ever drawn past its end, so the old bright
|
||||
// kerb (a far edge parallel to the wall, cut while still lit) cannot come
|
||||
// back at an oblique sun.
|
||||
// The "light never crosses a wall" clip is asserted in demo/smoke_sun.mjs
|
||||
// (wedgeClippedToRoom) — the polygons arrive from computeSunRays() already
|
||||
// intersected with the room, which is why no clip-path is needed here.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
|
||||
@@ -61,16 +69,56 @@ const res = await page.evaluate(async () => {
|
||||
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);
|
||||
// ---- 3) SHARP sides: no blur on the wedge, and none defined anywhere ----
|
||||
const wedges = () => [...sr().querySelectorAll('.sunlayer polygon')];
|
||||
// walk the polygon and its ancestors up to and including .sunlayer — the
|
||||
// day/night `brightness` filter lives further up, on the zoomwrap, and is
|
||||
// none of this test's business
|
||||
const blurredChain = (el) => {
|
||||
for (let n = el; n; n = n.parentElement) {
|
||||
const attr = n.getAttribute && n.getAttribute('filter');
|
||||
if (attr && attr !== 'none') return true;
|
||||
const cs = getComputedStyle(n).filter;
|
||||
if (cs && cs !== 'none' && cs !== '') return true;
|
||||
if (n.classList && n.classList.contains('sunlayer')) break;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
out.wedgesDrawn = wedges().length > 0;
|
||||
out.everyWedgeHasSharpSides = wedges().length > 0 && wedges().every((p) => !blurredChain(p));
|
||||
out.noSoftFilterDefined = sr().querySelectorAll('filter[id^=hp-sunsoft-]').length === 0;
|
||||
out.noGaussianBlurAtAll = sr().querySelectorAll('feGaussianBlur').length === 0;
|
||||
|
||||
// ---- 4) an OBLIQUE sun: the shaft still dies of its gradient -----------
|
||||
// The sides are hard again, so the only thing that may end the wedge is the
|
||||
// gradient. That holds ONLY if the far edge is square to the RAY: with the
|
||||
// old wall-parallel edge one far corner sat at offset ~0.7 (low sun) or
|
||||
// ~0.11 (high sun) — i.e. still lit — which is exactly the bright kerb.
|
||||
const tOf = (r, p) => {
|
||||
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
|
||||
return ((p[0] - mx) * r.dir[0] + (p[1] - my) * r.dir[1]) / r.len;
|
||||
};
|
||||
const skew = (r) => {
|
||||
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
|
||||
return Math.abs((r.a[0] - mx) * r.dir[0] + (r.a[1] - my) * r.dir[1]) / r.len;
|
||||
};
|
||||
out.obliqueChecked = [];
|
||||
out.sunIsReallyOblique = true;
|
||||
out.nothingDrawnPastTheGradient = true;
|
||||
for (const [az, el] of [[230, 8], [225, 55]]) {
|
||||
await setSun(az, el);
|
||||
const rays = c._sunRaysCache.rays;
|
||||
out.obliqueChecked.push(rays.length);
|
||||
if (!rays.length) { out.sunIsReallyOblique = false; continue; }
|
||||
if (!rays.some((r) => skew(r) > 1e-3)) out.sunIsReallyOblique = false;
|
||||
for (const r of rays) {
|
||||
for (const poly of r.polys) {
|
||||
for (const p of poly) if (tOf(r, p) > 1 + 1e-6) out.nothingDrawnPastTheGradient = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.obliqueSunHasWedges = out.obliqueChecked.every((n) => n > 0);
|
||||
delete out.obliqueChecked;
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
|
||||
+217
-227
File diff suppressed because one or more lines are too long
Vendored
+217
-227
File diff suppressed because one or more lines are too long
+40
-31
@@ -127,7 +127,8 @@ when BOTH hold:
|
||||
toward the sun is positive (the sun actually faces this window).
|
||||
|
||||
The wedge is a quadrilateral cast from the window's span along the
|
||||
direction AWAY from the sun (light falls inward), clipped by the
|
||||
direction AWAY from the sun (light falls inward) and cut off
|
||||
PERPENDICULAR to the ray (see «Dissolving» below), clipped by the
|
||||
room's polygon (`polyclip` intersection, the same dependency
|
||||
`src/resize.ts` already uses). Its length is `k(elevation)` in window
|
||||
lengths: ~1.75 at sunrise/sunset tapering to ~0.56 at the zenith
|
||||
@@ -141,41 +142,47 @@ opacity is `RAY_MAX_ALPHA` = 0.30 (owner 2026-08-03: «лучи поярче,
|
||||
still stay under a readable ceiling on white paper and on the dark glow
|
||||
canvas alike).
|
||||
|
||||
### Dissolving — never a kerb (owner 2026-08-04)
|
||||
### Dissolving — along the ray only (owner 2026-08-04)
|
||||
|
||||
«Проверить, чтобы они всегда плавно рассеивались (сейчас есть
|
||||
ощущение, что они упираются во что-то невидимое)». A wedge is a
|
||||
polygon, and until v1.56 nothing hid that:
|
||||
Two rounds with the owner on the same day:
|
||||
|
||||
- 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.
|
||||
1. «Проверить, чтобы они всегда плавно рассеивались (сейчас есть
|
||||
ощущение, что они упираются во что-то невидимое)» — the wedge was
|
||||
ending on a visible line;
|
||||
2. «С лучами солнца ты сделал фигню — не надо размывать их боковые
|
||||
грани» — the first answer to (1) was a Gaussian blur over the whole
|
||||
wedge, which feathered the SIDES too. Wrong: a shaft of sunlight
|
||||
through a window has crisp sides. Only its reach fades.
|
||||
|
||||
The contract now:
|
||||
So the falloff is one-dimensional: **along the ray, from the glass
|
||||
inward, and nothing else.** The contract:
|
||||
|
||||
- the gradient still spans the FULL wedge length (`x1,y1` at the glass,
|
||||
- the gradient 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.
|
||||
- `rayQuad()` therefore ends the wedge ON an iso-alpha line of that
|
||||
gradient: both sides are extruded until they reach the same distance
|
||||
`len` ALONG `dir`, so the far edge is perpendicular to the RAY, not
|
||||
parallel to the wall. This is what killed the old bright kerb. A
|
||||
parallelogram (equal extrusion of both ends) has its far edge
|
||||
parallel to the WALL, while the gradient's iso-alpha lines are square
|
||||
to the sun; for any sun that does not face the glass head-on the two
|
||||
disagree and one far corner sits at offset `1 − 0.5/k` — still lit
|
||||
(~0.71 at a low sun, ~0.11 at a high one). That corner was the
|
||||
straight bright kerb hanging in mid-floor;
|
||||
- the two SIDES carry no falloff at all, on purpose. They are hard
|
||||
lines, because that is what light through a window looks like. There
|
||||
is **no filter, no `feGaussianBlur`, no `clip-path`** anywhere in the
|
||||
sun layer — the polygons arrive from `computeSunRays()` already
|
||||
intersected with the room, so a wall stops the light by geometry;
|
||||
- where the room outline does cut a still-lit shaft (the opposite wall,
|
||||
the inner corner of an L, an OPEN boundary) the edge stays crisp:
|
||||
that is light landing on a wall, and blurring it was the mistake.
|
||||
|
||||
Clipping by the room stays exactly as before; only its visible edge
|
||||
changed.
|
||||
Clipping by the room is unchanged; only the visible edge changed.
|
||||
|
||||
### The 3° threshold and the 2-second fade
|
||||
|
||||
@@ -250,11 +257,13 @@ 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/smoke_sun_soft.mjs` — the −30 % reach and the "dissolves along
|
||||
the ray only" contract: the gradient spans the wedge and dies at
|
||||
85 %, the sides are sharp (no filter on the wedge, no
|
||||
`feGaussianBlur` at all), and at an oblique sun nothing is drawn past
|
||||
the end of the gradient — the kerb cannot come back.
|
||||
- `demo/smoke_sun_live_bg.mjs` — the sky follows `sun.sun` on a plain
|
||||
`hass` tick with no reload, asserted on the COMPUTED background of the
|
||||
stage; small steps still glide, big ones catch up at once.
|
||||
- `demo/shot_sun_short.mjs` — before/after stills at a low and a high
|
||||
sun.
|
||||
- `demo/shot_sun_short.mjs` — stills at a low and a high sun
|
||||
(`node demo/shot_sun_short.mjs <outdir> <prefix>`).
|
||||
|
||||
+12
-24
@@ -32,7 +32,7 @@ import {
|
||||
import {
|
||||
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, weatherEntityOf,
|
||||
sunStateOf, cloudFactor, rayPeakAlpha, raysVisible, rayColor, RAY_FADE_MS, type SunRay,
|
||||
rayStops, raySoftness, skyElevation, skyNeedsSnap,
|
||||
rayStops, skyElevation, skyNeedsSnap,
|
||||
} from './sun';
|
||||
import { ContentSigner } from './signing';
|
||||
import { mdiHomeCityOutline } from '@mdi/js';
|
||||
@@ -4969,14 +4969,14 @@ class HouseplanCard extends LitElement {
|
||||
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(' '));
|
||||
}
|
||||
// NO filter here, and none in <defs>. Owner 2026-08-04: «не надо размывать
|
||||
// их боковые грани» — the shaft keeps the crisp sides real light has, and
|
||||
// the only falloff is the gradient running ALONG the ray. The tip needs no
|
||||
// blur either: `rayStops()` is already at zero from RAY_FADE_END on, and
|
||||
// `rayQuad()` ends the wedge on that same iso-alpha line, so the far edge
|
||||
// has nothing left to draw. The polygons come out of `computeSunRays()`
|
||||
// already intersected with the room, so no clip-path is needed to keep the
|
||||
// light off the far side of a wall.
|
||||
return svg`<defs>
|
||||
${rays.map((r, i) => {
|
||||
const mx = (r.a[0] + r.b[0]) / 2;
|
||||
@@ -4985,24 +4985,12 @@ class HouseplanCard extends LitElement {
|
||||
x1="${mx}" y1="${my}" x2="${mx + r.dir[0] * r.len}" y2="${my + r.dir[1] * r.len}">
|
||||
${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}`;
|
||||
</linearGradient>`;
|
||||
})}
|
||||
</defs>
|
||||
<g class="sunlayer ${this._sunOut ? 'out' : ''}">
|
||||
${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>`)}
|
||||
${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>`))}
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
|
||||
+32
-16
@@ -143,13 +143,37 @@ export function rayLength(elevation: number): number {
|
||||
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`. */
|
||||
/**
|
||||
* The unclipped wedge: the window span a-b extruded along `dir` and cut off
|
||||
* PERPENDICULAR to the ray, `len` from the span's midpoint.
|
||||
*
|
||||
* Not the parallelogram an equal extrusion of both ends would give. The fade
|
||||
* is a linear gradient running ALONG `dir`, so its iso-alpha lines are
|
||||
* perpendicular to `dir`, while a parallelogram's far edge stays parallel to
|
||||
* the WALL. For any sun that does not face the glass head-on the two are
|
||||
* different lines: one half of that far edge got cut while it still carried
|
||||
* colour — the straight bright kerb hanging in mid-floor. Ending both sides on
|
||||
* the SAME iso-alpha line makes the geometry and the gradient describe one
|
||||
* shaft, so a wedge dies of its gradient (empty from RAY_FADE_END on) and
|
||||
* never of its own outline.
|
||||
*
|
||||
* The two SIDES stay razor-sharp on purpose — owner 2026-08-04: «с лучами
|
||||
* солнца ты сделал фигню — не надо размывать их боковые грани». A shaft of
|
||||
* light through a window HAS crisp sides; only its reach fades.
|
||||
*/
|
||||
export function rayQuad(a: number[], b: number[], dir: number[], len: number): number[][] {
|
||||
const mx = (a[0] + b[0]) / 2;
|
||||
const my = (a[1] + b[1]) / 2;
|
||||
// how far along `dir` each end of the span already sits, from the midpoint
|
||||
const pa = (a[0] - mx) * dir[0] + (a[1] - my) * dir[1];
|
||||
const pb = (b[0] - mx) * dir[0] + (b[1] - my) * dir[1];
|
||||
const ea = Math.max(0, len - pa); // the trailing end travels further
|
||||
const eb = Math.max(0, len - pb);
|
||||
return [
|
||||
[a[0], a[1]],
|
||||
[b[0], b[1]],
|
||||
[b[0] + dir[0] * len, b[1] + dir[1] * len],
|
||||
[a[0] + dir[0] * len, a[1] + dir[1] * len],
|
||||
[b[0] + dir[0] * eb, b[1] + dir[1] * eb],
|
||||
[a[0] + dir[0] * ea, a[1] + dir[1] * ea],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -284,6 +308,11 @@ export const RAY_FADE_END = 0.85;
|
||||
* 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).
|
||||
*
|
||||
* This gradient is the ONLY thing that dissolves a wedge: the falloff runs
|
||||
* along the ray, from the glass inward, and the sides of the shaft keep the
|
||||
* hard edge light actually has (owner 2026-08-04: «не надо размывать их
|
||||
* боковые грани»). No blur is involved anywhere.
|
||||
*/
|
||||
export function rayStops(): [number, number][] {
|
||||
return [
|
||||
@@ -297,19 +326,6 @@ export function rayStops(): [number, number][] {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Day/night sky: how far the painted sky may drift from the real sun before
|
||||
* the card stops gliding and simply JUMPS to the right colour.
|
||||
|
||||
+43
-9
@@ -6,7 +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,
|
||||
RAY_LENGTH_K, RAY_FADE_END, rayStops,
|
||||
SKY_SNAP_DEG, skyNeedsSnap, skyElevation,
|
||||
northDegOf, bgModeOf, sunRaysOn, weatherEntityOf, sunStateOf,
|
||||
} from '../test-build/sun.js';
|
||||
@@ -138,14 +138,6 @@ test('rayStops: the shaft is fully dissolved BEFORE its own far edge', () => {
|
||||
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('skyNeedsSnap / skyElevation: glide with the sun, jump when we were away', () => {
|
||||
assert.equal(SKY_SNAP_DEG, 3);
|
||||
assert.equal(skyNeedsSnap(null, 12), true); // nothing painted yet
|
||||
@@ -160,6 +152,48 @@ test('skyNeedsSnap / skyElevation: glide with the sun, jump when we were away',
|
||||
assert.equal(skyElevation('nonsense'), 0);
|
||||
});
|
||||
|
||||
test('rayQuad: sharp sides, far edge square to the RAY (owner 2026-08-04)', () => {
|
||||
// «не надо размывать их боковые грани» — the shaft's sides are hard lines,
|
||||
// so the only thing that may dissolve it is the gradient along the ray. That
|
||||
// works only if the wedge ends exactly ON an iso-alpha line: the far edge is
|
||||
// perpendicular to `dir`, not parallel to the wall.
|
||||
const a = [100, 100];
|
||||
const b = [100, 200]; // a window along +y
|
||||
const len = 300;
|
||||
for (const deg of [0, 20, 45, 70, -35, -60]) {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const dir = [Math.cos(rad), Math.sin(rad)]; // oblique sun in most cases
|
||||
const q = rayQuad(a, b, dir, len);
|
||||
assert.equal(q.length, 4);
|
||||
// the near edge is still the window itself
|
||||
assert.deepEqual(q[0], [100, 100]);
|
||||
assert.deepEqual(q[1], [100, 200]);
|
||||
// both sides run exactly along the ray — razor-sharp, never splayed
|
||||
for (const [near0, far] of [[q[0], q[3]], [q[1], q[2]]]) {
|
||||
const ex = far[0] - near0[0];
|
||||
const ey = far[1] - near0[1];
|
||||
const cross = ex * dir[1] - ey * dir[0];
|
||||
assert.ok(Math.abs(cross) < 1e-9, 'side parallel to the ray at ' + deg);
|
||||
assert.ok(ex * dir[0] + ey * dir[1] > 0, 'side runs away from the glass');
|
||||
}
|
||||
// ...and both far corners sit at the SAME distance along the ray, i.e. on
|
||||
// one iso-alpha line of the gradient. This is what kills the bright kerb.
|
||||
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
const t = (p) => ((p[0] - mid[0]) * dir[0] + (p[1] - mid[1]) * dir[1]) / len;
|
||||
assert.ok(near(t(q[2]), 1, 1e-9), 'far corner B at offset 1 at ' + deg);
|
||||
assert.ok(near(t(q[3]), 1, 1e-9), 'far corner A at offset 1 at ' + deg);
|
||||
// nothing is drawn past the end of the gradient
|
||||
for (const p of q) assert.ok(t(p) <= 1 + 1e-9, 'no vertex past the gradient');
|
||||
// the far edge really is square to the ray
|
||||
const fx = q[2][0] - q[3][0];
|
||||
const fy = q[2][1] - q[3][1];
|
||||
assert.ok(Math.abs(fx * dir[0] + fy * dir[1]) < 1e-9, 'far edge ⊥ ray at ' + deg);
|
||||
}
|
||||
// head-on sun: the classic parallelogram, unchanged
|
||||
const straight = rayQuad(a, b, [1, 0], len);
|
||||
assert.deepEqual(straight, [[100, 100], [100, 200], [400, 200], [400, 100]]);
|
||||
});
|
||||
|
||||
test('rayQuad + clipToRoom: the wedge is cut by the room outline', () => {
|
||||
const quad = rayQuad([100, 270], [100, 330], [1, 0], 1000); // way past the wall
|
||||
const clipped = clipToRoom(quad, ROOMS[0].poly);
|
||||
|
||||
Reference in New Issue
Block a user