diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs
index 31accf37..9d3079cd 100644
--- a/harness/scene/bundle.cjs
+++ b/harness/scene/bundle.cjs
@@ -29,6 +29,7 @@ esbuild
plugins: [rawPlugin],
format: 'iife',
target: 'es2020',
+ define: { 'import.meta.env.BASE_URL': '"/"' },
})
.then(() => {
if (ridge) {
diff --git a/scripts/bake-horizon.py b/scripts/bake-horizon.py
new file mode 100644
index 00000000..372e70e8
--- /dev/null
+++ b/scripts/bake-horizon.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+"""Bake the 360 degree horizon seen from the ridge viewpoint (src/app/features/scene/ridge/vienna.ts: VIENNA) into
+src/app/features/scene/ridge/viennaHorizon.json: per azimuth, the highest elevation angle the terrain subtends, the distance and
+height of the point that sets it, and the surface slope there (the normal). The source is Copernicus DEM GLO-30, a DSM (it
+includes buildings and trees, which is what a horizon is made of). Source, licence and attribution go beside the bake, in its
+`source` block, so the file cannot travel without them.
+
+ python3 scripts/bake-horizon.py
[out.json]
+
+Needs numpy, tifffile, imagecodecs (pip, in a venv: this is a build-time tool, the bake is committed).
+"""
+import json
+import math
+import sys
+from pathlib import Path
+
+import numpy as np
+import tifffile
+
+LAT, LON = 48.1929826, 16.3534237 # the server location Meta gave, 2026-11-11; keep equal to VIENNA in vienna.ts
+STEP_DEG = 0.5
+RADIUS_KM = 60.0
+EARTH_R = 6_371_000.0
+REFRACTION_K = 0.13 # standard terrestrial refraction: effective radius R / (1 - k)
+EYE_ABOVE_GROUND = 0.0 # the viewpoint is the DSM surface at the point (a roof, in a city); the page lifts it with VIENNA.altitudeKm
+MIN_KM = 1.0 # the DSM at 30 m cannot resolve neighbouring buildings as a horizon; nearer than this is not ridge
+
+
+class Mosaic:
+ def __init__(self, folder: Path):
+ self.tiles = {}
+ for p in sorted(folder.glob("Copernicus_DSM_COG_10_N*_E*_DEM.tif")):
+ lat0 = int(p.name.split("_N")[1][:2])
+ lon0 = int(p.name.split("_E")[1][:3])
+ with tifffile.TiffFile(p) as t:
+ self.tiles[(lat0, lon0)] = t.pages[0].asarray()
+
+ def height(self, lat: float, lon: float) -> float:
+ key = (math.floor(lat), math.floor(lon))
+ a = self.tiles.get(key)
+ if a is None:
+ return float("nan")
+ n = a.shape[0]
+ # Tile origin is the north-west corner; pixels are 1/n degree, pixel-is-area (centre at +0.5).
+ y = (key[0] + 1 - lat) * n - 0.5
+ x = (lon - key[1]) * n - 0.5
+ y0, x0 = int(math.floor(y)), int(math.floor(x))
+ y0 = min(max(y0, 0), n - 2)
+ x0 = min(max(x0, 0), n - 2)
+ fy, fx = min(max(y - y0, 0), 1), min(max(x - x0, 0), 1)
+ top = a[y0, x0] * (1 - fx) + a[y0, x0 + 1] * fx
+ bot = a[y0 + 1, x0] * (1 - fx) + a[y0 + 1, x0 + 1] * fx
+ return float(top * (1 - fy) + bot * fy)
+
+
+def offset(lat: float, lon: float, az_deg: float, d_m: float):
+ az = math.radians(az_deg)
+ dlat = d_m * math.cos(az) / 111_320.0
+ dlon = d_m * math.sin(az) / (111_320.0 * math.cos(math.radians(lat)))
+ return lat + dlat, lon + dlon
+
+
+def main() -> None:
+ folder = Path(sys.argv[1])
+ out = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("src/app/features/scene/ridge/viennaHorizon.json")
+ m = Mosaic(folder)
+ h0 = m.height(LAT, LON) + EYE_ABOVE_GROUND
+ az_count = int(360 / STEP_DEG)
+ elev, dist, peak, east, north = [], [], [], [], []
+ for i in range(az_count):
+ az = i * STEP_DEG
+ best, best_d, best_h, best_pt = -90.0, 0.0, h0, (LAT, LON)
+ d = MIN_KM * 1000
+ while d <= RADIUS_KM * 1000:
+ lat, lon = offset(LAT, LON, az, d)
+ h = m.height(lat, lon)
+ if not math.isnan(h):
+ drop = d * d / (2 * EARTH_R) * (1 - REFRACTION_K)
+ ang = math.degrees(math.atan2(h - h0 - drop, d))
+ if ang > best:
+ best, best_d, best_h, best_pt = ang, d, h, (lat, lon)
+ d += 30.0 if d < 5000 else 60.0 if d < 20000 else 120.0
+ # Surface slope at the peak (rise per metre, east and north), central difference over 60 m.
+ la, lo = best_pt
+ dl, dn = 60.0 / 111_320.0, 60.0 / (111_320.0 * math.cos(math.radians(la)))
+ ge = (m.height(la, lo + dn) - m.height(la, lo - dn)) / 120.0
+ gn = (m.height(la + dl, lo) - m.height(la - dl, lo)) / 120.0
+ elev.append(round(best, 3))
+ dist.append(round(best_d / 1000, 2))
+ peak.append(round(best_h, 1))
+ east.append(round(ge, 3))
+ north.append(round(gn, 3))
+ doc = {
+ "viewpoint": {"lat": LAT, "lon": LON, "groundM": round(h0, 1)},
+ "stepDeg": STEP_DEG,
+ "radiusKm": RADIUS_KM,
+ "minKm": MIN_KM,
+ "curvature": f"drop d^2/2R*(1-{REFRACTION_K}), R={int(EARTH_R)} m",
+ "source": {
+ "dataset": "Copernicus DEM GLO-30 (COP-DEM_GLO-30-DGED), a DSM, EGM2008 heights, 1 arc-second grid",
+ "tiles": sorted(f"N{a:02d}_00_E{b:03d}_00" for a, b in m.tiles),
+ "tileBytesEach": "about 42-44 MB (Cloud Optimized GeoTIFF); nine tiles, about 380 MB, read once, not committed",
+ "url": "https://copernicus-dem-30m.s3.amazonaws.com/ (AWS Open Data, eu-central-1, no account)",
+ "licence": "Copernicus DEM licence: free of charge for any use incl. commercial, attribution required, no warranty "
+ "(https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model, Licencing)",
+ "attribution": "Contains modified Copernicus data: DEM © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 "
+ "provided under COPERNICUS by the European Union and ESA; all rights reserved.",
+ "bakedBy": "scripts/bake-horizon.py",
+ },
+ "elevationDeg": elev,
+ "distanceKm": dist,
+ "peakHeightM": peak,
+ "slopeEast": east,
+ "slopeNorth": north,
+ }
+ out.write_text(json.dumps(doc, separators=(",", ":")) + "\n")
+ top = int(np.argmax(elev))
+ print(f"ground {h0:.1f} m; {az_count} azimuths; highest {elev[top]:.2f} deg at az {top * STEP_DEG}, {dist[top]} km, {peak[top]} m")
+ print(f"wrote {out} ({out.stat().st_size} bytes)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md
index 957f1a42..45fec5cd 100644
--- a/src/app/features/scene/README.md
+++ b/src/app/features/scene/README.md
@@ -78,6 +78,22 @@ holds a GL context gives no 2D one, so a GL failure after context creation (comp
x `AMBIENT_GAIN` 0.25 x `NIGHT_LIFT` up to 4; now `ambient_linear` as baked, so midnight's fill is about 2.5 to 3.5 times darker than
the lifted one). `HAZE_K` (0.0004 per metre) is unchanged.
+## Ridge scene: Vienna, the real horizon and the weather
+
+The ridge sky is the physical atmosphere (`ridge/earthAtmosphere.ts`) lit by the real sun over the viewpoint, 48.1929826 N, 16.3534237 E
+(`ridge/vienna.ts`). Its farthest layer is the real horizon of that viewpoint, standing still under the sun: `ridge/viennaHorizon.json`,
+baked by `scripts/bake-horizon.py` from Copernicus DEM GLO-30 (nine 1 degree tiles, about 380 MB, kept out of the repo). The file carries its
+own `source` block with the dataset, tiles, licence and the attribution the licence requires. Its elevation range is 2.1 degrees, so the
+layer draws the shape of the ring stretched to a fixed relief, not its scale.
+
+The weather is one number (`ridge/weather.ts`), from the page URL's `?weather=`, else `weather.json` beside `config.json`, else clear.
+The browser never calls a weather service. `weather.json` is written by Eureka's refresher (gamecult-ops `docs/weather-feed.md`, a 5-minute timer; the only writer) from GeoSphere Austria's TAWES station 11035 (Wien Hohe Warte), credit
+"Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at". Shape: `{fetched_at, observed_at, station, precip_mm_10min, humidity_pct,
+glow_wm2, sunshine_s_10min, attribution}`, null for a missing measurement. A file fetched more than 30 minutes ago, or holding an observation more than 40 minutes old, or from another station, is a clear
+sky. Rain is the gauge; cloud is an estimate from the global radiation against Haurwitz's clear-sky value, cross-checked by the sunshine
+duration, with the humidity as the fallback when the sun is low (`ridge/weatherFeed.ts`). The weather draws a cloud deck in the sky
+(`ridge/clouds.ts`), thickens the layers' valley mist, and lays tiled rain streaks over everything (`ridge/rain.ts`, compositor-driven, none in a still).
+
## Entry points
- `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's
diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx
index b664dc07..30efaa1c 100644
--- a/src/app/features/scene/SkyStateControl.tsx
+++ b/src/app/features/scene/SkyStateControl.tsx
@@ -15,6 +15,7 @@ import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../../utils/keyboard';
import { useSkyState } from '../../hooks/useSkyState';
import { SKY_STATES, setSkyState, type SkyState } from './skyState';
+import { HORIZON_CREDIT, WEATHER_CREDIT, WEATHER_CREDIT_URL, WEATHER_DATASET } from './ridge/credits';
import { useClientConfig } from '../../hooks/useClientConfig';
import {
getSceneStyleOverride,
@@ -116,6 +117,23 @@ export function SkyStateControl() {
))}
>
)}
+ {backgroundScene && sceneStyle === 'ridge' && (
+
+
+ Data
+
+
+ Datenquelle: GeoSphere Austria -{' '}
+
+ {WEATHER_CREDIT_URL}
+
+ . {WEATHER_DATASET}.
+
+
+ {HORIZON_CREDIT}
+
+
+ )}
diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts
index b6fcf2a7..5574d60c 100644
--- a/src/app/features/scene/index.test.ts
+++ b/src/app/features/scene/index.test.ts
@@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { atStandardTime } from './standardTime.testkit';
-import { baked } from '../../../sky/baked';
import type { Scene } from './Scene';
import { startBackgroundScene } from './index';
import { setSkyClock, setSkyState } from './skyState';
@@ -48,7 +47,7 @@ describe('startBackgroundScene follows the sky state', () => {
createRidgeScene.mockReturnValue(fakeScene());
const scene = await startBackgroundScene(config, caps, { canvas });
expect(createRidgeScene).toHaveBeenCalledOnce();
- expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]);
+ expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(hex(ridgeSkyFor('night').top));
scene?.dispose();
});
@@ -67,7 +66,7 @@ describe('startBackgroundScene follows the sky state', () => {
setSkyState('night');
createRidgeScene.mockReturnValue(fakeScene());
const scene = await startBackgroundScene(config, caps, { canvas });
- expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]);
+ expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(hex(ridgeSkyFor('night').top));
scene?.dispose();
});
@@ -79,7 +78,7 @@ describe('startBackgroundScene follows the sky state', () => {
setSkyState('day');
await flush();
expect(first.dispose).toHaveBeenCalledOnce();
- expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]);
+ expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(hex(ridgeSkyFor('day').top));
scene?.dispose();
expect(second.dispose).toHaveBeenCalledOnce();
});
@@ -92,7 +91,7 @@ describe('startBackgroundScene follows the sky state', () => {
const scene = await started;
await flush();
expect(createRidgeScene).toHaveBeenCalledTimes(2);
- expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]);
+ expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(hex(ridgeSkyFor('day').top));
expect(a.dispose).toHaveBeenCalledOnce();
scene?.dispose();
expect(b.dispose).toHaveBeenCalledOnce();
diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts
index 4e1db231..36010b40 100644
--- a/src/app/features/scene/index.ts
+++ b/src/app/features/scene/index.ts
@@ -16,6 +16,9 @@ export type StartOptions = Omit void;
};
+/** The observation file the ridge sky reads: the page's own, beside config.json. The browser calls no weather service. */
+const weatherUrl = (): string => `${import.meta.env.BASE_URL.replace(/\/$/, '')}/weather.json`;
+
async function create(
config: BackgroundSceneConfig | undefined,
options: StartOptions & { tier: SceneTier; debug?: boolean },
@@ -23,7 +26,7 @@ async function create(
): Promise {
try {
// Loaded here, with the scenes, so the main chunk does not carry the terrain sky or the baked tables.
- const { ridgeSkyFor, terrainSkyFor } = await import('./skyForState');
+ const { loadWeather, ridgeSkyFor, terrainSkyFor } = await import('./skyForState');
if (config?.style === 'raymarch') return (await import('./Scene')).createScene(options);
if (config?.style === 'terrain') {
const { createTerrainScene } = await import('./terrain/Terrain');
@@ -35,6 +38,7 @@ async function create(
return createTerrainSkyScene({ ...options, ...sky });
}
const { createRidgeScene } = await import('./ridge/Ridge');
+ await loadWeather(weatherUrl());
return createRidgeScene({ ...options, sky: ridgeSkyFor(state) });
} catch (e) {
options.onCompileFail?.(e instanceof Error ? e : new Error(String(e)));
@@ -52,8 +56,10 @@ async function skyKey(
state: SkyState
): Promise {
try {
- const { ridgeSkyFor, terrainSkyFor } = await import('./skyForState');
- return JSON.stringify(config?.style === 'terrain' ? terrainSkyFor(state) : ridgeSkyFor(state));
+ const { loadWeather, ridgeSkyKey, terrainSkyFor } = await import('./skyForState');
+ if (config?.style === 'terrain') return JSON.stringify(terrainSkyFor(state));
+ await loadWeather(weatherUrl());
+ return ridgeSkyKey(state);
} catch {
// The chunk failed to load: no key, so every change rebuilds (and `create` reports the failure).
return null;
diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts
index 864491f1..0234e558 100644
--- a/src/app/features/scene/ridge/Ridge.test.ts
+++ b/src/app/features/scene/ridge/Ridge.test.ts
@@ -17,6 +17,7 @@ import {
} from './ridgeWorld';
import { skyColours, SkyColours } from './skyColour';
import { makeStars } from './stars';
+import { makePhysicalSky } from './physicalSky';
import { startBackgroundScene } from '../index';
describe('noise', () => {
@@ -208,6 +209,7 @@ function fakeEl(tag = 'div') {
this.attrs[k] = v;
},
getContext: vi.fn(() => ctx()),
+ toDataURL: () => 'data:,',
remove() {
if (this.parentElement)
this.parentElement.children = this.parentElement.children.filter((c: any) => c !== this);
@@ -273,6 +275,8 @@ function ctx(withFilter = false) {
putImageData: (img: any) => puts.push(img),
beginPath() {},
moveTo() {},
+ stroke() {},
+ createRadialGradient: () => ({ addColorStop: vi.fn() }),
lineTo() {},
closePath() {},
// Stars: arc+fill is one draw. A context that has cleared a rect is the twinkle overlay's.
@@ -378,9 +382,9 @@ describe('createRidgeScene', () => {
expect(s).not.toBeNull();
const tracks = tracksOf(parent);
expect(tracks).toHaveLength(LAYERS.length);
- tracks.forEach((t: any) => {
+ tracks.forEach((t: any, i: number) => {
expect(t.children).toHaveLength(3);
- expect(t.animate).toHaveBeenCalledTimes(1);
+ expect(t.animate).toHaveBeenCalledTimes(Number.isFinite(LAYERS[i].seconds) ? 1 : 0);
t.children.forEach((c: any) => {
expect(Number.isInteger(c.width) && Number.isInteger(c.height)).toBe(true);
expect(c.width).toBe(layerSize(400, 800, 2).chunk);
@@ -428,7 +432,7 @@ describe('createRidgeScene', () => {
const { canvas } = host();
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
const anims = animsOf(canvas.parentElement);
- expect(anims.length).toBe(LAYERS.length + 1); // one drift per layer and the sway
+ expect(anims.length).toBe(LAYERS.filter((l) => Number.isFinite(l.seconds)).length + 1); // one drift per drifting layer (the horizon does not) and the sway
expect(anims.every((a: any) => a.pause.mock.calls.length > 0)).toBe(true);
anims.forEach((a: any) => expect(a.play).not.toHaveBeenCalled());
expect(rafQueue).toHaveLength(0);
@@ -759,7 +763,7 @@ describe('endless drift: chunks baked on demand and recycled', () => {
it('when the drift animation ends, the chunks move one slot left and the track restarts', async () => {
const { parent, s, records } = start();
await until(() => records.length === 3 * LAYERS.length);
- const track = tracksOf(parent)[0];
+ const track = tracksOf(parent)[1]; // the first layer that drifts: the horizon, [0], stands still
const [e0, e1, e2] = track.children;
const first = track.anims[0];
// One chunk of drift, not one viewport: the track slides by the width of a chunk.
@@ -777,8 +781,8 @@ describe('endless drift: chunks baked on demand and recycled', () => {
it('the canvas that left is baked with the next stretch of world, and each rotation after it, without end', async () => {
const { parent, s, records } = start();
await until(() => records.length === 3 * LAYERS.length);
- const track = tracksOf(parent)[0];
- const done = () => records.filter((r) => r.layer === 0).map((r) => r.chunk);
+ const track = tracksOf(parent)[1];
+ const done = () => records.filter((r) => r.layer === 1).map((r) => r.chunk);
for (let m = 3; m <= 5; m += 1) {
track.anims[track.anims.length - 1].finish();
await until(() => done().includes(m));
@@ -786,9 +790,9 @@ describe('endless drift: chunks baked on demand and recycled', () => {
expect(done()).toEqual([0, 1, 2, 3, 4, 5]);
// The pixels of chunks 3 to 5 are the world's, not a repeat of an earlier one.
const imgs = baked();
- const layer0 = imgs.filter((_, i) => records[i].layer === 0);
- [3, 4, 5].forEach((m, k) => expect(layer0[3 + k].equals(expected(0, m))).toBe(true));
- expect(layer0[3].equals(layer0[0])).toBe(false);
+ const layer1 = imgs.filter((_, i) => records[i].layer === 1);
+ [3, 4, 5].forEach((m, k) => expect(layer1[3 + k].equals(expected(1, m))).toBe(true));
+ expect(layer1[3].equals(layer1[0])).toBe(false);
s.dispose();
});
@@ -812,3 +816,43 @@ describe('endless drift: chunks baked on demand and recycled', () => {
s.dispose();
});
});
+
+describe('rain in the scene', () => {
+ const rainy = (w: number): SkyColours => ({ ...DAY, physical: makePhysicalSky('day', new Date('2026-06-21T10:40:00Z'), w) });
+ const rainEls = (parent: any) => parent.children.filter((c: any) => c.tag === 'div' && c !== rigOf(parent));
+
+ it('lays one falling tile per rain layer over the ridges when it rains, each slid by its own height on the compositor', () => {
+ const { parent, canvas } = host();
+ const s = createRidgeScene({ canvas, tier: 'full', sky: rainy(1) })!;
+ const els = rainEls(parent);
+ expect(els).toHaveLength(2);
+ els.forEach((e: any) => {
+ expect(e.anims).toHaveLength(1);
+ expect(e.anims[0].options.iterations).toBe(Infinity);
+ });
+ s.dispose();
+ expect(rainEls(parent)).toHaveLength(0);
+ els.forEach((e: any) => expect(e.anims[0].cancel).toHaveBeenCalled());
+ });
+
+ it('draws no rain in dry weather, and none in a still', () => {
+ const dry = host();
+ createRidgeScene({ canvas: dry.canvas, tier: 'full', sky: rainy(0.3) })!.dispose();
+ const a = host();
+ const sa = createRidgeScene({ canvas: a.canvas, tier: 'full', sky: rainy(0.3) })!;
+ expect(rainEls(a.parent)).toHaveLength(0);
+ sa.dispose();
+ const still = host();
+ const ss = createRidgeScene({ canvas: still.canvas, tier: 'still', sky: rainy(1) })!;
+ expect(rainEls(still.parent)).toHaveLength(0);
+ ss.dispose();
+ });
+
+ it('pauses with the rest when motion is reduced', () => {
+ motion.matches = true;
+ const { parent, canvas } = host();
+ const s = createRidgeScene({ canvas, tier: 'full', sky: rainy(1) })!;
+ rainEls(parent).forEach((e: any) => expect(e.anims[0].play).not.toHaveBeenCalled());
+ s.dispose();
+ });
+});
diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts
index 72985d4c..8590a833 100644
--- a/src/app/features/scene/ridge/Ridge.ts
+++ b/src/app/features/scene/ridge/Ridge.ts
@@ -3,9 +3,13 @@ import type { Scene, SceneOptions } from '../Scene';
import { overlayRequested } from '../debugOverlays';
import { FrameSampler, FrameStats } from './probe';
import { skyColours, SkyColours, Srgb } from './skyColour';
+import { paintSky as paintPhysicalSky } from './physicalSky';
import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars';
import { bakeSlice, BakeParams, layerPaint } from './ridgeBake';
-import { LAYERS, LayerSpec, edgeBlur, layerSize, scatterAlpha, transmittance } from './ridgeWorld';
+import { LAYERS, LayerSpec, edgeBlur, HORIZON_TRACK_SHIFT, layerSize, scatterAlpha, transmittance } from './ridgeWorld';
+import { hfovDeg } from './horizon';
+import { viewFor, VFOV_DEG } from './skyDome';
+import { drawRainTile, RAIN_TILES } from './rain';
/** One chunk's bake: `ms` is the summed task time, `maxTaskMs` the longest single task; `first` marks the bake of the first view. */
export interface BakeRecord {
@@ -34,9 +38,55 @@ function context2d(c: HTMLCanvasElement): CanvasRenderingContext2D | null {
return c.getContext('2d');
}
-function paintSky(canvas: HTMLCanvasElement, sky: SkyColours): boolean {
+/** The sun: a bright core of its true angular size (at least 2.5 px) and a short soft falloff, in the colour the air leaves it. */
+function drawSun(
+ ctx: CanvasRenderingContext2D,
+ d: NonNullable['sunDisc']>,
+ w: number,
+ h: number
+): void {
+ const r = Math.max(2.5, d.radius * h);
+ const x = d.x * w;
+ const y = d.y * h;
+ const g = ctx.createRadialGradient(x, y, 0, x, y, r * 3.5);
+ const [cr, cg, cb] = d.rgb;
+ g.addColorStop(0, `rgba(${cr},${cg},${cb},${d.alpha})`);
+ g.addColorStop(1 / 3.5, `rgba(${cr},${cg},${cb},${d.alpha})`);
+ g.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
+ ctx.fillStyle = g;
+ ctx.fillRect(x - r * 3.5, y - r * 3.5, r * 7, r * 7);
+}
+
+/**
+ * The host canvas's sky: the physical sky (`physicalSky.ts`) as a small grid of display pixels, scaled up smoothly (it is a soft
+ * field); without one, the three-stop gradient.
+ */
+function paintSky(
+ canvas: HTMLCanvasElement,
+ sky: SkyColours,
+ cssW: number,
+ cssH: number,
+ makeCanvas: () => HTMLCanvasElement
+): boolean {
const ctx = context2d(canvas);
if (!ctx) return false;
+ if (sky.physical) {
+ const px = paintPhysicalSky(sky.physical, cssW, cssH);
+ const small = makeCanvas();
+ small.width = px.w;
+ small.height = px.h;
+ const sctx = context2d(small);
+ if (sctx) {
+ const img = sctx.createImageData(px.w, px.h);
+ img.data.set(px.data);
+ sctx.putImageData(img, 0, 0);
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = 'high';
+ ctx.drawImage(small, 0, 0, canvas.width, canvas.height);
+ if (px.sunDisc) drawSun(ctx, px.sunDisc, canvas.width, canvas.height);
+ return true;
+ }
+ }
const g = ctx.createLinearGradient(0, 0, 0, canvas.height);
g.addColorStop(0, css(sky.top));
g.addColorStop(0.5, css(sky.mid));
@@ -111,6 +161,13 @@ function chunkJob(
const params: BakeParams = {
spec,
sky,
+ horizonView:
+ spec.horizon && sky.physical
+ ? {
+ azimuthDeg: viewFor(sky.physical.sun, size.period / viewH).azimuthDeg,
+ hfovDeg: hfovDeg(size.period / viewH, VFOV_DEG),
+ }
+ : undefined,
period: size.period,
scale,
viewH,
@@ -237,6 +294,8 @@ interface Built {
makeCanvas: () => HTMLCanvasElement;
/** The twinkling stars' own canvas, between the sky and the ridges; absent in a day sky and in 'still'. */
twinkle?: HTMLCanvasElement;
+ /** The rain's tiles and their falling, present only when the weather rains (`rain.ts`). */
+ rain?: { els: HTMLElement[]; anims: (Animation | undefined)[] };
}
/** Yaw and pitch: the camera's slow sway, a loop of this many seconds over the whole rig. */
@@ -291,6 +350,8 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
built?.layers.forEach((l) => l.anim?.cancel());
built?.rig.remove();
built?.twinkle?.remove();
+ built?.rain?.anims.forEach((a) => a?.cancel());
+ built?.rain?.els.forEach((e) => e.remove());
cancelAnimationFrame(twRaf);
twRaf = 0;
built = undefined;
@@ -355,7 +416,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
/** One chunk of drift as one animation, holding its end until `rotate` hands the track the next chunk. */
const startDrift = (l: LayerState, onEnd: (l: LayerState) => void) => {
- if (!built) return;
+ if (!built || !Number.isFinite(l.spec.seconds)) return; // the horizon layer does not drift
const { size, scale } = built;
l.anim = l.track.animate?.(
[
@@ -406,6 +467,47 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
);
};
+ /**
+ * The rain over everything: per tile, a div of the tile's texture (drawn once) taller than the viewport by one tile, slid down one
+ * tile height per loop on the compositor. 'still' draws none (a settled still has no animation to carry it).
+ */
+ const startRain = (rain: number, colour: Srgb, w: number, h: number, scale: number): NonNullable => {
+ const els: HTMLElement[] = [];
+ const anims: (Animation | undefined)[] = [];
+ RAIN_TILES.forEach((t, k) => {
+ const tile = document.createElement('canvas');
+ tile.width = t.size;
+ tile.height = t.size;
+ const tctx = context2d(tile);
+ if (!tctx) return;
+ drawRainTile(tctx, t, rain, colour, scale, k * 41);
+ const px = t.size / scale;
+ const el = document.createElement('div');
+ el.setAttribute('aria-hidden', 'true');
+ Object.assign(el.style, {
+ position: 'absolute',
+ left: '0',
+ top: `${-px}px`,
+ width: '100%',
+ height: `${h + px}px`,
+ backgroundImage: `url(${tile.toDataURL()})`,
+ backgroundSize: `${px}px ${px}px`,
+ pointerEvents: 'none',
+ willChange: 'transform',
+ });
+ parent.appendChild(el);
+ els.push(el);
+ anims.push(
+ el.animate?.([{ transform: 'translate3d(0,0,0)' }, { transform: `translate3d(0,${px}px,0)` }], {
+ duration: t.seconds * 1000,
+ iterations: Infinity,
+ easing: 'linear',
+ })
+ );
+ });
+ return { els, anims };
+ };
+
/** Bake the sky, then the layers. Returns false when a 2D context is missing or there is no area yet. */
const build = (): boolean => {
teardownLayers();
@@ -415,7 +517,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
const size = layerSize(w, h, scale);
canvas.width = size.period;
canvas.height = Math.round(h * scale);
- if (!paintSky(canvas, sky)) return false;
+ if (!paintSky(canvas, sky, w, h, () => document.createElement('canvas'))) return false;
// Every star is baked into the sky; the twinkling ones get their own canvas, which a per-frame
// pass clears and paints over. 'still' has no overlay.
const stars = makeStars(sky, w, h);
@@ -454,7 +556,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
const track = document.createElement('div');
Object.assign(track.style, {
position: 'absolute',
- left: '0',
+ left: spec.horizon ? `${(-HORIZON_TRACK_SHIFT * size.period) / scale}px` : '0',
bottom: '0',
width: `${size.period / scale}px`,
height: `${size.height / scale}px`,
@@ -504,6 +606,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
parent.appendChild(rig);
layers.forEach((l) => startDrift(l, rotate));
built.sway = startSway(rig, w, h);
+ if (tier !== 'still' && sky.physical && sky.physical.weather.rain > 0) built.rain = startRain(sky.physical.weather.rain, sky.horizon, w, h, scale);
pump();
syncPlayState();
return true;
@@ -537,7 +640,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
};
function syncPlayState() {
- const anims = built ? [built.sway, ...built.layers.map((l) => l.anim)] : [];
+ const anims = built ? [built.sway, ...built.layers.map((l) => l.anim), ...(built.rain?.anims ?? [])] : [];
anims.forEach((a) => (moving() ? a?.play() : a?.pause()));
if (!built?.twinkle) return;
if (moving()) {
diff --git a/src/app/features/scene/ridge/clouds.ts b/src/app/features/scene/ridge/clouds.ts
new file mode 100644
index 00000000..1b4e8757
--- /dev/null
+++ b/src/app/features/scene/ridge/clouds.ts
@@ -0,0 +1,74 @@
+/* eslint-disable no-param-reassign -- `cloudRadiance` fills the caller's array */
+// A cloud deck over the sky dome: per texel, the view ray hits a plane at cloud height, a three-octave value noise there is the
+// deck's thickness, `cover` says how much of it is cloud, and a cloudy texel is the cloud's own radiance (lit by the sun through the
+// air and by the sky, thinned for the thickness of the deck) in place of the sky behind it. One noise sample per octave per texel on
+// the dome's small grid: no march, no texture, nothing per frame.
+import { hash2 } from './noise';
+import type { Weather } from './weather';
+
+const smooth = (t: number): number => t * t * (3 - 2 * t);
+const smoothstep = (a: number, b: number, v: number): number => {
+ const t = Math.min(1, Math.max(0, (v - a) / (b - a)));
+ return smooth(t);
+};
+
+/** 2D value noise in [0, 1). */
+export function noise2(x: number, y: number): number {
+ const cx = Math.floor(x);
+ const cy = Math.floor(y);
+ const u = smooth(x - cx);
+ const v = smooth(y - cy);
+ const a = hash2(cx, cy);
+ const b = hash2(cx + 1, cy);
+ const c = hash2(cx, cy + 1);
+ const d = hash2(cx + 1, cy + 1);
+ return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
+}
+
+/** Plane-projected texels per unit of ray slope, and the octave gain. */
+const SCALE = 1.6;
+/** Below this ray elevation (sine) the deck fades into the horizon haze. */
+const HORIZON_FADE: readonly [number, number] = [0.02, 0.22];
+
+/**
+ * Cloud amount 0..1 along a ray with horizontal direction (`dx`, `dy`) and vertical sine `vz`. At cover 0 there is none and at 1
+ * the sky is solid overcast; between, the noise thresholds into puffs with soft edges. `drift` offsets the pattern (the weather's
+ * wind, in plane units).
+ */
+export function cloudAmount(dx: number, dy: number, vz: number, cover: number, drift = 0): number {
+ if (cover <= 0 || vz <= 0) return 0;
+ const px = (dx / (vz + 0.1)) * SCALE + drift;
+ const py = (dy / (vz + 0.1)) * SCALE;
+ let n = 0;
+ let amp = 0.55;
+ let f = 1;
+ let norm = 0;
+ for (let k = 0; k < 3; k += 1) {
+ n += amp * noise2(px * f + k * 17.3, py * f + k * 5.1);
+ norm += amp;
+ amp *= 0.5;
+ f *= 2.1;
+ }
+ n /= norm;
+ // cover 1 puts the threshold below every value: solid; cover 0 above every value: none.
+ const thr = 1.05 - cover * 0.95;
+ return smoothstep(thr - 0.12, thr + 0.12, n) * smoothstep(HORIZON_FADE[0], HORIZON_FADE[1], vz);
+}
+
+/**
+ * The radiance of cloud, per unit of sun irradiance, for a sun at sine `sunSin` seen through `sunTrans` (the air's transmittance to
+ * the sun) over a sky whose zenith radiance is `zenith`. A white scatterer (albedo 0.8): the sun's direct light, which thick cover
+ * blocks (`cover` 1 passes a fifth), and the sky's, which it does not. Written into `out`.
+ */
+export function cloudRadiance(
+ sunSin: number,
+ sunTrans: readonly number[],
+ zenith: readonly number[],
+ cover: number,
+ out: number[]
+): void {
+ const direct = (Math.max(sunSin, 0) / Math.PI) * (1 - 0.8 * cover);
+ for (let i = 0; i < 3; i += 1) out[i] = 0.8 * (sunTrans[i] * direct + zenith[i]);
+}
+
+export const hasClouds = (w: Weather): boolean => w.cover > 0.02;
diff --git a/src/app/features/scene/ridge/credits.test.ts b/src/app/features/scene/ridge/credits.test.ts
new file mode 100644
index 00000000..633a1b33
--- /dev/null
+++ b/src/app/features/scene/ridge/credits.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+import { HORIZON_CREDIT, WEATHER_CREDIT, WEATHER_CREDIT_URL, WEATHER_DATASET } from './credits';
+import { HORIZON_ATTRIBUTION } from './horizon';
+import { WEATHER_ATTRIBUTION } from './weatherFeed';
+
+describe('the credits the Sky menu shows', () => {
+ it('are the sources\' own', () => {
+ expect(HORIZON_CREDIT).toBe(HORIZON_ATTRIBUTION);
+ expect(WEATHER_CREDIT).toBe(WEATHER_ATTRIBUTION);
+ });
+ it('credit GeoSphere with its requested line, and state a licence only with the dataset named', () => {
+ expect(WEATHER_CREDIT).toBe(`Datenquelle: GeoSphere Austria - ${WEATHER_CREDIT_URL}`);
+ expect(WEATHER_CREDIT).not.toMatch(/CC BY/);
+ expect(WEATHER_DATASET).toMatch(/TAWES.*10-minute.*CC BY 4\.0/);
+ });
+});
diff --git a/src/app/features/scene/ridge/credits.ts b/src/app/features/scene/ridge/credits.ts
new file mode 100644
index 00000000..1a0327ea
--- /dev/null
+++ b/src/app/features/scene/ridge/credits.ts
@@ -0,0 +1,11 @@
+// What the Ridge background owes its data sources, shown in the Sky menu (SkyStateControl). Plain strings in their own module so the
+// main chunk carries these and not the baked horizon table or the weather parser; credits.test.ts holds them equal to the sources'.
+
+/** GeoSphere's own requested credit line. It names no licence: that is the dataset's (below). */
+export const WEATHER_CREDIT = 'Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at';
+export const WEATHER_CREDIT_URL = 'https://data.hub.geosphere.at';
+/** The dataset the weather is read from, and its licence as the dataset page names it (data.hub.geosphere.at, tawes-v1-10min). */
+export const WEATHER_DATASET = 'TAWES weather stations, 10-minute data (station 11035), CC BY 4.0';
+/** The terrain silhouette's credit, as the Copernicus DEM licence asks (equal to `HORIZON_ATTRIBUTION`). */
+export const HORIZON_CREDIT =
+ 'Contains modified Copernicus data: DEM © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.';
diff --git a/src/app/features/scene/ridge/earthAtmosphere.test.ts b/src/app/features/scene/ridge/earthAtmosphere.test.ts
new file mode 100644
index 00000000..cc5d4c43
--- /dev/null
+++ b/src/app/features/scene/ridge/earthAtmosphere.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest';
+import { bakeAtmosphere, bakeCostSteps, PLANET_KM } from './earthAtmosphere';
+import { TOP_KM } from './earthAtmosphere';
+
+const rad = (deg: number) => Math.cos(((90 - deg) * Math.PI) / 180);
+const luma = (c: number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
+
+const atNoon = bakeAtmosphere({ sunElevationDeg: 45 });
+const atSunset = bakeAtmosphere({ sunElevationDeg: 0 });
+const atDark = bakeAtmosphere({ sunElevationDeg: -20 });
+
+/** Sky radiance looking `elev` degrees above the horizon, `az` degrees from the sun's azimuth. */
+const sky = (a: ReturnType, elev: number, az: number) => {
+ const out = [0, 0, 0];
+ a.radiance(Math.sin((elev * Math.PI) / 180), Math.cos((az * Math.PI) / 180), out);
+ return out;
+};
+
+describe('earthAtmosphere: the published Earth atmosphere, baked to look-up tables', () => {
+ it('has the geometry of the references (a 100 km shell on a 6360 km planet)', () => {
+ expect(PLANET_KM).toBe(6360);
+ expect(TOP_KM - PLANET_KM).toBe(100);
+ });
+
+ it('transmittance to space: the zenith is the published vertical optical depth, red passes most, blue least', () => {
+ const t = [0, 0, 0];
+ atNoon.transmittanceToSpace(1, t);
+ // Rayleigh column at 8 km: 5.802e-3 * 8 = 0.0464 (red), 0.108 (green), 0.265 (blue); Mie 4.44e-3 * 1.2 = 0.0053; ozone
+ // 0.65e-3 * 15 = 0.00975 (red), 0.0282 (green), 0.0013 (blue). So exp(-tau) there.
+ expect(t[0]).toBeCloseTo(Math.exp(-(0.0464 + 0.0053 + 0.0098)), 1);
+ expect(t[1]).toBeCloseTo(Math.exp(-(0.1085 + 0.0053 + 0.0282)), 1);
+ expect(t[2]).toBeCloseTo(Math.exp(-(0.2648 + 0.0053 + 0.0013)), 1);
+ expect(t[0]).toBeGreaterThan(t[1]);
+ expect(t[1]).toBeGreaterThan(t[2]);
+ });
+
+ it('a noon sky is blue: the zenith is bluer than the horizon, and bluer than it is red', () => {
+ const zenith = sky(atNoon, 70, 90);
+ expect(zenith[2]).toBeGreaterThan(zenith[0] * 1.5);
+ const horizon = sky(atNoon, 1, 90);
+ expect(luma(horizon)).toBeGreaterThan(luma(zenith)); // the long path whitens and brightens the horizon
+ expect(horizon[2] / horizon[0]).toBeLessThan(zenith[2] / zenith[0]);
+ });
+
+ it('the sky toward the sun at sunset is red-orange, and the zenith opposite is blue', () => {
+ const toward = sky(atSunset, 3, 5);
+ expect(toward[0]).toBeGreaterThan(toward[2] * 1.5);
+ const away = sky(atSunset, 40, 180);
+ expect(away[2]).toBeGreaterThan(away[0]);
+ });
+
+ it('a clear sky brightens toward the sun (the Mie lobe)', () => {
+ expect(luma(sky(atNoon, 45, 4))).toBeGreaterThan(2 * luma(sky(atNoon, 30, 90)));
+ });
+
+ it('the sky is dark when the sun is 20 degrees under the horizon, a thousandth of noon or less', () => {
+ expect(luma(sky(atDark, 20, 90))).toBeLessThan(luma(sky(atNoon, 20, 90)) * 1e-3);
+ });
+
+ it('twilight keeps a blue zenith (the ozone absorption of red)', () => {
+ const twilight = bakeAtmosphere({ sunElevationDeg: -6 });
+ const z = sky(twilight, 60, 180);
+ expect(z[2]).toBeGreaterThan(z[0] * 1.5);
+ });
+
+ it('haze (Mie x4) whitens and brightens the horizon haze band', () => {
+ const hazy = bakeAtmosphere({ sunElevationDeg: 30, mieScale: 4 });
+ const clear = bakeAtmosphere({ sunElevationDeg: 30 });
+ const h = sky(hazy, 12, 20);
+ const c = sky(clear, 12, 20);
+ expect(luma(h)).toBeGreaterThan(luma(c)); // more aerosol, more forward scattering toward the sun
+ expect(h[2] / h[0]).toBeLessThan(c[2] / c[0]); // and less blue
+ });
+
+ it('is energy-sane: the multiple-scattering term never makes the sky brighter than the sun it takes in, and stays finite', () => {
+ for (const a of [atNoon, atSunset, atDark]) {
+ for (let i = 0; i < a.skyView.data.length; i += 1) {
+ expect(Number.isFinite(a.skyView.data[i])).toBe(true);
+ expect(a.skyView.data[i]).toBeGreaterThanOrEqual(0);
+ expect(a.skyView.data[i]).toBeLessThan(5);
+ }
+ }
+ expect(rad(0)).toBeCloseTo(0, 6);
+ });
+
+ it('bake cost: the three LUTs take about the stated number of march steps, in tens of milliseconds', () => {
+ const steps = bakeCostSteps();
+ const t0 = performance.now();
+ bakeAtmosphere({ sunElevationDeg: 24 });
+ const ms = performance.now() - t0;
+ // eslint-disable-next-line no-console
+ console.log(`atmosphere bake: ${steps} march steps, ${ms.toFixed(1)} ms`);
+ expect(steps).toBeLessThan(1_000_000);
+ expect(ms).toBeLessThan(1500);
+ });
+});
diff --git a/src/app/features/scene/ridge/earthAtmosphere.ts b/src/app/features/scene/ridge/earthAtmosphere.ts
new file mode 100644
index 00000000..f5350244
--- /dev/null
+++ b/src/app/features/scene/ridge/earthAtmosphere.ts
@@ -0,0 +1,552 @@
+/* eslint-disable no-param-reassign, no-plusplus, no-bitwise -- numeric kernels over typed arrays */
+// Earth's atmosphere as three small look-up tables, baked once on the CPU: transmittance, multiple scattering and
+// the sky-view LUT. Per sky texel the scene then does one bilinear lookup. No ray march after the bake.
+//
+// The method is Hillaire 2020, "A Scalable and Production Ready Sky and Atmosphere Rendering Technique" (EGSR):
+// the transmittance LUT in Bruneton and Neyret's (2008, 2017) parameterisation, the multiple-scattering LUT of his
+// section 5.5 (a geometric series over an isotropic second order), and the sky-view LUT of 5.3 with the
+// squared elevation distribution. The kernels follow his reference code (sebh/UnrealEngineSkyAtmosphere,
+// Resources/RenderSkyRayMarching.hlsl `IntegrateScatteredLuminance`, `NewMultiScattCS`, `SkyViewLutPS`; MIT).
+//
+// Earth's coefficients are the ones that code takes from Bruneton's 2017 reference implementation
+// (ebruneton/precomputed_atmospheric_scattering, `atmosphere/demo/demo.cc`), in 1/km, at sea level:
+// Rayleigh scattering (5.802, 13.558, 33.1) e-3 at (R, G, B) wavelengths, scale height 8 km. No Rayleigh absorption.
+// Mie scattering 3.996e-3 and extinction 4.44e-3 (single scattering albedo 0.9), grey, scale height 1.2 km,
+// Cornette-Shanks phase, asymmetry g 0.8.
+// Ozone absorption (0.650, 1.881, 0.085) e-3, a tent layer 15 km either side of 25 km (it makes the zenith blue at twilight).
+// Planet 6360 km, top of the atmosphere 6460 km (100 km: the Karman line, 99.99% of the air).
+// Rayleigh phase 3/(16 pi) (1 + cos^2). The sun is a point of unit irradiance; the consumer scales it (`toneMap.ts`).
+//
+// Single value the references do not give: ground albedo. 0.25 here, a grass, roofs and bare ground figure for a
+// city and its wood; it only feeds the multiple-scattering term (and so the dusk and the shadow side of the sky).
+//
+// Simplifications against the paper, all on the cheap side: no ground bounce inside the sky-view LUT itself (its rays
+// leave to space), fixed observer height, a spherical Earth with no terrain, the sun a point (the disc is the
+// consumer's), no refraction, no clouds (`weather.ts`: haze is a multiplier on the Mie coefficients).
+
+export const PLANET_KM = 6360;
+export const TOP_KM = 6460;
+const RAYLEIGH_SCATTER: Vec3 = [5.802e-3, 13.558e-3, 33.1e-3];
+const RAYLEIGH_SCALE_KM = 8;
+const MIE_SCATTER = 3.996e-3;
+const MIE_EXTINCT = 4.44e-3;
+const MIE_SCALE_KM = 1.2;
+export const MIE_G = 0.8;
+const OZONE_ABSORB: Vec3 = [0.65e-3, 1.881e-3, 0.085e-3];
+const OZONE_PEAK_KM = 25;
+const OZONE_HALF_WIDTH_KM = 15;
+export const GROUND_ALBEDO = 0.25;
+const RADIUS_OFFSET_KM = 0.01;
+
+export type Vec3 = readonly [number, number, number];
+
+const PI = Math.PI;
+const FOUR_PI = 4 * PI;
+const clamp = (x: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, x));
+
+export interface AtmosphereOptions {
+ /** Sun elevation above the horizon in degrees (Vienna's, `vienna.ts`). */
+ sunElevationDeg: number;
+ /** Multiplier on both Mie coefficients: haze. 1 is the reference air. */
+ mieScale?: number;
+ /** Observer height above the ground in km. Vienna's lowest point is 0.151. */
+ observerKm?: number;
+}
+
+/** Sizes of the three tables and the march step counts. The sum is the bake's cost, see `bakeCostSteps`. */
+export const LUT = {
+ transmittance: { w: 64, h: 24, steps: 32 },
+ multiScatter: { w: 16, h: 16, dirs: 6, steps: 14 },
+ skyView: { w: 72, h: 40, steps: 24 },
+} as const;
+
+const NO_WEATHER = 1;
+
+interface Medium {
+ scatter: Vec3;
+ extinction: Vec3;
+ /** Mie scattering and Rayleigh scattering separately, for the phase functions. */
+ mie: number;
+ ray: Vec3;
+}
+
+function sampleMedium(altKm: number, mieScale: number): Medium {
+ const dRay = Math.exp(-altKm / RAYLEIGH_SCALE_KM);
+ const dMie = Math.exp(-altKm / MIE_SCALE_KM) * mieScale;
+ const tent =
+ altKm < OZONE_PEAK_KM
+ ? altKm / OZONE_HALF_WIDTH_KM - OZONE_PEAK_KM / OZONE_HALF_WIDTH_KM + 1
+ : -altKm / OZONE_HALF_WIDTH_KM + OZONE_PEAK_KM / OZONE_HALF_WIDTH_KM + 1;
+ const dOzone = clamp(tent, 0, 1);
+ const ray: Vec3 = [
+ dRay * RAYLEIGH_SCATTER[0],
+ dRay * RAYLEIGH_SCATTER[1],
+ dRay * RAYLEIGH_SCATTER[2],
+ ];
+ const mie = dMie * MIE_SCATTER;
+ const ext = dMie * MIE_EXTINCT;
+ return {
+ ray,
+ mie,
+ scatter: [ray[0] + mie, ray[1] + mie, ray[2] + mie],
+ extinction: [
+ ray[0] + ext + dOzone * OZONE_ABSORB[0],
+ ray[1] + ext + dOzone * OZONE_ABSORB[1],
+ ray[2] + ext + dOzone * OZONE_ABSORB[2],
+ ],
+ };
+}
+
+/** Nearest positive hit of a unit-direction ray with a sphere at the origin: its distance, or -1. */
+function raySphere(
+ ox: number,
+ oy: number,
+ oz: number,
+ dx: number,
+ dy: number,
+ dz: number,
+ r: number
+): number {
+ const b = ox * dx + oy * dy + oz * dz;
+ const c = ox * ox + oy * oy + oz * oz - r * r;
+ if (c > 0 && b > 0) return -1;
+ const disc = b * b - c;
+ if (disc < 0) return -1;
+ const s = Math.sqrt(disc);
+ const t0 = -b - s;
+ return t0 > 0 ? t0 : -b + s > 0 ? -b + s : -1;
+}
+
+const rayleighPhase = (cosT: number): number => (3 / (16 * PI)) * (1 + cosT * cosT);
+/** Henyey-Greenstein, Hillaire's `hgPhase` with the view direction flipped (his WorldDir is an "in" direction). */
+const hgPhase = (g: number, cosT: number): number => {
+ const denom = 1 + g * g + 2 * g * cosT;
+ return (1 - g * g) / (FOUR_PI * denom * Math.sqrt(denom));
+};
+
+/** A 2D table with `c` channels, sampled bilinearly with clamped edges. */
+export interface Lut2 {
+ w: number;
+ h: number;
+ c: number;
+ data: Float32Array;
+}
+const makeLut = (w: number, h: number, c: number): Lut2 => ({
+ w,
+ h,
+ c,
+ data: new Float32Array(w * h * c),
+});
+
+/** Bilinear at texel-space coordinates (0..w-1, 0..h-1), written into `out`. */
+function sampleTexel(l: Lut2, x: number, y: number, out: number[] | Float32Array): void {
+ const fx = clamp(x, 0, l.w - 1);
+ const fy = clamp(y, 0, l.h - 1);
+ const x0 = Math.floor(fx);
+ const y0 = Math.floor(fy);
+ const x1 = Math.min(l.w - 1, x0 + 1);
+ const y1 = Math.min(l.h - 1, y0 + 1);
+ const tx = fx - x0;
+ const ty = fy - y0;
+ for (let k = 0; k < l.c; k++) {
+ const a = l.data[(y0 * l.w + x0) * l.c + k];
+ const b = l.data[(y0 * l.w + x1) * l.c + k];
+ const c = l.data[(y1 * l.w + x0) * l.c + k];
+ const d = l.data[(y1 * l.w + x1) * l.c + k];
+ out[k] = (a + (b - a) * tx) * (1 - ty) + (c + (d - c) * tx) * ty;
+ }
+}
+
+// Transmittance LUT, Bruneton's parameterisation (r, mu) -> (x_mu, x_r) over rho = horizon distance of height r.
+const H_TOP = Math.sqrt(TOP_KM * TOP_KM - PLANET_KM * PLANET_KM);
+
+function transmittanceUv(r: number, mu: number): [number, number] {
+ const rho = Math.sqrt(Math.max(0, r * r - PLANET_KM * PLANET_KM));
+ const disc = r * r * (mu * mu - 1) + TOP_KM * TOP_KM;
+ const d = Math.max(0, -r * mu + Math.sqrt(Math.max(0, disc)));
+ const dMin = TOP_KM - r;
+ const dMax = rho + H_TOP;
+ return [(d - dMin) / (dMax - dMin), rho / H_TOP];
+}
+function transmittanceParams(xMu: number, xR: number): [number, number] {
+ const rho = H_TOP * xR;
+ const r = Math.sqrt(rho * rho + PLANET_KM * PLANET_KM);
+ const dMin = TOP_KM - r;
+ const dMax = rho + H_TOP;
+ const d = dMin + xMu * (dMax - dMin);
+ const mu = d === 0 ? 1 : (H_TOP * H_TOP - rho * rho - d * d) / (2 * r * d);
+ return [r, clamp(mu, -1, 1)];
+}
+
+const toSub = (u: number, res: number): number => (u + 0.5 / res) * (res / (res + 1));
+const fromSub = (u: number, res: number): number => (u - 0.5 / res) * (res / (res - 1));
+
+export interface Atmosphere {
+ readonly transmittance: Lut2;
+ readonly multiScatter: Lut2;
+ readonly skyView: Lut2;
+ readonly sunElevationDeg: number;
+ readonly mieScale: number;
+ readonly observerKm: number;
+ /** Cosine of the sun's zenith angle. */
+ readonly sunCosZenith: number;
+ /** Zenith angle (radians) of the astronomical horizon at the observer: a little past 90 degrees. */
+ readonly horizonZenith: number;
+ /** Transmittance from the observer along a direction `mu` (cos of its zenith angle), to space, per channel. */
+ transmittanceToSpace(mu: number, out: number[]): void;
+ /** Sky radiance per unit of sun irradiance for view zenith cos and cos of the azimuth from the sun; linear RGB. */
+ radiance(viewCosZenith: number, cosFromSun: number, out: number[]): void;
+ /** Light the sky and sun give a horizontal surface at the observer: direct (per unit sun irradiance) and ambient. */
+ irradiance: { direct: Vec3; ambient: Vec3 };
+}
+
+interface March {
+ L: [number, number, number];
+ transmittance: [number, number, number];
+ multiScatAs1: [number, number, number];
+ opticalDepth: [number, number, number];
+}
+
+/**
+ * Hillaire's `IntegrateScatteredLuminance`. Ray from (ox,oy,oz) along unit d, sun unit s; `miePhase` picks the real phase
+ * functions (the sky) or the isotropic one (the multiple-scattering bake); `ms` the multiple-scattering LUT (absent while it is being built).
+ */
+function march(
+ ox: number,
+ oy: number,
+ oz: number,
+ dx: number,
+ dy: number,
+ dz: number,
+ sx: number,
+ sy: number,
+ sz: number,
+ steps: number,
+ ground: boolean,
+ miePhase: boolean,
+ variable: boolean,
+ mieScale: number,
+ trLut: Lut2,
+ ms: Lut2 | undefined,
+ out: March
+): void {
+ out.L = [0, 0, 0];
+ out.transmittance = [1, 1, 1];
+ out.multiScatAs1 = [0, 0, 0];
+ out.opticalDepth = [0, 0, 0];
+ const tBottom = raySphere(ox, oy, oz, dx, dy, dz, PLANET_KM);
+ const tTop = raySphere(ox, oy, oz, dx, dy, dz, TOP_KM);
+ let tMax: number;
+ if (tBottom < 0) {
+ if (tTop < 0) return;
+ tMax = tTop;
+ } else {
+ tMax = tTop > 0 ? Math.min(tTop, tBottom) : tBottom;
+ }
+ const cosT = dx * sx + dy * sy + dz * sz;
+ const phaseMie = miePhase ? hgPhase(MIE_G, -cosT) : 1 / FOUR_PI;
+ const phaseRay = miePhase ? rayleighPhase(cosT) : 1 / FOUR_PI;
+ const tmp = [0, 0];
+ const msv = [0, 0, 0];
+ const trv = [0, 0, 0];
+ let tPrev = 0;
+ for (let s = 0; s < steps; s++) {
+ let t0;
+ let t1;
+ if (variable) {
+ t0 = tMax * ((s / steps) * (s / steps));
+ t1 = tMax * (((s + 1) / steps) * ((s + 1) / steps));
+ } else {
+ t0 = (tMax * s) / steps;
+ t1 = (tMax * (s + 1)) / steps;
+ }
+ const t = t0 + (t1 - t0) * 0.3;
+ const dt = t1 - t0;
+ tPrev = t;
+ const px = ox + t * dx;
+ const py = oy + t * dy;
+ const pz = oz + t * dz;
+ const r = Math.sqrt(px * px + py * py + pz * pz);
+ const med = sampleMedium(r - PLANET_KM, mieScale);
+ const sunCos = (px * sx + py * sy + pz * sz) / r;
+ const [u, v] = transmittanceUv(r, sunCos);
+ sampleTexel(trLut, u * (trLut.w - 1), v * (trLut.h - 1), trv);
+ // Planet shadow: the sun's ray from here must not meet the ground.
+ const shadow = raySphere(px, py, pz, sx, sy, sz, PLANET_KM) >= 0 ? 0 : 1;
+ if (ms) {
+ const uu = toSub(sunCos * 0.5 + 0.5, ms.w);
+ const vv = toSub(clamp((r - PLANET_KM) / (TOP_KM - PLANET_KM), 0, 1), ms.h);
+ sampleTexel(ms, uu * ms.w - 0.5, vv * ms.h - 0.5, msv);
+ } else {
+ msv[0] = 0;
+ msv[1] = 0;
+ msv[2] = 0;
+ }
+ for (let k = 0; k < 3; k++) {
+ const ext = med.extinction[k];
+ const od = ext * dt;
+ const sampleT = Math.exp(-od);
+ out.opticalDepth[k] += od;
+ const phaseScatter = med.mie * phaseMie + med.ray[k] * phaseRay;
+ const S = shadow * trv[k] * phaseScatter + msv[k] * med.scatter[k];
+ const inv = ext > 0 ? (1 - sampleT) / ext : dt;
+ const th = out.transmittance[k];
+ out.L[k] += th * S * inv;
+ out.multiScatAs1[k] += th * med.scatter[k] * inv;
+ out.transmittance[k] = th * sampleT;
+ }
+ // Isotropic second-order inputs for the multiple-scattering bake use the same quantities; they are
+ // accumulated by the caller through `out.L` with `miePhase` false (uniform phase) so nothing more is kept here.
+ }
+ void tPrev;
+ void tmp;
+ if (ground && tBottom > 0 && tMax === tBottom) {
+ const px = ox + tBottom * dx;
+ const py = oy + tBottom * dy;
+ const pz = oz + tBottom * dz;
+ const r = Math.sqrt(px * px + py * py + pz * pz);
+ const sunCos = (px * sx + py * sy + pz * sz) / r;
+ const [u, v] = transmittanceUv(r, sunCos);
+ sampleTexel(trLut, u * (trLut.w - 1), v * (trLut.h - 1), trv);
+ const ndotl = Math.max(0, sunCos);
+ for (let k = 0; k < 3; k++) {
+ out.L[k] += (trv[k] * out.transmittance[k] * ndotl * GROUND_ALBEDO) / PI;
+ }
+ }
+}
+
+function bakeTransmittance(mieScale: number): Lut2 {
+ const { w, h, steps } = LUT.transmittance;
+ const lut = makeLut(w, h, 3);
+ const noLut = makeLut(1, 1, 3); // not read: `ms` is absent and the sun terms are discarded below
+ noLut.data.fill(1);
+ for (let j = 0; j < h; j++) {
+ for (let i = 0; i < w; i++) {
+ const [r, mu] = transmittanceParams(i / (w - 1), j / (h - 1));
+ const sinT = Math.sqrt(1 - mu * mu);
+ // Optical depth only: a straight integration of the extinction to space.
+ const tTop = raySphere(0, 0, r, 0, sinT, mu, TOP_KM);
+ const tBottom = raySphere(0, 0, r, 0, sinT, mu, PLANET_KM);
+ const tMax = tBottom > 0 ? Math.min(tTop, tBottom) : tTop;
+ const od = [0, 0, 0];
+ for (let s = 0; s < steps; s++) {
+ const t = (tMax * (s + 0.3)) / steps;
+ const px = t * sinT;
+ const pz = r + t * mu;
+ const med = sampleMedium(Math.sqrt(px * px + pz * pz) - PLANET_KM, mieScale);
+ const dt = tMax / steps;
+ for (let k = 0; k < 3; k++) od[k] += med.extinction[k] * dt;
+ }
+ const o = (j * w + i) * 3;
+ for (let k = 0; k < 3; k++) lut.data[o + k] = Math.exp(-od[k]);
+ }
+ }
+ return lut;
+}
+
+function bakeMultiScatter(mieScale: number, trLut: Lut2): Lut2 {
+ const { w, h, dirs, steps } = LUT.multiScatter;
+ const lut = makeLut(w, h, 3);
+ const res: March = {
+ L: [0, 0, 0],
+ transmittance: [1, 1, 1],
+ multiScatAs1: [0, 0, 0],
+ opticalDepth: [0, 0, 0],
+ };
+ const count = dirs * dirs;
+ for (let j = 0; j < h; j++) {
+ for (let i = 0; i < w; i++) {
+ const u = fromSub((i + 0.5) / w, w);
+ const v = fromSub((j + 0.5) / h, h);
+ const cosSun = clamp(u * 2 - 1, -1, 1);
+ const sx = Math.sqrt(Math.max(0, 1 - cosSun * cosSun));
+ const sz = cosSun;
+ // Hillaire's PLANET_RADIUS_OFFSET: a ray that starts exactly on the ground is degenerate.
+ const height = PLANET_KM + RADIUS_OFFSET_KM + clamp(v, 0, 1) * (TOP_KM - PLANET_KM - RADIUS_OFFSET_KM);
+ const sumMs = [0, 0, 0];
+ const sumL = [0, 0, 0];
+ for (let a = 0; a < dirs; a++) {
+ for (let b = 0; b < dirs; b++) {
+ const theta = (2 * PI * (a + 0.5)) / dirs;
+ const cosPhi = 1 - (2 * (b + 0.5)) / dirs;
+ const sinPhi = Math.sqrt(1 - cosPhi * cosPhi);
+ march(
+ 0,
+ 0,
+ height,
+ Math.cos(theta) * sinPhi,
+ Math.sin(theta) * sinPhi,
+ cosPhi,
+ sx,
+ 0,
+ sz,
+ steps,
+ true,
+ false,
+ false,
+ mieScale,
+ trLut,
+ undefined,
+ res
+ );
+ for (let k = 0; k < 3; k++) {
+ sumMs[k] += (res.multiScatAs1[k] * FOUR_PI) / count;
+ sumL[k] += (res.L[k] * FOUR_PI) / count;
+ }
+ }
+ }
+ const o = (j * w + i) * 3;
+ for (let k = 0; k < 3; k++) {
+ const fms = sumMs[k] / FOUR_PI; // equation 7
+ const l2 = sumL[k] / FOUR_PI; // equation 5
+ lut.data[o + k] = l2 / (1 - fms); // equation 10: the geometric series of all orders
+ }
+ }
+ }
+ return lut;
+}
+
+/**
+ * Sky-view LUT axes (Hillaire's non-linear mapping, upper hemisphere only: nothing below the horizon is drawn). y: view zenith angle
+ * from 0 to the horizon's, squared toward it; x: the azimuth from the sun, the cosine mapped `-(2 c^2 - 1)`.
+ */
+function skyViewParamsToUv(viewCos: number, cosFromSun: number, horizonZenith: number): [number, number] {
+ const zen = Math.acos(clamp(viewCos, -1, 1));
+ let coord = 1 - clamp(zen / horizonZenith, 0, 1);
+ coord = Math.sqrt(coord);
+ const y = 1 - coord; // 0 at the zenith, 1 at the horizon
+ const x = Math.sqrt(clamp(-cosFromSun * 0.5 + 0.5, 0, 1));
+ return [x, y];
+}
+function skyViewUvToParams(x: number, y: number, horizonZenith: number): [number, number] {
+ let coord = 1 - y;
+ coord *= coord;
+ coord = 1 - coord;
+ const viewCos = Math.cos(horizonZenith * coord);
+ const c = x * x;
+ return [viewCos, -(c * 2 - 1)];
+}
+
+function bakeSkyView(
+ sunCos: number,
+ observerKm: number,
+ mieScale: number,
+ trLut: Lut2,
+ ms: Lut2,
+ horizonZenith: number
+): Lut2 {
+ const { w, h, steps } = LUT.skyView;
+ const lut = makeLut(w, h, 3);
+ const res: March = {
+ L: [0, 0, 0],
+ transmittance: [1, 1, 1],
+ multiScatAs1: [0, 0, 0],
+ opticalDepth: [0, 0, 0],
+ };
+ const sx = Math.sqrt(Math.max(0, 1 - sunCos * sunCos));
+ const height = PLANET_KM + observerKm;
+ for (let j = 0; j < h; j++) {
+ for (let i = 0; i < w; i++) {
+ const [viewCos, lightViewCos] = skyViewUvToParams(i / (w - 1), j / (h - 1), horizonZenith);
+ const sinZ = Math.sqrt(1 - viewCos * viewCos);
+ march(
+ 0,
+ 0,
+ height,
+ sinZ * lightViewCos,
+ sinZ * Math.sqrt(Math.max(0, 1 - lightViewCos * lightViewCos)),
+ viewCos,
+ sx,
+ 0,
+ sunCos,
+ steps,
+ false,
+ true,
+ true,
+ mieScale,
+ trLut,
+ ms,
+ res
+ );
+ const o = (j * w + i) * 3;
+ lut.data[o] = res.L[0];
+ lut.data[o + 1] = res.L[1];
+ lut.data[o + 2] = res.L[2];
+ }
+ }
+ return lut;
+}
+
+/** Number of ray-march steps the three bakes take: the CPU cost in the unit the tests budget. */
+export const bakeCostSteps = (): number => {
+ const t = LUT.transmittance;
+ const m = LUT.multiScatter;
+ const s = LUT.skyView;
+ return t.w * t.h * t.steps + m.w * m.h * m.dirs * m.dirs * m.steps + s.w * s.h * s.steps;
+};
+
+export function bakeAtmosphere(opts: AtmosphereOptions): Atmosphere {
+ const mieScale = opts.mieScale ?? NO_WEATHER;
+ const observerKm = opts.observerKm ?? 0.151;
+ const sunCos = Math.cos(((90 - opts.sunElevationDeg) * PI) / 180);
+ const height = PLANET_KM + observerKm;
+ const beta = Math.acos(Math.sqrt(height * height - PLANET_KM * PLANET_KM) / height);
+ const horizonZenith = PI - beta;
+ const transmittance = bakeTransmittance(mieScale);
+ const multiScatter = bakeMultiScatter(mieScale, transmittance);
+ const skyView = bakeSkyView(sunCos, observerKm, mieScale, transmittance, multiScatter, horizonZenith);
+ const tmp = [0, 0, 0];
+ const trv = [0, 0, 0];
+ const atm: Atmosphere = {
+ transmittance,
+ multiScatter,
+ skyView,
+ sunElevationDeg: opts.sunElevationDeg,
+ mieScale,
+ observerKm,
+ sunCosZenith: sunCos,
+ horizonZenith,
+ transmittanceToSpace(mu, out) {
+ const [u, v] = transmittanceUv(height, mu);
+ sampleTexel(transmittance, u * (transmittance.w - 1), v * (transmittance.h - 1), trv);
+ out[0] = trv[0];
+ out[1] = trv[1];
+ out[2] = trv[2];
+ },
+ radiance(viewCos, cosFromSun, out) {
+ const [x, y] = skyViewParamsToUv(Math.max(viewCos, Math.cos(horizonZenith)), cosFromSun, horizonZenith);
+ sampleTexel(skyView, x * (skyView.w - 1), y * (skyView.h - 1), tmp);
+ out[0] = tmp[0];
+ out[1] = tmp[1];
+ out[2] = tmp[2];
+ },
+ irradiance: { direct: [0, 0, 0], ambient: [0, 0, 0] },
+ };
+ // Direct: the sun's transmittance times the cosine; ambient: the sky's radiance averaged over the upper hemisphere (cosine weighted).
+ const direct: number[] = [0, 0, 0];
+ if (opts.sunElevationDeg > -1) {
+ atm.transmittanceToSpace(Math.max(sunCos, 0), direct);
+ const c = Math.max(0, sunCos);
+ direct[0] *= c;
+ direct[1] *= c;
+ direct[2] *= c;
+ }
+ const amb = [0, 0, 0];
+ const out = [0, 0, 0];
+ const N = 12;
+ const M = 6;
+ let wsum = 0;
+ for (let a = 0; a < N; a++) {
+ for (let b = 0; b < M; b++) {
+ const az = (PI * (a + 0.5)) / N; // 0..pi from the sun: the sky is symmetric
+ const zen = (0.5 * PI * (b + 0.5)) / M;
+ const wgt = Math.cos(zen) * Math.sin(zen);
+ atm.radiance(Math.cos(zen), Math.cos(az), out);
+ for (let k = 0; k < 3; k++) amb[k] += out[k] * wgt;
+ wsum += wgt;
+ }
+ }
+ atm.irradiance.direct = [direct[0], direct[1], direct[2]];
+ atm.irradiance.ambient = [(amb[0] / wsum) * PI, (amb[1] / wsum) * PI, (amb[2] / wsum) * PI];
+ return atm;
+}
diff --git a/src/app/features/scene/ridge/horizon.test.ts b/src/app/features/scene/ridge/horizon.test.ts
new file mode 100644
index 00000000..092d5835
--- /dev/null
+++ b/src/app/features/scene/ridge/horizon.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import bake from './viennaHorizon.json';
+import { VIENNA } from './vienna';
+import { horizonDistanceKm, horizonElevation, horizonNormal } from './horizon';
+
+describe('the baked horizon', () => {
+ it('was baked from the viewpoint the sun is computed for', () => {
+ expect(bake.viewpoint.lat).toBe(VIENNA.lat);
+ expect(bake.viewpoint.lon).toBe(VIENNA.lon);
+ expect(bake.viewpoint.groundM / 1000).toBeCloseTo(VIENNA.altitudeKm, 2);
+ });
+ it('carries its source and licence beside the data', () => {
+ expect(bake.source.licence).toMatch(/attribution required/);
+ expect(bake.source.attribution).toMatch(/COPERNICUS/);
+ });
+ it('is a closed ring of plausible angles: the Wienerwald NW, open Marchfeld E', () => {
+ expect(bake.elevationDeg).toHaveLength(720);
+ expect(horizonElevation(0)).toBeCloseTo(horizonElevation(360), 6);
+ expect(horizonElevation(305)).toBeGreaterThan(horizonElevation(70));
+ expect(Math.max(...bake.elevationDeg)).toBeLessThan(5);
+ expect(horizonDistanceKm(305)).toBeGreaterThan(1);
+ });
+ it('interpolates between samples and returns unit normals', () => {
+ expect(horizonElevation(100.25)).toBeCloseTo((bake.elevationDeg[200] + bake.elevationDeg[201]) / 2, 6);
+ expect(Math.hypot(...horizonNormal(250))).toBeCloseTo(1, 6);
+ });
+});
diff --git a/src/app/features/scene/ridge/horizon.ts b/src/app/features/scene/ridge/horizon.ts
new file mode 100644
index 00000000..82265a77
--- /dev/null
+++ b/src/app/features/scene/ridge/horizon.ts
@@ -0,0 +1,54 @@
+// The terrain horizon seen from the viewpoint, baked from open DEM by `scripts/bake-horizon.py` (source, licence and attribution are
+// in the file's `source` block and in `HORIZON_ATTRIBUTION`).
+import bake from './viennaHorizon.json';
+
+export const HORIZON_ATTRIBUTION = bake.source.attribution;
+
+const N = bake.elevationDeg.length;
+
+/** Index pair and weight of an azimuth (degrees clockwise from north) on the baked ring. */
+function at(azimuth: number): [number, number, number] {
+ const f = ((((azimuth / bake.stepDeg) % N) + N) % N);
+ const i = Math.floor(f);
+ return [i, (i + 1) % N, f - i];
+}
+
+const lerp = (arr: readonly number[], az: number): number => {
+ const [a, b, t] = at(az);
+ return arr[a] * (1 - t) + arr[b] * t;
+};
+
+/** Elevation of the horizon, degrees above the astronomical horizon, at an azimuth. */
+export const horizonElevation = (azimuth: number): number => lerp(bake.elevationDeg, azimuth);
+
+/** Distance of the terrain that sets the horizon, km. */
+export const horizonDistanceKm = (azimuth: number): number => lerp(bake.distanceKm, azimuth);
+
+/** Unit surface normal of the terrain at the horizon point: x east, y up, z north. */
+export function horizonNormal(azimuth: number): [number, number, number] {
+ const e = lerp(bake.slopeEast, azimuth);
+ const n = lerp(bake.slopeNorth, azimuth);
+ const l = Math.hypot(e, 1, n);
+ return [-e / l, 1 / l, -n / l];
+}
+
+/** The elevations blurred over +-SMOOTH_DEG (a box, around the ring): at 30 m a city's roofs are the horizon's teeth, and a silhouette of them reads as a fence. */
+const SMOOTH_DEG = 1.5;
+const SMOOTH = Array.from({ length: N }, (_, i) => {
+ const k = Math.round(SMOOTH_DEG / bake.stepDeg);
+ let sum = 0;
+ for (let d = -k; d <= k; d += 1) sum += bake.elevationDeg[(i + d + N) % N];
+ return sum / (2 * k + 1);
+});
+const MIN_E = Math.min(...SMOOTH);
+const RANGE_E = Math.max(...SMOOTH) - MIN_E;
+
+/** The horizon at an azimuth as 0..1 of the ring's own range (the lowest point of the horizon 0, the highest 1): the silhouette's shape, not its scale. */
+export const horizonShape = (azimuth: number): number => (lerp(SMOOTH, azimuth) - MIN_E) / RANGE_E;
+
+/** Real range of the smoothed horizon, degrees: what `horizonShape` stretches to the layer's relief. */
+export const HORIZON_RANGE_DEG = RANGE_E;
+
+/** Horizontal field of view, degrees, of a camera with the sky's vertical field (`skyDome.ts` VFOV_DEG) at an aspect ratio. */
+export const hfovDeg = (aspect: number, vfovDeg: number): number =>
+ (2 * Math.atan(aspect * Math.tan((vfovDeg * Math.PI) / 360)) * 180) / Math.PI;
diff --git a/src/app/features/scene/ridge/horizonLayer.test.ts b/src/app/features/scene/ridge/horizonLayer.test.ts
new file mode 100644
index 00000000..8ed27e4c
--- /dev/null
+++ b/src/app/features/scene/ridge/horizonLayer.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest';
+import { hfovDeg, horizonShape } from './horizon';
+import { HORIZON_TRACK_SHIFT, LAYERS, layerProfile, profile } from './ridgeWorld';
+import { layerPaint } from './ridgeBake';
+import { weatherOf } from './weather';
+import { skyColours } from './skyColour';
+import { makePhysicalSky } from './physicalSky';
+import { VFOV_DEG } from './skyDome';
+
+describe('the horizon layer', () => {
+ const far = LAYERS[0];
+ const view = { azimuthDeg: 200, hfovDeg: hfovDeg(16 / 9, VFOV_DEG) };
+
+ it('is the farthest layer, real and standing still', () => {
+ expect(far.horizon).toBe(true);
+ expect(Number.isFinite(far.seconds)).toBe(false);
+ expect(LAYERS.slice(1).every((l) => !l.horizon && Number.isFinite(l.seconds))).toBe(true);
+ });
+
+ it('reads world-x as azimuth across the viewport: the left edge is half the field to the left of the view, the right edge half to the right', () => {
+ const left = layerProfile(far, HORIZON_TRACK_SHIFT, view);
+ const right = layerProfile(far, HORIZON_TRACK_SHIFT + 1, view);
+ expect(left).toBeCloseTo(horizonShape(200 - view.hfovDeg / 2), 9);
+ expect(right).toBeCloseTo(horizonShape(200 + view.hfovDeg / 2), 9);
+ });
+
+ it('stays in 0..1 and covers a real range of shape, not a line', () => {
+ const v = Array.from({ length: 200 }, (_, i) => layerProfile(far, -0.06 + (i / 200) * 1.2, { azimuthDeg: 305, hfovDeg: view.hfovDeg }));
+ expect(Math.min(...v)).toBeGreaterThanOrEqual(0);
+ expect(Math.max(...v)).toBeLessThanOrEqual(1);
+ expect(Math.max(...v) - Math.min(...v)).toBeGreaterThan(0.2);
+ });
+
+ it('is the noise profile for the other layers, and for the horizon layer when no view is known', () => {
+ expect(layerProfile(LAYERS[2], 0.4, view)).toBe(profile(LAYERS[2], 0.4));
+ expect(layerProfile(far, 0.4)).toBe(profile(far, 0.4));
+ });
+
+ it('the horizontal field of a 1:1 view is the vertical one, and grows with the aspect', () => {
+ expect(hfovDeg(1, VFOV_DEG)).toBeCloseTo(VFOV_DEG, 9);
+ expect(hfovDeg(16 / 9, VFOV_DEG)).toBeGreaterThan(VFOV_DEG);
+ });
+});
+
+describe('weather in the layers', () => {
+ it('mist multiplies the layers\' own, and clear air leaves it alone', () => {
+ const base = skyColours();
+ const day = new Date('2026-06-21T10:40:00Z');
+ const clear = layerPaint(LAYERS[1], { ...base, physical: makePhysicalSky('day', day, 0) });
+ const misty = layerPaint(LAYERS[1], { ...base, physical: makePhysicalSky('day', day, 0.8) });
+ expect(clear.mistK).toBe(1);
+ expect(misty.mistK).toBeGreaterThan(2);
+ expect(layerPaint(LAYERS[1], base).mistK).toBe(1);
+ });
+});
+
+describe('the weather number', () => {
+ it('a number that is not finite is a clear sky, not an error', () => {
+ expect(weatherOf(Number.NaN).value).toBe(0);
+ expect(weatherOf(Infinity).value).toBe(0);
+ expect(weatherOf(0.5).value).toBe(0.5);
+ });
+});
diff --git a/src/app/features/scene/ridge/physicalSky.test.ts b/src/app/features/scene/ridge/physicalSky.test.ts
new file mode 100644
index 00000000..bb8c75a2
--- /dev/null
+++ b/src/app/features/scene/ridge/physicalSky.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it } from 'vitest';
+import { makePhysicalSky, paintSky } from './physicalSky';
+import { skyBackdrop } from '../skyState';
+import type { FixedSkyState } from '../skyBand';
+
+// The Aqua method on tonemapped pixels: paint the sky the way the host canvas does and measure the WCAG luminance of every 8-bit
+// texel, against the bounds the page text was checked against (`skyBackdrop`, 4.5:1). Over the weather, the day of the year, and
+// viewports of different shape (the fit is made on one 16:9 grid and the paint on another).
+const lin = (v: number): number => {
+ const c = v / 255;
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+};
+const luma = (d: Uint8ClampedArray, i: number): number => 0.2126 * lin(d[i]) + 0.7152 * lin(d[i + 1]) + 0.0722 * lin(d[i + 2]);
+
+const DAYS = ['2026-03-20', '2026-06-21', '2026-09-23', '2026-12-21'];
+const WEATHER = [0, 0.3, 0.55, 0.75, 1];
+const VIEWPORTS: [number, number][] = [
+ [1920, 1080],
+ [1024, 768],
+ [2560, 1080],
+ [390, 844],
+];
+
+describe.each(['day', 'dusk', 'night'] as FixedSkyState[])('the %s sky stays inside its backdrop bounds', (state) => {
+ it('over weather, season and viewport shape', () => {
+ const b = skyBackdrop(state);
+ let lo = 2;
+ let hi = -1;
+ for (const day of DAYS) {
+ for (const w of WEATHER) {
+ const sky = makePhysicalSky(state, new Date(`${day}T10:40:00Z`), w);
+ for (const [vw, vh] of VIEWPORTS) {
+ const px = paintSky(sky, vw, vh);
+ for (let i = 0; i < px.w * px.h * 4; i += 4) {
+ const l = luma(px.data, i);
+ lo = Math.min(lo, l);
+ hi = Math.max(hi, l);
+ }
+ }
+ }
+ }
+ expect(lo).toBeGreaterThanOrEqual(b.darkest);
+ expect(hi).toBeLessThanOrEqual(b.lightest);
+ });
+});
+
+describe('weather in the sky', () => {
+ it('overcast is cloud: less blue and less contrast than clear air at the same sun', () => {
+ const at = new Date('2026-06-21T10:40:00Z');
+ const clear = paintSky(makePhysicalSky('day', at, 0), 640, 360);
+ const grey = paintSky(makePhysicalSky('day', at, 0.7), 640, 360);
+ const spread = (p: typeof clear) => {
+ let lo = 2;
+ let hi = -1;
+ for (let i = 0; i < p.w * p.h * 4; i += 4) {
+ const l = luma(p.data, i);
+ lo = Math.min(lo, l);
+ hi = Math.max(hi, l);
+ }
+ return hi - lo;
+ };
+ expect(spread(grey)).toBeLessThan(spread(clear));
+ const mid = (p: typeof clear) => {
+ const i = (Math.floor(p.h * 0.3) * p.w + Math.floor(p.w / 2)) * 4;
+ return p.data[i + 2] - p.data[i];
+ };
+ expect(mid(grey)).toBeLessThan(mid(clear));
+ });
+});
+
+describe('the sun disc', () => {
+ it('keeps the display floor the dark link was fitted to, at every hour it is drawn', () => {
+ const lin = (c: number) => (c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4);
+ let drawn = 0;
+ for (const state of ['day', 'dusk'] as FixedSkyState[]) {
+ for (let m = 0; m < 24 * 60; m += 10) {
+ const d = paintSky(makePhysicalSky(state, new Date(Date.UTC(2026, 10, 11, 0, m)), 0), 1280, 720).sunDisc;
+ if (!d) continue;
+ drawn += 1;
+ expect(0.2126 * lin(d.rgb[0]) + 0.7152 * lin(d.rgb[1]) + 0.0722 * lin(d.rgb[2])).toBeGreaterThanOrEqual(0.4744);
+ }
+ }
+ expect(drawn).toBeGreaterThan(0);
+ });
+ const noon = new Date('2026-11-11T10:40:00Z'); // 21 degrees: in June the noon sun is above the top of the image
+ it('is drawn by the host, in a clear sky by day, white with the sun high', () => {
+ const d = paintSky(makePhysicalSky('day', noon, 0), 1280, 720).sunDisc;
+ expect(d).toBeDefined();
+ expect(d!.x).toBeGreaterThan(0);
+ expect(d!.x).toBeLessThan(1);
+ expect(d!.y).toBeGreaterThan(0);
+ expect(d!.y).toBeLessThan(0.78); // above the horizon line
+ expect(d!.alpha).toBe(1);
+ expect(Math.min(...d!.rgb)).toBeGreaterThan(150); // a warm white at 21 degrees
+ });
+ it('is hidden by overcast and absent at night', () => {
+ expect(paintSky(makePhysicalSky('day', noon, 0.7), 1280, 720).sunDisc).toBeUndefined();
+ expect(paintSky(makePhysicalSky('night', noon, 0), 1280, 720).sunDisc).toBeUndefined();
+ });
+ it('is orange-tinted low in the sky', () => {
+ const d = paintSky(makePhysicalSky('dusk', noon, 0), 1280, 720).sunDisc;
+ expect(d).toBeDefined();
+ expect(d!.rgb[0]).toBeGreaterThan(d!.rgb[2]);
+ });
+});
diff --git a/src/app/features/scene/ridge/physicalSky.ts b/src/app/features/scene/ridge/physicalSky.ts
new file mode 100644
index 00000000..48a10a16
--- /dev/null
+++ b/src/app/features/scene/ridge/physicalSky.ts
@@ -0,0 +1,124 @@
+// The ridge scene's sky: Vienna's sun on the real clock, the atmosphere baked from it, a camera, and the display that makes it
+// pixels. `makePhysicalSky` does the one expensive thing (the LUT bake, tens of ms) and fits the display from a fixed 16:9
+// grid, so resizing the window repaints the same sky. `paintSky` is per-texel lookups only.
+import type { FixedSkyState } from '../skyBand';
+import { bakeAtmosphere } from './earthAtmosphere';
+import type { Atmosphere } from './earthAtmosphere';
+import { domeSize, HORIZON_AT, renderDome, SUN_RADIUS, sunOnScreen, viewFor, VFOV_DEG } from './skyDome';
+import type { Dome, DomeView } from './skyDome';
+import type { Srgb } from './skyColour';
+import { display8, fitDisplay, holdBudget, MARGIN } from './toneMap';
+import type { Display, Fit } from './toneMap';
+import { sunForState, VIENNA } from './vienna';
+import type { SunSample } from './vienna';
+import { weatherOf } from './weather';
+import type { Weather } from './weather';
+
+export interface PhysicalSky {
+ state: FixedSkyState;
+ sun: SunSample;
+ weather: Weather;
+ atm: Atmosphere;
+ /** The display fitted to this sky's budget. */
+ fit: Fit;
+ /** The sky at the top, the middle and the horizon of the image (mean of each row), for the layers' haze and the stars' cover. */
+ stops: { top: Srgb; mid: Srgb; horizon: Srgb };
+ /** How long the bake took, ms. */
+ bakeMs: number;
+}
+
+const CANON = { w: 64, h: 36, aspect: 16 / 9 };
+
+const clock = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
+
+export function rowMean(dome: Dome, display: Display, row: number): Srgb {
+ const out = [0, 0, 0];
+ const acc = [0, 0, 0];
+ const j = Math.min(dome.h - 1, Math.max(0, row));
+ for (let i = 0; i < dome.w; i += 1) {
+ const o = (j * dome.w + i) * 3;
+ display8(dome.hdr[o], dome.hdr[o + 1], dome.hdr[o + 2], display, out);
+ acc[0] += out[0];
+ acc[1] += out[1];
+ acc[2] += out[2];
+ }
+ return [Math.round(acc[0] / dome.w), Math.round(acc[1] / dome.w), Math.round(acc[2] / dome.w)];
+}
+
+export function makePhysicalSky(state: FixedSkyState, now: Date, weatherValue: number): PhysicalSky {
+ const t0 = clock();
+ const sun = sunForState(state, now);
+ const weather = weatherOf(weatherValue);
+ const atm = bakeAtmosphere({
+ sunElevationDeg: sun.elevation,
+ mieScale: weather.mieScale,
+ observerKm: VIENNA.altitudeKm,
+ });
+ const dome = renderDome(atm, sun, viewFor(sun, CANON.aspect), CANON.w, CANON.h, weather.cover);
+ const fit = fitDisplay(state, dome.hdr, dome.meanLuma);
+ const at = (f: number) => rowMean(dome, fit.display, Math.round(f * CANON.h));
+ return {
+ state,
+ sun,
+ weather,
+ atm,
+ fit,
+ stops: { top: at(0.02), mid: at(0.5), horizon: at(HORIZON_AT) },
+ bakeMs: clock() - t0,
+ };
+}
+
+export interface SkyPixels {
+ w: number;
+ h: number;
+ /** RGBA, 8 bit. */
+ data: Uint8ClampedArray;
+ dome: Dome;
+ view: DomeView;
+ /** The sun's disc for the host to draw on the upscaled canvas (it is smaller than a texel): place as fractions of the image, colour, radius in fractions of the height, opacity after the cloud. */
+ sunDisc?: { x: number; y: number; rgb: Srgb; radius: number; alpha: number };
+}
+
+/** The sky for a viewport, as a small RGBA grid the host canvas upscales smoothly. */
+export function paintSky(p: PhysicalSky, cssW: number, cssH: number): SkyPixels {
+ const { w, h } = domeSize(cssW, cssH);
+ const view = viewFor(p.sun, cssW / cssH);
+ const dome = renderDome(p.atm, p.sun, view, w, h, p.weather.cover);
+ const data = new Uint8ClampedArray(w * h * 4);
+ const out = [0, 0, 0];
+ const display = holdBudget(p.state, dome.hdr, p.fit.display);
+ for (let i = 0; i < w * h; i += 1) {
+ display8(dome.hdr[i * 3], dome.hdr[i * 3 + 1], dome.hdr[i * 3 + 2], display, out);
+ data[i * 4] = out[0];
+ data[i * 4 + 1] = out[1];
+ data[i * 4 + 2] = out[2];
+ data[i * 4 + 3] = 255;
+ }
+ return { w, h, data, dome, view, sunDisc: sunDisc(p, view) };
+}
+
+/** The luma the disc must keep: the day/dusk floor of toneMap's budget. */
+const DISC_FLOOR = 0.4744 + MARGIN;
+const smooth01 = (a: number, b: number, v: number): number => {
+ const t = Math.min(1, Math.max(0, (v - a) / (b - a)));
+ return t * t * (3 - 2 * t);
+};
+
+/**
+ * The sun's disc: always brighter than the display's white, so what is left of it is its colour, the air's transmittance to the sun
+ * normalised to its strongest channel (white high up, orange near the horizon). Thick cloud hides it.
+ */
+function sunDisc(p: PhysicalSky, view: DomeView): SkyPixels['sunDisc'] {
+ const at = sunOnScreen(p.sun, view);
+ const alpha = 1 - smooth01(0.1, 0.6, p.weather.cover);
+ if (!at || alpha <= 0) return undefined;
+ const trans = [0, 0, 0];
+ p.atm.transmittanceToSpace(Math.max(Math.sin((p.sun.elevation * Math.PI) / 180), 0), trans);
+ const top = Math.max(...trans, 1e-9);
+ const rgb = trans.map((t) => Math.round(255 * (t / top) ** 0.6));
+ // The disc sits on the sky, so it keeps the display floor the dark link was fitted to: mix toward white until it does.
+ const lin = (c: number): number => (c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4);
+ const luma = (c: number[]): number => 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]);
+ for (let m = 0; luma(rgb) < DISC_FLOOR && m < 40; m += 1) for (let i = 0; i < 3; i += 1) rgb[i] = Math.round(rgb[i] + (255 - rgb[i]) * 0.1);
+ return { ...at, rgb: rgb as unknown as Srgb, radius: (SUN_RADIUS * 180) / Math.PI / VFOV_DEG, alpha };
+}
diff --git a/src/app/features/scene/ridge/rain.test.ts b/src/app/features/scene/ridge/rain.test.ts
new file mode 100644
index 00000000..6575ea20
--- /dev/null
+++ b/src/app/features/scene/ridge/rain.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest';
+import { drawRainTile, RAIN_TILES, streakCount } from './rain';
+
+const recorder = () => {
+ const calls: string[] = [];
+ const ctx = new Proxy({} as Record, {
+ get: (t, k: string) => (k in t ? t[k] : (...a: unknown[]) => calls.push(`${k}(${a.join(',')})`)),
+ set: (t, k: string, v) => {
+ t[k] = v;
+ return true;
+ },
+ }) as unknown as CanvasRenderingContext2D;
+ return { ctx, calls };
+};
+
+describe('rain', () => {
+ it('draws no streaks without rain and more with more', () => {
+ expect(streakCount(RAIN_TILES[0], 0)).toBe(0);
+ expect(streakCount(RAIN_TILES[0], 0.3)).toBeLessThan(streakCount(RAIN_TILES[0], 1));
+ const { ctx, calls } = recorder();
+ drawRainTile(ctx, RAIN_TILES[0], 0, [200, 210, 220], 1);
+ expect(calls.filter((c) => c.startsWith('stroke'))).toHaveLength(0);
+ });
+ it('is the same tile every time, and every streak is drawn in the nine wraps so the tile repeats without a seam', () => {
+ const a = recorder();
+ const b = recorder();
+ drawRainTile(a.ctx, RAIN_TILES[1], 1, [200, 210, 220], 1);
+ drawRainTile(b.ctx, RAIN_TILES[1], 1, [200, 210, 220], 1);
+ expect(a.calls).toEqual(b.calls);
+ expect(a.calls.filter((c) => c.startsWith('stroke'))).toHaveLength(streakCount(RAIN_TILES[1], 1) * 9);
+ });
+});
diff --git a/src/app/features/scene/ridge/rain.ts b/src/app/features/scene/ridge/rain.ts
new file mode 100644
index 00000000..699130cf
--- /dev/null
+++ b/src/app/features/scene/ridge/rain.ts
@@ -0,0 +1,54 @@
+// Rain as one tiled texture of slanted streaks, drawn once and slid down by the compositor (a CSS animation on a transform: no per-frame
+// script, no per-frame paint). Two tiles of different size and speed read as depth. The streaks wear the sky's own horizon colour, so
+// whatever they lighten stays inside the state's luminance bounds (a streak is a blend of two colours the sky already has).
+import { hash2 } from './noise';
+import type { Srgb } from './skyColour';
+
+export interface RainTile {
+ /** Tile edge, canvas px. */
+ size: number;
+ /** Seconds for a tile to fall its own height. */
+ seconds: number;
+ /** Streaks per tile at rain 1. */
+ streaks: number;
+ /** Streak length, tile fractions. */
+ length: number;
+ /** Peak alpha. */
+ alpha: number;
+}
+
+/** Far, fine and slow; near, long and quick. */
+export const RAIN_TILES: readonly RainTile[] = [
+ { size: 192, seconds: 0.9, streaks: 46, length: 0.16, alpha: 0.32 },
+ { size: 320, seconds: 0.6, streaks: 22, length: 0.24, alpha: 0.42 },
+];
+
+/** Slant of a streak: pixels left per pixel down. */
+export const RAIN_SLANT = 0.18;
+
+/** Streaks to draw at a rain amount 0..1 (none at 0). */
+export const streakCount = (t: RainTile, rain: number): number => (rain <= 0 ? 0 : Math.max(4, Math.round(t.streaks * rain)));
+
+/** Draw one tile. The streaks wrap across the tile's edges so the tile repeats without a seam. */
+export function drawRainTile(ctx: CanvasRenderingContext2D, t: RainTile, rain: number, colour: Srgb, scale: number, seed = 0): void {
+ const n = streakCount(t, rain);
+ ctx.clearRect(0, 0, t.size, t.size);
+ ctx.lineCap = 'round';
+ ctx.lineWidth = Math.max(1, scale);
+ for (let i = 0; i < n; i += 1) {
+ const x = hash2(i, seed + 3) * t.size;
+ const y = hash2(i, seed + 7) * t.size;
+ const len = t.length * t.size * (0.7 + 0.6 * hash2(i, seed + 11));
+ const a = t.alpha * (0.5 + 0.5 * hash2(i, seed + 13)) * (0.5 + 0.5 * rain);
+ ctx.strokeStyle = `rgba(${colour[0]},${colour[1]},${colour[2]},${a.toFixed(3)})`;
+ // Draw each streak at its place and one tile over in each direction it can cross an edge.
+ [-t.size, 0, t.size].forEach((ox) =>
+ [-t.size, 0, t.size].forEach((oy) => {
+ ctx.beginPath();
+ ctx.moveTo(x + ox, y + oy);
+ ctx.lineTo(x + ox - len * RAIN_SLANT, y + oy + len);
+ ctx.stroke();
+ })
+ );
+ }
+}
diff --git a/src/app/features/scene/ridge/ridgeBake.ts b/src/app/features/scene/ridge/ridgeBake.ts
index 770c30e0..72736d85 100644
--- a/src/app/features/scene/ridge/ridgeBake.ts
+++ b/src/app/features/scene/ridge/ridgeBake.ts
@@ -3,7 +3,7 @@
// slope of the heightfield's crest line against the sun; the face under it takes a light-to-haze
// gradient, erosion streaks and a lit rim on the crest. The depth treatment (colour, haze, contrast
// by distance) is ridgeWorld's `layerColour`, `hazeColour` and `transmittance`, used here and not redone.
-import { gullyAt, LayerSpec, profile, hazeColour, layerColour, transmittance } from './ridgeWorld';
+import { gullyAt, HorizonView, LayerSpec, layerProfile, hazeColour, layerColour, transmittance } from './ridgeWorld';
import type { SkyBand } from '../skyBand';
import { LUMA, SKY_BAND } from '../skyBand';
import type { SkyColours, Srgb } from './skyColour';
@@ -45,6 +45,8 @@ export interface BakeParams {
rows: number;
/** Row count of the visible band; the crest is measured from the top of it. */
bandH: number;
+ /** Where world-x is a compass azimuth, for the horizon layer (`LayerSpec.horizon`); absent: every layer is noise. */
+ horizonView?: HorizonView;
/** World pixel of the buffer's column 0 (may be fractional-free negative: a chunk's left minus its padding). */
px0: number;
/** Columns of the buffer. */
@@ -67,6 +69,8 @@ export interface LayerPaint {
warm: Srgb;
contrast: number;
sun: Vec3;
+ /** How much the weather's mist multiplies the layer's own (`weather.ts`): 1 in clear air. */
+ mistK: number;
}
const decode = (v: number): number => {
@@ -123,6 +127,7 @@ export function layerPaint(spec: LayerSpec, sky: SkyColours): LayerPaint {
// Distance flattens relief: the same transmittance that thins the colour.
contrast: 0.35 + 0.65 * transmittance(spec),
sun: sunDirection(sky),
+ mistK: 1 + 2.5 * (sky.physical?.weather.mist ?? 0),
};
}
@@ -132,7 +137,7 @@ function crestRows(p: BakeParams, c0: number, c1: number): Float64Array {
const amp = p.spec.relief * p.viewH;
const lift = p.spec.base * p.viewH;
for (let c = c0 - 1; c <= c1; c += 1) {
- const top = Math.min(p.bandH, lift + amp * profile(p.spec, (p.px0 + c) / p.period));
+ const top = Math.min(p.bandH, lift + amp * layerProfile(p.spec, (p.px0 + c) / p.period, p.horizonView));
out[c - c0 + 1] = p.bandH - top;
}
return out;
@@ -155,7 +160,7 @@ export function bakeSlice(
const faceH = 0.45 * p.viewH;
const rim = 2.2 * p.scale;
const glow = 9 * p.scale;
- const { base, haze, warm, contrast, sun, shift } = paint;
+ const { base, haze, warm, contrast, sun, shift, mistK } = paint;
for (let c = c0; c < c1; c += 1) {
const cy = crest[c - c0 + 1];
const slope = (crest[c - c0 + 2] - crest[c - c0]) * -0.5; // rows up per column
@@ -182,7 +187,7 @@ export function bakeSlice(
(clamp01(1 - d / rim) * 0.4 + (d < glow * 5 ? Math.exp(-d / glow) * 0.08 : 0)) *
contrast *
(0.3 + 0.7 * lit);
- const mist = spec.mist * smoothstep(0, 1, dn);
+ const mist = Math.min(0.9, spec.mist * mistK) * smoothstep(0, 1, dn);
const o = (y * p.cols + c) * 4;
for (let i = 0; i < 3; i += 1) {
let v = base[i] * shade + warm[i] * (warmK + rimK);
diff --git a/src/app/features/scene/ridge/ridgeWorld.ts b/src/app/features/scene/ridge/ridgeWorld.ts
index 569b43bd..6bd8e3b1 100644
--- a/src/app/features/scene/ridge/ridgeWorld.ts
+++ b/src/app/features/scene/ridge/ridgeWorld.ts
@@ -1,6 +1,7 @@
// Every number of the ridge scene, and the pure functions that turn them into silhouettes. No DOM.
// A ridge is a function of world-x (t, in viewport widths): ridged multifractal noise, domain-warped,
// one function for every layer with the parameters below. Nothing in it repeats.
+import { horizonShape } from './horizon';
import { valueNoise } from './noise';
import type { Srgb, SkyColours } from './skyColour';
@@ -29,23 +30,40 @@ export interface LayerSpec {
gullyPx: number;
/** 0..1 how far the foot of the face dissolves into the valley mist. */
mist: number;
+ /**
+ * The layer is the real horizon of the viewpoint (`horizon.ts`), not noise: world-x is compass azimuth across the viewport
+ * (`HorizonView`) and it does not drift, so it stays under the sun it is lit by. Its `relief` is the ring's own range.
+ */
+ horizon?: boolean;
}
+/** Where the camera looks and how wide it sees, to read world-x as an azimuth: the sky's own view (`skyDome.ts viewFor`). */
+export interface HorizonView {
+ /** Compass azimuth of the viewport's centre, degrees clockwise from north. */
+ azimuthDeg: number;
+ hfovDeg: number;
+}
+
+/** The static horizon layer's track sits this many viewport widths left of the screen, so the camera's sway never shows its edge. */
+export const HORIZON_TRACK_SHIFT = 0.06;
+
+
/** Four layers: one function, four parameter rows. */
export const LAYERS: readonly LayerSpec[] = [
{
index: 0,
baseCells: 2.2,
- relief: 0.24,
+ relief: 0.16,
crag: 0.85,
- base: 0.34,
+ base: 0.3,
depth: 0.12,
- seconds: 420,
+ seconds: Infinity,
octaves: 5,
warp: 0.6,
gully: 0.3,
gullyPx: 16,
mist: 0.35,
+ horizon: true,
},
{
index: 1,
@@ -128,6 +146,15 @@ export function profile(spec: LayerSpec, t: number): number {
return Math.min(1, 0.85 * (sum / norm) * PEAK_NORM + 0.15 * swell);
}
+/**
+ * Silhouette height in [0, 1] of a layer at world-x `t` (viewport widths). The horizon layer reads the baked horizon at the azimuth `t`
+ * is under `view`; every other layer, or a horizon layer with no view, is the noise `profile`.
+ */
+export function layerProfile(spec: LayerSpec, t: number, view?: HorizonView): number {
+ if (!spec.horizon || !view) return profile(spec, t);
+ return horizonShape(view.azimuthDeg + (t - HORIZON_TRACK_SHIFT - 0.5) * view.hfovDeg);
+}
+
/** Erosion streak strength in [0, 1] at world-x `wxCss` (CSS px): a folded noise at gully width. */
export function gullyAt(spec: LayerSpec, wxCss: number): number {
const g = 1 - Math.abs(2 * valueNoise(wxCss / spec.gullyPx, spec.index * 131 + 5) - 1);
@@ -198,7 +225,7 @@ export function layerSize(cssW: number, cssH: number, scale: number) {
}
/** Ridge-top y (canvas pixels, from the top of the layer's band) at world-x `t` (viewport widths). */
-export function ridgeY(spec: LayerSpec, t: number, bandH: number, viewH: number): number {
- const topFromBottom = spec.base * viewH + spec.relief * viewH * profile(spec, t);
+export function ridgeY(spec: LayerSpec, t: number, bandH: number, viewH: number, view?: HorizonView): number {
+ const topFromBottom = spec.base * viewH + spec.relief * viewH * layerProfile(spec, t, view);
return bandH - Math.min(bandH, topFromBottom);
}
diff --git a/src/app/features/scene/ridge/skyColour.ts b/src/app/features/scene/ridge/skyColour.ts
index 1fa7e6fe..e2898a0b 100644
--- a/src/app/features/scene/ridge/skyColour.ts
+++ b/src/app/features/scene/ridge/skyColour.ts
@@ -5,6 +5,7 @@
// and bakes the result.
import type { SkyBand } from '../skyBand';
+import type { PhysicalSky } from './physicalSky';
export type Srgb = readonly [number, number, number];
@@ -21,6 +22,8 @@ export interface SkyColours {
sunAzimuth?: number;
/** The state's luminance band (skyBand.ts): the layers are shaped into it as the sky is, so the theme ink clears 4.5:1 on a ridge as on bare sky. Omit for none. */
band?: SkyBand;
+ /** The physical sky this palette was read from; when present the host canvas is painted from it, not from the three stops. */
+ physical?: PhysicalSky;
}
export function skyColours(_now: Date = new Date()): SkyColours {
diff --git a/src/app/features/scene/ridge/skyDome.ts b/src/app/features/scene/ridge/skyDome.ts
new file mode 100644
index 00000000..41da66b8
--- /dev/null
+++ b/src/app/features/scene/ridge/skyDome.ts
@@ -0,0 +1,122 @@
+// The sky as a camera sees it: for each texel of a small grid, the view ray, its radiance out of the atmosphere's sky-view
+// LUT (`earthAtmosphere.ts`), and nothing else; one lookup per texel, no march. The result is linear HDR
+// radiance per unit of sun irradiance (a `Float32Array`); `toneMap.ts` makes it pixels.
+import type { Atmosphere } from './earthAtmosphere';
+import type { SunSample } from './vienna';
+import { cloudAmount, cloudRadiance } from './clouds';
+
+const RAD = Math.PI / 180;
+/** The sun's angular radius in radians (Hillaire's/Bruneton's `sun_angular_radius`). */
+export const SUN_RADIUS = 0.004675;
+/** Vertical field of view of the camera, degrees, and where the horizon sits down the image: the ridge scene's sky stops. */
+export const VFOV_DEG = 50;
+export const HORIZON_AT = 0.78;
+
+export interface DomeView {
+ /** Compass azimuth the camera faces, degrees clockwise from north. */
+ azimuthDeg: number;
+ /** Width over height of the viewport. */
+ aspect: number;
+}
+
+/** Where the camera looks: toward the sun's side while it is up, so the sun sits right of centre; south in the dark. */
+export const viewFor = (sun: SunSample, aspect: number): DomeView => ({
+ azimuthDeg: sun.elevation > -6 ? sun.azimuth - 24 : 180,
+ aspect,
+});
+
+export interface Dome {
+ w: number;
+ h: number;
+ /** Linear radiance, 3 floats per texel, row 0 at the top. */
+ hdr: Float32Array;
+ /** Mean over the grid of the log of the radiance luminance, exponentiated: what auto-exposure reads. */
+ meanLuma: number;
+}
+
+/** Grid for a viewport: about 8 CSS px per texel, capped. */
+export const domeSize = (cssW: number, cssH: number): { w: number; h: number } => ({
+ w: Math.min(256, Math.max(8, Math.ceil(cssW / 8))),
+ h: Math.min(160, Math.max(8, Math.ceil(cssH / 8))),
+});
+
+/**
+ * Render the HDR sky. Rays below the horizon take the horizon's radiance (the ridges cover them). The sun's disc is not here: it is smaller than a texel, so the host draws it on the upscaled canvas (`sunOnScreen`). `cover` (0..1, `weather.ts`) lays the cloud deck (`clouds.ts`) over it.
+ */
+export function renderDome(
+ atm: Atmosphere,
+ sun: SunSample,
+ view: DomeView,
+ w: number,
+ h: number,
+ cover = 0
+): Dome {
+ const hdr = new Float32Array(w * h * 3);
+ const tanHalf = Math.tan((VFOV_DEG * RAD) / 2);
+ const fa = view.azimuthDeg * RAD;
+ const fx = Math.sin(fa);
+ const fy = Math.cos(fa);
+ const rx = Math.cos(fa);
+ const ry = -Math.sin(fa);
+ const sunAz = sun.azimuth * RAD;
+ const sunEl = sun.elevation * RAD;
+ const sz = Math.sin(sunEl);
+ const trans = [0, 0, 0];
+ atm.transmittanceToSpace(Math.max(sz, 0), trans); // the cloud's sunlight
+ const rgb = [0, 0, 0];
+ const cloud = [0, 0, 0];
+ if (cover > 0) {
+ const zenith = [0, 0, 0];
+ atm.radiance(1, 1, zenith);
+ cloudRadiance(sz, trans, zenith, cover, cloud);
+ }
+ let logSum = 0;
+ for (let j = 0; j < h; j += 1) {
+ const v = (HORIZON_AT - (j + 0.5) / h) * 2 * tanHalf;
+ for (let i = 0; i < w; i += 1) {
+ const u = ((i + 0.5) / w - 0.5) * view.aspect * 2 * tanHalf;
+ const dx = fx + rx * u;
+ const dy = fy + ry * u;
+ const dz = v;
+ const n = Math.hypot(dx, dy, dz);
+ const vz = dz / n;
+ const cosFromSun = Math.cos(Math.atan2(dx, dy) - sunAz);
+ atm.radiance(vz, cosFromSun, rgb);
+ let r = rgb[0];
+ let g = rgb[1];
+ let b = rgb[2];
+ let lum = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
+ if (cover > 0) {
+ const c = cloudAmount(dx / n, dy / n, vz, cover);
+ lum += (0.2126 * cloud[0] + 0.7152 * cloud[1] + 0.0722 * cloud[2] - lum) * c;
+ r += (cloud[0] - r) * c;
+ g += (cloud[1] - g) * c;
+ b += (cloud[2] - b) * c;
+ }
+ const o = (j * w + i) * 3;
+ hdr[o] = r;
+ hdr[o + 1] = g;
+ hdr[o + 2] = b;
+ logSum += Math.log(Math.max(1e-9, lum));
+ }
+ }
+ return { w, h, hdr, meanLuma: Math.exp(logSum / (w * h)) };
+}
+
+/** Where the sun is on the image, as fractions of its width and height (y down), or null when it is behind the camera or below the horizon. */
+export function sunOnScreen(sun: SunSample, view: DomeView): { x: number; y: number } | null {
+ if (sun.elevation < -0.3) return null;
+ const tanHalf = Math.tan((VFOV_DEG * RAD) / 2);
+ const fa = view.azimuthDeg * RAD;
+ const az = sun.azimuth * RAD;
+ const el = sun.elevation * RAD;
+ const sx = Math.cos(el) * Math.sin(az);
+ const sy = Math.cos(el) * Math.cos(az);
+ const along = sx * Math.sin(fa) + sy * Math.cos(fa);
+ if (along <= 0.05) return null;
+ const u = (sx * Math.cos(fa) - sy * Math.sin(fa)) / along;
+ const v = Math.sin(el) / along;
+ const x = u / (view.aspect * 2 * tanHalf) + 0.5;
+ const y = HORIZON_AT - v / (2 * tanHalf);
+ return x < -0.05 || x > 1.05 || y < -0.05 || y > 1.05 ? null : { x, y };
+}
diff --git a/src/app/features/scene/ridge/toneMap.ts b/src/app/features/scene/ridge/toneMap.ts
new file mode 100644
index 00000000..b91a4d6d
--- /dev/null
+++ b/src/app/features/scene/ridge/toneMap.ts
@@ -0,0 +1,194 @@
+// The one display transform of the ridge scene: linear scene radiance in, 8-bit sRGB out. Exposure, then the ACES fit of
+// Stephen Hill (BakingLab, "ACES.hlsl": the sRGB-to-AP1 input matrix, the RRT and ODT rational fit, the AP1-to-sRGB output
+// matrix), then the sRGB encode. A per-pixel function with no state, applied once to the baked sky (CPU: the scene is a 2D
+// canvas, there is no float render target to fall back from).
+//
+// Exposure is chosen per sky state (`exposureFor`): the sky is physical radiance, so brightness is the camera's, not a
+// shaping of the result. Day and dusk expose to the sky's own luminance (adaptation, as an eye or auto-exposure does) so the
+// darkest of the sky stays where the state's dark ink needs a backdrop; night is fixed, the physical dark.
+import type { FixedSkyState } from '../skyBand';
+
+type Rgb = readonly [number, number, number];
+
+const IN: readonly Rgb[] = [
+ [0.59719, 0.35458, 0.04823],
+ [0.076, 0.90834, 0.01566],
+ [0.0284, 0.13383, 0.83777],
+];
+const OUT: readonly Rgb[] = [
+ [1.60475, -0.53108, -0.07367],
+ [-0.10208, 1.10813, -0.00605],
+ [-0.00327, -0.07276, 1.07602],
+];
+const fit = (v: number): number => (v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.432951) + 0.238081);
+const encode = (l: number): number => {
+ const c = Math.min(1, Math.max(0, l));
+ return 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
+};
+
+/** ACES (Hill's fit) of linear radiance `r, g, b` times `exposure`, to linear display RGB 0..1, written into `out`. */
+export function acesLinear(r: number, g: number, b: number, exposure: number, out: number[]): void {
+ const x = r * exposure;
+ const y = g * exposure;
+ const z = b * exposure;
+ const p = [0, 1, 2].map((i) => fit(Math.max(0, IN[i][0] * x + IN[i][1] * y + IN[i][2] * z)));
+ for (let i = 0; i < 3; i += 1) out[i] = Math.min(1, Math.max(0, OUT[i][0] * p[0] + OUT[i][1] * p[1] + OUT[i][2] * p[2]));
+}
+
+/** The 8-bit sRGB of linear radiance, exposed and tone-mapped. */
+export function toneMap8(r: number, g: number, b: number, exposure: number, out: number[]): void {
+ acesLinear(r, g, b, exposure, out);
+ out[0] = Math.round(encode(out[0]));
+ out[1] = Math.round(encode(out[1]));
+ out[2] = Math.round(encode(out[2]));
+}
+
+/** WCAG relative luminance of linear display RGB. */
+export const displayLuma = (c: ArrayLike): number => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
+
+/** What a sky state needs of its backdrop's display luminance, from the ink it wears (skyBand.ts, skyInk.ts). */
+export interface Budget {
+ /** The darkest texel may not be darker than this (dark ink needs a light backdrop). 0: no floor. */
+ floor: number;
+ /** The lightest texel may not be lighter than this (light ink needs a dark one). 1: no ceiling. */
+ ceiling: number;
+ /** Display luminance the log-mean of the sky is exposed to, before the budget is applied; undefined: a fixed exposure. */
+ key?: number;
+ /** Exposure range, in multiples of scene radiance (per unit sun irradiance). */
+ min: number;
+ max: number;
+}
+
+/** Display luminance slack for the 8-bit rounding and the smooth upscale of the baked grid. */
+export const MARGIN = 0.004;
+
+/**
+ * Day and Dusk wear the same dark ink, so one floor: its bound plus margin, 0.4744, which is what the dark link needs (about 0.4554 for 4.5:1, skyTheme.test.ts); a lower day floor (0.317 was tried) leaves the link at 3.3:1. Night wears light ink, and its
+ * dimmer foldout headers (L 0.386 on screen, rat-crest-a7c95ce-20261111) need a backdrop of at most 0.047.
+ */
+export const BUDGET: Record = {
+ day: { floor: 0.4744 + MARGIN, ceiling: 1, key: 0.45, min: 4, max: 60 },
+ dusk: { floor: 0.4744 + MARGIN, ceiling: 1, key: 0.5, min: 4, max: 400 },
+ night: { floor: 0, ceiling: 0.047 - MARGIN, key: 0.03, min: 8, max: 3000 },
+};
+
+/** The display transform of a sky: exposure, then ACES, then a black lift (veiling glare) in display-linear. */
+export interface Display {
+ exposure: number;
+ /** Added to every channel after the curve, then rescaled so white stays white: out = lift + (1 - lift) * aces. */
+ lift: number;
+}
+
+/** The 8-bit sRGB of linear radiance through a display. */
+export function display8(r: number, g: number, b: number, d: Display, out: number[]): void {
+ acesLinear(r, g, b, d.exposure, out);
+ for (let i = 0; i < 3; i += 1) out[i] = Math.round(encode(d.lift + (1 - d.lift) * out[i]));
+}
+
+const decode = (v: number): number => {
+ const c = v / 255;
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+};
+/** Display luminance (WCAG relative, from the encoded bytes) of a radiance through a display. */
+export function displayLumaOf(r: number, g: number, b: number, d: Display, tmp: number[] = [0, 0, 0]): number {
+ display8(r, g, b, d, tmp);
+ return displayLuma([decode(tmp[0]), decode(tmp[1]), decode(tmp[2])]);
+}
+
+export interface Fit {
+ display: Display;
+ /** Darkest and lightest display luminance of the grid under `display`. */
+ lo: number;
+ hi: number;
+ /** The exposure before the budget moved it, and what the budget took (for the numbers a report cites). */
+ keyExposure: number;
+}
+
+/**
+ * Choose the display of a sky, the one place brightness is decided. `hdr` is the grid's radiance (3 floats a texel) and
+ * `meanLuma` its log-mean luminance. Exposure starts at the state's key (adaptation); a ceiling lowers it until the
+ * lightest texel is inside the budget (bisection: luminance is monotone in exposure); a floor raises the black lift until the
+ * darkest is (luminance is monotone in lift, and linear: the lift needed is solved in one step).
+ */
+export function fitDisplay(state: FixedSkyState, hdr: Float32Array, meanLuma: number): Fit {
+ const b = BUDGET[state];
+ const keyExposure =
+ b.key === undefined ? b.min : Math.min(b.max, Math.max(b.min, (b.key * 1.6) / Math.max(meanLuma, 1e-9)));
+ const n = hdr.length / 3;
+ const stride = Math.max(1, Math.floor(n / 4000));
+ const tmp = [0, 0, 0];
+ const range = (d: Display) => {
+ let lo = 2;
+ let hi = -1;
+ for (let i = 0; i < n; i += stride) {
+ const l = displayLumaOf(hdr[i * 3], hdr[i * 3 + 1], hdr[i * 3 + 2], d, tmp);
+ if (l < lo) lo = l;
+ if (l > hi) hi = l;
+ }
+ return { lo, hi };
+ };
+ let exposure = keyExposure;
+ let r = range({ exposure, lift: 0 });
+ if (r.hi > b.ceiling) {
+ let lo = 0;
+ let hiE = exposure;
+ for (let k = 0; k < 24; k += 1) {
+ const mid = (lo + hiE) / 2;
+ if (range({ exposure: mid, lift: 0 }).hi > b.ceiling) hiE = mid;
+ else lo = mid;
+ }
+ exposure = lo;
+ r = range({ exposure, lift: 0 });
+ }
+ let lift = 0;
+ if (r.lo < b.floor) {
+ // Linear in the displayed linear luminance: L' = lift + (1 - lift) L, but through the 8-bit encode; solve in linear then check once.
+ lift = (b.floor - r.lo) / (1 - r.lo);
+ let probe = range({ exposure, lift });
+ for (let k = 0; k < 6 && probe.lo < b.floor; k += 1) {
+ lift += (b.floor - probe.lo) * 1.05;
+ probe = range({ exposure, lift });
+ }
+ r = probe;
+ }
+ return { display: { exposure, lift }, lo: r.lo, hi: r.hi, keyExposure };
+}
+
+/**
+ * The display, moved only as far as the budget needs, for a grid other than the one it was fitted on (another viewport's shape,
+ * its own texels): the fit is made once on a canonical grid so that resizing repaints the same sky, and this keeps a texel the canonical
+ * grid did not have inside the state's bounds. A display that already holds them is returned as it is.
+ */
+export function holdBudget(state: FixedSkyState, hdr: Float32Array, d: Display): Display {
+ const b = BUDGET[state];
+ const n = hdr.length / 3;
+ const tmp = [0, 0, 0];
+ const range = (x: Display) => {
+ let lo = 2;
+ let hi = -1;
+ for (let i = 0; i < n; i += 1) {
+ const l = displayLumaOf(hdr[i * 3], hdr[i * 3 + 1], hdr[i * 3 + 2], x, tmp);
+ if (l < lo) lo = l;
+ if (l > hi) hi = l;
+ }
+ return { lo, hi };
+ };
+ let cur = d;
+ let r = range(cur);
+ if (r.hi > b.ceiling) {
+ let lo = 0;
+ let hiE = cur.exposure;
+ for (let k = 0; k < 20; k += 1) {
+ const mid = (lo + hiE) / 2;
+ if (range({ exposure: mid, lift: cur.lift }).hi > b.ceiling) hiE = mid;
+ else lo = mid;
+ }
+ cur = { exposure: lo, lift: cur.lift };
+ r = range(cur);
+ }
+ for (let k = 0; k < 8 && r.lo < b.floor; k += 1) {
+ cur = { exposure: cur.exposure, lift: cur.lift + ((b.floor - r.lo) / (1 - r.lo)) * 1.05 + 1e-4 };
+ r = range(cur);
+ }
+ return cur;
+}
diff --git a/src/app/features/scene/ridge/vienna.test.ts b/src/app/features/scene/ridge/vienna.test.ts
new file mode 100644
index 00000000..e0d4a4c7
--- /dev/null
+++ b/src/app/features/scene/ridge/vienna.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest';
+import { solarNoon, STATE_ELEVATION, sunForState, VIENNA, viennaSun } from './vienna';
+
+const at = (iso: string) => new Date(iso);
+
+describe('the sun over Vienna', () => {
+ it('stands at 90 - lat + declination at solar noon: the solstices and equinox', () => {
+ const june = viennaSun(solarNoon(at('2026-06-21T12:00:00Z')));
+ expect(june.elevation).toBeCloseTo(90 - VIENNA.lat + 23.44, 0);
+ const december = viennaSun(solarNoon(at('2026-12-21T12:00:00Z')));
+ expect(december.elevation).toBeCloseTo(90 - VIENNA.lat - 23.44, 0);
+ const march = viennaSun(solarNoon(at('2026-03-20T12:00:00Z')));
+ expect(march.elevation).toBeCloseTo(90 - VIENNA.lat, 0);
+ });
+
+ it('is due south at solar noon, east in the morning and west in the afternoon', () => {
+ const noon = solarNoon(at('2026-09-23T12:00:00Z'));
+ expect(Math.abs(viennaSun(noon).azimuth - 180)).toBeLessThan(2); // solarNoon is found to 5 minutes, which is 1.25 degrees of azimuth
+ expect(viennaSun(new Date(noon.getTime() - 3 * 3600_000)).azimuth).toBeLessThan(150);
+ expect(viennaSun(new Date(noon.getTime() + 3 * 3600_000)).azimuth).toBeGreaterThan(210);
+ });
+
+ it('solar noon at 16.35 E falls near 10:40 UTC in mid November', () => {
+ // 16.3534 E is 65.4 minutes ahead of Greenwich; the equation of time in mid November is +15 minutes (sun early): 12:00 - 65.5 - 15 = 10:40 UTC.
+ const n = solarNoon(at('2026-11-11T12:00:00Z'));
+ const minutes = n.getUTCHours() * 60 + n.getUTCMinutes();
+ expect(minutes).toBeGreaterThan(10 * 60 + 30);
+ expect(minutes).toBeLessThan(10 * 60 + 50);
+ });
+
+ it('has a short day in December and a long one in June: published 8 h 4 min and 16 h 0 min with refraction, which adds about 6 minutes each end the ephemeris leaves out', () => {
+ const daylight = (day: string) => {
+ let n = 0;
+ for (let m = 0; m < 24 * 60; m += 1) if (viennaSun(new Date(Date.parse(day) + m * 60_000)).elevation > 0) n += 1;
+ return n;
+ };
+ expect(Math.abs(daylight('2026-12-21T00:00:00Z') - (8 * 60 + 4))).toBeLessThan(15);
+ expect(Math.abs(daylight('2026-06-21T00:00:00Z') - (16 * 60 + 0))).toBeLessThan(15);
+ });
+
+ it('sunForState: the real sun when it is in the state, else a moment of today that is', () => {
+ const noon = solarNoon(at('2026-11-11T12:00:00Z'));
+ expect(sunForState('day', noon).time).toEqual(noon);
+ const midnight = new Date(noon.getTime() + 12 * 3600_000);
+ for (const state of ['day', 'dusk', 'night'] as const) {
+ for (const now of [noon, midnight, at('2026-11-11T15:30:00Z')]) {
+ const s = sunForState(state, now);
+ const [lo, hi] = STATE_ELEVATION[state];
+ expect(s.elevation).toBeGreaterThanOrEqual(lo - 0.01);
+ expect(s.elevation).toBeLessThanOrEqual(hi + 0.01);
+ }
+ }
+ expect(sunForState('dusk', noon).elevation).toBeCloseTo(0, 1);
+ expect(sunForState('dusk', noon).azimuth).toBeGreaterThan(180); // sets in the west half
+ });
+});
diff --git a/src/app/features/scene/ridge/vienna.ts b/src/app/features/scene/ridge/vienna.ts
new file mode 100644
index 00000000..56188226
--- /dev/null
+++ b/src/app/features/scene/ridge/vienna.ts
@@ -0,0 +1,74 @@
+// The sun over the server's viewpoint in Vienna (48.1929826 N, 16.3534237 E, 183 m: Meta's location, the Copernicus DSM height there) at a real moment: one ephemeris, `sunPosition` of src/sky/sun.js (the
+// low-precision NOAA/Meeus series, a hundredth of a degree for 1900..2100; no refraction), and the choice of a moment for each
+// fixed sky state. The ridge sky is the physical atmosphere (`earthAtmosphere.ts`) lit from this sun.
+import { sunPosition } from '../../../../sky/sun.js';
+import { DAY_FROM } from '../skyState';
+import type { FixedSkyState } from '../skyBand';
+
+export const VIENNA = { lat: 48.1929826, lon: 16.3534237, altitudeKm: 0.183 } as const;
+
+export interface SunSample {
+ /** Degrees above the horizon, no refraction. */
+ elevation: number;
+ /** Degrees clockwise from north. */
+ azimuth: number;
+ /** The moment this is the sun of. */
+ time: Date;
+}
+
+export function viennaSun(time: Date): SunSample {
+ const { elevation, azimuth } = sunPosition(time, VIENNA.lat, VIENNA.lon) as {
+ elevation: number;
+ azimuth: number;
+ };
+ return { elevation, azimuth, time };
+}
+
+const MIN = 60_000;
+
+/** Sun elevation range of each fixed state: the thresholds `resolveSkyState` uses (day from DAY_FROM, night below -DAY_FROM). */
+export const STATE_ELEVATION: Record = {
+ day: [DAY_FROM, 90],
+ dusk: [-DAY_FROM, DAY_FROM],
+ night: [-90, -DAY_FROM],
+};
+
+const startOfUtcDay = (t: Date): number => Math.floor(t.getTime() / (24 * 60 * MIN)) * 24 * 60 * MIN;
+
+/** The moment of the day (UTC day of `t`, every 5 minutes) the sun is highest over Vienna: solar noon to the step. */
+export function solarNoon(t: Date): Date {
+ const d0 = startOfUtcDay(t);
+ let best = d0;
+ let bestEl = -90;
+ for (let m = 0; m < 24 * 60; m += 5) {
+ const el = viennaSun(new Date(d0 + m * MIN)).elevation;
+ if (el > bestEl) {
+ bestEl = el;
+ best = d0 + m * MIN;
+ }
+ }
+ return new Date(best);
+}
+
+/**
+ * The sun a state draws. The real sun over Vienna now when it is in the state's range (so 'auto' is the real sky); otherwise the
+ * sun of today at a moment that is: noon for Day, the evening sunset for Dusk, the opposite of noon for Night. A picker that
+ * forces Day at night shows a noon sky, as a person looking at a noon photograph would.
+ */
+export function sunForState(state: FixedSkyState, now: Date): SunSample {
+ const real = viennaSun(now);
+ const [lo, hi] = STATE_ELEVATION[state];
+ if (real.elevation >= lo && real.elevation <= hi) return real;
+ const noon = solarNoon(now);
+ if (state === 'day') return viennaSun(noon);
+ if (state === 'night') return viennaSun(new Date(noon.getTime() + 12 * 60 * MIN));
+ // Dusk: bisect the evening crossing of the horizon between noon and the opposite moment.
+ let a = noon.getTime();
+ let b = noon.getTime() + 12 * 60 * MIN;
+ for (let i = 0; i < 30; i += 1) {
+ const m = (a + b) / 2;
+ if (viennaSun(new Date(m)).elevation > 0) a = m;
+ else b = m;
+ }
+ return viennaSun(new Date((a + b) / 2));
+}
diff --git a/src/app/features/scene/ridge/viennaHorizon.json b/src/app/features/scene/ridge/viennaHorizon.json
new file mode 100644
index 00000000..62a4a126
--- /dev/null
+++ b/src/app/features/scene/ridge/viennaHorizon.json
@@ -0,0 +1 @@
+{"viewpoint":{"lat":48.1929826,"lon":16.3534237,"groundM":183.3},"stepDeg":0.5,"radiusKm":60.0,"minKm":1.0,"curvature":"drop d^2/2R*(1-0.13), R=6371000 m","source":{"dataset":"Copernicus DEM GLO-30 (COP-DEM_GLO-30-DGED), a DSM, EGM2008 heights, 1 arc-second grid","tiles":["N47_00_E015_00","N47_00_E016_00","N47_00_E017_00","N48_00_E015_00","N48_00_E016_00","N48_00_E017_00","N49_00_E015_00","N49_00_E016_00","N49_00_E017_00"],"tileBytesEach":"about 42-44 MB (Cloud Optimized GeoTIFF); nine tiles, about 380 MB, read once, not committed","url":"https://copernicus-dem-30m.s3.amazonaws.com/ (AWS Open Data, eu-central-1, no account)","licence":"Copernicus DEM licence: free of charge for any use incl. commercial, attribution required, no warranty (https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model, Licencing)","attribution":"Contains modified Copernicus data: DEM \u00a9 DLR e.V. 2010-2014 and \u00a9 Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.","bakedBy":"scripts/bake-horizon.py"},"elevationDeg":[1.608,1.522,1.467,1.452,1.433,1.426,1.497,1.6,1.597,1.564,1.532,1.495,1.467,1.544,1.62,1.655,1.707,1.775,1.803,1.683,1.532,1.394,1.263,1.297,1.358,1.412,1.433,1.339,1.264,1.269,1.195,1.142,1.13,1.22,1.401,1.368,1.326,1.294,1.378,1.46,1.488,1.516,1.549,1.598,1.647,1.698,1.769,1.816,1.801,1.783,1.695,1.619,1.586,1.547,1.442,1.335,1.282,1.286,1.335,1.421,1.449,1.437,1.313,1.268,1.164,1.14,1.098,1.06,0.984,0.938,0.91,0.877,0.884,0.872,0.943,0.974,0.919,0.873,0.861,0.839,0.731,0.673,0.56,0.494,0.527,0.601,0.566,0.584,0.579,0.643,0.666,0.741,0.851,0.711,0.703,0.573,0.54,0.488,0.433,0.452,0.467,0.483,0.475,0.517,0.419,0.428,0.457,0.489,0.522,0.482,0.5,0.527,0.528,0.471,0.458,0.487,0.521,0.578,0.553,0.419,0.301,0.324,0.425,0.468,0.512,0.558,0.575,0.521,0.477,0.444,0.372,0.297,0.365,0.446,0.486,0.523,0.532,0.516,0.516,0.531,0.52,0.546,0.565,0.535,0.527,0.508,0.516,0.37,0.359,0.33,0.375,0.454,0.546,0.496,0.449,0.425,0.447,0.398,0.388,0.439,0.476,0.496,0.517,0.541,0.494,0.439,0.383,0.411,0.502,0.621,0.578,0.548,0.568,0.642,0.713,0.708,0.654,0.704,0.741,0.709,0.677,0.661,0.66,0.666,0.662,0.651,0.699,0.727,0.746,0.809,0.788,0.799,0.793,0.702,0.734,0.79,0.754,0.704,0.616,0.598,0.708,0.719,0.706,0.704,0.726,0.826,0.849,0.85,0.813,0.853,0.926,0.971,0.918,1.003,1.0,0.927,0.985,1.031,1.043,1.067,1.13,1.022,1.02,1.043,1.101,1.144,1.099,1.082,1.064,1.2,1.238,1.16,1.084,1.202,1.326,1.39,1.429,1.405,1.332,1.358,1.361,1.357,1.354,1.335,1.319,1.376,1.309,1.289,1.257,1.283,1.237,1.296,1.353,1.259,1.314,1.236,1.307,1.344,1.36,1.251,1.292,1.308,1.26,1.288,1.365,1.391,1.414,1.32,1.299,1.35,1.288,1.361,1.303,1.268,1.29,1.305,1.326,1.344,1.328,1.32,1.358,1.375,1.443,1.477,1.492,1.525,1.513,1.422,1.314,1.294,1.337,1.414,1.343,1.397,1.431,1.409,1.378,1.423,1.438,1.343,1.257,1.208,1.209,1.233,1.251,1.267,1.274,1.248,1.254,1.297,1.34,1.359,1.34,1.199,1.162,1.255,1.292,1.29,1.24,1.222,1.24,1.271,1.264,1.317,1.256,1.26,1.321,1.301,1.266,1.268,1.308,1.357,1.474,1.419,1.438,1.393,1.354,1.343,1.354,1.333,1.321,1.359,1.437,1.47,1.395,1.413,1.446,1.466,1.434,1.446,1.458,1.419,1.522,1.502,1.506,1.512,1.528,1.526,1.485,1.427,1.508,1.5,1.48,1.534,1.514,1.449,1.449,1.386,1.368,1.388,1.404,1.374,1.374,1.352,1.3,1.347,1.332,1.265,1.235,1.39,1.285,1.226,1.233,1.235,1.211,1.164,1.183,1.217,1.143,1.128,1.117,1.112,1.086,1.071,1.118,1.103,1.002,0.99,0.995,1.025,0.998,1.024,0.971,1.045,1.09,1.11,1.108,1.081,1.171,1.308,1.446,1.482,1.484,1.494,1.469,1.472,1.479,1.493,1.467,1.388,1.293,1.203,1.104,1.145,1.12,1.028,1.004,0.982,0.968,0.933,0.97,0.986,1.035,1.12,1.227,1.281,1.316,1.312,1.273,1.187,1.205,1.197,1.199,1.176,1.177,1.142,1.19,1.377,1.369,1.322,1.295,1.385,1.493,1.576,1.679,1.769,1.622,1.711,1.641,1.517,1.582,1.525,1.39,1.389,1.343,1.331,1.369,1.343,1.257,1.212,1.134,1.039,1.03,1.075,1.027,1.059,1.112,1.132,1.14,1.16,1.167,1.178,1.179,1.192,1.214,1.245,1.289,1.286,1.279,1.259,1.256,1.202,1.138,1.159,1.116,1.12,1.102,0.999,1.042,1.051,1.09,1.143,1.244,1.269,1.296,1.353,1.412,1.579,1.686,1.814,1.857,1.773,1.731,1.748,1.742,1.668,1.594,1.628,1.692,1.76,1.822,1.863,1.904,1.895,1.849,1.786,1.778,1.731,1.666,1.633,1.561,1.526,1.662,1.747,1.75,1.704,1.631,1.563,1.473,1.501,1.488,1.464,1.441,1.386,1.31,1.298,1.293,1.306,1.371,1.42,1.416,1.417,1.418,1.409,1.399,1.408,1.431,1.434,1.437,1.509,1.544,1.58,1.674,1.528,1.534,1.517,1.497,1.525,1.546,1.562,1.58,1.55,1.634,1.614,1.66,1.643,1.663,1.68,1.829,1.99,2.058,2.014,1.911,2.025,2.023,2.036,2.031,2.009,2.012,2.02,2.109,2.107,2.12,2.171,2.179,2.125,2.084,2.09,2.129,2.183,2.16,2.172,2.239,2.237,2.218,2.217,2.137,2.169,2.203,2.233,2.262,2.272,2.21,2.133,1.955,1.909,1.97,2.138,2.187,2.229,2.278,2.191,2.167,2.213,2.327,2.409,2.31,2.242,2.205,2.152,2.154,2.258,2.217,2.126,2.054,1.97,1.942,2.022,1.894,1.886,1.921,1.897,1.936,1.964,1.978,1.961,1.927,1.879,1.904,1.96,1.9,1.922,2.023,2.061,2.115,1.99,1.805,1.736,1.776,1.841,1.863,1.934,1.942,1.962,2.043,2.082,2.082,2.092,2.062,1.957,1.794,1.821,1.913,2.009,2.066,2.105,2.036,1.964,1.984,2.005,2.036,2.072,2.094,2.022,2.153,2.177,2.146,2.125,2.061,2.004,1.955,1.902,1.84,1.767,1.7,1.696,1.718,1.725,1.709,1.745,1.805,1.836,1.939,2.053,2.14,2.139,2.142,2.035,1.902,1.779,1.667,1.564,1.57,1.581,1.628,1.693,1.739,1.738,1.738,1.675],"distanceKm":[1.0,1.0,1.06,1.06,1.06,1.06,1.06,1.03,1.0,1.0,1.0,1.0,1.09,1.09,1.0,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.12,1.12,1.12,1.12,1.12,1.12,1.09,1.09,1.09,1.09,1.0,1.0,1.0,1.0,1.03,1.06,1.06,1.03,1.03,1.03,1.03,1.03,1.03,1.0,1.0,1.0,1.0,1.0,1.03,1.03,1.03,1.03,1.03,1.0,1.0,1.06,1.06,1.06,1.06,1.06,1.12,1.0,1.0,1.24,1.24,1.21,1.21,1.3,1.3,1.27,1.3,1.33,1.33,1.33,1.33,1.27,1.24,1.27,1.27,1.27,1.03,1.03,1.42,1.84,1.84,1.81,1.03,1.72,1.63,1.63,1.63,1.33,1.33,1.06,1.06,1.72,1.72,1.69,1.69,1.69,1.69,1.69,1.81,1.81,1.75,1.51,1.75,1.75,1.75,1.75,1.75,1.72,1.72,1.72,1.72,1.72,1.72,1.72,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.42,1.42,1.24,1.3,1.3,1.3,1.3,1.3,1.3,1.3,1.27,1.03,1.36,1.36,1.36,1.36,1.36,1.45,1.45,1.09,1.12,1.12,1.54,1.57,1.48,1.48,1.48,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.18,1.18,1.21,1.21,1.21,1.12,1.12,1.12,1.12,1.12,1.15,1.15,1.15,1.15,1.18,1.15,1.15,1.24,1.24,1.27,1.27,1.24,1.24,1.24,1.27,1.27,1.27,1.9,1.9,1.93,1.93,1.93,1.99,1.99,1.99,1.99,1.9,2.02,2.02,2.02,2.05,2.02,1.99,1.99,1.99,1.48,1.21,1.21,1.57,1.69,1.69,1.66,1.39,1.39,1.39,1.54,1.6,1.54,1.54,1.54,1.48,1.45,1.21,1.21,1.21,1.27,1.39,1.39,1.36,1.39,1.39,1.39,1.33,1.33,1.33,1.33,1.33,1.48,1.45,1.39,1.39,1.18,1.18,1.18,1.48,1.48,1.27,1.24,1.27,1.36,1.36,1.39,1.33,1.3,1.3,1.3,1.3,1.3,1.3,1.33,1.0,1.42,1.42,1.27,1.27,1.27,1.27,1.03,1.03,1.03,1.03,1.03,1.03,1.03,1.03,1.06,1.06,1.03,1.03,1.03,1.36,1.06,1.06,1.06,1.06,1.09,1.09,1.18,1.21,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.15,1.15,1.36,1.36,1.36,1.36,1.33,1.33,3.13,1.21,1.21,3.34,3.31,2.83,2.65,2.65,1.3,2.29,1.3,1.15,1.15,1.15,2.29,2.68,2.41,2.23,2.23,2.2,1.12,2.23,2.17,2.17,2.17,1.84,2.74,1.87,1.87,2.95,2.2,2.17,2.17,2.35,2.35,2.23,2.23,2.11,2.14,2.08,2.2,2.2,2.2,2.2,2.2,2.2,2.41,2.41,2.38,2.41,2.41,2.44,2.44,2.44,1.99,2.08,2.08,2.08,2.08,2.05,2.05,2.5,2.38,2.47,2.29,2.23,2.17,1.69,1.69,2.38,2.47,2.38,2.17,2.71,2.71,2.2,2.47,2.41,2.41,2.32,2.38,2.38,2.08,2.5,2.11,2.11,1.75,1.75,1.48,1.81,1.81,1.78,1.78,1.87,17.2,17.2,17.26,17.32,17.44,18.1,17.56,17.5,17.38,17.5,17.62,17.68,17.74,17.8,14.86,14.92,14.98,2.98,26.38,26.38,26.38,26.62,26.14,26.02,26.02,26.38,26.26,26.38,26.38,26.26,26.26,26.26,14.98,14.98,14.98,14.44,13.12,13.18,13.66,13.72,13.72,13.84,13.84,13.84,13.72,12.52,12.58,12.58,12.28,12.22,12.28,16.54,14.02,15.04,16.18,16.24,17.08,17.26,17.44,17.5,17.44,17.32,17.44,17.02,13.96,14.14,9.22,9.16,9.1,9.22,9.28,9.28,9.22,9.16,9.16,9.34,8.68,8.68,8.62,8.74,8.86,8.92,8.86,8.92,8.92,11.74,14.08,11.98,12.22,12.04,34.3,10.36,10.06,9.64,9.58,9.58,9.7,9.7,10.18,10.18,10.18,10.24,10.06,10.48,9.94,9.82,9.76,9.7,8.02,8.14,8.14,8.14,8.08,8.56,8.38,8.38,8.32,8.38,8.02,8.02,8.02,8.14,8.2,7.9,7.9,7.84,7.78,7.66,7.72,7.72,7.72,1.0,1.0,1.0,1.0,1.0,1.45,1.21,1.21,1.3,1.15,1.15,1.15,1.15,1.15,1.15,1.15,1.33,1.33,1.33,1.48,1.3,1.3,1.3,1.42,1.09,2.02,1.81,2.59,2.59,2.53,1.21,1.75,1.69,1.66,2.38,1.57,1.57,1.57,7.18,7.18,7.24,7.24,7.18,7.12,1.0,1.0,1.0,1.0,1.0,7.36,7.3,7.3,7.18,7.24,7.06,7.06,7.06,7.06,7.0,7.12,7.12,7.12,7.0,7.0,7.0,7.0,7.24,7.24,7.06,7.18,7.0,7.06,7.0,7.0,6.94,7.0,7.24,1.03,1.03,1.03,1.0,1.0,1.0,1.06,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.03,1.03,1.03,1.03,1.03,1.03,1.27,1.27,1.27,1.24,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.09,1.09,1.06,1.06,1.06,1.06,1.06,1.09,1.09,9.64,9.58,9.64,9.64,9.7,9.7,9.7,9.7,9.7,9.64,9.64,9.64,9.7,1.03,1.03,1.03,1.03,1.03,1.03,9.52,1.0,1.0,9.4,8.68,8.68,8.68,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,9.52,9.46,9.46,9.46,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"peakHeightM":[211.4,209.9,210.5,210.2,209.9,209.7,211.0,212.1,211.2,210.6,210.1,209.4,211.3,212.7,211.6,214.8,215.8,217.1,217.7,215.4,212.5,209.9,207.4,208.7,209.9,211.0,211.4,209.5,208.1,207.5,206.1,205.1,204.8,204.6,207.8,207.2,206.5,206.6,208.8,210.4,210.1,210.6,211.2,212.1,213.0,213.9,214.2,215.0,214.8,214.5,212.9,212.5,211.9,211.2,209.3,207.3,205.7,205.8,208.1,209.6,210.2,209.9,207.6,208.2,203.7,203.2,207.2,206.3,204.2,203.2,204.0,203.3,203.0,203.2,205.3,206.0,204.7,203.7,202.5,201.5,199.6,198.3,195.8,192.2,192.8,198.3,201.7,202.3,201.8,194.9,203.5,204.5,207.7,203.7,199.7,196.7,193.3,192.4,196.5,197.1,197.2,197.7,197.5,198.7,195.8,197.0,197.9,198.4,197.2,198.2,198.7,199.6,199.6,197.9,197.2,198.1,199.1,200.8,200.1,196.0,192.5,189.0,190.8,191.5,192.3,193.1,193.4,192.4,191.7,191.1,189.8,190.8,192.5,193.0,194.4,195.2,195.5,195.1,195.1,195.4,195.2,195.5,193.5,196.1,195.9,195.5,195.6,192.2,192.5,191.8,190.5,192.2,194.0,196.8,195.7,194.4,195.0,193.7,190.1,191.0,191.7,192.0,192.4,192.8,192.0,191.0,190.0,191.8,193.7,196.5,195.6,194.9,194.5,195.9,197.3,197.2,196.1,197.5,198.2,197.6,196.9,197.0,196.6,196.7,197.7,197.5,198.9,199.5,199.5,200.9,200.4,201.1,201.0,198.9,207.9,209.7,208.9,207.2,204.3,204.3,208.1,208.5,208.1,206.9,209.1,212.7,213.5,214.0,212.2,213.2,215.7,217.3,207.1,204.6,204.5,208.8,212.5,213.9,213.7,209.3,210.8,208.2,210.9,212.6,213.0,214.2,213.0,211.4,210.3,208.7,209.5,207.9,207.4,212.6,215.6,216.4,218.1,217.5,215.7,214.9,215.0,214.9,214.8,214.4,217.5,218.3,215.2,214.7,209.3,209.8,208.9,216.9,218.4,211.3,211.8,210.8,214.4,215.3,216.4,212.4,212.7,213.1,212.0,212.6,214.4,215.0,216.2,206.4,215.6,216.9,211.9,213.6,212.3,211.5,206.5,206.8,207.2,207.5,207.2,207.1,207.8,208.1,210.0,210.7,210.2,210.8,210.5,217.2,207.7,207.3,208.1,209.5,208.9,209.9,212.8,213.1,209.6,210.4,210.7,208.9,207.3,206.3,206.4,206.8,207.2,207.5,208.9,208.4,213.2,214.2,215.2,215.7,214.5,211.2,247.4,209.9,210.7,259.2,255.7,244.2,241.1,242.6,212.1,236.3,211.9,208.7,209.9,209.5,234.2,243.1,238.7,236.5,241.0,238.1,211.5,237.8,234.9,234.5,234.9,226.3,246.9,227.9,230.4,259.6,237.2,237.1,238.4,243.8,242.5,239.9,240.4,235.8,240.4,238.1,241.4,241.7,242.3,242.2,240.6,238.4,247.1,246.8,245.2,248.2,247.4,245.4,245.4,242.7,231.1,234.0,234.5,233.4,233.5,231.9,230.1,242.5,239.0,238.2,233.0,237.7,232.3,219.6,219.9,235.0,235.9,232.0,228.4,241.4,237.8,226.9,231.8,230.5,229.4,227.0,230.1,229.5,219.9,226.9,220.2,221.3,214.0,214.8,208.5,216.5,217.9,218.0,217.9,218.8,554.9,596.3,639.3,651.8,655.9,677.7,654.8,653.7,652.5,660.3,655.7,632.9,605.1,578.6,484.8,496.7,491.5,237.4,693.3,682.9,676.3,665.1,672.6,677.4,699.5,746.5,793.0,820.8,836.9,831.7,813.8,774.3,513.7,511.5,512.0,493.9,464.6,457.9,479.7,526.0,524.0,515.7,509.3,531.0,553.8,538.4,562.8,582.7,541.4,558.5,545.3,640.0,583.8,599.0,593.8,595.2,603.6,604.7,620.9,614.5,586.6,570.3,549.1,511.8,447.5,462.2,354.3,358.3,365.5,371.3,373.8,377.0,376.8,377.3,377.6,383.6,372.4,377.1,382.3,384.6,386.4,384.8,382.9,375.9,365.9,430.3,471.0,427.2,428.5,403.2,887.7,380.7,381.5,382.0,397.5,401.8,409.1,418.8,441.2,470.9,490.1,514.8,516.4,515.3,490.4,489.5,486.6,472.2,410.8,419.1,428.3,437.9,444.8,466.6,466.6,465.3,456.6,449.4,436.6,430.0,421.0,419.8,411.4,398.0,416.8,426.6,425.1,415.1,407.2,398.0,385.9,209.6,209.3,208.9,208.5,207.5,216.6,210.8,210.7,213.0,210.9,211.9,211.8,211.8,211.8,211.6,211.4,216.1,216.6,216.7,220.6,217.6,218.4,219.2,224.9,212.4,237.7,231.4,251.4,252.7,252.0,216.4,231.7,229.2,230.8,250.7,228.9,228.5,229.0,397.4,416.0,438.4,447.0,439.3,424.3,218.7,218.7,218.9,218.8,218.4,445.6,444.4,455.8,451.0,454.8,454.3,455.3,448.6,443.6,442.1,451.4,458.2,455.3,452.1,460.3,460.0,457.7,467.1,457.0,454.1,463.0,459.5,465.5,464.3,456.8,445.0,425.5,428.2,218.8,221.8,222.7,222.3,223.1,221.6,223.5,222.0,224.0,225.4,223.7,222.5,221.8,220.9,222.1,224.0,223.2,221.6,220.3,218.8,226.4,228.2,225.4,224.2,216.9,216.5,217.1,217.6,217.9,217.6,217.0,216.1,216.6,220.6,219.5,218.9,220.8,221.5,222.5,220.2,217.7,216.4,488.4,497.5,503.2,515.2,518.5,522.0,535.7,542.3,542.3,541.8,536.7,518.9,493.5,216.1,217.8,219.5,220.5,221.2,220.0,516.0,218.0,218.4,523.4,502.5,505.8,494.9,220.9,221.3,220.8,220.4,219.3,218.3,217.5,216.5,215.5,214.2,213.0,212.9,213.3,213.5,473.5,477.6,487.5,492.6,217.2,219.2,220.7,220.7,220.7,218.9,216.5,214.4,212.5,210.6,210.7,210.9,211.8,212.9,213.7,213.7,213.7,212.6],"slopeEast":[0.002,-0.001,0.056,0.043,0.022,0.002,0.007,-0.005,0.014,0.012,0.002,-0.013,0.046,0.027,-0.083,-0.005,-0.017,-0.01,-0.007,-0.03,-0.051,-0.056,-0.058,-0.088,-0.109,-0.099,-0.085,-0.079,-0.077,0.008,0.005,0.006,-0.01,0.062,0.068,0.071,0.074,0.084,0.019,-0.001,0.023,0.02,0.021,0.024,0.025,0.031,0.054,0.031,0.003,-0.022,-0.034,-0.054,-0.055,-0.049,-0.047,-0.045,-0.038,-0.041,-0.051,-0.054,-0.073,-0.086,-0.104,-0.05,-0.085,-0.083,0.125,0.082,0.049,0.018,0.064,0.034,-0.013,-0.004,-0.06,-0.087,-0.128,-0.166,-0.129,-0.038,-0.154,-0.158,-0.163,-0.116,-0.12,0.088,0.055,0.048,0.095,0.036,0.154,0.016,-0.009,-0.018,-0.079,-0.121,-0.161,-0.174,0.003,0.037,0.108,0.103,0.1,0.106,0.101,-0.064,-0.045,0.013,0.149,0.083,0.078,0.071,0.039,-0.062,-0.065,-0.118,-0.105,-0.072,-0.045,-0.025,-0.001,-0.008,-0.007,-0.02,-0.037,-0.058,-0.078,-0.075,-0.069,-0.063,-0.054,-0.008,0.003,0.123,0.036,0.015,-0.005,0.017,0.049,0.086,0.068,0.013,0.032,-0.024,-0.01,0.005,0.009,-0.005,-0.004,0.021,0.062,-0.012,-0.014,-0.013,-0.031,0.02,-0.004,-0.021,-0.005,-0.018,-0.026,-0.032,-0.038,-0.046,-0.055,-0.063,-0.067,-0.007,0.009,-0.048,-0.051,-0.051,-0.004,-0.015,-0.025,-0.018,0.002,-0.002,0.005,0.013,0.02,0.049,0.013,0.002,-0.023,-0.009,-0.075,-0.065,0.018,0.02,0.024,-0.023,-0.022,-0.049,0.01,0.056,-0.003,-0.001,0.008,-0.048,-0.05,-0.03,-0.015,0.002,-0.045,-0.077,-0.052,-0.113,-0.035,0.052,0.029,-0.006,-0.008,0.119,0.111,0.049,0.019,-0.001,-0.008,0.003,0.025,0.028,0.004,-0.059,0.008,0.005,-0.0,0.055,0.03,-0.044,-0.024,-0.002,-0.029,-0.015,-0.028,-0.04,-0.079,-0.084,-0.073,0.118,0.104,0.095,0.098,0.122,-0.042,0.014,0.108,0.117,-0.077,-0.066,-0.052,0.006,0.018,-0.026,-0.008,0.016,-0.019,-0.02,0.006,0.033,0.056,0.035,0.017,0.002,0.011,0.009,-0.015,0.039,-0.016,-0.019,0.073,0.06,0.028,0.012,-0.052,-0.037,-0.027,-0.026,-0.025,-0.027,-0.034,-0.033,-0.112,-0.098,-0.018,-0.005,0.014,-0.002,-0.012,-0.021,-0.041,-0.06,-0.073,-0.068,-0.007,0.023,-0.002,0.014,0.031,0.042,0.045,0.05,0.06,0.075,0.066,0.054,0.029,0.03,-0.066,-0.045,-0.033,-0.035,0.1,0.072,0.022,-0.017,0.002,0.006,0.105,0.007,-0.015,0.014,-0.019,-0.045,0.021,-0.027,-0.032,-0.043,0.023,-0.048,0.051,-0.072,-0.099,-0.013,0.016,0.062,0.05,0.066,0.041,0.022,-0.031,-0.028,-0.003,0.047,-0.019,-0.007,0.014,-0.047,0.019,-0.023,-0.023,0.008,-0.064,0.049,-0.035,-0.026,0.01,0.043,0.057,0.094,-0.076,-0.07,0.043,0.029,0.051,0.001,0.036,0.056,-0.016,-0.042,-0.015,-0.0,0.037,0.052,0.089,0.032,0.021,0.06,-0.018,0.018,0.002,0.036,0.058,-0.043,0.005,-0.0,-0.004,-0.084,-0.088,0.055,-0.004,0.02,0.027,0.018,-0.003,-0.013,-0.01,-0.005,0.032,0.037,-0.085,-0.114,0.016,-0.076,-0.081,0.043,0.029,0.014,-0.247,-0.334,-0.183,-0.03,-0.023,0.027,0.067,0.018,-0.17,0.04,0.076,0.16,0.142,0.214,-0.239,-0.036,0.236,0.006,-0.075,-0.015,-0.134,0.191,-0.098,-0.235,-0.094,-0.348,-0.156,-0.126,0.06,-0.008,0.039,0.184,0.048,0.037,0.035,-0.057,-0.06,0.062,-0.295,-0.071,-0.175,0.046,-0.045,-0.173,-0.173,-0.161,-0.148,0.042,-0.034,-0.145,0.247,0.002,-0.209,0.028,-0.129,-0.037,0.154,-0.069,-0.023,0.149,0.119,-0.146,0.175,0.112,-0.005,-0.029,-0.11,-0.064,-0.06,-0.018,-0.04,0.03,-0.087,-0.043,-0.015,-0.031,-0.13,-0.068,-0.06,0.001,-0.021,-0.0,-0.013,0.032,0.012,-0.025,0.013,0.081,0.008,-0.101,0.006,-0.063,-0.072,-0.034,-0.109,-0.03,-0.058,-0.068,-0.174,-0.1,-0.092,-0.088,-0.053,0.072,0.007,-0.043,-0.029,-0.004,-0.033,-0.07,-0.071,-0.07,-0.077,-0.07,-0.021,-0.02,-0.061,-0.004,-0.114,-0.02,-0.023,0.032,0.05,-0.037,-0.005,-0.02,-0.109,-0.063,0.014,-0.015,-0.037,0.007,0.019,0.035,0.051,0.064,-0.018,-0.034,-0.025,0.038,-0.074,-0.071,-0.068,-0.065,-0.061,-0.056,-0.05,-0.038,-0.032,-0.02,0.035,-0.023,-0.034,-0.042,-0.009,-0.053,-0.061,-0.048,0.032,0.055,-0.052,0.019,0.04,-0.026,0.007,-0.033,-0.04,-0.044,-0.047,0.134,-0.002,0.095,0.061,-0.082,-0.127,0.039,0.057,0.077,0.092,0.081,0.007,-0.041,0.057,-0.038,0.02,0.004,-0.126,-0.118,-0.042,-0.061,-0.009,-0.034,-0.028,-0.016,0.066,-0.008,-0.033,-0.062,-0.168,0.054,0.058,-0.007,0.007,-0.054,-0.042,-0.257,-0.152,0.009,0.029,0.032,0.036,-0.033,-0.046,-0.05,0.018,-0.059,-0.065,-0.078,-0.093,-0.109,-0.126,-0.136,-0.072,-0.057,-0.05,-0.039,-0.032,-0.029,-0.024,-0.055,-0.05,-0.057,0.019,0.031,0.057,0.078,0.091,0.084,0.071,0.05,0.033,0.066,0.024,-0.039,-0.043,-0.062,-0.086,-0.078,-0.035,-0.041,0.114,-0.004,0.08,-0.043,0.008,0.035,0.117,0.075,0.091,-0.088,-0.074,-0.157,-0.187,0.023,0.023,0.021,0.021,0.017,0.002,-0.01,0.054,0.037,0.016,0.058,-0.096,-0.292,0.009,0.007,-0.003,-0.011,-0.009,-0.005,-0.017,-0.031,-0.04,-0.029,-0.019,0.007,0.036,0.064,0.06,0.091,0.084,0.021,0.044,0.024,0.007,0.007,0.006,-0.001,-0.004,-0.011,-0.026,-0.037,-0.048,-0.06,-0.057,-0.054,-0.051,-0.031,-0.014,-0.007],"slopeNorth":[-0.005,0.005,-0.003,0.002,-0.006,-0.016,-0.044,-0.027,-0.024,-0.026,-0.026,-0.026,-0.05,-0.054,0.02,-0.042,-0.033,-0.022,-0.009,0.008,0.025,0.032,0.037,0.003,0.01,0.01,0.013,0.017,0.026,0.059,0.018,-0.02,-0.039,0.024,0.024,0.008,-0.005,-0.003,-0.06,-0.088,-0.024,-0.033,-0.037,-0.039,-0.046,-0.052,-0.026,-0.019,-0.023,-0.025,-0.027,-0.106,-0.113,-0.118,-0.111,-0.103,-0.016,-0.016,-0.129,-0.122,-0.096,-0.075,-0.046,-0.071,0.056,0.061,-0.108,-0.077,0.028,0.067,-0.029,-0.009,0.048,-0.009,-0.029,-0.018,0.006,0.025,0.144,0.19,0.17,0.167,0.149,-0.078,-0.083,-0.0,-0.081,-0.04,-0.001,-0.033,-0.082,-0.031,0.061,0.14,0.078,0.132,0.104,0.127,-0.011,-0.01,0.071,0.129,0.113,0.103,0.13,-0.038,-0.037,0.044,0.052,0.016,0.01,-0.01,-0.02,-0.014,0.06,0.086,0.082,0.067,0.049,0.051,0.058,-0.08,-0.095,-0.099,-0.098,-0.093,-0.064,-0.024,0.007,0.03,0.041,-0.085,-0.064,-0.021,-0.069,-0.063,-0.054,-0.027,0.013,0.058,0.082,0.091,0.065,0.04,0.085,0.117,0.139,0.134,0.059,0.047,0.008,0.002,0.005,0.01,0.034,0.039,0.068,0.077,-0.041,-0.017,-0.011,-0.017,-0.021,-0.021,-0.003,0.016,0.034,-0.057,-0.046,-0.067,-0.062,-0.059,-0.032,-0.028,-0.023,-0.01,-0.01,-0.033,-0.028,-0.004,0.019,-0.01,0.056,0.067,-0.061,-0.049,-0.108,-0.079,-0.0,0.018,0.024,0.053,0.08,0.085,-0.008,0.015,0.016,0.027,0.048,-0.05,-0.038,-0.058,-0.086,-0.001,-0.061,-0.038,-0.014,0.027,0.062,-0.036,-0.007,0.046,-0.023,-0.001,0.01,-0.073,0.053,0.073,-0.02,0.004,-0.001,-0.014,-0.069,-0.016,-0.041,0.001,0.021,-0.017,-0.019,-0.115,-0.11,-0.083,-0.119,-0.097,-0.076,-0.101,-0.069,-0.054,-0.022,-0.045,-0.028,-0.016,-0.008,0.019,-0.037,0.005,0.007,0.01,-0.092,-0.096,-0.079,0.074,0.07,-0.076,-0.025,-0.075,-0.039,0.003,-0.006,0.013,-0.03,-0.064,-0.079,-0.076,-0.056,-0.026,0.042,-0.084,0.038,0.055,0.002,0.033,0.01,-0.003,0.001,-0.02,-0.041,-0.057,-0.056,-0.053,-0.045,-0.037,0.043,0.037,-0.023,-0.013,-0.012,0.063,0.029,0.024,0.012,-0.007,0.01,0.006,-0.057,0.007,-0.017,0.02,0.061,0.058,0.039,0.021,0.001,-0.02,-0.025,-0.024,0.039,0.052,0.03,0.03,0.028,0.035,0.003,0.005,0.027,-0.048,-0.059,0.005,-0.037,-0.029,-0.058,-0.066,-0.009,-0.02,0.014,-0.009,-0.015,-0.01,-0.045,-0.036,-0.085,-0.017,0.011,-0.052,0.048,-0.025,-0.024,-0.035,-0.031,-0.05,-0.058,-0.013,-0.05,-0.053,-0.058,-0.033,-0.017,0.001,-0.025,-0.066,-0.023,-0.014,-0.011,-0.059,-0.019,-0.009,0.005,-0.007,-0.034,-0.051,0.002,0.036,0.0,0.007,-0.022,-0.028,-0.046,-0.051,-0.018,-0.021,-0.013,0.012,-0.005,-0.035,-0.036,0.011,-0.065,0.025,0.006,0.018,-0.103,0.005,-0.007,-0.025,-0.061,-0.044,-0.049,-0.008,0.004,-0.043,0.022,-0.062,-0.019,-0.055,0.001,-0.01,-0.057,-0.005,-0.051,-0.064,-0.023,0.029,-0.044,0.079,0.066,-0.049,-0.043,0.043,0.037,0.009,-0.04,-0.175,-0.046,-0.025,-0.055,-0.045,0.01,-0.078,-0.036,-0.127,-0.126,-0.145,0.102,0.094,-0.123,0.055,-0.109,0.133,-0.142,-0.009,-0.085,0.067,0.024,0.056,-0.06,0.156,0.025,-0.068,0.018,-0.106,0.077,0.019,-0.02,-0.043,0.021,-0.021,0.053,-0.023,-0.091,-0.044,0.016,0.174,0.024,0.172,0.213,-0.05,-0.021,-0.051,-0.255,0.041,0.076,-0.168,0.031,-0.153,-0.058,-0.022,0.063,-0.084,-0.122,-0.153,-0.157,-0.171,0.014,0.054,0.036,0.084,0.009,0.019,0.018,-0.012,0.013,0.008,0.011,0.061,0.061,0.057,-0.013,-0.036,-0.024,-0.045,-0.046,-0.101,-0.156,-0.066,-0.239,-0.1,-0.077,-0.243,-0.08,0.073,0.039,0.179,0.106,0.005,0.086,0.044,0.321,0.232,0.195,0.145,-0.074,-0.069,-0.057,0.021,-0.1,-0.165,0.063,0.139,0.12,0.091,0.134,0.082,0.064,-0.056,-0.118,-0.146,-0.004,-0.123,-0.142,-0.092,-0.121,0.172,0.209,0.083,-0.15,-0.088,-0.128,-0.152,-0.196,0.004,-0.017,-0.027,-0.037,-0.042,0.021,0.014,0.009,0.003,0.082,0.074,0.061,0.048,0.031,0.013,-0.006,0.081,0.074,0.055,-0.036,0.041,0.024,0.006,-0.068,-0.022,0.073,0.021,0.055,0.009,0.022,0.079,-0.019,0.052,0.08,0.02,0.073,0.04,0.01,0.224,0.291,0.218,-0.011,-0.137,-0.234,0.091,0.094,0.095,0.093,0.062,0.02,0.061,0.003,0.044,-0.063,0.145,-0.012,-0.046,-0.011,0.075,0.117,0.046,-0.033,0.108,0.084,-0.011,-0.094,-0.024,-0.13,0.075,0.1,0.124,-0.018,-0.058,-0.136,-0.149,-0.227,-0.022,0.105,0.119,0.113,0.09,0.076,0.045,-0.045,0.032,0.029,0.034,0.051,0.055,0.044,0.028,-0.072,-0.095,-0.088,-0.06,-0.032,-0.005,0.003,-0.016,-0.008,0.039,0.085,0.076,0.061,0.046,0.035,0.031,0.022,0.018,0.014,0.006,0.014,0.009,-0.002,-0.002,0.003,-0.003,-0.024,-0.018,0.068,0.212,0.049,0.187,0.06,0.024,0.003,-0.084,-0.173,0.03,-0.138,-0.17,-0.157,0.006,-0.007,-0.026,-0.034,-0.038,-0.045,0.001,-0.075,-0.067,0.237,0.215,0.109,0.055,-0.068,-0.066,-0.059,-0.054,-0.059,-0.062,-0.065,-0.07,-0.072,-0.064,-0.054,-0.045,-0.035,-0.022,0.059,0.161,0.071,0.021,-0.021,-0.027,-0.033,-0.039,-0.044,-0.038,-0.029,-0.034,-0.057,-0.078,-0.075,-0.071,-0.076,-0.085,-0.083,-0.057,-0.03,-0.018]}
diff --git a/src/app/features/scene/ridge/weather.ts b/src/app/features/scene/ridge/weather.ts
new file mode 100644
index 00000000..68ea88ea
--- /dev/null
+++ b/src/app/features/scene/ridge/weather.ts
@@ -0,0 +1,51 @@
+// One weather number, 0..1, and what it does to the sky. It is the page URL's `?weather=` (for a still), else the number the
+// observation file gives (`weatherFeed.ts`, set by `setFedWeather`), else a clear sky. 0 clear, about 0.3 fair (a few clouds), 0.55 overcast, 0.75 drizzle and mist, 1 steady rain.
+// What each part touches: haze multiplies the atmosphere's Mie coefficients before the LUTs are baked (`earthAtmosphere.ts`:
+// the same air, more aerosol); cloud cover, mist and rain are layers over the baked sky (`clouds.ts`, `Ridge.ts`).
+
+let fed: number | undefined;
+/** The weather number read from `weather.json`, or undefined to forget it. */
+export const setFedWeather = (v: number | undefined): void => {
+ fed = v;
+};
+
+/** The weather the ridge sky is drawn for: the URL's, else the file's, else clear. */
+export const ridgeWeather = (): number => urlWeather() ?? fed ?? 0;
+
+export interface Weather {
+ /** The number it was made from. */
+ value: number;
+ /** 0..1 share of the sky the cloud layer covers. */
+ cover: number;
+ /** Multiplier on the Mie coefficients: 1 is the reference air. */
+ mieScale: number;
+ /** 0..1 strength of the mist over the lower ridges. */
+ mist: number;
+ /** 0..1 rain streaks; 0 draws none and builds no layer. */
+ rain: number;
+}
+
+const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
+const smooth = (a: number, b: number, v: number): number => {
+ const t = clamp01((v - a) / (b - a));
+ return t * t * (3 - 2 * t);
+};
+
+export function weatherOf(value: number): Weather {
+ const w = clamp01(Number.isFinite(value) ? value : 0);
+ return {
+ value: w,
+ cover: smooth(0.1, 0.7, w),
+ mieScale: 1 + 5 * smooth(0.2, 1, w),
+ mist: smooth(0.45, 0.9, w),
+ rain: smooth(0.68, 0.95, w),
+ };
+}
+
+/** The page URL's `?weather=<0..1>`, when it is a number. */
+export function urlWeather(search: string = typeof location === 'undefined' ? '' : location.search): number | undefined {
+ const v = new URLSearchParams(search).get('weather');
+ if (v === null || v.trim() === '') return undefined;
+ const n = Number(v);
+ return Number.isFinite(n) ? clamp01(n) : undefined;
+}
diff --git a/src/app/features/scene/ridge/weatherFeed.test.ts b/src/app/features/scene/ridge/weatherFeed.test.ts
new file mode 100644
index 00000000..e12bbefc
--- /dev/null
+++ b/src/app/features/scene/ridge/weatherFeed.test.ts
@@ -0,0 +1,164 @@
+import { describe, expect, it } from 'vitest';
+import { viennaSun } from './vienna';
+import {
+ CLEAR_SKY,
+ currentWeather,
+ fetchWeatherFile,
+ parseWeatherFile,
+ OBSERVATION_STALE_MS,
+ STALE_MS,
+ WEATHER_ATTRIBUTION,
+ weatherFromFile,
+ clearSkyGhi,
+ cloudEstimate,
+ cloudFromGlow,
+ WeatherFile,
+} from './weatherFeed';
+
+const file = (over: Partial = {}): WeatherFile => ({
+ fetched_at: '2026-11-11T10:45:00Z',
+ observed_at: '2026-11-11T10:40:00Z',
+ station: 11035,
+ precip_mm_10min: 0,
+ humidity_pct: 60,
+ glow_wm2: null,
+ sunshine_s_10min: null,
+ attribution: WEATHER_ATTRIBUTION,
+ ...over,
+});
+const NOW = new Date('2026-11-11T10:50:00Z');
+
+describe('weather from the file', () => {
+ it('credits GeoSphere as its legal page asks, and claims no licence', () => {
+ expect(WEATHER_ATTRIBUTION).toBe('Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at');
+ expect(WEATHER_ATTRIBUTION).not.toMatch(/CC/);
+ });
+ it('rain is 0.8..1, by rate', () => {
+ expect(weatherFromFile(file({ precip_mm_10min: 0.1 }), 20)).toBeCloseTo(0.813, 2);
+ expect(weatherFromFile(file({ precip_mm_10min: 3 }), -20)).toBe(1);
+ });
+ it('the clear-sky radiation is Haurwitz: about 335 W/m2 at 21 degrees, none below the horizon', () => {
+ expect(clearSkyGhi(21)).toBeCloseTo(335, -1);
+ expect(clearSkyGhi(-5)).toBe(0);
+ });
+ it('noon clear: radiation at the clear-sky value is no cloud', () => {
+ expect(weatherFromFile(file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 600, humidity_pct: 95 }), 21)).toBe(0);
+ expect(cloudFromGlow(300, 21)).toBeCloseTo(0.56, 1); // k = 0.9 is still a few oktas
+ });
+ it('noon overcast: a fifth of the clear-sky radiation is nearly full cloud, and with saturated air, mist', () => {
+ expect(cloudFromGlow(0.2 * clearSkyGhi(21), 21)).toBeGreaterThan(0.9);
+ expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 80 }), 21)).toBeGreaterThan(0.6);
+ expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 100 }), 21)).toBe(0.75);
+ });
+ it('the radiation, not the humidity, decides by day: humid air under a bright sky is clear', () => {
+ expect(weatherFromFile(file({ glow_wm2: clearSkyGhi(30), sunshine_s_10min: 600, humidity_pct: 98 }), 30)).toBe(0);
+ });
+ it('dusk: with the sun at 3 degrees the humidity ramp speaks, cloud 0 at 60% and 1 at 95%', () => {
+ expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 60 }), 3)).toBe(0);
+ expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 77.5 }), 3)).toBeCloseTo(0.35, 2);
+ expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 95 }), 3)).toBeCloseTo(0.7, 2);
+ });
+ it('night: the radiation and sunshine are 0.0 and the humidity speaks; saturated air is mist, 0.75', () => {
+ expect(weatherFromFile(file({ glow_wm2: 0, humidity_pct: 40 }), -20)).toBe(0);
+ expect(weatherFromFile(file({ glow_wm2: 0, humidity_pct: 100 }), -20)).toBe(0.75);
+ });
+ it('the 2026-11-10 midday fixture, GLOW 56-89 W/m2 under an overcast sky, is full cloud; the clear midday of 2026-10-15, 546-569, is none', () => {
+ const nov = viennaSun(new Date('2026-11-10T10:40:00Z')).elevation;
+ const oct = viennaSun(new Date('2026-10-15T10:50:00Z')).elevation;
+ for (const g of [56, 72, 89]) expect(cloudFromGlow(g, nov)).toBeGreaterThan(0.95);
+ for (const g of [546, 569]) expect(cloudFromGlow(g, oct)).toBe(0);
+ expect(weatherFromFile(file({ glow_wm2: 72, sunshine_s_10min: 0 }), nov)).toBeGreaterThan(0.65);
+ expect(weatherFromFile(file({ glow_wm2: 560, sunshine_s_10min: 600, humidity_pct: 70 }), oct)).toBe(0);
+ });
+ it('the sunshine cross-checks the radiation: agreement keeps the radiation, a half-sky disagreement splits it', () => {
+ expect(cloudEstimate(file({ glow_wm2: 0.2 * clearSkyGhi(21), sunshine_s_10min: 0 }), 21)).toBeGreaterThan(0.9);
+ // Radiation says clear, the sunshine says none: each is half trusted.
+ expect(cloudEstimate(file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 0 }), 21)).toBe(0.5);
+ // No radiation reading by day: the sunshine speaks alone.
+ expect(cloudEstimate(file({ glow_wm2: null, sunshine_s_10min: 300 }), 21)).toBe(0.5);
+ expect(cloudEstimate(file({ glow_wm2: 0, sunshine_s_10min: 600 }), 21)).toBe(0);
+ });
+ it('with the sun below the instruments\' reach there is no estimate: the humidity speaks', () => {
+ expect(cloudEstimate(file({ glow_wm2: 3, sunshine_s_10min: 0 }), 3)).toBeNull();
+ });
+ it('null: unknown measurements are not zero, and nothing known is a clear sky', () => {
+ expect(weatherFromFile(file({ glow_wm2: null, humidity_pct: null, precip_mm_10min: null, sunshine_s_10min: null }), 21)).toBe(CLEAR_SKY);
+ expect(weatherFromFile(file({ glow_wm2: null, humidity_pct: 95 }), 21)).toBeCloseTo(0.7, 2);
+ });
+});
+
+describe('the weather now', () => {
+ it('uses the file while it is fresh', () => {
+ expect(currentWeather(file({ glow_wm2: 0, humidity_pct: 95 }), NOW)).toBeCloseTo(0.7, 2);
+ });
+ it('falls back to a clear sky when the file was fetched more than 30 minutes ago', () => {
+ const f = file({ humidity_pct: 100, fetched_at: '2026-11-11T10:19:00Z' });
+ expect(NOW.getTime() - Date.parse(f.fetched_at)).toBeGreaterThan(STALE_MS);
+ expect(currentWeather(f, NOW)).toBe(CLEAR_SKY);
+ });
+ it('the 30 minute mark itself is still fresh', () => {
+ expect(currentWeather(file({ glow_wm2: 0, humidity_pct: 100, fetched_at: '2026-11-11T10:20:00Z' }), NOW)).toBe(0.75);
+ });
+ it('falls back to a clear sky when the observation is more than 40 minutes old, though the file is fresh', () => {
+ const f = file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:09:00Z' });
+ expect(NOW.getTime() - Date.parse(f.observed_at)).toBeGreaterThan(OBSERVATION_STALE_MS);
+ expect(currentWeather(f, NOW)).toBe(CLEAR_SKY);
+ expect(currentWeather(file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:11:00Z' }), NOW)).toBeGreaterThan(0.8);
+ });
+ it('falls back to a clear sky with no file, and with a fetch time from the future', () => {
+ expect(currentWeather(null, NOW)).toBe(CLEAR_SKY);
+ expect(currentWeather(file({ precip_mm_10min: 2, fetched_at: '2026-11-12T10:40:00Z' }), NOW)).toBe(CLEAR_SKY);
+ });
+});
+
+describe('bad instrument values', () => {
+ it('a negative sunshine is no reading, not full cloud', () => {
+ expect(cloudEstimate(file({ glow_wm2: null, sunshine_s_10min: -5 }), 21)).toBeNull();
+ });
+ it('the radiation is set against the sun of the ten minutes it averaged, not the sun now', () => {
+ // Sun rising: 07:45Z observation (mean of 07:35-07:45), read 35 minutes later at the stale limit.
+ const f = file({ observed_at: '2026-11-11T07:45:00Z', fetched_at: '2026-11-11T07:46:00Z', glow_wm2: 100, sunshine_s_10min: null, humidity_pct: 50 });
+ const at = (m: number) => currentWeather(f, new Date(Date.parse(f.observed_at) + m * 60_000));
+ expect(at(0)).toBe(at(29));
+ });
+});
+
+describe('reading weather.json', () => {
+ it('reads Eureka\'s file as the host writes it: Z times, a whole-station gap as nulls, 0.0 at night', () => {
+ const night = JSON.parse(
+ '{"fetched_at":"2026-11-11T22:48:22Z","observed_at":"2026-11-11T22:40:00Z","station":11035,"precip_mm_10min":0.1,"humidity_pct":100.0,"sunshine_s_10min":0.0,"glow_wm2":0.0,"attribution":"Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at"}'
+ );
+ expect(parseWeatherFile(night)).toMatchObject({ precip_mm_10min: 0.1, humidity_pct: 100, glow_wm2: 0, sunshine_s_10min: 0 });
+ const gap = parseWeatherFile({ ...night, precip_mm_10min: null, humidity_pct: null, sunshine_s_10min: null, glow_wm2: null });
+ expect(gap).toMatchObject({ precip_mm_10min: null, humidity_pct: null, sunshine_s_10min: null, glow_wm2: null });
+ expect(currentWeather(gap, new Date('2026-11-11T22:50:00Z'))).toBe(CLEAR_SKY);
+ });
+ it('is not another station\'s file', () => {
+ expect(parseWeatherFile({ ...file(), station: 11036 })).toBeNull();
+ });
+ it('parses the agreed shape, keeping nulls and the sunshine it never reads', () => {
+ const raw = { ...file(), precip_mm_10min: null, glow_wm2: 12, sunshine_s_10min: 0 };
+ expect(parseWeatherFile(raw)).toEqual(raw);
+ });
+ it('rejects a body without usable times, and anything not an object', () => {
+ expect(parseWeatherFile({ ...file(), observed_at: 'yesterday' })).toBeNull();
+ expect(parseWeatherFile({ ...file(), fetched_at: undefined })).toBeNull();
+ expect(parseWeatherFile(null)).toBeNull();
+ expect(parseWeatherFile('x')).toBeNull();
+ });
+ it('rejects a time with no zone: it would be read in the client\'s local time', () => {
+ expect(parseWeatherFile({ ...file(), observed_at: '2026-11-10T10:40:00' })).toBeNull();
+ expect(parseWeatherFile({ ...file(), observed_at: '2026-11-10T11:40:00+01:00' })).not.toBeNull();
+ });
+ it('is no observation when the file is missing, fails, or is not JSON', async () => {
+ expect(await fetchWeatherFile('/weather.json', (async () => ({ ok: false })) as unknown as typeof fetch)).toBeNull();
+ expect(await fetchWeatherFile('/weather.json', (async () => { throw new Error('offline'); }) as unknown as typeof fetch)).toBeNull();
+ expect(
+ await fetchWeatherFile('/weather.json', (async () => ({ ok: true, json: async () => { throw new Error('bad'); } })) as unknown as typeof fetch)
+ ).toBeNull();
+ });
+ it('reads a good file', async () => {
+ const f = (async () => ({ ok: true, json: async () => file() })) as unknown as typeof fetch;
+ expect(await fetchWeatherFile('/weather.json', f)).toEqual(file());
+ });
+});
diff --git a/src/app/features/scene/ridge/weatherFeed.ts b/src/app/features/scene/ridge/weatherFeed.ts
new file mode 100644
index 00000000..39379fd5
--- /dev/null
+++ b/src/app/features/scene/ridge/weatherFeed.ts
@@ -0,0 +1,122 @@
+// The weather over the viewpoint as one `weather.ts` number, read from a static `weather.json` next to the page (config.json's
+// neighbour). The browser never calls a weather service: Eureka's refresher (gamecult-ops, docs/weather-feed.md; the only writer)
+// writes the file from GeoSphere Austria's TAWES 10-minute observations (station 11035, Wien Hohe
+// Warte). Rain is the gauge. CLOUD IS AN ESTIMATE: TAWES has no cloud cover. By day it is the measured global radiation (GLOW) against
+// what a clear sky gives at the sun's position now (`clearSkyGhi`, Haurwitz), cross-checked by the sunshine duration (`SO`, seconds of
+// 600 the direct beam was strong enough to burn the card); with the sun low or down it is a ramp of the humidity. A file that is
+// missing, unreadable, malformed or stale (below) is no observation, and no observation is a clear sky.
+import { WEATHER_CREDIT } from './credits';
+import { viennaSun } from './vienna';
+
+/** Reader rule of docs/weather-feed.md: the file is stale 30 minutes after it was fetched or 40 after the observation it holds (the station reports every 10). */
+export const STALE_MS = 30 * 60_000;
+export const OBSERVATION_STALE_MS = 40 * 60_000;
+export const STATION = 11035;
+const SKEW_MS = 5 * 60_000;
+export const CLEAR_SKY = 0;
+/** The credit GeoSphere's legal page asks for. */
+export const WEATHER_ATTRIBUTION = WEATHER_CREDIT;
+
+/** The file's shape; every measurement is null when the station did not report it. Times are ISO 8601. */
+export interface WeatherFile {
+ fetched_at: string;
+ observed_at: string;
+ station: number;
+ precip_mm_10min: number | null;
+ humidity_pct: number | null;
+ /** Global radiation, W/m2, last 10 minutes. */
+ glow_wm2: number | null;
+ sunshine_s_10min: number | null;
+ attribution: string;
+}
+
+const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
+const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null);
+
+/** An ISO time that names its zone (`Z` or an offset); `Date.parse` would read a bare one in the client's local time. */
+const isZonedTime = (v: unknown): v is string => typeof v === 'string' && /(Z|[+-]\d\d:?\d\d)$/.test(v) && !Number.isNaN(Date.parse(v));
+
+/** A `WeatherFile` from parsed JSON, or null when it does not have the shape (times missing or unparseable). */
+export function parseWeatherFile(json: unknown): WeatherFile | null {
+ const j = json as Record | null;
+ if (!j || typeof j !== 'object') return null;
+ if (!isZonedTime(j.observed_at) || !isZonedTime(j.fetched_at)) return null;
+ if (j.station !== STATION) return null;
+ return {
+ fetched_at: j.fetched_at,
+ observed_at: j.observed_at,
+ station: STATION,
+ precip_mm_10min: num(j.precip_mm_10min),
+ humidity_pct: num(j.humidity_pct),
+ glow_wm2: num(j.glow_wm2),
+ sunshine_s_10min: num(j.sunshine_s_10min),
+ attribution: typeof j.attribution === 'string' ? j.attribution : WEATHER_ATTRIBUTION,
+ };
+}
+
+/** Global horizontal radiation of a clear sky, W/m2, sun at `el` degrees: Haurwitz (1945), 1098 cos(z) exp(-0.057 / cos(z)). */
+export function clearSkyGhi(el: number): number {
+ const c = Math.sin((el * Math.PI) / 180);
+ return c <= 0 ? 0 : 1098 * c * Math.exp(-0.057 / c);
+}
+
+/** Below this sun elevation the radiation says too little (clear-sky ~100 W/m2 at 8 degrees) and the humidity is read instead. */
+export const GLOW_FROM_DEG = 8;
+
+/**
+ * Cloud 0..1 from measured global radiation against the clear-sky value: the clearness ratio k inverted through Kasten and
+ * Czeplak (1980), k = 1 - 0.75 (N/8)^3.4, N the oktas. Null when the sun is too low or the reading is missing or 0.
+ */
+export function cloudFromGlow(glow: number | null, sunEl: number): number | null {
+ if (glow === null || glow <= 0 || sunEl < GLOW_FROM_DEG) return null;
+ const k = Math.min(1, glow / clearSkyGhi(sunEl));
+ return clamp01(((1 - k) / 0.75) ** (1 / 3.4));
+}
+
+/** The cloud the sunshine duration implies: the share of the ten minutes the sun did not shine. Null when the sun is too low or it is unreported. */
+export function cloudFromSunshine(so: number | null, sunEl: number): number | null {
+ if (so === null || so < 0 || sunEl < GLOW_FROM_DEG) return null;
+ return 1 - clamp01(so / 600);
+}
+
+/** The cloud estimate by day, or null when the sun is too low for either instrument (the caller reads the humidity). */
+export function cloudEstimate(f: WeatherFile, sunEl: number): number | null {
+ const g = cloudFromGlow(f.glow_wm2, sunEl);
+ const s = cloudFromSunshine(f.sunshine_s_10min, sunEl);
+ if (g !== null && s !== null) return Math.abs(g - s) > 0.5 ? (g + s) / 2 : g; // instruments that disagree by half a sky are each half trusted
+ return g ?? s;
+}
+
+/**
+ * The weather number of an observation with the sun at `sunEl` degrees. Rain sets 0.8..1 by rate (0.1 mm in ten minutes is the
+ * gauge's step: drizzle). Without rain, cloud (above) puts the number up to 0.7 (overcast); where the instruments cannot give it
+ * (the sun low or down), cloud is a ramp of the humidity, 0 at 60% to 1 at 95%. Saturated air (92% to 100%) sets a mist floor of up to 0.75 when the sky is
+ * more than half cloud.
+ */
+export function weatherFromFile(f: WeatherFile, sunEl: number): number {
+ const rain = f.precip_mm_10min ?? 0;
+ if (rain > 0) return clamp01(0.8 + 0.2 * Math.min(1, rain / 1.5));
+ const cloud = cloudEstimate(f, sunEl) ?? (f.humidity_pct === null ? 0 : clamp01((f.humidity_pct - 60) / 35));
+ const mist = f.humidity_pct === null || cloud < 0.5 ? 0 : clamp01((f.humidity_pct - 92) / 8) * 0.75;
+ return Math.max(0.7 * cloud, mist);
+}
+
+/** The weather number now: the file's, or a clear sky when there is none or it is stale (see STALE_MS) or from the future. */
+export function currentWeather(f: WeatherFile | null, now: Date = new Date()): number {
+ if (!f) return CLEAR_SKY;
+ const fetched = now.getTime() - Date.parse(f.fetched_at);
+ const observed = now.getTime() - Date.parse(f.observed_at);
+ if (!(fetched <= STALE_MS && fetched >= -SKEW_MS && observed <= OBSERVATION_STALE_MS && observed >= -SKEW_MS)) return CLEAR_SKY;
+ // GLOW and SO are means of the ten minutes ending at observed_at: the sun of their midpoint is the one to set them against.
+ return weatherFromFile(f, viennaSun(new Date(Date.parse(f.observed_at) - 5 * 60_000)).elevation);
+}
+
+/** Fetch and parse `weather.json`; null on any failure. */
+export async function fetchWeatherFile(url: string, fetcher: typeof fetch = fetch): Promise {
+ try {
+ const res = await fetcher(url, { cache: 'no-store' });
+ return res.ok ? parseWeatherFile(await res.json()) : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts
index 37e170cb..7c8e14b1 100644
--- a/src/app/features/scene/skyForState.test.ts
+++ b/src/app/features/scene/skyForState.test.ts
@@ -9,12 +9,13 @@ import {
TERRAIN_REACH_DEG,
ridgeDrawn,
ridgeSkyFor,
+ ridgeSkyKey,
terrainDrawn,
terrainSkyFor,
} from './skyForState';
import { displayedLuminance, luma, shapeToBand, SKY_BAND } from './skyBand';
import type { FixedSkyState } from './skyBand';
-import { setSkyClock, skyBackdrop } from './skyState';
+import { DAY_FROM, setSkyClock, skyBackdrop } from './skyState';
import { view } from './terrain/terrainWorld';
import { skyThemeId } from './skyTheme';
@@ -77,47 +78,50 @@ describe('the bands the text ink needs', () => {
});
describe('ridgeSkyFor', () => {
- it('carries the state band, which the layers are shaped into', () => {
- for (const state of ['day', 'dusk', 'night'] as const) {
- expect(ridgeSkyFor(state).band).toEqual(SKY_BAND[state]);
+ it('carries the state band, which the layers are shaped into, and the physical sky of that state', () => {
+ for (const state of STATES) {
+ const sky = ridgeSkyFor(state, 0.3);
+ expect(sky.band).toEqual(SKY_BAND[state]);
+ expect(sky.physical?.state).toBe(state);
+ expect(sky.physical?.stops).toEqual({ top: sky.top, mid: sky.mid, horizon: sky.horizon });
}
});
- it('draws the gradient of the sky auto resolves to, never nothing', () => {
+ it('draws the sky auto resolves to, never nothing', () => {
+ const key = (s: ReturnType) => [s.top, s.mid, s.horizon, s.band, s.physical?.sun];
setSkyClock(() => atStandardTime(12, 0));
- expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('day'));
+ expect(key(ridgeSkyFor('auto', 0.3))).toEqual(key(ridgeSkyFor('day', 0.3)));
setSkyClock(() => atStandardTime(0, 0));
- expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('night'));
+ expect(key(ridgeSkyFor('auto', 0.3))).toEqual(key(ridgeSkyFor('night', 0.3)));
setSkyClock(() => atStandardTime(18, 10));
- expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('dusk'));
+ expect(key(ridgeSkyFor('auto', 0.3))).toEqual(key(ridgeSkyFor('dusk', 0.3)));
});
- it('draws a lavender, rose and orange twilight for dusk', () => {
- const d = ridgeSkyFor('dusk');
- expect(d.ground).toEqual(skyColours().ground);
- const [t, m, h] = [d.top, d.mid, d.horizon];
- expect(t[2]).toBeGreaterThan(t[1]); // lavender: blue over green
- expect(t[0]).toBeGreaterThan(t[1]);
- expect(m[0]).toBeGreaterThan(m[2]); // rose: red over blue
- expect(m[2]).toBeGreaterThan(m[1]);
- expect(h[0]).toBeGreaterThan(h[1]); // orange: red over green over blue
- expect(h[1]).toBeGreaterThan(h[2]);
+ it("is lit from the real sun over Vienna: a forced state's sun is inside the state, the clear noon sky is blue", () => {
+ const day = ridgeSkyFor('day', 0);
+ expect(day.physical?.sun.elevation).toBeGreaterThanOrEqual(DAY_FROM);
+ expect(day.top[2]).toBeGreaterThan(day.top[0]);
+ const dusk = ridgeSkyFor('dusk', 0);
+ expect(Math.abs(dusk.physical?.sun.elevation ?? 99)).toBeLessThanOrEqual(DAY_FROM);
+ expect(ridgeSkyFor('night', 0).physical?.sun.elevation).toBeLessThanOrEqual(-DAY_FROM);
+ expect(day.ground).toEqual(skyColours().ground);
});
- it('draws the baked gradients for day and night', () => {
- const day = ridgeSkyFor('day');
- const night = ridgeSkyFor('night');
- expect(hex(day.top)).toBe(baked.noonday.gradient[0]);
- expect(hex(day.horizon)).toBe(baked.noonday.gradient[6]);
- expect(hex(night.top)).toBe(baked.midnight.gradient[0]);
- expect(hex(night.horizon)).toBe(baked.midnight.gradient[6]);
+ it('keys a rebuild by state and weather, not by the moving sun', () => {
+ setSkyClock(() => atStandardTime(12, 0));
+ expect(ridgeSkyKey('auto', 0.3)).toBe(ridgeSkyKey('day', 0.3));
+ expect(ridgeSkyKey('day', 0.3)).not.toBe(ridgeSkyKey('day', 0.8));
+ expect(ridgeSkyKey('day', 0.3)).not.toBe(ridgeSkyKey('night', 0.3));
});
- it.each(STATES)('%s: every stop and blend lies in the band its text ink needs', (state) => {
+ it.each(STATES)('%s: the display is fitted to the budget its text ink needs', (state) => {
const { floor, ceiling } = needs(state);
const r = ridgeDrawn(state);
expect(r.darkest).toBeGreaterThanOrEqual(floor);
expect(r.lightest).toBeLessThanOrEqual(ceiling);
+ const fit = ridgeSkyFor(state, 0.3).physical?.fit;
+ expect(fit?.lo).toBeGreaterThanOrEqual(r.darkest - 1e-9);
+ expect(fit?.hi).toBeLessThanOrEqual(r.lightest + 1e-9);
});
});
diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts
index a9551f65..8a5f9768 100644
--- a/src/app/features/scene/skyForState.ts
+++ b/src/app/features/scene/skyForState.ts
@@ -8,56 +8,61 @@ import { chooseSky, skyColour, urlSky } from './terrain/terrainSky';
import type { SkyName } from './terrain/terrainSky';
import type { SkyColours, Srgb } from './ridge/skyColour';
import { skyColours } from './ridge/skyColour';
-import { resolveSkyState } from './skyState';
+import { makePhysicalSky } from './ridge/physicalSky';
+import { BUDGET } from './ridge/toneMap';
+import { ridgeWeather, setFedWeather } from './ridge/weather';
+import { currentWeather, fetchWeatherFile } from './ridge/weatherFeed';
+import { resolveSkyState, skyNow } from './skyState';
import type { SkyState } from './skyState';
-import { displayedLuminance, hexToLinear, shapeToBand, SKY_BAND } from './skyBand';
+import { displayedLuminance, SKY_BAND } from './skyBand';
import type { SkyBand } from './skyBand';
import { luminance } from '../../../sky/skyTokens.js';
import { CAMERA } from './terrain/terrainWorld';
-const rgbHex = (c: Srgb) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
-
-const rgb = (hex: string): Srgb =>
- [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb;
-
-const fromGradient = (g: readonly string[], ground: Srgb, band: SkyBand): SkyColours => ({
- band,
- top: rgb(g[0]),
- mid: rgb(g[Math.floor(g.length / 2)]),
- horizon: rgb(g[g.length - 1]),
- ground,
-});
-
/** Sun elevation in degrees for the terrain's dusk: just above the horizon, between the two bakes (as `?sun=` puts it). */
export const DUSK_SUN_ELEVATION = 2;
-const toByte = (v: number): number =>
- Math.round(255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055));
+/**
+ * What a rebuild of the ridge scene is for: the fixed state it draws and the weather. The sun moves with the clock inside a state and
+ * is not part of the key (the scene is baked at its start), as the sky was one gradient per state before.
+ */
+export const ridgeSkyKey = (
+ state: SkyState,
+ weather: number = ridgeWeather(),
+ now: Date = skyNow()
+): string => `ridge:${resolveSkyState(state, now)}:${weather}`;
-/** A stop moved into the state's band, the way the terrain sky is (`shapeToBand`), through the sRGB curve the canvas draws with. */
-const inBand = (c: Srgb, band: SkyBand): Srgb =>
- shapeToBand(hexToLinear(rgbHex(c)), band).map(toByte) as unknown as Srgb;
+const WEATHER_REFRESH_MS = 10 * 60_000;
+let weatherAt = -Infinity;
+/**
+ * Read `weather.json` (the page's own, next to config.json) into the weather the ridge sky is drawn for; at most once in ten minutes.
+ * Never throws: a missing, stale or broken file is a clear sky (`currentWeather`).
+ */
+export async function loadWeather(url: string, now: Date = skyNow()): Promise {
+ if (now.getTime() - weatherAt < WEATHER_REFRESH_MS) return;
+ weatherAt = now.getTime();
+ setFedWeather(currentWeather(await fetchWeatherFile(url), now));
+}
-/** The ridge scene's `sky` option: the gradient of the state's sky ('auto': of the sky it resolves to). Dusk's twilight stops are shaped into its band. */
-export function ridgeSkyFor(state: SkyState): SkyColours {
+/**
+ * The ridge scene's `sky` option: the physical sky over Vienna for the state ('auto': the real sun now; a forced state: the
+ * sun of today at a moment inside it, `sunForState`), as a palette for the layers and stars plus the `physical` sky the host canvas
+ * is painted from. The atmosphere LUTs are baked here, once per scene start (tens of ms).
+ */
+export function ridgeSkyFor(
+ state: SkyState,
+ weather: number = ridgeWeather(),
+ now: Date = skyNow()
+): SkyColours {
const { ground } = skyColours();
- switch (resolveSkyState(state)) {
- case 'dusk': {
- const twilight = skyColours();
- const band = SKY_BAND.dusk;
- return {
- ...twilight,
- band,
- top: inBand(twilight.top, band),
- mid: inBand(twilight.mid, band),
- horizon: inBand(twilight.horizon, band),
- };
- }
- case 'day':
- return fromGradient(baked.noonday.gradient, ground, SKY_BAND.day);
- default:
- return fromGradient(baked.midnight.gradient, ground, SKY_BAND.night);
- }
+ const fixed = resolveSkyState(state, now);
+ const physical = makePhysicalSky(fixed, now, weather);
+ return {
+ ...physical.stops,
+ ground,
+ band: SKY_BAND[fixed],
+ physical,
+ };
}
/**
@@ -93,26 +98,12 @@ export interface Range {
}
/**
- * The ridge sky's range: its top, middle and horizon stops and the blends between them (the canvas interpolates
- * encoded sRGB, and luminance is convex in it, so a blend can be darker than both ends: each stop pair is sampled).
+ * The ridge sky's range: what its display is fitted to hold (`BUDGET`, toneMap.ts), which `physicalSky.test.ts` proves over the
+ * sun's whole year over Vienna. Dark ink needs the floor; light ink the ceiling.
*/
export function ridgeDrawn(state: SkyState): Range {
- const { top, mid, horizon } = ridgeSkyFor(state);
- const l: number[] = [];
- [
- [top, mid],
- [mid, horizon],
- [top, horizon],
- ].forEach(([a, b]) => {
- for (let k = 0; k <= 100; k += 1) {
- l.push(
- luminance(
- rgbHex(a.map((v, i) => Math.round(v + ((b[i] - v) * k) / 100)) as unknown as Srgb)
- )
- );
- }
- });
- return { darkest: Math.min(...l), lightest: Math.max(...l) };
+ const b = BUDGET[resolveSkyState(state)];
+ return { darkest: b.floor, lightest: b.ceiling };
}
/**
diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts
index ae4d9e83..d0a902a2 100644
--- a/src/app/features/scene/skyState.test.ts
+++ b/src/app/features/scene/skyState.test.ts
@@ -107,10 +107,10 @@ describe('sky state', () => {
});
describe('skyBackdrop', () => {
- it('puts night below day and dusk, and dusk reaches the sun glow', () => {
+ it('puts night below day and dusk', () => {
expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('day').darkest);
expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('dusk').lightest);
- expect(skyBackdrop('dusk').lightest).toBeGreaterThan(skyBackdrop('day').lightest);
+ expect(skyBackdrop('dusk').lightest).toBeGreaterThanOrEqual(skyBackdrop('day').lightest);
});
it('holds the baked gradients for day and night', () => {
diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts
index 0b60d99b..52b0cdc8 100644
--- a/src/app/features/scene/skyState.ts
+++ b/src/app/features/scene/skyState.ts
@@ -80,6 +80,9 @@ function sync(): void {
timer = setTimeout(tick, TICK_MS);
}
+/** The moment the sky is drawn for: the real time, or the test clock. */
+export const skyNow = (): Date => clock();
+
/** Replace the clock (tests); no argument restores the real one. */
export function setSkyClock(now?: () => Date): void {
clock = now ?? (() => new Date());
@@ -123,16 +126,17 @@ export interface SkyBackdrop {
}
// The union of every sky a scene draws for the state: the ridge sky and the terrain sky, each from
-// the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). Held as constants so
+// the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). The ridge sky is the physical sky over Vienna, its
+// display fitted to the state's budget (ridge/toneMap.ts `BUDGET`): the budget is its extreme, whatever the sun's elevation. Held as constants so
// this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering
// tables with it; skyForState.test.ts fails when they drift from the computed union. The ridge sky counts
// over its whole range, the terrain sky over what its frozen camera reaches (TERRAIN_REACH_DEG, skyForState.ts).
// Night's lightest also covers a sky the page URL steers (shaped to the band ceiling, skyBand.ts).
// Bounds what the 8-bit screen shows too (one step each way for rounding and dither), rounded outward to six places.
const BACKDROPS: Record = {
- day: { darkest: 0.461063, lightest: 0.901108 },
- dusk: { darkest: 0.460352, lightest: 0.968744 },
- night: { darkest: 0.05987, lightest: 0.081642 },
+ day: { darkest: 0.461063, lightest: 1 },
+ dusk: { darkest: 0.460352, lightest: 1 },
+ night: { darkest: 0, lightest: 0.081642 },
};
export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)];