/**
 * The sky: what the sun and the weather mean for the light rig.
 *
 * `Atmosphere` is the **sole owner of lighting**. It takes an `Environment` — an
 * observation of the world, `{ time, sun, weather }` — and returns a
 * `LightingState`, which a `SceneKit` applies. One direction, no write-backs,
 * and nothing else in the engine is allowed to reach into the three lights. Two
 * modules both constructing and mutating the same `DirectionalLight` is the
 * failure this shape exists to prevent; see CONTRACT.md §4.
 *
 * Two things follow from that ownership and are worth stating up front:
 *
 *   1. **An office gets no Atmosphere at all.** An interior has walls, no
 *      horizon and no weather: it wants `fog: null`, `sky: null` — which tells
 *      `SceneKit` to leave `scene.background` alone entirely — and a fixed
 *      interior rig of its own that never moves. Daylight through windows is a
 *      later refinement and deliberately not a v1 coupling. Nothing in this
 *      file is for an interior, and an office importing it is a mistake.
 *   2. **`apply` is pure.** It reads its argument, allocates one small object,
 *      and touches nothing. Call it every frame or once a minute; the cost is
 *      the same table lookup either way.
 *
 * The solar half is computed locally by `solar.ts` with no network, the lunar
 * half by `moonPosition` below, and the weather half degrades to `null` — which
 * this file reads as a clear day with the local climatology still running. The
 * whole engine has to work with no account, no key and no network, and a sky
 * that goes flat grey the moment the wifi drops would fail that in the most
 * visible way possible.
 *
 * There is a second output alongside the rig, `cloudCover`, and it is here for
 * the same reason. It answers "how much sky has cloud in it" rather than "what
 * does that cloud do to the light" — the number a cloud layer draws from, not a
 * light — and it falls back to the same local climatology when nothing was
 * observed. See `modelledCloudCover`: a caller that reads
 * `weather?.cloudCover ?? 0` instead gets an empty sky on every deployment
 * without a weather API, which is the default one.
 *
 * Wiring one to a city, in full:
 *
 * ```ts
 * const atmosphere = createAtmosphere({
 *   lng: city.center.lng,
 *   // Degrees of latitude are ~111.32 km everywhere, so the city's own
 *   // `latScale` is the scale conversion already.
 *   metresPerUnit: 111_320 / city.latScale,
 *   marineLayer: PACIFIC_MARINE_LAYER,
 * });
 * handle.setLighting(atmosphere.apply(observe(city.center.lat, city.center.lng, new Date())));
 * ```
 *
 * The marine layer is opt-in and off by default, because it is a fact about a
 * coast and this module has no idea which one it is looking at.
 */

import { solarPosition, sunDirection, type SolarPosition } from "./solar.ts";
import type { LightingMoon, LightingState } from "./types.ts";

// ---- The observation ------------------------------------------------------

/**
 * Sky conditions as a renderer can use them.
 *
 * This mirrors `WeatherCondition` in `src/server/wire.ts` member for member,
 * and is named differently on purpose. The engine must not import the wire —
 * `wire.ts` already imports `engine/types.ts`, and pointing the arrow back
 * would make the engine's type graph depend on the server's. A `WeatherBody`
 * off the wire is structurally assignable to `WeatherObservation` as it stands,
 * so the adapter is a pass-through and not a translation; if a member is ever
 * added on one side, add it on the other.
 */
export type SkyCondition =
  | "clear"
  | "partly-cloudy"
  | "cloudy"
  | "overcast"
  | "fog"
  | "rain"
  | "snow"
  | "thunderstorm";

/**
 * What the sky is doing, reduced to the four things that change a light rig.
 *
 * `null` fields mean *not reported*, never zero. The difference matters: a
 * station that does not measure visibility is not a station reporting perfect
 * visibility, and treating the two the same is how a foggy morning renders
 * clear.
 */
export interface WeatherObservation {
  /** 0..1. */
  cloudCover: number;
  /** 0..1, an intensity rather than a rate. */
  precipitation: number;
  visibilityKm: number | null;
  windKph: number | null;
  /** Degrees clockwise from true north, the direction the wind blows *from*. */
  windDirDeg: number | null;
  condition: SkyCondition;
}

/**
 * Everything the light rig is a consequence of.
 *
 * An *observation*, emphatically not a state: this is what the world is doing,
 * and a `LightingState` is what that means for the three lights. The two were
 * once one type called `Environment` in two places meaning both things at once,
 * which is why the names are now this far apart.
 */
export interface Environment {
  time: Date;
  sun: SolarPosition;
  /**
   * The other light in the sky. Required rather than optional because a night
   * without it is the black rectangle this file exists to avoid, and an
   * `Environment` assembled without one would silently be that.
   */
  moon: MoonPosition;
  /** `null` when nobody was asked. A supported state, not an error. */
  weather: WeatherObservation | null;
}

/**
 * Build an `Environment` for a place and an instant, computing both bodies
 * locally.
 */
export function observe(
  lat: number,
  lng: number,
  when: Date,
  weather: WeatherObservation | null = null,
): Environment {
  return {
    time: when,
    sun: solarPosition(lat, lng, when),
    moon: moonPosition(lat, lng, when),
    weather,
  };
}

// ---- The moon -------------------------------------------------------------

/**
 * Where the moon is, and how much of it is lit.
 *
 * This lives here rather than in `solar.ts` because it exists for exactly one
 * consumer: the key light after sunset. `solar.ts` is the sun's own module and
 * has a much harder accuracy contract to keep — sunrise to the minute — whereas
 * nothing downstream of this can tell a tenth of a degree of moon from the
 * right answer.
 */
export interface MoonPosition {
  /** Degrees clockwise from true north, matching `SolarPosition.azimuth`. */
  azimuth: number;
  /** Degrees above the horizon, corrected for parallax. */
  elevation: number;
  /** Fraction of the visible disc in sunlight: 0 at new, 1 at full. */
  illuminated: number;
  /** 0 new, 0.25 first quarter, 0.5 full, 0.75 last quarter. */
  phase: number;
  /** Centre to centre, in kilometres. Roughly 356,500 to 406,700. */
  distanceKm: number;
}

const DEG = Math.PI / 180;
const RAD = 180 / Math.PI;
const JD_UNIX_EPOCH = 2_440_587.5;
const J2000 = 2_451_545;
/** Equatorial radius, for the parallax correction. */
const EARTH_RADIUS_KM = 6378.14;

/**
 * The moon's position and phase, computed rather than fetched.
 *
 * Meeus, *Astronomical Algorithms* chapter 47, truncated hard. The full ELP
 * series is sixty periodic terms in longitude alone; what is here is the
 * thirteen largest, which are the ones with names — the equation of centre
 * (6.29°, the orbit being an ellipse), the **evection** (1.27°, the sun pulling
 * the orbit's own ellipse around every 32 days and the single largest thing
 * Ptolemy did not know about), the **variation** (0.66°, the moon running fast
 * at the syzygies and slow at the quadratures), the annual equation and the
 * parallactic inequality. Against Meeus's own worked example 47.a — 1992 April
 * 12, 0h TD — this returns 133.150° against his 133.163°, a latitude of -3.223°
 * against -3.229°, and 368,335 km against 368,410. Thirteen thousandths of a
 * degree. The moon's disc is half a degree wide, so a renderer cannot see the
 * error and neither can anyone looking at it.
 *
 * Two deliberate omissions. The eccentricity factor `E` that Meeus applies to
 * every term in the sun's mean anomaly is dropped, because it is a correction
 * of about 0.017 to terms already under a fifth of a degree. And the elevation
 * is not refracted — `solar.ts` owns that curve and does not export it, and at
 * moonrise the moon is contributing almost nothing anyway. Parallax *is*
 * applied, because it is the big one: the moon is close enough that standing on
 * the surface of the Earth rather than at its centre moves it by most of a
 * degree, which is fifty times the truncation error.
 */
export function moonPosition(lat: number, lng: number, when: Date): MoonPosition {
  const jd = when.getTime() / MS_PER_DAY + JD_UNIX_EPOCH;
  const t = (jd - J2000) / 36_525;
  const sin = (deg: number) => Math.sin(deg * DEG);
  const cos = (deg: number) => Math.cos(deg * DEG);

  // The Delaunay arguments, Meeus 47.1-47.5: the moon's mean longitude, its
  // mean elongation from the sun, the sun's mean anomaly, the moon's own mean
  // anomaly, and its argument of latitude — the angle from the ascending node,
  // which is what makes the moon's path wander 5° either side of the ecliptic.
  const meanLongitude = 218.3164477 + 481_267.88123421 * t - 0.0015786 * t * t;
  const elongation = 297.8501921 + 445_267.1114034 * t - 0.0018819 * t * t;
  const sunAnomaly = 357.5291092 + 35_999.0502909 * t - 0.0001536 * t * t;
  const anomaly = 134.9633964 + 477_198.8675055 * t + 0.0087414 * t * t;
  const argLatitude = 93.272095 + 483_202.0175233 * t - 0.0036539 * t * t;

  const d = elongation;
  const m = sunAnomaly;
  const mp = anomaly;
  const f = argLatitude;

  const longitude =
    meanLongitude +
    6.288774 * sin(mp) + // equation of centre
    1.274027 * sin(2 * d - mp) + // evection
    0.658314 * sin(2 * d) + // variation
    0.213618 * sin(2 * mp) -
    0.185116 * sin(m) - // annual equation
    0.114332 * sin(2 * f) +
    0.058793 * sin(2 * d - 2 * mp) +
    0.057066 * sin(2 * d - m - mp) +
    0.053322 * sin(2 * d + mp) +
    0.045758 * sin(2 * d - m) -
    0.040923 * sin(m - mp) -
    0.03472 * sin(d) - // parallactic inequality
    0.030383 * sin(m + mp);

  const latitude =
    5.128122 * sin(f) +
    0.280602 * sin(mp + f) +
    0.277693 * sin(mp - f) +
    0.173237 * sin(2 * d - f) +
    0.055413 * sin(2 * d - mp + f) +
    0.046271 * sin(2 * d - mp - f) +
    0.032573 * sin(2 * d + f) +
    0.017198 * sin(2 * mp + f) +
    0.009266 * sin(2 * d + mp - f) +
    0.008822 * sin(2 * mp - f);

  const distanceKm =
    385_000.56 -
    20_905.355 * cos(mp) -
    3699.111 * cos(2 * d - mp) -
    2955.968 * cos(2 * d) -
    569.925 * cos(2 * mp);

  // The sun's apparent longitude, to the same standard: needed only for the
  // elongation the phase is read off, where a hundredth of a degree is three
  // decimal places more than the illuminated fraction can carry.
  const sunLongitude =
    280.46646 +
    36_000.76983 * t +
    1.914602 * sin(m) +
    0.019993 * sin(2 * m) +
    0.000289 * sin(3 * m);

  // Ecliptic to equatorial.
  const obliquity = (23.4392911 - 0.0130042 * t) * DEG;
  const lambda = longitude * DEG;
  const beta = latitude * DEG;
  const rightAscension = Math.atan2(
    Math.sin(lambda) * Math.cos(obliquity) - Math.tan(beta) * Math.sin(obliquity),
    Math.cos(lambda),
  );
  const declination = Math.asin(
    clamp(
      Math.sin(beta) * Math.cos(obliquity) + Math.cos(beta) * Math.sin(obliquity) * Math.sin(lambda),
      -1,
      1,
    ),
  );

  // Equatorial to horizontal, through the local hour angle. Greenwich sidereal
  // time is Meeus 12.4: the extra 0.98564736629° a day over 360 is the Earth's
  // orbital motion, which is the whole reason a sidereal day is four minutes
  // short of a solar one.
  const gmst = mod(280.46061837 + 360.98564736629 * (jd - J2000) + 0.000387933 * t * t, 360);
  const hourAngle = (gmst + lng) * DEG - rightAscension;
  const phi = lat * DEG;
  const sinAltitude = clamp(
    Math.sin(phi) * Math.sin(declination) +
      Math.cos(phi) * Math.cos(declination) * Math.cos(hourAngle),
    -1,
    1,
  );
  let altitude = Math.asin(sinAltitude);
  const azimuth = Math.atan2(
    -Math.cos(declination) * Math.sin(hourAngle),
    Math.sin(declination) * Math.cos(phi) - Math.cos(declination) * Math.sin(phi) * Math.cos(hourAngle),
  );
  altitude -= Math.asin(EARTH_RADIUS_KM / distanceKm) * Math.cos(altitude);

  // Phase from the sun-moon elongation. The proper phase angle also wants the
  // earth-sun distance, which moves the answer by about a sixth of a degree —
  // two parts in a thousand of the illuminated fraction, and this drives a
  // light rig.
  const separation = mod(longitude - sunLongitude, 360);

  return {
    azimuth: mod(azimuth * RAD, 360),
    elevation: altitude * RAD,
    illuminated: (1 - Math.cos(separation * DEG)) / 2,
    phase: separation / 360,
    distanceKm,
  };
}

// ---- Options --------------------------------------------------------------

/**
 * Coastal advection fog, as a season, a clock and a wind gate.
 *
 * Parameterised rather than hard-coded because the model is climatology, not
 * geography: the same three curves describe the eastern Pacific layer, the
 * Namibian one and the Peruvian one, with different numbers in them.
 */
export interface MarineLayerOptions {
  /** Day of year, 1-366, at which the season peaks. */
  peakDay: number;
  /** Half-width of the season in days. Outside it, `offSeason` applies. */
  seasonDays: number;
  /** Residual strength out of season, 0..1. */
  offSeason: number;
  /** Bearing the onshore wind blows *from*, degrees clockwise from true north. */
  onshoreBearing: number;
  /** How far off that bearing still counts as onshore, in degrees. */
  onshoreHalfWidth: number;
  /** Strength at the peak of the season, 0..1. */
  strength: number;
  /** Visibility inside the layer, in metres. */
  visibilityM: number;
}

/**
 * The eastern-Pacific summer layer: San Francisco's fog.
 *
 * This is the single most recognisable atmospheric fact about the city and it
 * is worth getting specifically right rather than approximating with generic
 * haze. The layer is cold air over the California Current, dragged inland
 * through the one sea-level gap in the coast range by the Central Valley's
 * afternoon heat low. That gives it a shape a renderer can actually use:
 *
 *   - **A season.** It is a summer phenomenon, peaking in July, essentially
 *     gone by the clear warm October that surprises every visitor who packed
 *     for August. Winter fog in the Bay Area is a different animal — radiation
 *     fog, mostly inland — and the small `offSeason` residual is all of it that
 *     belongs here.
 *   - **A clock.** In through the Gate in the late afternoon, thickest from
 *     midnight to a couple of hours after sunrise, burning off through the late
 *     morning and back again from about five.
 *   - **A wind.** It is *advection* fog: it has to be blown in. A westerly at
 *     15-30 km/h is the engine of it; an offshore easterly kills it outright,
 *     and a gale mixes it up into stratus instead.
 *
 * What is deliberately not modelled: the layer is shallow — a few hundred
 * metres — so downtown's towers and Twin Peaks stand in clear air above a white
 * floor, and the Sunset is buried while the Mission is in sunshine. Both facts
 * need height fog and a horizontal gradient; `THREE.Fog` is a single global
 * linear ramp and can express neither. Rather than fake it, the strength is
 * capped so that the city dims and flattens instead of disappearing.
 */
export const PACIFIC_MARINE_LAYER: MarineLayerOptions = {
  peakDay: 196, // 15 July
  seasonDays: 88, // roughly mid-April to mid-October
  offSeason: 0.1,
  onshoreBearing: 275, // just north of due west, straight in through the Gate
  onshoreHalfWidth: 75,
  strength: 1,
  visibilityM: 5000,
};

/**
 * Moonlight as a look rather than as a photometry.
 *
 * Full moonlight is about one four-hundred-thousandth of sunlight. Reproducing
 * that ratio faithfully gives you a black screen, because a monitor has three
 * orders of magnitude of range and this needs six, and because the eye that
 * makes a moonlit landscape legible is doing an hour of dark adaptation that a
 * lit room will not allow. So the numbers here are not the ratio; they are what
 * a moonlit night *looks like* once you are in it — a low, soft, blue-shifted
 * key you can read shapes by, on a sky that is deep blue rather than absent.
 *
 * The blue is the interesting lie. Moonlight is reflected sunlight off a
 * grey-brown rock and is very slightly *warmer* than daylight, around 4,100 K.
 * It looks blue because at those levels the eye is running on rods, whose peak
 * sensitivity sits about 50 nm bluer than the cones' — the Purkinje shift — so
 * a moonlit scene genuinely is blue to the person standing in it while being
 * neutral to a light meter. Rendering it neutral is the more accurate choice
 * and the wrong one, and every cinematographer since the 1930s has agreed.
 */
export interface MoonlightOptions {
  /** Key intensity of a full moon, high, in clear air. Compare a noon sun at 2.1. */
  intensity: number;
  /** The key's colour. */
  color: number;
  /** How far a full moon lifts the night sky toward moonlit blue, 0..1. */
  skyLift: number;
}

export const DEFAULT_MOONLIGHT: MoonlightOptions = {
  // Physically absurd — moonlight is about 1/400,000 of sunlight — and the
  // right number anyway. What is being reproduced is the *look* of a moonlit
  // night on a screen someone is looking at in a lit room, not the photon
  // count. At the honest value the map is a black rectangle, which is the bug
  // this exists to fix.
  //
  // Left where it was when the moonless floor came up under it, and that is a
  // decision rather than an oversight. The key is already three quarters of the
  // light in a full-moon frame, so raising it to keep the gap would have been
  // raising the one term that is nearest to overshooting into a blue-graded day;
  // the gap is defended in `applyNight` instead, on the fill, where there was
  // room. What did have to be checked is that moonrise is still an *event* —
  // see the sanity checks at the foot of this file, where a full moon 43° up is
  // seven times the key of a moonless night and better than twice its ground
  // luminance on screen.
  intensity: 1.15,
  color: 0x9db4e8,
  skyLift: 0.85,
};

export interface AtmosphereOptions {
  /**
   * Observer longitude, degrees east. Needed for apparent solar time, which is
   * what the marine layer's clock runs on — a fog that burns off at 11 a.m.
   * needs to know when 11 a.m. is, and we ship no timezone database.
   */
  lng: number;
  /**
   * Metres in one scene unit. Visibility arrives in kilometres and fog
   * distances leave in scene units, and this is the only thing that knows the
   * exchange rate: ~94 m per unit for San Francisco, 1 m for an interior that
   * will never call this anyway.
   */
  metresPerUnit: number;
  /**
   * The clear-noon sky this city wants. Every other stop in the table is a
   * consequence of where the sun is and is not a city's business to override.
   */
  sky?: { top: number; horizon: number };
  /** Fog on a clear day, in scene units. */
  clearFog?: { near: number; far: number };
  /**
   * Visibility, in metres, that fog is never allowed to fall below.
   *
   * A deliberate lie. Real advection fog at the Golden Gate has visibility
   * under 400 m, and rendering that honestly produces a white rectangle with a
   * city somewhere inside it. What reads as fog is the *look* — no horizon, no
   * shadows, a dead sun, everything the same flat grey — at a range you can
   * still fly a camera through.
   */
  minVisibilityM?: number;
  /**
   * Degrees. The light direction is never allowed below this elevation. `0`
   * turns the lift off; see `liftedElevation` for why it exists.
   */
  shadowFloorDeg?: number;
  /** Coastal fog model, or nothing. Off unless a city asks for it. */
  marineLayer?: MarineLayerOptions | null;
  /**
   * Moonlight, or `null` for none — which leaves the keyframe table's token
   * night sidelight in charge, as it was before there was a moon to replace it.
   * Defaults to `DEFAULT_MOONLIGHT`, because a black night is the failure and
   * having to opt out of the fix is the wrong way round.
   */
  moonlight?: MoonlightOptions | null;
}

/**
 * Where the camera is, for the one term in the rig that depends on it.
 *
 * Deliberately not part of `Environment`: an `Environment` is what was
 * *observed* about the world, and a camera position is not an observation of
 * anything. Deliberately not a field on `Atmosphere` either — that would make
 * `apply` depend on a mutable it does not own, and `apply` being pure is what
 * CONTRACT §4's one-way rule rests on.
 */
export interface AerialView {
  /**
   * Camera height above the ground it is looking at, in metres. `null` when
   * nobody knows, which yields exactly the rig this function produced before
   * altitude was a term in it.
   */
  altitudeMetres: number | null;
  /**
   * How far the camera is from what it is looking at, in metres.
   *
   * Optional, and its absence is a *stricter* answer rather than a missing one:
   * without it the fog is whatever the air at this altitude supports, which on a
   * board 1,063 km across can be nearer than the subject of the shot. See
   * `AERIAL_SUBJECT_CLEARANCE`.
   */
  standoffMetres?: number | null;
}

/**
 * How far you can see, and nothing else about the light.
 *
 * The **entire** camera-dependent surface of this module, stated as a type so
 * that it is a fact somebody has to edit rather than a habit. `aerialReach`
 * scales three fog distances and touches nothing else in a `LightingState`, so
 * two views of the same `Environment` differ in exactly these two numbers —
 * `aerialPerspective.test.ts` asserts that field by field.
 *
 * Deliberately **not** a colour. The haze colour is the horizon's, and the
 * horizon is a fact about the sky and the sun rather than about where the
 * camera is standing. That is not a stylistic preference: `environmentRig.ts`
 * fingerprints `sky.horizon` in order to decide whether to re-render and
 * re-convolve the PMREM cubemap, so the day a camera-dependent term reaches a
 * colour is the day every orbit step rebuilds the environment map. Keeping this
 * type to two distances is what makes that impossible by construction, and
 * widening it is a decision to be taken in front of that sentence.
 */
export interface AerialFog {
  near: number;
  far: number;
}

/**
 * How long the board takes to disappear into its own haze, and how long the next
 * one takes to come out of it. Seconds.
 *
 * ## Why a fog dip is the transition, and a crossfade is not
 *
 * Two independent reasons, either one sufficient.
 *
 * **A crossfade cannot be afforded.** Every both-boards-live pairing breaks a
 * desktop triangle cap: California + the Bay Area is 2,640,307 against a cap of
 * 2,600,000; California + the Southland is 1,805,344 against 1,700,000; the two
 * metros together are 3,694,949 against everything. Rendering only the outgoing
 * board's terrain still breaks the California direction (586,535 against
 * 440,000). And `scripts/performance-budget.mjs` samples eight seconds after a
 * three-second warm-up, so a 0.8 s fade never lands inside the window — shipping
 * one would create exactly the cap-you-do-not-measure this repo argues against.
 *
 * **A crossfade should not be wanted.** The three packs draw three different
 * Californias, and a true dissolve at a matched pose puts the two loudest
 * differences on screen simultaneously and in register: terrain and buildings
 * deflate 4.17x across the California-to-Bay seam and 4.41x into the Southland,
 * because `verticalExaggeration` is 15 / 3.6 / 3.4; and the Southland slides up
 * to 4,025 m sideways, because `World.lngScale` squashes longitude by the cosine
 * of each pack's *own* centre latitude. A dissolve does not hide a 4.2x
 * deflation, it stages it. A dip to haze hides the deflation, the slide and
 * California's 1,919 m-wide freeway symbols all at once, because all three
 * happen at maximum obscuration — and it costs zero triangles, zero draw calls
 * and no render target, because `SceneHandle.setAerialFog` already exists and
 * `main.ts` already drives it from the frame loop.
 *
 * Out is shorter than in on purpose: leaving is an instruction the visitor just
 * gave and wants obeyed, arriving is a picture they are being shown.
 */
export const FOG_DIP_OUT_SECONDS = 0.3;
export const FOG_DIP_IN_SECONDS = 0.5;

/**
 * The fog pair that hides a board, in that board's own scene units.
 *
 * `near: 0` and a far plane at a small fraction of the board's span puts every
 * fragment past the far plane, which `fog_fragment` resolves to the fog colour
 * outright — a flat field of the horizon's own colour, which is what the sky
 * dome behind it is already painted with, so the two meet without a seam.
 *
 * The far plane is not zero, and the reason is the night. The night fog colour
 * lands around #16203a and `atmosphere.ts` has already had to floor the night
 * ground rig once to stop a fog plane behind the camera turning the whole map
 * off. A collapse that leaves a few per cent of the board's span in front of the
 * camera keeps the nearest relief faintly readable through the haze, so the dip
 * reads as weather rather than as a power cut.
 */
/**
 * How deep the dip goes, as a fraction of how far the camera is standing back.
 *
 * ## It is measured against the camera, not against the board, and that is a fix
 *
 * The first version collapsed the far plane to 6% of the board's span. It hid
 * everything, which was the intent, and at night it hid everything *uniformly*:
 * photographed at 04:35Z the whole frame was one flat field of #16203a with no
 * relief, no coastline and no horizon in it. That is the failure
 * `atmosphere.ts` already had to floor the night ground rig once for — a dip
 * that turns the map off rather than putting weather on it — and it reads as a
 * wipe rather than as a descent through haze.
 *
 * Against the camera it behaves. `fog_fragment` is linear between the two
 * planes, so a far plane at 70% of the stand-off leaves the *near* limb of the
 * board at roughly 85% obscuration and everything at or beyond the orbit target
 * at 100%: the land keeps a faint shape while the relief — which is what the
 * seam actually changes, by 4.17x — goes completely. Losing the mountains and
 * keeping the coastline is the right thing to lose, because the coastline is the
 * half that is continuous across the seam.
 *
 * It also scales itself. The same fraction is a plan view of a state and a
 * street-level oblique, with no per-board number to keep in step.
 */
export const FOG_DIP_REACH_FRACTION = 0.7;

/**
 * The near plane sits a quarter of the way in rather than on the camera, and
 * that is arithmetic rather than taste.
 *
 * `dipFog` interpolates in ratios, and a ratio cannot reach zero from a positive
 * start — so a collapsed `near` of exactly 0 has to be lerped linearly while
 * `far` is lerped geometrically, and the two cross partway through: measured on
 * the Bay Area's clear pair, the near plane overtakes the far plane at about the
 * midpoint of the dip and `scene.fog` is then a pair that draws nothing
 * coherent. Keeping both distances positive and proportional keeps the whole
 * dip on one curve, and `near < far` for every value of `t` falls out of the
 * arithmetic instead of needing a guard.
 */
const FOG_DIP_NEAR_FRACTION = 0.25;

/**
 * The fog pair that hides a board, in that board's own scene units.
 *
 * `reachUnits` is how far the camera is standing back from what it is looking
 * at, in **scene units of the board being hidden** — the fog planes are scene
 * distances, and California's unit is twenty times the Bay Area's, so one shared
 * pair would leave one board unfogged and wash the other out completely.
 *
 * The caller floors it against the board's own span, because a camera that has
 * been dragged onto its target has a stand-off near zero and a fog pair of
 * (0, 0) draws nothing at all.
 */
export function collapsedFog(reachUnits: number): AerialFog {
  const far = Math.max(1e-3, reachUnits * FOG_DIP_REACH_FRACTION);
  return { near: far * FOG_DIP_NEAR_FRACTION, far };
}

/**
 * One step of a dip, as a pure function of how far through it is.
 *
 * **Not a third source of fog.** Both endpoints are pairs `Atmosphere` authored
 * — the live aerial perspective at one end, `collapsedFog` at the other — so
 * CONTRACT §4's single direction is intact: this interpolates between two of
 * this module's own answers and cannot invent a colour, a sun or a sky. It is
 * here rather than in `main.ts` for that reason, and because it is the only part
 * of the transition worth testing without a GPU.
 *
 * Geometric rather than linear, because fog distances are perceived in ratios:
 * a far plane stepping 3,900 -> 2,000 -> 1,000 -> 500 units looks like an even
 * fade, and 3,900 -> 2,925 -> 1,950 -> 975 looks like nothing at all followed by
 * a slam. `t` is clamped, so a caller that overshoots its own clock lands on the
 * endpoint rather than past it.
 */
export function dipFog(from: AerialFog, to: AerialFog, t: number): AerialFog {
  // `NaN` is a caller whose clock went wrong, and the honest answer to that is
  // the pair it started from rather than a fog of `NaN`, which three writes
  // straight into a uniform and renders as an untextured void.
  const clamped = Number.isFinite(t) ? t : 0;
  // The endpoints are returned rather than computed. `a * (b / a) ** 1` is not
  // `b` in floating point, and a transition that settles a rounding error away
  // from the pair `Atmosphere` authored leaves the board very slightly wrong for
  // as long as nothing else recomputes the fog.
  if (clamped <= 0) return { near: from.near, far: from.far };
  if (clamped >= 1) return { near: to.near, far: to.far };
  const u = clamped * clamped * (3 - 2 * clamped);
  const ratio = (a: number, b: number): number => {
    if (a <= 0 || b <= 0) return a + (b - a) * u;
    return a * Math.pow(b / a, u);
  };
  return { near: ratio(from.near, to.near), far: ratio(from.far, to.far) };
}

export interface Atmosphere {
  /**
   * The rig this observation implies. Pure; the caller applies the result.
   *
   * `view` is optional and omitting it is not a degraded mode: the fog is then
   * the clear-day pair the board was authored with, which is what every caller
   * got before aerial perspective existed. See `aerialReach`.
   */
  apply(env: Environment, view?: AerialView): LightingState;
  /**
   * The half of the rig that follows the camera, alone. Pure, like `apply`.
   *
   * ## Why this exists at all
   *
   * Because the sun and the camera move at completely different rates and were
   * being served by one call. `main.ts` recomputes on the clock **once a
   * minute**; it recomputes on the city's tick, throttled to 2% of altitude,
   * which is a few dozen times during a single drag or a chapter flight. Routing
   * the second of those through `apply` + `Scene.setLighting` fans a camera
   * gesture out across six layer setters, a material `needsUpdate`, an
   * instanced-mesh rebuild and the environment rig's fingerprint — none of which
   * have anything to do with the camera having moved, because none of them read
   * a distance. The tick, rather than OrbitControls `change`, is load-bearing:
   * drive, crow and aircraft follow-cameras write the pose directly and never
   * fire that event.
   *
   * ## Why it returns two numbers and not a `LightingState`
   *
   * So that the narrow path *cannot* carry a sky. Fog distances are screen-space
   * and per-frame; the sky, the sun and the environment map are the light, they
   * are decided on the clock, and CONTRACT.md §4 keeps `Atmosphere` their sole
   * owner. A setter that took a whole rig would let the camera path quietly
   * become a second way to change the light, which is the exact merge this split
   * exists to prevent. See `AerialFog`.
   *
   * ## Why it recomputes the rig rather than a cheaper fog of its own
   *
   * Measured at 0.019 ms a call, against 0.14-0.19 ms for the full apply-and-
   * light path it replaces, so the saving is already in the fan-out and a second
   * cheaper formula would buy a rounding error. What it would cost is the
   * property that matters: the fog on the camera path is *the same number*
   * `apply` would have written, bit for bit, because it **is** that number. Two
   * derivations of one fog is how a drag and a clock tick start disagreeing
   * about the weather, and it is the same argument this file already makes for
   * `cloudCover` being a method rather than a second return.
   *
   * `view` is required here, unlike on `apply`. Asking for the fog without
   * saying where the camera is has no answer worth having — it is the authored
   * ceiling, which is what `apply` already gives you.
   */
  aerial(env: Environment, view: AerialView): AerialFog;
  /**
   * How much of the sky has cloud in it, 0..1 — observed if anyone observed it,
   * modelled from this place and this instant if nobody did. Pure, like `apply`.
   *
   * The cause, not the consequence: what a cloud layer needs in order to draw
   * the right amount of cloud, as distinct from everything in `LightingState`,
   * which is what that cloud does to the light once it is there.
   */
  cloudCover(env: Environment): number;
}

// ---- Constants ------------------------------------------------------------

const MS_PER_MINUTE = 60_000;
const MS_PER_DAY = 86_400_000;

/** Matches `cityDaylight()` in `scene.ts`, so switching to a live sun at noon does not jolt. */
const DEFAULT_SKY_TOP = 0x8fb8d8;
const DEFAULT_SKY_HORIZON = 0xd9e6ee;
const DEFAULT_FOG_NEAR = 210;
const DEFAULT_FOG_FAR = 460;
const DEFAULT_MIN_VISIBILITY_M = 4500;
const DEFAULT_SHADOW_FLOOR_DEG = 7;

/**
 * The darkest the night sky is ever allowed to get, per channel.
 *
 * A correct night is `#000`, and `#000` is unusable: the horizon disappears,
 * the skyline stops having a silhouette against anything, and the frame reads
 * as a failed render rather than as darkness. These are the values of a clear
 * moonless sky as a dark-adapted eye reports it rather than as a photometer
 * does — still unmistakably night, with the horizon a little warmer and
 * brighter than the zenith because that is where the airglow and everyone
 * else's city lights are.
 */
const NIGHT_FLOOR_TOP = 0x090e1c;
const NIGHT_FLOOR_HORIZON = 0x16203a;

/**
 * The same floor, for the light that lands on the ground.
 *
 * `NIGHT_FLOOR_TOP` above makes the argument for the sky and then only fixes
 * the sky, which is exactly half the job — and the half that hides the other
 * half, because a lifted sky behind a black landmass reads as a working render
 * of an empty ocean. What it was actually producing at -18° was terrain at
 * #000004 under a sky at #16203a: the coastline gone, the hills gone, water and
 * land the same colour, and nothing left in the frame but the lit windows and
 * the freeway threads floating in it.
 *
 * A photometrically correct answer here really is close to zero. A moonless
 * night is a few thousandths of a lux of airglow and starlight, against a hundred
 * thousand at noon, and any honest ratio lands under one code value. But three
 * things make zero the wrong number to render:
 *
 *   - **The display has no room underneath.** Everything from 1/1000 of white
 *     down to nothing shares the bottom two or three code values of an 8-bit
 *     sRGB ramp. A correctly exposed night does not come out dim, it comes out
 *     quantised to black, and no amount of squinting recovers a coastline that
 *     was rounded to #000.
 *   - **Nobody is dark-adapted.** The eye that can read a moonless landscape has
 *     spent forty minutes getting there. The eye looking at this has a lit room
 *     behind it and a white browser chrome around it, and its black point is
 *     several stops above the screen's.
 *   - **This is a map.** It is looked at from eighty kilometres up, from outside
 *     the atmosphere it is depicting, by someone who wants to know where the bay
 *     is. A view that goes correctly blank at 3 a.m. is not a night mode, it is
 *     an outage — and it is reported as one.
 *
 * So these are the same kind of lie as `DEFAULT_MOONLIGHT.intensity`: not the
 * light there is, but the light a moonless night *looks like* it has once you
 * are standing in it. Held deliberately low enough that the city's own lit
 * windows stay the brightest thing in the frame by a factor of four or five,
 * which is the one relationship that makes it read as night rather than as a
 * blue-graded day.
 *
 * Split five ways rather than folded into one brightness because the *ratio*
 * between them is what stops the result looking like fog. Ambient is
 * unshaped — every surface gets the same number whichever way it faces — so a
 * night lit by ambient alone is flat, and flat and dark is fog, not darkness.
 * The hemisphere carries most of it instead, sky term well above ground term, so
 * a roof is lighter than a wall; and the keyframe table's token sidelight
 * survives at full strength on a moonless night (see `applyNight`) so the hills
 * still have a lit side and a dark one.
 *
 * ### Why these two numbers moved when tone mapping arrived
 *
 * They went up by a third, and the ratios between all five did not change,
 * which is the point. `stage.ts` now runs ACES filmic instead of a bare
 * `saturate()`, and ACES has a toe: it is steeper than a plain sRGB encode
 * everywhere below about linear 0.1, which is the entire range a moonless night
 * occupies. A ground reading that displayed at 0.212 under the old renderer
 * came out at 0.174 under the new one for the same physical light — an 18% loss
 * concentrated exactly where this file has the least to give.
 *
 * A third more linear light puts it back at 0.240 and leaves the *shape* of the
 * night alone, because every one of the five terms was scaled by the same
 * factor. That is deliberate: the comment above spends four paragraphs on the
 * ratios between them, and a re-tune that fixed the brightness by flattening the
 * sky-to-ground gradient would have thrown away the argument to keep the number.
 * The city's own lit windows keep their four-to-five-times lead as well — they
 * are emissive, they were clipping at 1.0 before, and under a shoulder they now
 * separate rather than all rendering as the same white.
 */
const NIGHT_FLOOR_HEMI_SKY = 0x354c88;
const NIGHT_FLOOR_HEMI_GROUND = 0x1f2740;
const NIGHT_FLOOR_HEMI_INTENSITY = 1.05;
const NIGHT_FLOOR_AMBIENT = 0x47557f;
const NIGHT_FLOOR_AMBIENT_INTENSITY = 0.3;

/**
 * The same sky, and the same fill, with a full moon in it.
 *
 * These moved up when the floor did, and they had to: a floor raised to meet the
 * moon has deleted the moon, and `moonPosition` is four hundred lines of Meeus
 * that would then be decorative. The gap is the point — a moonlit night has to
 * arrive as an event, four to five times the moonless floor in linear light, and
 * with a *direction* in it that the floor by construction does not have.
 */
const MOONLIT_SKY_TOP = 0x111d3e;
const MOONLIT_SKY_HORIZON = 0x2d3c62;
const MOONLIT_HEMI_SKY = 0x40597f;
const MOONLIT_AMBIENT = 0x546490;

/**
 * Where fog starts, as a fraction of where it ends. `cityDaylight`'s 210/460 is
 * 0.457 and looks right, so the ratio is held rather than the distance: a fog
 * that closes to 60 units with its near plane still at 210 is not fog, it is a
 * solid wall.
 */
const FOG_NEAR_RATIO = 0.45;

/**
 * Kilometres at and above which a reported visibility means "as far as anyone
 * bothered to look".
 *
 * This is the correction that stops every clear day rendering hazy. A METAR of
 * `10SM` is the *maximum value the report can carry*, not a measurement of ten
 * miles — the observer stopped counting. Taking it literally puts the fog plane
 * at 170 scene units, inside the camera's own orbit range, and washes out a sky
 * that is in fact unlimited. The blend from `VISIBILITY_HAZY_KM` upward also
 * keeps the transition smooth, because a cliff at exactly 16 km would make the
 * whole city snap between hazy and crisp on a one-decimal change upstream.
 */
const VISIBILITY_UNLIMITED_KM = 16;
const VISIBILITY_HAZY_KM = 8;

/** Visibility assumed when a source says "fog" and reports no number. */
const FOG_CONDITION_VISIBILITY_KM = 1.5;

// ---- Aerial perspective ---------------------------------------------------

/**
 * The density scale height of the atmosphere, in metres.
 *
 * Air thins as `exp(-h/H)` and H is about 8.5 km, so a camera at 8.5 km looks
 * through `1/e` of the air a camera at sea level does and sees `e` times as
 * far. This is the only physical constant in the whole of the change below and
 * it is the one that is not negotiable.
 */
const AERIAL_SCALE_HEIGHT_M = 8_500;

/**
 * How far you can see horizontally at sea level on a clear day, in metres.
 *
 * 86 km, which is not a round number and is not a guess: it is what San
 * Francisco's fog *already is* once its `boardSpan * 0.91` is converted through
 * 94.34 m per unit, and San Francisco is the one board of the three whose fog
 * is physically correct. Converted to metres the three boards disagree by 8x —
 * SF at 86 km, Southern California at 140, the state board at 748 — so this is
 * the number the other two are being measured against rather than a new one.
 */
const AERIAL_CLEAR_VISIBILITY_M = 86_000;

/**
 * Never let the fog collapse entirely, however low the camera gets.
 *
 * Deliberately below where the curve actually lands, so that it is a safety net
 * and not a term in the answer: at zero altitude every board comes out at 86 km
 * of visibility, which is 0.040 of the state board's authored reach, 0.28 of
 * Southern California's and 0.45 of the Bay Area's. Two per cent sits under all
 * three. Its only job is that a camera put at a nonsense altitude by a
 * controller bug renders haze rather than a wall — the failure mode of an
 * aerial-perspective term has to be a board you can still read.
 */
const AERIAL_MIN_REACH = 0.02;

/**
 * How far the fog must reach past whatever the camera is looking at, as a
 * multiple of the camera's own stand-off.
 *
 * **The correction that turns a physical model into a picture, and it was found
 * by taking one.** A camera parked 223 km from a mountain on the state board and
 * 8 km above the ground is, physically, looking at something it cannot see: the
 * air alone supports 231 km of visibility and the subject is at 223 of it, so
 * the frame came back as a white rectangle with a faint cone in it. That is a
 * correct rendering of an incorrect question. A board is a **map**, and a map is
 * looked at from outside the atmosphere it depicts — which is the same argument
 * `minVisibilityM` already makes and the reason it exists at all.
 *
 * So the floor generalises from "1.6 board spans" to "six times however far the
 * camera actually is", which is the same idea sized by the shot rather than by
 * the pack. With `main.ts`'s 1.15/3.9 near-to-far ratio that puts the near plane
 * at 1.77 stand-offs: **nothing closer to the camera than about 1.8 times its
 * own distance to its subject is fogged at all**, and everything past that hazes
 * out toward six.
 *
 * Six and not four, and the number is set by one measurement rather than by
 * taste. It is what makes every board's whole-board pose saturate at the ceiling
 * — the guarantee this whole change rests on, since those are the frames every
 * marketing still is shot from. California's opening pose is 67.6 km up and
 * Southern California's 17.0 km, so both clear it on the air term alone; the Bay
 * Area's is 11.3 km up against a 12.4 km saturation altitude, and it is the one
 * board that needs the stand-off term to get there. At six it clears with 16%
 * to spare; at four it renders at 88% of its authored reach and the far corner
 * of the board picks up haze it has never had. `aerialPerspective.test.ts`
 * asserts all three against the deployed formula so this cannot drift.
 *
 * `null` turns it off, which is what the pure tests pass: the altitude curve on
 * its own is the thing being asserted there, and a clearance term would make
 * those numbers a fact about a camera rather than about the air.
 */
const AERIAL_SUBJECT_CLEARANCE = 6;

/**
 * How much of a board's authored visibility the air at this altitude supports,
 * 0..1.
 *
 * ## Why this is a fraction and not a distance
 *
 * The obvious change — the one the investigation actually proposed — is to
 * state fog in physical metres and be done with it. It does not survive
 * arithmetic. 86 km of clear-day visibility is 0.91 spans on San Francisco's
 * 1,003-unit board, which is right, and **0.085 spans on the extended state
 * board**, where the camera orbits out to 1,108 units. A literal metre fog
 * would bury California in haze from 47 units out and there would be no pose on
 * that board from which the state was visible at all.
 *
 * So the invariant is not a distance. It is *visibility through the atmospheric
 * column as the camera climbs*, expressed as a fraction of the reach the board
 * was authored for. At the wide pose it saturates at 1 and every board renders
 * exactly as it did before this function existed — San Francisco is left
 * untouched by construction rather than by measurement, because its resting
 * chapter sits 40 km up and the fraction reached 1 by 6.7 km. Low down, on the
 * board where a chase camera is two kilometres off the ground and the Sierra is
 * eighty kilometres away, it is a fifth, and the range hazes the way a range
 * eighty kilometres away does.
 *
 * `null` means nobody said where the camera is, and the answer is 1: today's
 * behaviour, unchanged, which is what every caller that has not been taught
 * about altitude must keep getting.
 */
export function aerialReach(
  altitudeMetres: number | null,
  authoredReachM: number,
  standoffM: number | null = null,
): number {
  if (altitudeMetres === null || !Number.isFinite(altitudeMetres)) return 1;
  if (!Number.isFinite(authoredReachM) || authoredReachM <= 0) return 1;
  const h = Math.max(0, altitudeMetres);
  const air = AERIAL_CLEAR_VISIBILITY_M * Math.exp(h / AERIAL_SCALE_HEIGHT_M);
  const clears =
    standoffM !== null && Number.isFinite(standoffM) && standoffM > 0
      ? standoffM * AERIAL_SUBJECT_CLEARANCE
      : 0;
  return clamp(Math.max(air, clears) / authoredReachM, AERIAL_MIN_REACH, 1);
}

/**
 * The clear-day fog pair a camera at this altitude earns, in scene units.
 *
 * The ceiling is the pair the board was authored with and is never exceeded —
 * `boardSpan * 0.91` and `boardSpan * 2` are `cityDaylight`'s, and `main.ts`
 * hands in its own slightly wider pair for a board with a satellite dome over
 * it. Either way this only ever pulls the fog *in*, so no board can be made to
 * render further than it was tuned to.
 *
 * Exported because it is the whole of the change and it is testable with no GL
 * context, no board and no camera: three numbers in, two numbers out.
 */
export function aerialFog(options: {
  /** The pair this board would use with no altitude term at all. Scene units. */
  ceiling: { near: number; far: number };
  /** Metres in one scene unit. */
  metresPerUnit: number;
  /** Camera height above the ground it is looking at, metres. `null` = unknown. */
  altitudeMetres: number | null;
  /** How far the camera is from what it is looking at, metres. See the clearance. */
  standoffMetres?: number | null;
}): { near: number; far: number } {
  const { ceiling, metresPerUnit, altitudeMetres } = options;
  const reach = aerialReach(
    altitudeMetres,
    ceiling.far * metresPerUnit,
    options.standoffMetres ?? null,
  );
  return { near: ceiling.near * reach, far: ceiling.far * reach };
}

/**
 * Whether the camera has climbed or dropped far enough to recompute aerial fog.
 *
 * Two per cent of the last altitude, floored at a metre so a camera sitting on
 * the ground does not recompute on every sample. The first sample — `last` not
 * a finite non-negative number, which is how `main.ts` seeds a new board —
 * always counts, because a board that just arrived has no previous altitude.
 *
 * This is a threshold on *altitude*, not on time and not on the fog pair. A
 * chapter flight therefore collapses to a few dozen recomputations instead of
 * sixty a second; a follow-camera descent still steps whenever height moves.
 * A dip that changes near/far with the altitude held still is a different
 * trigger and does not go through here.
 */
export function aerialFogMoved(altitudeMetres: number, lastAltitudeMetres: number): boolean {
  if (!(lastAltitudeMetres >= 0)) return true;
  if (!Number.isFinite(altitudeMetres)) return false;
  return Math.abs(altitudeMetres - lastAltitudeMetres) >= Math.max(1, lastAltitudeMetres * 0.02);
}

// ---- The daylight table ---------------------------------------------------

interface Rig {
  skyTop: number;
  skyHorizon: number;
  sunColor: number;
  sunIntensity: number;
  hemiSky: number;
  hemiGround: number;
  hemiIntensity: number;
  ambientColor: number;
  ambientIntensity: number;
}

interface Keyframe extends Rig {
  /** Solar elevation, in degrees, that this frame describes exactly. */
  elevation: number;
}

/**
 * The sky at eight solar elevations, interpolated between.
 *
 * The stops sit on the twilight boundaries `solar.ts` already names — -18, -12,
 * -6, the refracted horizon, and up through golden hour into full day — so that
 * `daylightPhase` and this table agree about where a phase begins. A table
 * rather than a formula because the interesting part of a sunset is not
 * physical: the horizon band goes salmon while the zenith is still deep blue,
 * and the ratio between them is a thing you tune by looking, not by deriving.
 *
 * Two stops carry the city's own daylight colours (see `AtmosphereOptions.sky`),
 * so a city that declares a paler or bluer sky keeps it at noon and still gets
 * the same dusk as everywhere else — dusk is not regional in any way this
 * renderer can see.
 *
 * ### The intensities are tuned against a tone curve, and it is ACES
 *
 * Read the three intensity columns as a set, because they were re-tuned as one
 * when `stage.ts` stopped rendering through `NoToneMapping`. The old numbers
 * were not wrong — they were correct for a renderer that hard-clipped at linear
 * 1.0, and being correct for that is exactly what makes them wrong now.
 *
 * Two things changed, in opposite directions, and both are visible in the table.
 *
 * **Below the horizon the fills went up by about a third.** ACES's toe is
 * steeper than a plain sRGB encode everywhere under about linear 0.1, which is
 * the whole of the range a night frame lives in. See `NIGHT_FLOOR_HEMI_SKY` for
 * the arithmetic; the three night stops move with the floor because they sit
 * just under it and it is the floor that binds.
 *
 * **Above the horizon the key went up and the fill came down.** That is not a
 * brightness change, it is a contrast change, and it is the whole reason to have
 * done this. Under a clipping renderer a sunlit 0.7-albedo wall and a sunlit
 * 0.95-albedo wall were both exactly white, so the only way to make a daylit
 * scene read as *lit* was to pour in fill until the shadows came up to meet the
 * blown highlights — which is a description of a flat picture. With a shoulder
 * over the top, a sun at 2.6 puts a lit face at about 0.88 display and its own
 * shaded face at about 0.52, and the difference between them is the modelling
 * that was missing. So `hemiIntensity` loses roughly a tenth and
 * `ambientIntensity` roughly a quarter at the two day stops.
 *
 * The fill can afford that for a second reason: it is no longer the only
 * indirect light in the scene. `environmentRig.ts` derives a real sky
 * environment from this same `LightingState` and puts it on `Scene.environment`,
 * so the diffuse bounce the hemisphere light was standing in for now arrives
 * from something with a direction and a horizon in it. Trimming here and adding
 * there is one move, not two.
 *
 * The sky columns are untouched, and that is not an oversight. Three marks the
 * background mesh `toneMapped = false` for an sRGB-transfer texture and mixes
 * fog after the tone map from an already-encoded uniform, so `skyTop`,
 * `skyHorizon` and the fog colour are displayed exactly as written here. Only
 * the lit geometry moved, so only the light was re-tuned.
 */
function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
  return [
    {
      // Full night. The directional light is not the sun and is not the moon
      // either; it is a token sidelight standing in for moonlight and city
      // glow, because a scene lit by hemisphere alone has no silhouettes in it
      // and reads as a bug rather than as darkness.
      //
      // The three night stops used to be an order of magnitude below this, and
      // the reason they were wrong is instructive: they were read off a
      // photograph of a night sky, which is a picture of the *sky* and says
      // nothing about the ground under it. Multiplied out, `hemiSky` at 0x121a30
      // times 0.25 came to about four thousandths of the fill at noon, which is
      // roughly honest and rendered San Francisco as #000004. What is here now
      // is the ground reading the eye reports — the coastline findable, the
      // hills with a lit side, the bay darker than the land around it — with the
      // sky stops left where they were, because those were never the problem.
      // See `NIGHT_FLOOR_HEMI_SKY`, which is what actually holds this up: these
      // rows sit just under the floor and the floor is what binds.
      elevation: -18,
      skyTop: 0x05070f,
      skyHorizon: 0x0b1120,
      sunColor: 0x44558a,
      sunIntensity: 0.22,
      hemiSky: 0x2f447e,
      hemiGround: 0x1b2234,
      hemiIntensity: 0.74,
      ambientColor: 0x414e78,
      ambientIntensity: 0.22,
    },
    {
      elevation: -12,
      skyTop: 0x080d1e,
      skyHorizon: 0x141d38,
      sunColor: 0x51629b,
      sunIntensity: 0.26,
      hemiSky: 0x32477d,
      hemiGround: 0x1d2437,
      hemiIntensity: 0.77,
      ambientColor: 0x424f7a,
      ambientIntensity: 0.23,
    },
    {
      elevation: -6,
      skyTop: 0x101a3a,
      skyHorizon: 0x2b3560,
      sunColor: 0x66699a,
      sunIntensity: 0.35,
      hemiSky: 0x3c558c,
      hemiGround: 0x23293c,
      hemiIntensity: 0.81,
      ambientColor: 0x485389,
      ambientIntensity: 0.26,
    },
    {
      // The sun on the horizon. Warm at the bottom, cold at the top, and the
      // widest colour spread the sky ever has.
      elevation: -0.4,
      skyTop: 0x2a4275,
      skyHorizon: 0x9a6a63,
      sunColor: 0xc2795c,
      sunIntensity: 0.58,
      hemiSky: 0x4a5f8c,
      hemiGround: 0x2a2a2c,
      hemiIntensity: 0.72,
      ambientColor: 0x6a6a80,
      ambientIntensity: 0.25,
    },
    {
      elevation: 3,
      skyTop: 0x4d76ac,
      skyHorizon: 0xdba078,
      sunColor: 0xff9c56,
      sunIntensity: 1.45,
      hemiSky: 0x86a6cc,
      hemiGround: 0x54503f,
      hemiIntensity: 0.9,
      ambientColor: 0xffd9b8,
      ambientIntensity: 0.26,
    },
    {
      elevation: 8,
      skyTop: 0x6b96c6,
      skyHorizon: 0xebc9a4,
      sunColor: 0xffc489,
      sunIntensity: 2.0,
      hemiSky: 0xb2cbe4,
      hemiGround: 0x6a6752,
      hemiIntensity: 0.95,
      ambientColor: 0xffe7cf,
      ambientIntensity: 0.26,
    },
    {
      // Ordinary daylight, and the one stop that reproduces `cityDaylight()`.
      elevation: 25,
      skyTop: dayTop,
      skyHorizon: dayHorizon,
      sunColor: 0xfff3e0,
      sunIntensity: 2.35,
      hemiSky: 0xdcecf7,
      hemiGround: 0x6b6f5e,
      hemiIntensity: 0.92,
      ambientColor: 0xffffff,
      ambientIntensity: 0.24,
    },
    {
      // A high sun. The zenith deepens — less air to scatter through overhead —
      // while the horizon whitens, so the gradient is at its steepest at noon.
      elevation: 65,
      skyTop: mixHex(dayTop, 0x2f6bb0, 0.35),
      skyHorizon: mixHex(dayHorizon, 0xffffff, 0.2),
      sunColor: 0xfffdf6,
      sunIntensity: 2.6,
      hemiSky: 0xe6f2fb,
      hemiGround: 0x74786a,
      hemiIntensity: 0.95,
      ambientColor: 0xffffff,
      ambientIntensity: 0.22,
    },
  ];
}

/**
 * The frame for an elevation, blended between the two stops around it.
 *
 * Eased rather than linear. Straight lerp between table rows leaves a visible
 * crease every time the sun crosses a stop — the rate of change jumps, and on
 * a sky the eye reads that as a seam sliding down the screen. Smoothstep makes
 * the derivative zero at each stop, so the stops stop being findable.
 */
function sample(frames: readonly Keyframe[], elevation: number): Rig {
  const first = frames[0];
  const last = frames[frames.length - 1];
  if (!first || !last) throw new Error("atmosphere: empty keyframe table");
  if (elevation <= first.elevation) return first;
  if (elevation >= last.elevation) return last;

  for (let i = 1; i < frames.length; i++) {
    const a = frames[i - 1];
    const b = frames[i];
    if (!a || !b) continue;
    if (elevation <= b.elevation) {
      const t = ease((elevation - a.elevation) / (b.elevation - a.elevation));
      return {
        skyTop: mixHex(a.skyTop, b.skyTop, t),
        skyHorizon: mixHex(a.skyHorizon, b.skyHorizon, t),
        sunColor: mixHex(a.sunColor, b.sunColor, t),
        sunIntensity: lerp(a.sunIntensity, b.sunIntensity, t),
        hemiSky: mixHex(a.hemiSky, b.hemiSky, t),
        hemiGround: mixHex(a.hemiGround, b.hemiGround, t),
        hemiIntensity: lerp(a.hemiIntensity, b.hemiIntensity, t),
        ambientColor: mixHex(a.ambientColor, b.ambientColor, t),
        ambientIntensity: lerp(a.ambientIntensity, b.ambientIntensity, t),
      };
    }
  }
  return last;
}

// ---- The atmosphere -------------------------------------------------------

export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
  const { lng, metresPerUnit } = options;
  const table = keyframes(
    options.sky?.top ?? DEFAULT_SKY_TOP,
    options.sky?.horizon ?? DEFAULT_SKY_HORIZON,
  );
  const clearNear = options.clearFog?.near ?? DEFAULT_FOG_NEAR;
  const clearFar = options.clearFog?.far ?? DEFAULT_FOG_FAR;
  const floorFar = (options.minVisibilityM ?? DEFAULT_MIN_VISIBILITY_M) / metresPerUnit;
  const shadowFloor = options.shadowFloorDeg ?? DEFAULT_SHADOW_FLOOR_DEG;
  const marineOptions = options.marineLayer ?? null;
  const moonOptions = options.moonlight === undefined ? DEFAULT_MOONLIGHT : options.moonlight;

  function apply(env: Environment, view?: AerialView): LightingState {
    const elevation = env.sun.elevation;
    /**
     * Aerial perspective, as one scalar applied to all three fog distances.
     *
     * A second argument rather than a field on `Environment`, and the
     * distinction is CONTRACT §4's: an `Environment` is what the *world* is
     * doing, and where the camera happens to be is not a fact about the world.
     * `apply` stays pure — it is now a pure function of two arguments — and a
     * caller that passes nothing gets exactly the rig it got before this
     * existed, because `aerialReach(null, …)` is 1.
     *
     * The floor moves with the other two on purpose. `minVisibilityM` exists
     * because a map is looked at from outside the atmosphere it depicts, so it
     * has to be big; left fixed while the clear pair shrank, it would clamp the
     * haze straight back out again and this whole change would render as
     * nothing at all.
     */
    const reach = aerialReach(
      view?.altitudeMetres ?? null,
      clearFar * metresPerUnit,
      view?.standoffMetres ?? null,
    );
    const nearAt = clearNear * reach;
    const farAt = clearFar * reach;
    const floorAt = floorFar * reach;
    // Copied, because `sample` hands back a table row unchanged when the
    // elevation is off either end of it and everything below mutates in place.
    const rig = { ...sample(table, elevation) };

    // How much of the light is daylight at all. Used to keep the weather from
    // brightening the night: overcast at noon is grey, overcast at 2 a.m. is
    // still black, and a modifier that does not know the difference will
    // cheerfully raise the small hours to a uniform slate.
    const day = smoothstep(-6, 6, elevation);
    const night = nightFactor(elevation);

    const weather = env.weather;
    const cloud = clamp(weather?.cloudCover ?? 0, 0, 1);
    const precipitation = clamp(weather?.precipitation ?? 0, 0, 1);
    const condition = weather?.condition ?? "clear";

    // An observation always beats the climatology: `null` lets the model run
    // free, which is what gives an offline San Francisco its summer fog, but a
    // station reporting sunshine ends the argument. See `observedObscuration`.
    //
    // Computed here rather than after the rig passes, where it used to sit,
    // because the moon needs to know how much air is in the way before it can
    // say how much light is getting through. Both halves are pure functions of
    // the observation and neither touches the rig, so the move is a reordering
    // of independent statements and nothing else.
    const observed = weather ? observedObscuration(weather) : null;
    const modelled = marineOptions ? marineStrength(marineOptions, env, lng) : 0;
    const obscuration =
      observed === null ? modelled : observed === 0 ? 0 : Math.max(observed, modelled);

    // The moon goes in before the weather does, so that an overcast night is
    // the weather closing over a moonlit sky rather than over a black one.
    const moon = moonOptions
      ? moonRig(env.moon, night, cloud, obscuration, moonOptions, shadowFloor)
      : NO_MOON;
    applyNight(rig, moon, night, moonOptions);

    applyCloud(rig, cloud, day);
    applyPrecipitation(rig, precipitation, condition, day);

    let fogFar = visibilityFar(weather, condition, farAt, metresPerUnit);
    if (weather === null || weather.visibilityKm === null) {
      // Rain shortens the view; a source that measured visibility has already
      // said so, and applying both would count it twice.
      fogFar *= 1 - 0.45 * precipitation;
    }
    if (marineOptions && obscuration > 0) {
      const inside = marineOptions.visibilityM / metresPerUnit;
      fogFar = Math.min(fogFar, lerp(fogFar, inside, obscuration));
    }
    fogFar = Math.max(fogFar, floorAt);

    const fogColor = applyObscuration(rig, obscuration, day, night, condition);

    // A clear day keeps the near plane it was given; anything shorter holds the
    // ratio instead, because fog that starts where the clear day's did and ends
    // sixty units out is not fog, it is a wall.
    const near = clamp(fogFar >= farAt ? nearAt : fogFar * FOG_NEAR_RATIO, 2, fogFar * 0.9);

    return {
      sun: combineKey(lightDirection(env.sun, shadowFloor), rig.sunColor, rig.sunIntensity, moon),
      hemisphere: {
        sky: rig.hemiSky,
        ground: rig.hemiGround,
        intensity: Math.max(0, rig.hemiIntensity),
      },
      ambient: { color: rig.ambientColor, intensity: Math.max(0, rig.ambientIntensity) },
      sky: { top: rig.skyTop, horizon: rig.skyHorizon },
      fog: { color: fogColor, near, far: fogFar },
      moon: drawableMoon(env, night, cloud, obscuration),
    };
  }

  /**
   * `apply`'s own fog distances, taken off `apply`'s own answer.
   *
   * The one-line body is the point and is load-bearing: there is no second
   * formula here to keep in step, so the number a camera step writes and the
   * number the next clock tick writes cannot drift apart. The interface note
   * above carries the measurement that says this is affordable.
   *
   * The `null` branch is unreachable from a city rig — `apply` always returns a
   * fog — and is written rather than asserted because a `null` fog is a real
   * `LightingState` (it is what an office gets, CONTRACT.md §4), and the honest
   * answer for a rig with no fog in it is the pair the board was authored with.
   */
  function aerial(env: Environment, view: AerialView): AerialFog {
    const fog = apply(env, view).fog;
    return fog === null ? { near: clearNear, far: clearFar } : { near: fog.near, far: fog.far };
  }

  /**
   * The sky's own cover, 0..1, for whatever wants to draw it.
   *
   * **A method on `Atmosphere` rather than a field of `LightingState` or a
   * second return from `apply`.** The rig is a set of consequences — three
   * lights, a gradient and a fog — and CONTRACT.md §4's one-way rule survives
   * only while causes travel in an `Environment` and consequences travel in a
   * `LightingState`. A cover parked on the rig is an invitation for the next
   * module along to read a cause back out of a light, which is the shape this
   * file exists to prevent. It is not a field of `Environment` either: an
   * `Environment` is what was *observed*, `observe()` is handed nothing but a
   * place and an instant, and a modelled number sitting in the observation is
   * exactly the confusion that `WeatherObservation`'s `null`-means-unreported
   * rule is careful about.
   *
   * Which leaves a method, and the closure is the reason it is a good one: the
   * answer needs `lng`, because the marine layer's clock runs on apparent solar
   * time, and it needs this city's `marineLayer`, because the fog is a fact
   * about one coast. Both are already held here. A free function would have to
   * be handed both at every call site, and the call site that matters already
   * has an `Atmosphere` in scope.
   */
  function cloudCover(env: Environment): number {
    // **An observation wins outright, and nothing below is allowed to argue
    // with it.** `WeatherObservation.cloudCover` is a measured fraction — never
    // `null`, unlike the fields that can go unreported — so the presence of an
    // observation at all is the whole test. A *reported* clear sky ends the
    // argument here exactly as it does for obscuration in `apply`: the model
    // below is what to do when nobody was asked, not a second opinion.
    if (env.weather) return clamp(env.weather.cloudCover, 0, 1);
    return modelledCloudCover(marineOptions, env, lng);
  }

  return { apply, aerial, cloudCover };
}

// ---- The sun's direction, and the shadow camera ---------------------------

/**
 * A unit vector toward the sun, never allowed below `floorDeg`.
 *
 * This is the whole of the atmosphere's shadow handling, and it is a lift on
 * the light direction rather than a change to the shadow camera because a
 * `LightingState` deliberately carries no shadow fields — the extents belong to
 * the scene, which knows its own scale, and letting the sun reach into them
 * would be exactly the write-back CONTRACT.md §4 forbids. So the rule is
 * inverted: never ask for a direction the shadow camera cannot serve.
 *
 * What goes wrong without it, at `SceneKit`'s defaults — a 2048 map over a
 * 340-unit box, so one texel is ~0.17 units or about 16 m of San Francisco:
 *
 *   - Depth across a texel grows as 1/tan(elevation). At 20° a texel spans 0.46
 *     units of depth; at 3° it spans 3.2. A single fixed `shadow.bias` tuned at
 *     one of those is acne or peter-panning at the other, and there is no value
 *     that is right at both.
 *   - A shadow three hundred metres long and two metres wide is a shape a 16 m
 *     texel cannot represent at all. It comes out as a dashed line that crawls
 *     when the camera moves.
 *   - Below the horizon the direction points *up* from underneath, lighting the
 *     undersides of everything and leaving the roofs black. That is not a
 *     subtle artefact; it is a scene that looks inside out.
 *
 * The visible cost is that shadows stop lengthening a few degrees before
 * sunset. Against a sunset in which they shimmer, dash and then invert, that is
 * a cheap trade — and by the time the lift is doing real work the sun's
 * intensity is already down near a tenth, so there is very little shadow left
 * to be wrong about. A scene that genuinely wants grazing shadows widens its
 * own `shadowExtent`/`shadowMapSize` and passes `shadowFloorDeg: 0`.
 */
function lightDirection(sun: SolarPosition, floorDeg: number): [number, number, number] {
  const lifted = floorDeg > 0 ? Math.max(sun.elevation, floorDeg) : sun.elevation;
  const dir = sunDirection({ ...sun, elevation: lifted });
  return [dir.x, dir.y, dir.z];
}

// ---- Night ----------------------------------------------------------------

/**
 * How much of a night it is, from the sun alone: 0 in daylight, 1 once the
 * sun's own light is gone.
 *
 * Exported because it is the engine's single definition of dusk, and more than
 * one thing needs one. The moon takes over as the key light on this curve, and
 * `nightlights.ts` reads it to decide how brightly a lit window burns against
 * the sky behind it. Two modules each inventing their own idea of when night
 * begins is how a city ends up switching its lights on well after the moon has
 * already become the brightest thing in the frame.
 *
 * The upper edge is half a degree *above* the horizon rather than on it,
 * because the sun's last half degree is its own disc setting and the light has
 * already collapsed by then; the lower edge is the middle of nautical twilight,
 * by which point what is left of the sun is a glow in one direction and not a
 * light source.
 */
export function nightFactor(elevation: number): number {
  return 1 - smoothstep(-8, 0.5, elevation);
}

/**
 * The moon as something to draw, rather than as something to light with.
 *
 * See `LightingMoon`. Every number here already existed in this file and was
 * being discarded: `moonPosition` is computed to a hundredth of a degree for the
 * key light and its azimuth, elevation, phase and distance were never used for
 * anything else.
 *
 * `visibility` is the whole of the editorial judgement and it multiplies three
 * independent causes:
 *
 *   - **The horizon.** A smoothstep from half a degree below to four above,
 *     rather than a cut, because a disc that appears all at once at moonrise is
 *     the pop `HORIZON_FADE_DEG` exists to avoid one layer over.
 *   - **The sky.** A moon is genuinely visible in a blue afternoon and it is
 *     genuinely faint, so daylight keeps a fifth of it rather than none. That is
 *     the detail worth having: the daytime moon is one of the few things in the
 *     real sky that people are surprised to be shown.
 *   - **The air in the way.** Cloud cover and the marine layer's obscuration,
 *     taken at their worst rather than multiplied, because a coastal fog and an
 *     overcast deck are two descriptions of one sky and counting both would put
 *     the moon out twice. The cloud *layer* also draws over the dome, so this is
 *     the part of the occlusion that geometry cannot do: an overcast night with
 *     gaps in the puff field must not show a crisp moon through the gaps.
 */
function drawableMoon(
  env: Environment,
  night: number,
  cloud: number,
  obscuration: number,
): LightingMoon | null {
  const moon = env.moon;
  const up = smoothstep(-0.5, 4, moon.elevation);
  if (up <= 0) return null;

  // Both bodies in scene space. The moon's own elevation, not the floored one:
  // this is a drawn object and the floor exists for a shadow camera.
  const m = sunDirection({ ...env.sun, azimuth: moon.azimuth, elevation: moon.elevation });
  const s = sunDirection(env.sun);

  /*
   * The bright limb: the sun's direction with the moon's own component removed,
   * which is the projection of the sun onto the plane of the visible disc.
   *
   * Degenerate exactly twice — at new moon and at full, when the sun is behind
   * or in front of the moon and the projection has no length. At full there is
   * no terminator to orient and any axis is right; at new there is nothing lit
   * to orient. So a zero-length projection falls back to "up", which is a
   * defined answer rather than a `NaN` in a vertex shader.
   */
  const dot = s.x * m.x + s.y * m.y + s.z * m.z;
  let lx = s.x - m.x * dot;
  let ly = s.y - m.y * dot;
  let lz = s.z - m.z * dot;
  const length = Math.hypot(lx, ly, lz);
  if (length < 1e-6) {
    lx = 0;
    ly = 1;
    lz = 0;
  } else {
    lx /= length;
    ly /= length;
    lz /= length;
  }

  const air = Math.max(clamp(cloud, 0, 1), clamp(obscuration, 0, 1));
  return {
    direction: [m.x, m.y, m.z],
    brightLimb: [lx, ly, lz],
    illuminated: clamp(moon.illuminated, 0, 1),
    // 1737.4 km is the lunar radius; at perigee and apogee this runs 0.00427 to
    // 0.00487 radians, which is the 14% swing a supermoon headline is about.
    angularRadius: Math.atan(MOON_RADIUS_KM / Math.max(1, moon.distanceKm)),
    visibility: up * lerp(0.2, 1, night) * (1 - 0.9 * air),
  };
}

/** Lunar mean radius, in kilometres. Only the apparent size depends on it. */
const MOON_RADIUS_KM = 1737.4;

/** The moon's contribution, once phase, altitude and the weather have had a say. */
interface MoonRig {
  /** Unit vector toward the moon, floored like the sun's. `null` when it is down. */
  direction: [number, number, number] | null;
  color: number;
  /** Key intensity. */
  intensity: number;
  /** How moonlit the sky and the fill are, 0..1. */
  glow: number;
}

const NO_MOON: MoonRig = { direction: null, color: 0, intensity: 0, glow: 0 };

function moonRig(
  moon: MoonPosition,
  night: number,
  cloud: number,
  obscuration: number,
  options: MoonlightOptions,
  floorDeg: number,
): MoonRig {
  // Below the horizon it contributes nothing, and the ramp above it is several
  // degrees wide: a moon in the first degrees of its own rise is being
  // reddened and extinguished by the same long air path that does it to the
  // sun, and it has far less to lose.
  const up = smoothstep(-1, 8, moon.elevation);
  if (up <= 0 || night <= 0) return NO_MOON;

  // Not linear in the illuminated fraction, and this is the part that makes a
  // moon phase read as a moon phase. A half moon is nowhere near half as bright
  // as a full one — nearer a tenth. Two things do that: at quarter phase the
  // ground you can see is lit at a grazing angle and is mostly its own long
  // shadows, and at full phase those shadows all hide behind the rocks casting
  // them and the disc surges. The exponent is the cheap version of both. The
  // floor is earthshine, and the fact that a key of exactly zero leaves a scene
  // with no silhouettes in it at all.
  const lit = 0.06 + 0.94 * clamp(moon.illuminated, 0, 1) ** 1.6;

  // Cloud takes the key away much faster than it takes the glow away, and that
  // asymmetry is the whole character of an overcast night: no shadows at all,
  // but a deck lit from above that is brighter than a clear moonless sky.
  const key = night * up * lit * clamp((1 - 0.9 * cloud) * (1 - 0.92 * obscuration), 0, 1);
  const glow = night * up * lit * clamp(1 - 0.35 * cloud, 0, 1);

  const lifted = floorDeg > 0 ? Math.max(moon.elevation, floorDeg) : moon.elevation;
  return {
    direction: skyDirection(moon.azimuth, lifted),
    color: options.color,
    intensity: options.intensity * key,
    glow: clamp(glow, 0, 1),
  };
}

/**
 * Horizontal coordinates to the engine's axes: `x` east, `z` south, `y` up.
 *
 * `solar.ts` exports `sunDirection` for exactly this conversion and it would
 * work unchanged on the moon — it reads only an azimuth and an elevation — but
 * its argument is a `SolarPosition`, and inventing a declination and an
 * equation of time to satisfy a type is worse than four lines that say what
 * they mean. The convention is `solar.ts`'s and must stay it: azimuth is
 * measured from north, and north is `-z`.
 */
function skyDirection(azimuth: number, elevation: number): [number, number, number] {
  const el = elevation * DEG;
  const az = azimuth * DEG;
  const horizontal = Math.cos(el);
  return [horizontal * Math.sin(az), Math.sin(el), -horizontal * Math.cos(az)];
}

/**
 * Fold the night into the rig: the moon's fill, the residual glow of a moonless
 * night, and the floor under the sky.
 *
 * The key itself is not set here — see `combineKey` — because a `LightingState`
 * carries one directional light and the sun has not necessarily finished with
 * it yet.
 */
function applyNight(
  rig: Rig,
  moon: MoonRig,
  night: number,
  options: MoonlightOptions | null,
): void {
  if (night <= 0) return;

  if (options) {
    // The table's deepest stops carry a token sidelight standing in for
    // moonlight and city glow, because a scene lit by hemisphere alone has no
    // silhouettes in it and reads as a bug. There is a real moon now, so the
    // stand-in gets out of its way — not entirely, because something still has
    // to hold the shape of the city up on an overcast night at new moon.
    //
    // Weighted by how much moon there actually is, and not, as it was, by how
    // much night there is. Those are the same number only on the nights the
    // moon happens to be up, and the difference is the whole bug: `moonRig`
    // correctly returns nothing for a moon below the horizon, so on the common
    // case — which is most of every month, and *every* night before moonrise —
    // this line was removing 85% of the only directional light in the scene in
    // favour of a moon that was not there. The hills lost their lit side and
    // the frame went flat, on exactly the nights that needed the stand-in most.
    const present = clamp(moon.glow, 0, 1);
    rig.sunIntensity *= 1 - 0.85 * night * present;

    const glow = clamp(moon.glow * options.skyLift, 0, 1);
    rig.skyTop = mixHex(rig.skyTop, MOONLIT_SKY_TOP, glow);
    rig.skyHorizon = mixHex(rig.skyHorizon, MOONLIT_SKY_HORIZON, glow);
    rig.hemiSky = mixHex(rig.hemiSky, MOONLIT_HEMI_SKY, 0.7 * glow);
    rig.ambientColor = mixHex(rig.ambientColor, MOONLIT_AMBIENT, 0.7 * glow);
    rig.hemiIntensity *= 1 + 0.7 * glow;
    rig.ambientIntensity *= 1 + 0.6 * glow;
  }

  // Starlight, airglow, and the sodium of everywhere else bouncing off whatever
  // is overhead. Small, unshaped, and the difference between a night that is
  // dark and a night that is missing.
  rig.hemiIntensity *= 1 + 0.28 * night;
  rig.ambientIntensity *= 1 + 0.22 * night;

  // Upward only and per channel, so this can rescue a sky and can never dim
  // one — the weather passes that follow are free to keep taking light out of
  // the frame without having to know this ran.
  rig.skyTop = mixHex(rig.skyTop, atLeast(rig.skyTop, NIGHT_FLOOR_TOP), night);
  rig.skyHorizon = mixHex(rig.skyHorizon, atLeast(rig.skyHorizon, NIGHT_FLOOR_HORIZON), night);

  // And the same again for the light that lands on the ground, which is the
  // half the sky floor above was always missing. Colours per channel through
  // `atLeast` for the reason that function documents — the floor is a blue, not
  // a brightness — and the two intensities by plain maximum, because both terms
  // are colour *times* intensity and flooring only one of them can be undone by
  // the other. Both ramp in on `night`, so this is a floor that arrives through
  // civil twilight rather than a step that switches on at some elevation.
  //
  // A moonlit night is already well above all five of these and passes through
  // untouched, which is the whole reason the moon's own lift went up when this
  // went in. See `MOONLIT_HEMI_SKY`.
  rig.hemiSky = mixHex(rig.hemiSky, atLeast(rig.hemiSky, NIGHT_FLOOR_HEMI_SKY), night);
  rig.hemiGround = mixHex(rig.hemiGround, atLeast(rig.hemiGround, NIGHT_FLOOR_HEMI_GROUND), night);
  const ambientFloor = atLeast(rig.ambientColor, NIGHT_FLOOR_AMBIENT);
  rig.ambientColor = mixHex(rig.ambientColor, ambientFloor, night);
  rig.hemiIntensity = lerp(
    rig.hemiIntensity,
    Math.max(rig.hemiIntensity, NIGHT_FLOOR_HEMI_INTENSITY),
    night,
  );
  rig.ambientIntensity = lerp(
    rig.ambientIntensity,
    Math.max(rig.ambientIntensity, NIGHT_FLOOR_AMBIENT_INTENSITY),
    night,
  );
}

/**
 * One directional light, two things in the sky.
 *
 * `LightingState` carries a single key and that is the right shape — a second
 * shadow-casting light is a second shadow map and a second full pass over 24k
 * instances, for a source that is a two-hundred-thousandth as bright as the one
 * already there. So the two get averaged, weighted by their own intensities,
 * which is what a single light standing in for both ought to do: at dusk with a
 * bright moon already up the key points somewhere between them, and by the time
 * either one dominates it has arrived at that one. The crossover happens inside
 * civil twilight, where both are near a tenth of an intensity and there is
 * almost no shadow left to be wrong about.
 */
function combineKey(
  sunDir: [number, number, number],
  sunColor: number,
  sunIntensity: number,
  moon: MoonRig,
): LightingState["sun"] {
  const sun = Math.max(0, sunIntensity);
  if (!moon.direction || moon.intensity <= 0) {
    return { direction: sunDir, color: sunColor, intensity: sun };
  }

  const total = sun + moon.intensity;
  const weight = moon.intensity / total;
  const x = lerp(sunDir[0], moon.direction[0], weight);
  const y = lerp(sunDir[1], moon.direction[1], weight);
  const z = lerp(sunDir[2], moon.direction[2], weight);
  const length = Math.hypot(x, y, z);

  // A full moon rises as the sun sets and the two stand opposite each other,
  // which is the one configuration where the average of the two directions is
  // nothing at all. A zero direction would put the light inside the ground and
  // black the scene out, so take the brighter of the two instead.
  const direction: [number, number, number] =
    length < 0.05
      ? weight >= 0.5
        ? moon.direction
        : sunDir
      : [x / length, y / length, z / length];

  return { direction, color: mixHex(sunColor, moon.color, weight), intensity: total };
}

// ---- Weather --------------------------------------------------------------

/**
 * Cloud flattens and desaturates, and it does both by taking the sun away.
 *
 * Overcast is not "the same scene, dimmer": the directional component collapses
 * and what is left is a uniform dome, so shadows vanish, the sky's gradient
 * closes up and every colour loses its warmth because it is being lit by grey.
 * Raising ambient while cutting the sun is what reproduces that — the total
 * falls, but the ratio falls much further, which is the part the eye reads.
 */
function applyCloud(rig: Rig, cloud: number, day: number): void {
  if (cloud <= 0) return;

  rig.sunIntensity *= 1 - 0.78 * cloud;
  rig.sunColor = mixHex(rig.sunColor, 0xf2f4f6, 0.6 * cloud);

  // The zenith comes down to meet the horizon: an overcast sky has almost no
  // gradient left in it, which is why an overcast photograph has no top.
  rig.skyTop = desaturate(mixHex(rig.skyTop, rig.skyHorizon, 0.55 * cloud), 0.7 * cloud);
  rig.skyHorizon = desaturate(rig.skyHorizon, 0.6 * cloud);
  const dim = 1 - 0.16 * cloud * day;
  rig.skyTop = scale(rig.skyTop, dim);
  rig.skyHorizon = scale(rig.skyHorizon, dim);

  rig.hemiSky = desaturate(rig.hemiSky, 0.55 * cloud);
  rig.hemiIntensity *= 1 + 0.18 * cloud * day;
  rig.ambientColor = desaturate(rig.ambientColor, 0.7 * cloud);
  rig.ambientIntensity *= 1 + 0.55 * cloud * day;
}

function applyPrecipitation(
  rig: Rig,
  precipitation: number,
  condition: SkyCondition,
  day: number,
): void {
  if (condition === "snow") {
    // Snow is the largest reflector a scene ever acquires: the bounce light off
    // the ground stops being dirt-coloured and starts being sky-coloured, which
    // is most of why a snowy day looks the way it does from below.
    rig.hemiGround = mixHex(rig.hemiGround, 0xe9eef2, 0.75);
    rig.hemiIntensity *= 1 + 0.2 * day;
    rig.ambientIntensity *= 1 + 0.15 * day;
  }
  if (precipitation <= 0 && condition !== "thunderstorm") return;

  const heavy = condition === "thunderstorm" ? Math.max(0.75, precipitation) : precipitation;
  const dim = 1 - 0.3 * heavy * day;
  rig.sunIntensity *= 1 - 0.4 * heavy;
  rig.hemiIntensity *= dim;
  rig.ambientIntensity *= dim;
  rig.skyTop = scale(desaturate(rig.skyTop, 0.4 * heavy), dim);
  rig.skyHorizon = scale(desaturate(rig.skyHorizon, 0.4 * heavy), dim);
}

/**
 * Fog distance from a reported visibility, in scene units.
 *
 * `THREE.Fog` is linear and fully opaque at `far`, and meteorological
 * visibility is the range at which contrast is essentially gone, so the two are
 * the same number by definition — once the "10 miles means we stopped counting"
 * problem above is dealt with.
 */
function visibilityFar(
  weather: WeatherObservation | null,
  condition: SkyCondition,
  clearFar: number,
  metresPerUnit: number,
): number {
  let km = weather?.visibilityKm ?? null;
  if (km === null && condition === "fog") km = FOG_CONDITION_VISIBILITY_KM;
  if (km === null) return clearFar;

  const observed = (km * 1000) / metresPerUnit;
  return lerp(observed, clearFar, smoothstep(VISIBILITY_HAZY_KM, VISIBILITY_UNLIMITED_KM, km));
}

// ---- The marine layer -----------------------------------------------------

/**
 * How thoroughly the air itself is in the way, 0..1, as reported.
 *
 * Kept separate from the marine layer, and generic, because obscuration is not
 * a San Francisco phenomenon even though the model of where it comes from is:
 * a city with no `marineLayer` configured must still render a reported fog as
 * fog — flat, shadowless, no horizon — rather than as a blue sky with the far
 * shore mysteriously missing. The layer's job is to *supply* this number when
 * nobody was asked; this is what to do with one once it exists.
 */
function observedObscuration(weather: WeatherObservation): number {
  if (weather.condition === "fog") return 1;
  const visibility = weather.visibilityKm;
  // A continuous ramp rather than a threshold: 10 km is where a distant hill
  // starts losing its edges and half a kilometre is where everything has gone,
  // and a step anywhere between them would make the sky snap on a rounding
  // difference upstream.
  if (visibility !== null && visibility < 10) return clamp(1 - (visibility - 0.5) / 9.5, 0, 1);
  // High stratus: the same air mass a few hundred metres up. Locally this is
  // the overcast that gets called May grey and June gloom, and it flattens the
  // light the same way without ever touching the ground.
  if (weather.cloudCover > 0.8 && (visibility === null || visibility < 12)) return 0.35;
  return 0;
}

function marineStrength(layer: MarineLayerOptions, env: Environment, lng: number): number {
  const season = seasonFactor(layer, dayOfYear(env.time));
  const diurnal = diurnalFactor(solarHours(env.time, lng, env.sun.equationOfTime));
  const wind = windGate(layer, env.weather);
  return clamp(layer.strength * season * diurnal * wind, 0, 1);
}

/** A smooth bump centred on the season's peak, on a residual floor. */
function seasonFactor(layer: MarineLayerOptions, doy: number): number {
  const d = Math.abs(wrapSigned(doy - layer.peakDay, 365.25));
  if (d >= layer.seasonDays) return layer.offSeason;
  const bump = 0.5 * (1 + Math.cos((Math.PI * d) / layer.seasonDays));
  return layer.offSeason + (1 - layer.offSeason) * bump;
}

/**
 * The day's shape: thickest before dawn, burnt off through the late morning,
 * back in from mid-afternoon.
 *
 * The hours are **apparent solar**, not civil, which is both physically right —
 * burn-off is the sun doing work, and it starts when the sun does — and the
 * only clock available offline. It does mean the curve reads early against a
 * wristwatch: San Francisco's solar noon is around 13:07 PDT, so the 11.0 here
 * where the layer is thinnest is a little after midday on the clock, and the
 * 16.0 where it starts coming back is around five.
 */
const DIURNAL: readonly (readonly [number, number])[] = [
  [0, 0.95],
  [5, 1],
  [7, 0.95],
  [9, 0.6],
  [11, 0.2],
  [14, 0.12],
  [16, 0.4],
  [18, 0.8],
  [20, 0.92],
  [24, 0.95],
];

function diurnalFactor(hours: number): number {
  const h = mod(hours, 24);
  for (let i = 1; i < DIURNAL.length; i++) {
    const a = DIURNAL[i - 1];
    const b = DIURNAL[i];
    if (!a || !b) continue;
    if (h <= b[0]) return lerp(a[1], b[1], ease((h - a[0]) / (b[0] - a[0])));
  }
  return 0.95;
}

/**
 * Advection fog has to be blown in, so the wind is a gate and not a garnish.
 *
 * This is also the only place wind touches the rig at all, and that is
 * deliberate: wind moves clouds and shreds fog, and this renderer has no cloud
 * layer for it to move. Wiring it to anything else — a brightness, a colour —
 * would be decoration dressed as physics.
 *
 * Missing wind data returns 1. An unreported wind is not a calm.
 */
function windGate(layer: MarineLayerOptions, weather: WeatherObservation | null): number {
  const speed = weather?.windKph ?? null;
  const from = weather?.windDirDeg ?? null;
  if (speed === null && from === null) return 1;

  let gate = 1;
  if (from !== null) {
    const off = Math.abs(wrapSigned(from - layer.onshoreBearing, 360));
    gate *=
      off >= layer.onshoreHalfWidth
        ? 0.1
        : 0.1 + 0.9 * (0.5 * (1 + Math.cos((Math.PI * off) / layer.onshoreHalfWidth)));
  }
  if (speed !== null) {
    // Calm: the layer sits offshore and never arrives. Gale: mechanical mixing
    // lifts it clear of the ground into stratus, which is why the foggiest days
    // are breezy rather than windy.
    if (speed < 3) gate *= 0.55;
    else if (speed < 8) gate *= 0.55 + 0.45 * ((speed - 3) / 5);
    else if (speed > 35) gate *= Math.max(0.25, 1 - (speed - 35) / 35);
  }
  return gate;
}

/**
 * Fold obscuration into the rig and return the fog colour.
 *
 * Everything converges: the zenith, the horizon and the fog all end up the same
 * flat grey-white, which is what kills the horizon line — there is no boundary
 * left between sky and distance. The sun goes out but the total light does not,
 * because fog is a diffuser and not a lid; it is bright, shadowless and
 * directionless, and getting that combination right is the difference between
 * fog and dusk.
 *
 * At zero the fog colour is the horizon colour exactly, which is both what
 * `cityDaylight()` commits to and the physically honest answer — distant haze
 * is lit by the sky it sits in front of, so it goes warm at sunset along with
 * everything else rather than staying a neutral grey.
 *
 * **The convergence is a daytime effect and only a daytime effect**, which is
 * what `night` is for. Fog is bright because the sun is in it, so pulling the
 * fill toward the fog colour is a *brightening* and a flattening — the two
 * things that make an overcast noon look like an overcast noon. Run the same
 * mixes at 3 a.m., where the fog colour is a near-black derived from a night
 * sky, and they are pure subtraction: San Francisco's August marine layer is at
 * its thickest at three in the morning, and it was quietly taking 63% of the
 * ground fill and all of its colour straight back out again — after
 * `applyNight` had finished, so the night floor could not see it happen and had
 * no chance to defend the frame. Which is a fair description of what a fog does
 * to a photograph and a terrible description of what it does to a city, where
 * the deck is lit from *underneath* by everything that is still switched on.
 * The sky still converges at night; it should, since a foggy night has no stars
 * in it. The ground no longer does.
 */
function applyObscuration(
  rig: Rig,
  obscuration: number,
  day: number,
  night: number,
  condition: SkyCondition,
): number {
  let thick = mixHex(desaturate(rig.skyHorizon, 0.9), 0xbfc8cc, 0.5 * day);
  if (condition === "snow") thick = mixHex(thick, 0xeef2f5, 0.4);
  const fogColor = mixHex(rig.skyHorizon, thick, obscuration);
  if (obscuration <= 0) return fogColor;

  rig.skyTop = mixHex(rig.skyTop, fogColor, 0.85 * obscuration);
  rig.skyHorizon = mixHex(rig.skyHorizon, fogColor, 0.92 * obscuration);

  rig.sunIntensity *= 1 - 0.88 * obscuration;
  rig.sunColor = mixHex(rig.sunColor, 0xdfe6ea, 0.7 * obscuration);

  const lit = 1 - night;
  rig.hemiSky = mixHex(rig.hemiSky, fogColor, 0.7 * obscuration * lit);
  rig.hemiGround = desaturate(rig.hemiGround, 0.6 * obscuration * lit);
  rig.hemiIntensity *= 1 + 0.12 * obscuration * day;
  rig.ambientColor = mixHex(rig.ambientColor, fogColor, 0.6 * obscuration * lit);
  rig.ambientIntensity *= 1 + 0.45 * obscuration * day;

  return fogColor;
}

// ---- Modelled cloud cover -------------------------------------------------

/**
 * The most sky the generic term is ever allowed to cover.
 *
 * Scattered to broken, never overcast, and the cap is a statement about what
 * this module is entitled to claim. It knows one climate in detail — the coast
 * `MarineLayerOptions` describes — and for every other city it has a longitude
 * and a sun angle. That is enough to say "there is usually some cloud about,
 * more of it in the afternoon"; it is not enough to close a stranger's sky over
 * a city it has never been told anything about. An overcast is a real event with
 * a real cause, and if a deployment wants one rendered it can report one, at
 * which point `cloudCover` hands the report straight through.
 */
const SYNOPTIC_MAX_COVER = 0.55;

/**
 * The slow term: `[period in days, amplitude, phase in turns]`.
 *
 * Weather systems arrive, cover the sky for a day or two and leave, and that is
 * the variation a viewer notices across a week. Three cosines of deliberately
 * incommensurate period are the cheapest thing that produces it while staying
 * *smooth* — every requirement on this number at once. Continuous in time, and
 * in every derivative, so a clock that scrubs forward never steps. Deterministic
 * from the instant alone, so two people looking at the same city at the same
 * moment on different machines see the same sky, with no seed to agree on and
 * nothing stored. And non-repeating on any timescale anyone will watch: as
 * tenths of a day the three periods are 29, 67 and 151, all prime, so the
 * combined pattern closes after 29 × 67 × 151 tenths — 29,339.3 days, a little
 * over eighty years. A single period would come back around inside a fortnight
 * and be recognised.
 *
 * The phases are there only so the three do not all start aligned at the Unix
 * epoch, which is a real instant the clock can be scrubbed to.
 *
 * The amplitudes sum to 1, which is what lets `synopticCover` rescale without a
 * second constant to keep in step.
 */
const SYNOPTIC_WAVES: readonly (readonly [number, number, number])[] = [
  [2.9, 0.5, 0.13],
  [6.7, 0.32, 0.61],
  [15.1, 0.18, 0.29],
];

/**
 * Exponent leaning the slow term back toward a clear sky.
 *
 * Three cosines summed and rescaled pile up around their own midpoint. Over a
 * year of hourly samples the unshaped term runs 0.20 at the tenth percentile,
 * 0.50 at the median and 0.80 at the ninetieth — a sky that is half covered
 * half the time, which is not weather, it is a permanent haze the eye stops
 * seeing after a minute. Raising it moves the middle down and leaves both ends
 * alone: the shaped term still reaches its cap on the days all three waves
 * agree and still reaches zero, but the same samples now run 0.11 / 0.38 /
 * 0.73. Multiplied out through `SYNOPTIC_MAX_COVER` and `cumulusDiurnal`, a
 * city with no marine layer spends a year at a median cover of 0.14, a
 * ninetieth percentile of 0.30 and a maximum of 0.53 — some cloud up there
 * nearly always, a busy sky now and again, and never a lid.
 */
const SYNOPTIC_SHAPE = 1.4;

/**
 * Apparent solar hour the diurnal term peaks at, and how far it falls overnight.
 *
 * Cumulus over land is built by the ground under it: the surface heats, the
 * heat takes time to get into the air above it, and the cloud that results
 * peaks well after noon and thins out overnight. Mid-afternoon here rather than
 * noon is that lag — and it is the reason this cannot be driven off the sun's
 * elevation, which is the obvious idea and is symmetric about noon. Elevation
 * alone makes nine in the morning and three in the afternoon the same sky, and
 * they are not the same sky. Apparent solar hours are asymmetric about noon and
 * are what `DIURNAL` already runs the marine layer's clock on; see `solarHours`
 * for why that is also the only clock available offline.
 *
 * A floor rather than zero because not all cloud is convective. A sky that
 * emptied completely every night and refilled every morning would be a stronger
 * claim than this module has any way to support, and the marine layer — which
 * does exactly the opposite, being thickest before dawn — is the standing proof
 * that it would be wrong somewhere. Where it sits is a compromise between that
 * and the requirement that a day not be flat: 0.4 leaves the afternoon two and
 * a half times the pre-dawn sky at most, which is a shape you can watch arrive
 * without it ever emptying.
 */
const CUMULUS_PEAK_SOLAR_HOUR = 15;
const CUMULUS_NIGHT_FLOOR = 0.4;

/**
 * Cloud cover with nobody to ask: 0..1, from the calendar, the clock and the
 * coast.
 *
 * This is the offline path and the offline path is the *default* one. A city
 * with no weather API configured — no account, no key, no network, which is the
 * case this whole engine is written around — has no observation to draw a sky
 * from, and a caller that reads a cover of 0 out of that draws no cloud, ever.
 * The fix belongs in this file because this file already models a sky nobody
 * observed: it is where the marine layer lives, and the marine layer is the same
 * argument already won once for fog.
 *
 * Two terms, and the split is the point:
 *
 *   - **The coast, when there is one.** `marineStrength` is season × clock ×
 *     wind and is already what the fog is computed from; it is reused rather
 *     than paraphrased, so the deck a viewer sees and the grey the rig goes are
 *     the same event and cannot drift apart. Its strength reads directly as a
 *     cover because that is what it physically is — an advected stratus deck at
 *     full strength is a covered sky. It carries the season and the burn-off
 *     clock with it, which is why a June morning here comes out closed in and a
 *     December one does not.
 *   - **Everywhere else.** A slow synoptic drift over days, modulated by the
 *     afternoon build of cumulus over warm ground. Capped well short of
 *     overcast — see `SYNOPTIC_MAX_COVER` — because unlike the marine layer it
 *     is not a fact about anywhere in particular.
 *
 * The two combine by random overlap: two decks placed independently of one
 * another leave `(1 - a)(1 - b)` of the sky clear between them. Plain `max` was
 * the alternative and swallows the weaker layer whole, so a summer morning in
 * San Francisco would render identically whether or not there was anything else
 * in the sky that week.
 *
 * **Nothing here reaches the light rig, deliberately.** `apply` still reads its
 * `cloud` from `env.weather` alone and every keyframe, curve and constant above
 * is untouched — the rig is tuned and deployed and this is an output, not a new
 * input. It would also be wrong twice over: the marine half of this number
 * already reaches the rig as `obscuration`, so feeding it back in as `cloud`
 * would count the same deck against the sun twice.
 */
function modelledCloudCover(
  layer: MarineLayerOptions | null,
  env: Environment,
  lng: number,
): number {
  const marine = layer ? marineStrength(layer, env, lng) : 0;

  // UTC milliseconds, so the phase is an absolute instant rather than anything
  // to do with the viewer's timezone — two people in different zones are
  // looking at the same sky and must be given the same number for it.
  const days = env.time.getTime() / MS_PER_DAY;
  const background =
    SYNOPTIC_MAX_COVER *
    synopticCover(days) *
    cumulusDiurnal(solarHours(env.time, lng, env.sun.equationOfTime));

  return clamp(1 - (1 - marine) * (1 - background), 0, 1);
}

/** The slow term, 0..1. See `SYNOPTIC_WAVES` and `SYNOPTIC_SHAPE`. */
function synopticCover(days: number): number {
  let sum = 0;
  for (const [period, amplitude, phase] of SYNOPTIC_WAVES) {
    sum += amplitude * Math.cos(2 * Math.PI * (days / period + phase));
  }
  // The amplitudes sum to 1, so `sum` lands in -1..1 and the base in 0..1.
  // Clamped even so, and the reason is the exponent: it is fractional, and a
  // base a single float error *below* zero raised to a fractional power is
  // `NaN` rather than a small number. Neither 0.32 nor 0.18 is exact in binary,
  // so "the amplitudes sum to 1" is true of the decimals and not quite of the
  // doubles. One `NaN` leaving here is a cloud layer that silently stops
  // drawing at one instant on one machine, which is the least debuggable
  // failure available to a function this small.
  return clamp((sum + 1) / 2, 0, 1) ** SYNOPTIC_SHAPE;
}

/**
 * The afternoon build, as a multiplier on the slow term rather than a term of
 * its own: a cloudy week is cloudier in the afternoon, and a clear week is
 * still clear at four o'clock.
 *
 * A cosine rather than a table like `DIURNAL`, because this one has to close the
 * loop at midnight and a table has to be trusted to. Periodic by construction
 * means there is no midnight seam to get wrong later.
 */
function cumulusDiurnal(hours: number): number {
  const turns = mod(hours - CUMULUS_PEAK_SOLAR_HOUR, 24) / 24;
  const bump = 0.5 * (1 + Math.cos(2 * Math.PI * turns));
  return CUMULUS_NIGHT_FLOOR + (1 - CUMULUS_NIGHT_FLOOR) * bump;
}

// ---- Time -----------------------------------------------------------------

/**
 * Apparent solar hours since local midnight.
 *
 * `solar.ts` computes this same quantity on its way to an azimuth and does not
 * export it. Three lines here is cheaper than widening `SolarPosition` with a
 * field only the marine layer reads — and the equation of time, which is the
 * hard part, does come across on the observation.
 */
function solarHours(when: Date, lng: number, equationOfTime: number): number {
  const utcMinutes = mod(when.getTime() / MS_PER_MINUTE, 1440);
  return mod(utcMinutes + equationOfTime + 4 * lng, 1440) / 60;
}

/**
 * Day of the year, 1-366, in UTC. The seasonal curve is nearly three months
 * wide, so which side of midnight the local day falls on is not a difference it
 * can express.
 */
function dayOfYear(when: Date): number {
  const start = Date.UTC(when.getUTCFullYear(), 0, 1);
  return Math.floor((when.getTime() - start) / MS_PER_DAY) + 1;
}

// ---- Colour ---------------------------------------------------------------

/**
 * Blend two colours the way light blends, not the way bytes do.
 *
 * sRGB is a display encoding, and lerping in it sends the midpoint between a
 * twilight blue and a sunset orange through a dead brown-grey that neither
 * colour has any of. Squaring into approximately linear light, mixing there and
 * taking the square root back is the cheapest fix that removes it, and at dusk
 * — when almost every colour in the table is being interpolated at once — the
 * difference is the whole mood of the frame.
 */
function mixHex(a: number, b: number, t: number): number {
  const k = clamp(t, 0, 1);
  const [ar, ag, ab] = linear(a);
  const [br, bg, bb] = linear(b);
  return encode(lerp(ar, br, k), lerp(ag, bg, k), lerp(ab, bb, k));
}

/** Pull a colour toward its own brightness. `t = 1` is grey. */
function desaturate(hex: number, t: number): number {
  const k = clamp(t, 0, 1);
  const [r, g, b] = linear(hex);
  // Rec. 709 luminance, on linear values, which is the only place it means
  // anything.
  const y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
  return encode(lerp(r, y, k), lerp(g, y, k), lerp(b, y, k));
}

/**
 * Per-channel maximum, in linear light: a colour raised to a floor and never
 * pushed below it.
 *
 * Per channel rather than by luminance, because the floor is a *colour* — a
 * blue-black — and clamping a night sky by its brightness alone would let a
 * grey of the same luminance through, which is the one thing the night must not
 * be allowed to look like.
 */
function atLeast(hex: number, floor: number): number {
  const [r, g, b] = linear(hex);
  const [fr, fg, fb] = linear(floor);
  return encode(Math.max(r, fr), Math.max(g, fg), Math.max(b, fb));
}

/** Multiply a colour's light, not its bytes. */
function scale(hex: number, factor: number): number {
  const [r, g, b] = linear(hex);
  const f = Math.max(0, factor);
  return encode(r * f, g * f, b * f);
}

function linear(hex: number): [number, number, number] {
  const r = ((hex >> 16) & 0xff) / 255;
  const g = ((hex >> 8) & 0xff) / 255;
  const b = (hex & 0xff) / 255;
  return [r * r, g * g, b * b];
}

function encode(r: number, g: number, b: number): number {
  const to = (v: number) => Math.round(clamp(Math.sqrt(Math.max(0, v)), 0, 1) * 255);
  return (to(r) << 16) | (to(g) << 8) | to(b);
}

// ---- Helpers --------------------------------------------------------------

function lerp(a: number, b: number, t: number): number {
  return a + (b - a) * t;
}

function clamp(x: number, lo: number, hi: number): number {
  return x < lo ? lo : x > hi ? hi : x;
}

/** Hermite ease over a span, flat at both ends. */
function smoothstep(edge0: number, edge1: number, x: number): number {
  if (edge1 === edge0) return x < edge0 ? 0 : 1;
  return ease((x - edge0) / (edge1 - edge0));
}

function ease(t: number): number {
  const k = clamp(t, 0, 1);
  return k * k * (3 - 2 * k);
}

/** `%` keeps the sign of the dividend, which is wrong for angles and clocks. */
function mod(x: number, n: number): number {
  return ((x % n) + n) % n;
}

/** The shortest signed distance around a cycle: -180..180 for degrees. */
function wrapSigned(x: number, period: number): number {
  return mod(x + period / 2, period) - period / 2;
}

// ---- Sanity checks --------------------------------------------------------

/**
 * Values this file actually produces, so the numbers above can be argued with
 * rather than only read.
 *
 * All of these are San Francisco — 37.7749 N, 122.4194 W, `metresPerUnit` of
 * 94.34, `marineLayer: PACIFIC_MARINE_LAYER` — with fog distances in scene
 * units, where the clear-day baseline is 210/460.
 *
 * **With no weather at all, which is the offline case:**
 *
 *   - **Solar noon, 21 June** (75.6°): sun 2.03, fog 179/397. The layer's
 *     diurnal curve is near its minimum and its season factor near its
 *     maximum, which nets out as the faint July haze that softens the far side
 *     of the bay without touching the light.
 *   - **08:00 PDT, 21 June** (23.4°): sun 0.61, hemisphere 1.15, ambient 0.43,
 *     fog 60/133. A high sun almost entirely extinguished, the sky and the fog
 *     converged on one grey, and more fill light than at noon. That is the
 *     single most San Franciscan frame this engine can produce, and the fact
 *     that the *fill goes up* as the *sun goes down* is the whole trick.
 *   - **08:00 PDT, 21 October** (6.1°): sun 1.49, fog 190/421, horizon
 *     #e6bd98. The same hour, four months later, and the model has to give
 *     back a clear golden morning or it is not a model of anything — October is
 *     the month San Francisco is warm and cloudless and every visitor is
 *     surprised by it.
 *   - **03:00 PDT** (-23.7°, with the moon down): key 0.045, hemisphere 0.78,
 *     ambient 0.22, and the light direction's `y` pinned at 0.122, which is
 *     sin 7° — the shadow floor, keeping the token night sidelight from shining
 *     up through the ground. The hemisphere and the ambient are the night
 *     floor's own two numbers to the digit, which is the floor doing exactly the
 *     job it is there for; the key is down to 0.045 from the table's 0.16
 *     because the marine layer is at its thickest at 3 a.m. in June and takes
 *     88% of it, and the sky is a fog grey rather than a night blue for the same
 *     reason, which is right — a foggy night has no stars in it either.
 *   - **Solar noon, 21 December** (28.8°): sun 2.07, fog 204/453, sky exactly
 *     the palette's own. Out of season, the layer is not there.
 *
 * **With weather, at solar noon on 21 June:**
 *
 *   - `cloudCover: 0.05, visibilityKm: 16` — sun 2.26, fog 210/460 exactly.
 *     The observation says clear and the climatology is overruled; 16 km is
 *     read as "unlimited" rather than as 170 units of haze, which is the `10SM`
 *     correction doing its job.
 *   - `cloudCover: 1, visibilityKm: 14, condition: "overcast"` — sun 0.52,
 *     ambient 0.31 → 0.46, fog 185/411. Bright and shadowless, not dusk.
 *   - `condition: "fog"`, no visibility number — sun 0.06, hemisphere 1.45,
 *     ambient 0.67, fog 21/48. 48 units is the 4500 m floor: fog this thick is
 *     capped deliberately, because the honest number renders a white rectangle.
 *   - `visibilityKm: 6` under light cloud — sun 1.13, fog 27/59. Haze, not fog.
 *
 * **Without a `marineLayer` at all**, a reported fog still renders as fog —
 * sun 0.06, one flat grey, no horizon — and the same city with no weather
 * renders the clear baseline exactly. The layer decides where obscuration
 * *comes from* when nobody was asked; it is not what makes obscuration look
 * like anything.
 *
 * Tromsø on 5 January, at -2.98°, returns sun 0.37 against a twilight-blue sky
 * and nothing non-finite anywhere, which is the polar-night path through
 * `solar.ts` arriving here intact.
 *
 * **At night, San Francisco with no weather and no marine layer**, so that the
 * moon can be read on its own:
 *
 *   - **Full moon 43° up** (28 August 2026, 08:00 UTC): key 1.174, colour
 *     #9cb3e6, hemisphere 1.12 of #3a5188, ambient 0.29 of #4d5c87, sky #101b39
 *     over #2a385b. Half the sun's noon intensity, which is a preposterous
 *     number and the one that puts a soft directional key on the city with
 *     shadows you can find and not trip over — on a sky that is unmistakably
 *     night and unmistakably blue.
 *   - **New moon, below the horizon** (12 August 2026, 08:00 UTC): key 0.160 of
 *     the table's own sidelight colour #44558a, hemisphere 0.78 of #354c88 over
 *     #1f2740, ambient 0.22 of #47557f, and the sky at #090e1c over #16203a.
 *     Every one of those five is the floor to the digit. That is the darkest
 *     frame this file can produce, and it is the point: a genuinely correct
 *     night is `#000` and `#000` is a bug report. Rendered, the Bay Area board
 *     comes out at about y23 on the land against y15 on the bay and y29 on the
 *     sky, with downtown's windows peaking past y130 — dark, but a dark you can
 *     find a coastline in.
 *   - **Half moon 4° up** (20 August 2026, 06:00 UTC): key 0.397. A third of the
 *     full moon's key from half its disc and a tenth of its altitude, which is
 *     the phase curve and the rise ramp both doing visible work.
 *   - **The same full moon with `PACIFIC_MARINE_LAYER` on**: key 0.610 and the
 *     sky greyed to #232b43 over #2d3855. The fog takes half the moonlight and
 *     all of the colour, and August is when it would. The *fill* it no longer
 *     takes; see `applyObscuration`.
 *   - **The same night reported overcast**: key 0.131 — no shadows at all —
 *     with the fill barely down, because a cloud deck over a full moon is a
 *     softbox rather than a lid.
 *   - **`moonlight: null`** returns the pre-moon rig unchanged: key 0.160,
 *     colour #44558a, the table's token sidelight left in charge — which is now
 *     the same frame the moon-below-the-horizon case produces, and should be.
 *     Both floors still apply, because neither of them is about the moon.
 *
 * The gap between the second of those and the first is the one relationship
 * this file is tuned around: seven times the key, and on screen a Bay Area board
 * that goes from about y23 on the land to about y53. Moonrise is an event you
 * can watch happen, which is the whole justification for `moonPosition` being
 * four hundred lines of Meeus rather than a constant.
 *
 * The 28 August 2026 dusk is worth watching as a sequence, because it is the
 * configuration `combineKey` exists for — a full moon rising as the sun sets,
 * the two of them opposite each other in the sky. At sun +0.5° the key is 0.592
 * and #ce805b from the west; at -3.7° it is 0.328 and #8e6f87 from between
 * them; by -7.6° it is 0.572 and #8ea0d3 from the east. The shadows swing
 * across the city over about half an hour, which is not an artefact — it is
 * what actually happens, and on the one night a month it happens on.
 *
 * **`cloudCover`, with no weather at all**, which is the case it exists for. San
 * Francisco with `PACIFIC_MARINE_LAYER`, 21 June 2026, midnight to 23:00 PDT:
 *
 * ```
 * 0.83 0.83 0.83 0.83 0.84 0.85 0.86 0.84 0.82 0.73 0.57 0.46
 * 0.28 0.27 0.25 0.23 0.30 0.43 0.54 0.72 0.76 0.81 0.81 0.82
 * ```
 *
 * A June night closed in at over four fifths, burning back to under a quarter
 * by mid-afternoon and shut again by nine — which is the marine layer's own
 * diurnal curve arriving in the sky as well as in the fog, and is the day that
 * city actually has in June. The 08:00 in that row is 0.82; the same hour four
 * months later, on 21 October, is 0.12, and solar noon on 21 December 0.04:
 * out of season the layer is
 * not there, and neither is the cloud — the same October the fog notes above
 * are careful about, arriving here for the same reason and out of the same
 * curve. Los Angeles, same 24 hours and no marine layer, runs 0.15 down
 * to 0.09 before dawn and back to 0.14 through the afternoon — the generic term
 * alone, which is a few clouds about and a slight afternoon build, and never
 * pretends to be more than that.
 *
 * Over a full year sampled hourly, San Francisco's modelled cover runs a median
 * of 0.27 and a ninetieth percentile of 0.76, and touches 1.00 at the peak of
 * the fog season; Los Angeles runs 0.14 and 0.30 against a maximum of 0.53,
 * which is `SYNOPTIC_MAX_COVER` very nearly reached. Neither produces a
 * non-finite value or leaves 0..1 anywhere in that year, including at 69.65 N,
 * where the sun never sets and `solarHours` is doing the only clock there is.
 *
 * The largest change in one minute anywhere in that year is 0.0065, at San
 * Francisco's steepest burn-off. There is nothing in this to step on: a scrubbed
 * clock moves the sky the way an advancing one does.
 *
 * And with a station reporting, the model does not get a vote: the same June
 * morning that models 0.82 returns exactly 0.05 when the observation says 0.05.
 */
