/**
 * The city scene: layers, chapter flights, markers, and the handle the app
 * drives it all through.
 *
 * `createScene` owns a canvas and a `City` and nothing else. It knows nothing
 * about React, about any API, or about what the markers mean — the caller hands
 * it data and gets back a small imperative handle. That boundary is what lets
 * one renderer serve a private map coloured by pipeline state and a public one
 * coloured by sector without either being a fork.
 *
 * The renderer and the loop live in `Stage`, which is **handed in and not
 * built here**: one renderer serves the canvas for the life of the page, and a
 * city is a thing that is put on it and taken off again. Building a Stage per
 * city is what this function used to do, and `stage.ts` records what it cost —
 * every switch orphaned a 2048² shadow map on the GL context, because
 * `WebGLRenderer.dispose()` frees none of a renderer's own textures.
 *
 * The camera, lights, flights and picking live in a `SceneKit`. What is left
 * here — and it is the only thing that ought to be here — is the city itself:
 * which layers go in the scene, where a chapter puts the camera, and what a
 * pick means. An office builds the same two pieces with its own answers and
 * swaps in on the same `Stage`, which keeps this city alive and paused rather
 * than rebuilding it on the way back.
 * For the Bay Area that is about 2.2 s of layer construction, of which roughly
 * 730 ms is the heightfield — and the heightfield is now the only part of it
 * that happens off the main thread, so a rebuild would be 2.2 s of *frozen*
 * page rather than 2.2 s of busy one. See CONTRACT.md §1.
 */

import * as THREE from "three";
import { createBlocks, createLandmarks, updateBlocksDetail, type BuildingReservation } from "./blocks.ts";
import { createNightLights, type NightLights } from "./nightlights.ts";
import { createFlightLayer, type FlightLayer } from "./flights.ts";
import { createCloudLayer, type CloudLayer } from "./clouds.ts";
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
import { solarPosition, sunDirection } from "./solar.ts";
import { nightFactor, type AerialFog } from "./atmosphere.ts";
import { createStarlinkMeshLayer, type StarlinkMeshLayer } from "./starlinkMesh.ts";
import {
  createSatelliteLayer,
  type SatelliteCatalogue,
  type SatelliteLayer,
} from "./satellites.ts";
import { createSceneKit, type Pose } from "./scenekit.ts";
import type { EnvironmentRig } from "./environmentRig.ts";
import {
  createRoadTrafficLayer,
  type RoadTrafficLayer,
  type RoadTrafficOptions,
  type VehicleCameraMode,
} from "./roadTraffic.ts";
import type { Stage, StageScene } from "./stage.ts";
import type {
  VehicleActionSnapshot,
  VehicleControllerState,
} from "../transport/vehicleController.ts";
import { createAirports } from "./airports.ts";
import { createBridges, createFreewayWorld, createRoads } from "./structures.ts";
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
import { reliefScaleAt, surfaceWorldY } from "./relief.ts";
import type {
  Aircraft,
  Chapter,
  City,
  FlightSource,
  LightingState,
  MigrationField,
  Marker,
  MarkerPalette,
  RadarField,
  ScenePalette,
  Vessel,
} from "./types.ts";
import { World, type FieldProgress } from "./world.ts";
import { createSceneActor, type SceneActorOptions } from "../actors/sceneActor.ts";
import type {
  ActorActionSnapshot,
  ActorControllerSnapshot,
  ActorIdentity,
} from "../actors/controller.ts";
import {
  createSceneAircraft,
  type SceneAircraftOptions,
} from "../aircraft/sceneAircraft.ts";
import type {
  AircraftActionSnapshot,
  AircraftControllerSnapshot,
} from "../aircraft/controller.ts";
import type { ScenePeers, ScenePeersOptions } from "../realtime/scenePeers.ts";
import type { EntityPoseSnapshot } from "../realtime/types.ts";
import { cityControlOwnership, type CityControlMode } from "../play/controlMode.ts";

export type CityRealtimePeersOptions = Omit<ScenePeersOptions, "project" | "groundAt">;

/**
 * One promoted wildfire, as the renderer receives it.
 *
 * **Everything here is public.** The authority on this shape is `promote()` in
 * `src/server/fires.ts`, which returns a `FirePromotion` that is assignable to
 * `FireView` below; this is the same shape restated as the minimum a *renderer*
 * needs, not a second opinion about it. `promote()` is also the only place a
 * home-relative column could enter, and the place that must never let one —
 * `observations.distance_km`, `bearing_deg` and `threat`, and
 * `detections.distance_km`, are computed against the owner's house and invert to
 * a circle around it. `threat` is the subtle one: it is
 * `(16/distance)^2 x log10(acres) x momentum x containment x wind`, so with
 * `acres` and `pctContained` on the wire it *solves for the distance*. The
 * cloud-1 projection is what makes that structurally impossible; these types are
 * what make it obvious.
 *
 * Declared here rather than imported on purpose, and it is not duplication for
 * its own sake. `scene.ts` only *forwards* these values to a layer it did not
 * build, this repo is structurally typed, and keeping the minimum on this side
 * means the engine never takes a dependency on a wire module — the same rule
 * that keeps `Marker` in `engine/types.ts` and the marker row on the server.
 * The two are checked against each other at the one place they meet,
 * `SceneOptions.fires`, and neither module has to exist for the other to
 * compile.
 */
export interface DrawnFireMark {
  id: string;
  /** `null` where the agency published none. Never defaulted to the id. */
  name: string | null;
  lat: number;
  /** `lon`, not `lng` — the wire's spelling, kept so `promote()` output flows in. */
  lon: number;
  /** Burned area. A `number`, not `number | null`: past the gate, acreage is a fact. */
  acres: number;
  /** `null` means "the agency has not said", which is not zero. */
  pctContained: number | null;
  /** 1 = a mark. 2 = a mark with a plume. `promote()` decides; no renderer re-derives it. */
  tier: 1 | 2;
  /** ISO-8601 of the observation `acres` came from. */
  observedAt: string | null;
}

/**
 * One satellite thermal detection — **evidence, not an incident.**
 *
 * There is a permanent industrial heat source in the store that appears on every
 * pass with no matching incident, 4.7 km from the owner's house. Detections are
 * therefore drawn as a separate, visually weaker layer and are never promoted
 * into a fire client-side; `persistent` is the endpoint's own learned
 * ignore-list for that furniture. `confidence` carries two incompatible scales
 * in one field — MODIS is an integer 0-100, VIIRS is `low`/`nominal`/`high` — so
 * anything reading it must branch on `sat` first, or use
 * `detectionConfidence()` in `src/server/fires.ts`, which does the branch once.
 */
export interface FireDetectionMark {
  /** `MODIS`, `VIIRS-NOAA20`, `VIIRS-SNPP` — the instrument, verbatim. */
  sat: string;
  lat: number;
  lon: number;
  /** Fire radiative power, MW. `null` where the product did not report one. */
  frp: number | null;
  confidence: string | null;
  /** True when this cell is known furniture: a flare stack, a kiln, a landfill. */
  persistent: boolean;
  acquiredAt: string;
}

/**
 * The whole promoted set for one board — and everything it needs to explain an
 * *empty* one.
 *
 * `ageMs` is load-bearing rather than decorative. On a quiet day an empty board
 * is the correct and common answer: the gate drops twenty-two nameless LA County
 * dispatch numbers with no acreage between them. A silent board and a dead feed
 * are indistinguishable without an age beside them, which is the same argument
 * `HealthBody.degraded` makes, applied to a picture instead of a log.
 */
export interface FireView {
  /** Fires inside this board's bounds, worst first. */
  readonly drawn: readonly DrawnFireMark[];
  /** Hot pixels inside the bounds that are not known furniture. */
  readonly detections: readonly FireDetectionMark[];
  /** ISO-8601 of the last **successful** upstream fetch. Epoch zero when never. */
  readonly fetchedAt: string;
  /** Milliseconds since `fetchedAt`, or `null` when nothing has ever answered. */
  readonly ageMs: number | null;
}

/**
 * The fire layer, as `scene.ts` uses it.
 *
 * Deliberately the smallest surface that lets this file own the wiring: the
 * board's bounds, the rig, the sun and the wind all arrive here already and
 * have to reach the layer, and nothing else about fire belongs in a city.
 *
 * `smokeLoadAt` is the one read-back, and it is what couples the LA courtyard
 * to the real sky: a fire sixty kilometres up the San Gabriels is not a flame
 * seen from a courtyard, it is a brown horizon and a dimmed sun.
 */
export interface FireLayer {
  group: THREE.Object3D;
  /** Replace the drawn set. `null` clears it — nothing has answered yet. */
  setFires(view: FireView | null): void;
  /** Draw the plumes, or do not. The marks stay either way. */
  setSmokeVisible(visible: boolean): void;
  setLighting(state: LightingState): void;
  /** The fog distances alone. See `SceneHandle.setAerialFog`. */
  setFogDistances(near: number, far: number): void;
  setSolarElevation(degrees: number): void;
  /** Wind as the observation reports it: km/h, and the bearing it blows *from*. */
  setWind(kph: number | null, fromDeg: number | null): void;
  tick(dt: number): void;
  /** 0..1 smoke load at a coordinate, for haze somewhere else. */
  smokeLoadAt(lat: number, lng: number): number;
  dispose(): void;
}

/**
 * How a fire layer is built. The same shape `createCloudLayer` has, so the
 * layer sizes itself from the board rather than from a constant.
 */
export type FireLayerFactory = (
  world: World,
  options: { span: number },
) => FireLayer;

/**
 * The four layers this build added, and the one shape they all share.
 *
 * Every one of them is a **factory in `SceneOptions`, not a layer**, and every
 * factory has exactly `FireLayerFactory`'s signature — `(world, { span })`. That
 * is deliberate to the point of being copied rather than generalised. The layer
 * needs the `World` that `createScene` is in the middle of building, so it cannot
 * be handed in already made; and it is sized from the board rather than from a
 * constant, so it needs the span. Absent costs exactly nothing: no geometry, no
 * material, no draw call, and the corresponding setter becomes a no-op. That is
 * not an optimisation, it is the empty state — a quiet day is the commonest
 * correct answer all four of these will ever give, and a layer that is not
 * visited at all is cheaper and more honest than one drawing nothing.
 *
 * `scene.ts` declares the minimum a *renderer* needs and imports none of the
 * modules that implement them, exactly as it does for `FireLayer`. The engine
 * never takes a dependency on a wire module, and neither side has to exist for
 * the other to compile.
 */
export interface PortLayer {
  group: THREE.Object3D;
  setLighting(state: LightingState): void;
  dispose(): void;
}

export type PortLayerFactory = (
  world: World,
  options: { span: number },
) => PortLayer;

/**
 * Hulls and their wakes.
 *
 * `setVessels(null)` and `setVessels([])` are the same picture and a different
 * sentence, the same distinction `setFires` draws: `null` is "nothing has
 * answered", an empty array is "the feed answered and nothing is on this board".
 *
 * `tick` exists because a ship under way is dead-reckoned **along its reported
 * course at its reported speed** between fixes, and its wake is advanced with
 * it. It is never a spline between two fixes: upstream listens for thirty
 * seconds every fifteen minutes, and the chord between two samples is not a path
 * anything took.
 */
export interface VesselLayer {
  group: THREE.Object3D;
  setVessels(vessels: readonly Vessel[] | null): void;
  setLighting(state: LightingState): void;
  tick(dt: number): void;
  dispose(): void;
}

export type VesselLayerFactory = (
  world: World,
  options: { span: number },
) => VesselLayer;

/**
 * The reflectivity sheet: one quad at cloud base, one `DataTexture`.
 *
 * Built only on a board coarse enough to carry a 0.25-degree cell. 27.8 km is a
 * quarter of the SoCal board and a third of the Bay Area board, so on the fine
 * boards the honest answer is to build no layer at all rather than to draw four
 * texels over a city.
 */
export interface PrecipLayer {
  group: THREE.Object3D;
  /** Replace the raster. `null` clears it — nothing has answered yet. */
  setField(field: RadarField | null): void;
  setLighting(state: LightingState): void;
  /** Crossfades between the two newest frames across the ten-minute step. */
  tick(dt: number): void;
  dispose(): void;
}

export type PrecipLayerFactory = (
  world: World,
  options: { span: number },
) => PrecipLayer;

/**
 * Nocturnal migration as one `THREE.Points` drift field.
 *
 * `setSolarElevation` rather than a visibility flag, because the layer's own
 * subject is nocturnal: BirdCast measures only after dark, and whether anything
 * is aloft is a fact about the sun, not a preference. The same seam
 * `nightlights.ts` and `FireLayer` already take.
 */
export interface MigrationLayer {
  group: THREE.Object3D;
  setField(field: MigrationField | null): void;
  setLighting(state: LightingState): void;
  setSolarElevation(degrees: number): void;
  tick(dt: number): void;
  dispose(): void;
}

export type MigrationLayerFactory = (
  world: World,
  options: { span: number },
) => MigrationLayer;

/**
 * Whether this visitor has asked the platform for less movement.
 *
 * Read at the moment it is needed rather than cached, because the only caller
 * asks once per board and the query is a property read. `scenekit.ts` keeps a
 * live subscription for the same preference; it needs one because a chapter
 * flight can be in the air when the setting changes, and the opening move
 * cannot — it is started or it is not.
 */
function prefersReducedMotion(): boolean {
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

/** What the pointer is over: an authored place, or an observed aeroplane. */
type Pick =
  | { kind: "marker"; marker: Marker }
  | { kind: "aircraft"; aircraft: Aircraft };

export interface SceneOptions {
  city: City;
  markerPalette?: MarkerPalette;
  /**
   * Markers available at construction time.
   *
   * Building glyphs in this first set reserve their footprints in the
   * anonymous block scatter. Later `setMarkers()` calls remain cheap and do
   * not rebuild a city, so callers should put stable destinations here and use
   * updates for genuinely live marker feeds.
   */
  markers?: Marker[];
  /** Optional deterministic road traffic for a state/corridor-scale board. */
  roadTraffic?: RoadTrafficOptions;
  /** Optional possessed procedural actor for play mode; inactive by default. */
  actor?: SceneActorOptions;
  /** Geographic spawn anchor for that actor; defaults to the board centre. */
  actorAnchor?: { lat: number; lng: number };
  /** Optional possessed fixed-wing aircraft; the city supplies its projection and terrain. */
  aircraft?: Omit<SceneAircraftOptions, "project" | "groundAt">;
  /** Optional remote authoritative entities. Nothing is imported or allocated when absent. */
  realtimePeers?: CityRealtimePeersOptions;
  flights?: FlightSource;
  /**
   * Element sets to propagate, if this deployment has any.
   *
   * A catalogue rather than a source, and the asymmetry with `flights` is the
   * point: a `FlightSource` is polled because there is no closed form for where
   * aircraft are, and a `SatelliteCatalogue` is *evaluated* because a TLE is
   * exactly that closed form. Nothing here is ever fetched on a timer.
   */
  satellites?: SatelliteCatalogue;
  /**
   * How to build this board's fire layer, or nothing at all.
   *
   * A factory rather than a layer, because the layer needs the `World` this
   * function is in the middle of building. Absent on every board that has no
   * fire projection behind it, and absent costs exactly nothing: no geometry,
   * no material, no draw call, and `setFires` becomes a no-op.
   */
  fires?: FireLayerFactory;
  /**
   * How to build this board's port kit, or nothing at all.
   *
   * The ports themselves are `city.ports` and are plain authored data; this is
   * only the renderer for them, kept optional and injected for the same reason
   * `fires` is — so a board with no port allocates nothing and so `engine/ports.ts`
   * is never in the import graph of a build that does not draw one.
   */
  ports?: PortLayerFactory;
  /** How to build this board's vessel layer, or nothing at all. */
  vessels?: VesselLayerFactory;
  /** How to build this board's reflectivity sheet, or nothing at all. */
  precip?: PrecipLayerFactory;
  /** How to build this board's migration field, or nothing at all. */
  migration?: MigrationLayerFactory;
  /** Fires on hover/click of a marker head. */
  onMarkerPick?: (marker: Marker | null) => void;
  /**
   * Fires on hover of an aeroplane, and with `null` as the pointer leaves one.
   *
   * The same shape as `onMarkerPick` and for the same reason: `scenekit` reports
   * picks by hover, so a click handler upstairs reads whatever the last hover
   * resolved. What comes back is the engine's own `Aircraft` — a position and a
   * callsign — and nothing about where it came from, because that is a question
   * about the deployment and `adapters/http.ts` is the layer that can answer it.
   *
   * Not gated on anything. An ADS-B position is broadcast in clear to anybody
   * with a receiver, so there is nothing here an account could grant; see
   * `owner-decisions.md` and the note on `TrafficSource.detail`.
   */
  onAircraftPick?: (aircraft: Aircraft | null) => void;
  /**
   * After this tick's camera pose is committed.
   *
   * Drive, crow and aircraft follow-cameras write the pose directly and never
   * fire OrbitControls `change`. Aerial fog has to see those writes, so it
   * lives on this callback — the same pump `applyReliefRamp` already runs on —
   * rather than on an event the descent does not fire. Called after the follow
   * cameras have overwritten the pose and before the renderer paints, so a
   * frame of follow-camera motion is a frame of fog. Absent, the tick is what
   * it was.
   */
  onCameraFrame?: () => void;
  /**
   * The shared environment map, when the page has one.
   *
   * Handed in rather than built here, and that is the whole of the wiring rule:
   * a `PMREMGenerator` and its render targets belong to the **renderer**, which
   * outlives every city on the page, so one rig is built beside the `Stage` and
   * shared. A rig per `createScene` would allocate a fresh blur chain and a
   * fresh target for every board and leak both on the next switch, which is
   * exactly the arithmetic `stage.ts` records for the renderer itself.
   *
   * Absent, everything renders as it did before the rig existed: duller metal,
   * no sky in the water, and no error.
   */
  environment?: EnvironmentRig;
  /**
   * Opening light rig. Comes from an `Atmosphere` when there is one; without
   * one the city gets `cityDaylight()`, because a scene that renders black
   * until somebody wires up the sun is not a scene that boots with no config.
   */
  lighting?: LightingState;
  /**
   * Fires while the heightfield builds, several times a second. The caller
   * decides what to say about it; the engine only reports a phase and a
   * fraction. See `FieldProgress`.
   */
  onProgress?: (progress: FieldProgress) => void;
  /**
   * Abandons the build. `createScene` then resolves to `null` having allocated
   * no geometry and having touched the stage not at all — the point of aborting
   * is that the next city gets the machine to itself, and a half-built scene
   * parked on the stage defeats that.
   */
  signal?: AbortSignal;
  /**
   * Put this scene on the stage when it is finished. Defaults to **true**.
   *
   * Construction used to imply presentation: the last statement before the
   * handle was returned was `stage.setScene(stageScene)`, which was correct for
   * as long as a board was built only when there was nothing else to look at.
   * It is not correct once a board is built *behind* the one on screen — which
   * is the whole of what makes a switch stop being a full-screen card — because
   * the swap has to happen after the fog has closed over the outgoing board,
   * not the instant the incoming one has geometry.
   *
   * Opt-out rather than removed, so the default stays the thing every existing
   * caller and every test already expects, and so "build it but do not show me
   * yet" has to be asked for out loud. The caller that asks then owns the
   * `stage.setScene(handle.stageScene)` that presents it.
   */
  present?: boolean;
  /**
   * Which chapter the camera opens on. Defaults to `chapters[0]`.
   *
   * The pack's chapter list is a numbered tour and a capture contract — index
   * 0 is the state's whole-board pose, and twenty-nine shot-list guards aim at
   * buttons by that order. The *visitor* landing is a different question: a
   * bare tera.lumbridgecorp.com should open on a city you can read, not on
   * 1,551 km of tan. Passing `fidi` here does that without reordering the pack.
   * Unknown ids throw rather than silently falling back, so a renamed chapter
   * cannot photograph the state under a caption about downtown.
   */
  openingChapter?: string;
}

export interface SceneHandle {
  world: World;
  chapters: Chapter[];
  /**
   * The stage this city was built on, which it uses and does not own. An office
   * is swapped in with `stage.setScene(officeScene)` and this city back in the
   * same way; the one that steps out is paused, not thrown away.
   *
   * `dispose()` below takes the city off it and leaves it running. Whoever
   * built the stage disposes it, and on this page nobody does — it lives as
   * long as the canvas does.
   */
  stage: Stage;
  /** This city, as the thing `stage.setScene` takes. */
  stageScene: StageScene;
  /**
   * Play the opening move: stand off the authored opening shot, then settle
   * onto it.
   *
   * Called by the app once the board is on screen, rather than run from
   * `createScene`, because only the app knows whether this is an arrival at
   * all — a board built behind a progress card is not being looked at yet.
   *
   * Idempotent-ish and cheap to get wrong in the safe direction: calling it
   * twice restarts the move, and calling it after the visitor has already
   * touched the board is the one thing it must not do, so the app calls it
   * exactly once per mount and any input cancels it. Under
   * `prefers-reduced-motion` it places the camera on the resting pose and
   * returns, which is also what makes a capture of this board reproducible.
   */
  arrive(): void;
  /** Applies a rig computed elsewhere. The scene never works one out itself. */
  setLighting(state: LightingState): void;
  /**
   * Move the fog planes because the **camera** moved, without touching the light.
   *
   * ## Why this is a second setter and must stay one
   *
   * Aerial perspective is the one term in the rig that depends on where the
   * camera is standing, and the camera moves in a completely different tempo
   * from the sky: `main.ts` recomputes the sun once a minute and recomputes the
   * fog on this board's tick past a 2% altitude threshold — a few dozen times in
   * one drag or a chapter flight, and every frame of a follow-camera descent
   * that actually moved. Before this seam existed both went through
   * `setLighting`, so a gesture that changed two floats also re-pushed the sun,
   * the hemisphere, the ambient, the sky dome, the moon and the fog colour into
   * six layers, dirtied a `MeshLambertMaterial` in `ports.ts`, rebuilt the
   * vessel wake instances, and re-fingerprinted the PMREM environment in
   * `environmentRig.ts`. None of those read a distance. Measured over a dolly
   * plus a drag, that path costs 0.14-0.19 ms a step against 0.02 ms for this
   * one.
   *
   * ## Why it carries no colour, which is the load-bearing half
   *
   * `environmentRig.ts` decides whether to re-render and re-convolve the sky
   * cubemap by fingerprinting the rig's **colours**, `sky.horizon` among them,
   * and `interiors/daylight.ts` pins that horizon stop to the fog colour on
   * purpose — it is what hides the seam where the sky dome meets the haze. So a
   * camera-dependent *colour* would put a PMREM rebuild on every orbit step,
   * and the fix for that would not be to coarsen the fingerprint: quantising
   * harder hides one instance and leaves the mechanism armed for the next
   * feature that varies a colour. Keeping this setter to two distances is the
   * structural answer instead, and `atmosphere.ts`'s `AerialFog` is the type
   * that states it. Anything that genuinely changes the light — the sun, the
   * sky, the weather, the hour — goes through `setLighting` on the clock, and
   * `Atmosphere` stays the sole owner of both (CONTRACT.md §4).
   *
   * Reaches exactly the three things that draw fog: `scene.fog`, the cloud
   * deck's own uniforms, and the fire marks and plumes. Everything else in the
   * board is a stock material and reads `scene.fog` for free.
   */
  setAerialFog(fog: AerialFog): void;
  /**
   * Solar elevation in degrees, for the layers that need the sun's position
   * rather than the rig it implies. `LightingState` deliberately carries no
   * elevation, so the number has to arrive separately.
   */
  setSolarElevation(degrees: number): void;
  /**
   * How much of the sky has cloud in it, 0..1 — `WeatherObservation.cloudCover`.
   *
   * Separate from `setLighting` for the same reason `setSolarElevation` is: a
   * `LightingState` deliberately carries no cover, so the number has to arrive
   * on its own rather than be reverse-engineered out of a rig.
   */
  setCloudCover(fraction: number): void;
  /** Wind as the observation reports it: km/h, and the bearing it blows *from*. */
  setWind(kph: number | null, fromDeg: number | null): void;
  /**
   * The fires this board should be drawing, or `null` for none.
   *
   * `null` and an empty `incidents` array are the same picture and a different
   * sentence, which is why both exist: `null` is "nothing has answered", an
   * empty set is "the projection answered and nothing on this board qualifies".
   * On a quiet day the second is the correct and common case — the promotion
   * gate drops twenty-two nameless LA County dispatch numbers with no acreage
   * between them — and a board that says so with a fetch age is honest, where a
   * board that draws them is not.
   *
   * A no-op on a build with no fire layer, exactly like `setSatellitesVisible`.
   */
  setFires(view: FireView | null): void;
  /** Draw the smoke plumes, or do not. The marks are unaffected. */
  setFireSmoke(visible: boolean): void;
  /**
   * The ships this board should be drawing, or `null` for none.
   *
   * The same two-valued emptiness `setFires` has: `null` is "nothing has
   * answered", `[]` is "the feed answered and this board is empty". A no-op on a
   * build with no vessel layer.
   */
  setVessels(vessels: readonly Vessel[] | null): void;
  /**
   * How high the camera is above the ground it is looking at, in metres.
   *
   * The one number the light rig needs that a clock cannot supply.
   * `Atmosphere.apply` takes it as an optional second argument and turns it into
   * aerial perspective; see `aerialReach` there for why the invariant is a
   * fraction of the board's authored reach rather than a distance in metres.
   *
   * Metres and not scene units, deliberately, and it is the *scene* that
   * converts because the scene is the only thing that knows this board's
   * `metresPerUnit` and its vertical exaggeration. A caller handed units would
   * have to know both, which is how the engine's scale would leak into the app
   * for the third time.
   *
   * Measured to the controls' target rather than to the terrain directly under
   * the camera: the target is what the shot is *of*, and a camera looking across
   * a valley from over a ridge is not two kilometres up merely because there
   * happens to be a mountain beneath it.
   */
  cameraAltitudeMetres(): number;
  /**
   * How far the camera is from what it is looking at, in metres.
   *
   * The other half of what aerial perspective needs, and the half a physical
   * model does not think it needs. A map is looked at from outside the
   * atmosphere it depicts — the state board's chapters sit two hundred
   * kilometres from their own subjects — so the fog has to clear the shot as
   * well as follow the air. `atmosphere.ts` floors one against the other; see
   * `AERIAL_SUBJECT_CLEARANCE`.
   */
  cameraStandoffMetres(): number;
  /**
   * Whether the unrequested opening move is still in the air.
   *
   * Exposed for one caller and one reason: `main.ts`'s free-camera handover must
   * not read a stand-off that the *arrival* is still changing. `arrivalStart`
   * begins the move at `ARRIVAL_STANDOFF` — 1.5x the resting stand-off — and the
   * ladder demotes a board above `DEMOTE`, which is 1.15x its handover ceiling.
   * 1.5 is greater than 1.15 for every board there will ever be, so the opening
   * frame of *any* board is outside that board's own retention band and a
   * handover that ran during it would immediately throw the visitor back to the
   * coarser tier they came from. Which is exactly what it did.
   *
   * This is the same class of guard as the drag guard: not "the camera is
   * somewhere odd" but "nobody has asked for this camera position yet".
   */
  arriving(): boolean;
  /**
   * World-space Y of the ground at a lat/lng, after the relief ramp.
   * Camera look-ats use this; objects parented to the ground group do not.
   */
  groundWorldY(lat: number, lng: number): number;
  /** Current ground-group scale.y from the relief ramp. 1 when the pack does not ramp. */
  reliefScale(): number;
  /** The reflectivity raster, or `null`. A no-op without a precipitation layer. */
  setPrecip(field: RadarField | null): void;
  /** Tonight's migration, or `null`. A no-op without a migration layer. */
  setMigration(field: MigrationField | null): void;
  /**
   * 0..1 smoke load at a coordinate — how much of the drawn fire set is
   * upwind of it and near enough to matter. Zero with no fire layer.
   *
   * Read by the app to haze an *office* whose courtyard is open to this sky.
   */
  fireSmokeLoadAt(lat: number, lng: number): number;
  /**
   * Freeze the satellite sky at an instant, or pass `null` to follow the wall
   * clock. Exactly the shape of `main.ts`'s own time override, deliberately.
   *
   * Separate from `setSolarElevation` even though both follow the same clock,
   * because they need different things from it: the night-lights want a scalar
   * the caller has already worked out, and SGP4 wants the date itself. Handing
   * the layer an elevation would mean it could not propagate, and handing the
   * lights a `Date` would mean two modules computing the sun.
   *
   * No-op on a deployment with no catalogue.
   */
  setSkyInstant(when: Date | null): void;
  /** Draw the satellite layer, or do not. No-op with no catalogue. */
  setSatellitesVisible(visible: boolean): void;
  /**
   * How many objects were above the horizon at the end of the last complete
   * propagation pass, and how many element sets this build could read at all.
   * Both zero without a catalogue. For the godmode readout; nothing renders it.
   */
  satelliteCounts(): { visible: number; total: number };
  flyTo(chapterId: string): void;
  current(): string;
  onChapterChange(fn: (id: string) => void): void;
  /** Atomically hands local input and follow-camera ownership to one subsystem. */
  setControlMode(mode: CityControlMode): void;
  controlMode(): CityControlMode;
  onControlModeChange(fn: (mode: CityControlMode) => void): void;
  /** Device-neutral input for the corridor hero; a no-op on boards without one. */
  setVehicleActions(actions: Partial<VehicleActionSnapshot>): void;
  /** Current playable corridor state, or null on a city-scale board. */
  vehicleState(): Readonly<VehicleControllerState> | null;
  setVehicleCamera(mode: VehicleCameraMode): void;
  vehicleCamera(): VehicleCameraMode | null;
  setActorActions(actions: Partial<ActorActionSnapshot>): void;
  actorState(): Readonly<ActorControllerSnapshot> | null;
  /** Replace the visible local actor profile without disturbing its position or controls. */
  setActorIdentity(identity: ActorIdentity): void;
  /** Attach a caller-owned face texture to the local humanoid, if one is active. */
  attachActorFaceTexture(texture: THREE.Texture): boolean;
  /** Detach the current local face texture without disposing caller ownership. */
  clearActorFaceTexture(): void;
  setActorActive(active: boolean): void;
  actorActive(): boolean;
  setAircraftActions(actions: Partial<AircraftActionSnapshot>): void;
  aircraftState(): Readonly<AircraftControllerSnapshot> | null;
  setAircraftActive(active: boolean): void;
  aircraftActive(): boolean;
  /** Canonical remote snapshots only; a no-op when realtime peers were not configured. */
  upsertRemoteSnapshot(snapshot: EntityPoseSnapshot): boolean;
  removeRemoteEntity(id: string): boolean;
  clearRemoteEntities(): void;
  remoteEntityCount(): number;
  setMarkers(markers: Marker[]): void;
  /** Take this city off the stage and release everything it built. */
  dispose(): void;
}

/**
 * Build a city on a stage. Resolves to `null` if the build was abandoned.
 *
 * ## Why this is async, and why the handle is not
 *
 * `SceneHandle` is unchanged: every method on it is synchronous and every field
 * on it is real by the time you hold one. Only getting one takes a moment.
 *
 * The alternative was tried on paper and is worse. Handing back a handle
 * immediately means handing back a handle whose `stageScene` holds an empty
 * scene, whose `flyTo` cannot know where the ground is, and whose `world`
 * answers `groundAt` by building the heightfield on the main thread — the exact
 * block this change exists to remove, reintroduced by the first caller who
 * forgets to wait. Every one of those methods would need a "not yet" branch and
 * a queue, and the queue would be the real API.
 *
 * So the wait is where the wait actually is. The cost is that it ripples: the
 * app has to `await mountCity`, and `createMinimap` has to be constructed after
 * this resolves rather than alongside it. That is a handful of `await`s in
 * `main.ts` against an engine that cannot lie about whether its ground exists.
 */
export async function createScene(
  stage: Stage,
  options: SceneOptions,
): Promise<SceneHandle | null> {
  const { city } = options;
  const world = new World(city);
  // First, so an abandoned build has nothing to tear down: everything below
  // this line allocates, and a city that is no longer wanted should not have
  // built a single buffer.
  const ready = await world.ready({ signal: options.signal, onProgress: options.onProgress });
  if (!ready) return null;

  const pal = paletteFor(world);

  const scene = new THREE.Scene();

  /**
   * Every camera limit is derived from how big this city's board actually is.
   *
   * These were constants tuned for San Francisco — `maxDistance: 340`,
   * `far: 900`, a 170-unit shadow box. That silently made board size a fixed
   * property of the engine rather than of a city: expanding the pack from San
   * Francisco to the whole Bay Area took the board from 230 units across to
   * 1003, and the camera physically could not retreat far enough to frame it.
   * You got a close-up of the peninsula with everything else off-screen, and
   * nothing in the types said why.
   *
   * A city pack now chooses its own `latScale` freely and the camera follows.
   */
  const [westX, northZ] = world.project(city.bounds.maxLat, city.bounds.minLng);
  const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng);
  const boardSpan = Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ));

  /**
   * How far the board reaches **from the scene origin**, which is not the same
   * as how big it is.
   *
   * Scene space is centred on `city.center` — the city — and the Bay Area board
   * runs forty kilometres down the peninsula from there, so the origin is
   * nowhere near the middle of it. The furthest corner is 0.94 spans out where
   * the half-diagonal is only 0.65, and anything sized off the half-diagonal is
   * therefore too small by half.
   *
   * The satellite dome is centred on the origin, because that is where its look
   * angles were computed for, so this is the radius it has to clear.
   */
  const boardRadius = Math.max(
    Math.hypot(westX, northZ),
    Math.hypot(eastX, northZ),
    Math.hypot(westX, southZ),
    Math.hypot(eastX, southZ),
  );

  /**
   * Does this board carry metro detail? Asked here, off the pack, rather than
   * read from `hasDetail` below, because the orbit floor is a constructor
   * argument and `hasDetail` is not known until the blocks are built.
   */
  const carriesDetail = city.districts.some((d) => d.detail === true);

  /**
   * The closest the camera may stand to what it is looking at, in true metres.
   *
   * **1,900 m, and it is the Bay Area board's own floor rather than a new
   * number.** `boardSpan * 0.02` is 20.1 units there and 94.3 m to the unit, so
   * San Francisco has always let you come to 1.89 km — and that is the range
   * every close photograph of this product was taken at.
   *
   * On the merged board the *same rule* is 11.1 units of 1,919 m, which is
   * **21.3 km**: eleven times further out, on the one board that carries both
   * metros' districts, landmarks and bridges. The rule is a fraction of board
   * size, and board size is exactly the thing `cities/unify.ts` changed without
   * changing what is standing on it. So the state tier could never resolve a
   * city no matter what the level of detail did — not because the lots are
   * coarse, but because the camera is not allowed to arrive.
   *
   * A fraction of the board is still right for a board with nothing close to
   * look at: California with no cities on it is an atlas, 21 km is the scale it
   * reads at, and dropping its floor would only let a visitor push their face
   * into a 3,473 m terrain cell. Hence the two cases, keyed on the one fact
   * that distinguishes them.
   */
  const ORBIT_MIN_STANDOFF_M = 1_900;
  const orbitMinDistance = carriesDetail
    ? ORBIT_MIN_STANDOFF_M / world.metresPerUnit
    : Math.max(4, boardSpan * 0.02);
  /**
   * The far end of the orbit, held rather than written twice.
   *
   * `chapterPose` below has to know it: a pose beyond it is not obeyed, it is
   * silently clamped by `OrbitControls` on the next update, so a framing
   * correction that asks for more than this is a framing correction that does
   * nothing. See the note on the option itself for where 2.0 comes from.
   */
  const orbitMaxDistance = boardSpan * 2.0;
  const kit = createSceneKit({
    scene,
    dom: stage.renderer.domElement,
    fov: 42,
    near: 0.1,
    /**
     * Everything, at the worst pose, plus room.
     *
     * The furthest thing from the camera is the back of the satellite dome seen
     * from a chapter at the far end of the board: `maxChapterOffset` (0.94
     * spans) + `maxDistance` (2.0) + the dome radius (about 0.99) — call it 3.9.
     * At 3.0 the sky was being clipped from any off-centre chapter, which is
     * most of them.
     *
     * It also now sits at the fog's far plane, which is the honest statement of
     * the invariant: nothing should be cut off before it has fully faded out.
     */
    far: boardSpan * 4,
    minDistance: orbitMinDistance,
    /**
     * Far enough out to be **above the satellites**, which is what the extra
     * half-span buys.
     *
     * The satellite dome is at 0.7 spans (`engine/satellites.ts`), so at 1.5 the
     * camera was always inside the constellation and could only ever look up
     * through it. At 2.0 the far end of the zoom is outside it looking down, with
     * the whole dome in frame at this field of view and the board still filling
     * about two thirds of the screen.
     *
     * The far plane already covers it: the furthest thing from the camera is then
     * the back of the dome at 2.7 spans, against `far` at 3.0.
     */
    maxDistance: orbitMaxDistance,
    shadowExtent: boardSpan * 0.75,
    /**
     * The middle of the board, which is **not** the origin.
     *
     * Scene space is centred on `city.center` — the city — and the Bay Area
     * board runs forty kilometres down the peninsula from there, so a shadow
     * box centred on the origin spends half itself on empty ocean and leaves the
     * far end of the peninsula outside the frustum entirely. `shadowExtent`
     * sizes the box and says nothing about where it is; this says where.
     */
    shadowTarget: new THREE.Vector3((westX + eastX) / 2, 0, (northZ + southZ) / 2),
    shadowFar: boardSpan * 2.2,
  });
  // Held, because the cloud layer needs the same opening rig the kit just got —
  // and it must be the same object, not a second call to `cityDaylight`, or the
  // two disagree for the one frame before the app's first `setLighting`.
  const opening = options.lighting ?? cityDaylight(pal, boardSpan);
  kit.applyLighting(opening);
  /*
   * The environment before the first layer is added, so the very first frame has
   * a sky to reflect rather than acquiring one a `setLighting` later.
   *
   * `offstage` is the `present` flag, one line early. It has to be, because this
   * call is two hundred lines above the `if (options.present !== false)` that
   * decides whether anybody will ever look at this scene — and the rig holds one
   * probe per kind, disposes it on a key miss, and repoints every applied scene
   * at the replacement. A board built ahead of the camera is observed at its own
   * centre, so its key differs by construction, and without this a board loading
   * invisibly in the background would change the sky of the board on screen.
   * `onEnter` below applies it again, for real, when it is presented.
   */
  /**
   * The lighting this scene was last told about, and whether anybody is looking.
   *
   * Both are held because the environment is applied from three places — here at
   * construction, from `setLighting` on every clock tick, and from `onEnter` on
   * arrival — and all three have to agree about the same two facts. A board that
   * is off screen must never rebuild the shared probe; a board that is on screen
   * must always be allowed to.
   */
  let lighting = opening;
  let presented = options.present !== false;
  options.environment?.apply(scene, opening, "city", { offstage: !presented });

  /**
   * Everything whose height is a statement about the ground, under one node.
   *
   * The node exists so that `verticalExaggeration` can change without anything
   * being rebuilt. A board is built once, at `city.verticalExaggeration`, and
   * one `scale.y` on this group restates every height in it at once —
   * heightfield, shore plates, lots, landmarks, bridge decks, runway aprons,
   * berths, night lights, road ribbons, the actor standing on it. They stay
   * consistent with each other **by construction**: a building placed at the
   * ground height it was built against is still at it after both are
   * multiplied by the same number, which is exactly the property that the
   * alternative — re-deriving placements against a new ground — does not have,
   * and re-derivation is the blocker `TODO.md` names for the terrain quadtree.
   *
   * What is deliberately **not** in here is the sky: clouds, precipitation, the
   * bird migration, live aircraft, the satellite dome and the Starlink shells.
   * Their altitudes are facts about the atmosphere rather than about the
   * terrain, and an aeroplane that sinks when the hills flatten is an aeroplane
   * drawing the wrong thing. The visible consequence is honest and worth
   * stating: as the ramp flattens the ground, live traffic stands further off
   * it, because it always was that far off it in true metres.
   */
  const ground = new THREE.Group();
  ground.name = "ground";
  scene.add(ground);

  ground.add(createWater(world));
  ground.add(createShorePlates(world));
  ground.add(createTerrain(world));
  const atlasCorridor = options.roadTraffic
    ? createFreewayWorld(world, options.roadTraffic.pack, "atlas")
    : createRoads(world);
  const metreCorridor = options.roadTraffic
    ? createFreewayWorld(world, options.roadTraffic.pack, "metre")
    : null;
  ground.add(atlasCorridor);
  if (metreCorridor) {
    metreCorridor.visible = false;
    ground.add(metreCorridor);
  }
  const buildingReservations: BuildingReservation[] = [];
  for (const marker of options.markers ?? []) {
    const glyph = marker.glyph;
    if (marker.located === false || glyph?.kind !== "building") continue;
    const [x, z] = world.project(marker.lat, marker.lng);
    // Five metres of breathing room keeps an anonymous wall from sitting
    // exactly on the authored façade after both reservation circles touch.
    const radius = (Math.hypot(glyph.width, glyph.depth) / 2 + 5) / world.metresPerUnit;
    buildingReservations.push({ x, z, radius });
  }
  const blocks = createBlocks(world, buildingReservations);
  ground.add(blocks);
  const landmarkGroup = createLandmarks(world, buildingReservations);
  ground.add(landmarkGroup);

  /**
   * Metro detail on a merged board, revealed by how far the camera is standing
   * off. **A no-op on every board that is not the merged one.**
   *
   * `cities/unify.ts` folds two metros into the state board, which takes it from
   * 17 districts to 107 and from 58 landmarks to well past that. Drawn
   * unconditionally that measured **1,157,802 triangles against a 440,000 cap**
   * and 542 draw calls against 480 — for buildings sub-pixel at 1,919 m to the
   * unit, which is to say for nothing. This is the level of detail that makes
   * one California affordable, and it is deliberately the cheapest kind there
   * is: `createBlocks` has already ordered every detail lot *last*, so hiding
   * them is one assignment to `InstancedMesh.count` — no second mesh, no second
   * draw call, no allocation, and nothing for `nightlights.ts` to know about,
   * since it holds the same mesh either way. Landmarks are one mesh each, so
   * theirs is a `visible` flag rather than a count.
   *
   * `DETAIL_STANDOFF_M` is 120 km because that is just outside the ladder's own
   * handover ceilings — 71.2 km for the Bay and 108.8 for the Southland — so
   * the city under the camera has already appeared by the time the old build
   * would have swapped boards. Above it the merged board draws exactly what the
   * state pack always drew.
   */
  const detailLandmarks = landmarkGroup.children.filter(
    (child) => child.userData.detail === true,
  );
  /*
   * **Asked of the store, not of the count.** This used to read
   * `blocks.count - blocks.userData.baseCount`, which was zero the moment
   * `createBlocks` started returning the mesh already packed down to its base
   * set — the merged board passed only because it also carries detail
   * landmarks. Now that the base lots are packed like any other district and
   * `count` is returned at 0, the same expression would be *negative*. The
   * store's existence is the fact being asked about: `createBlocks` builds one
   * if and only if the pack declares a detail district.
   */
  const hasDetail = blocks.userData.detail !== undefined || detailLandmarks.length > 0;

  /*
   * Where the detail actually is, in scene units, so "close enough to draw the
   * cities" is a question about the cities and not only about altitude.
   *
   * Stand-off alone is not enough and the drive chapter is the proof: the
   * corridor camera sits a few tens of metres off the ground half way up the
   * state, which passes any altitude test while being two hundred kilometres
   * from the nearest building this flag governs. Measured that way the merged
   * board failed `california-drive` on triangles for lots nobody could see.
   *
   * One centre per detail district — 99 of them — and the test is the nearest.
   * That is 99 squared distances a frame against a saving of nearly eight
   * hundred thousand triangles, and it is computed from `controls.target`
   * rather than the camera because the target is *what is being looked at*: a
   * camera high over the Bay is looking at the Bay however far away it is.
   */
  const detailCentres: { x: number; z: number }[] = [];
  if (hasDetail) {
    for (const district of world.city.districts) {
      if (district.detail !== true || district.polygon.length === 0) continue;
      let lat = 0;
      let lng = 0;
      for (const [pLat, pLng] of district.polygon) {
        lat += pLat;
        lng += pLng;
      }
      const [x, z] = world.project(lat / district.polygon.length, lng / district.polygon.length);
      detailCentres.push({ x, z });
    }
  }
  const detailReachUnits = DETAIL_REACH_M / world.metresPerUnit;

  /**
   * The metro-scale layers, which follow the same reveal as the buildings.
   *
   * Filled after the port and vessel layers exist, a few dozen lines below;
   * `applyDetailLod` is only ever called from the frame loop, which starts
   * later still.
   *
   * **On a merged board every bridge, airport and port is metro detail**, and
   * that is a measured fact rather than an assumption: a scene census of the
   * state pack against the merged one showed all seven named bridges, all eight
   * airport layers and every port mesh going from *zero* to one — `california.ts`
   * authors none of them. Together with the crane heads, bridge lamps and hulls
   * they were 27 of the 58 draw calls that put the merged board over the mobile
   * cap, for structures that are sub-pixel at 1,919 m to the unit.
   *
   * Empty on every other board, because `hasDetail` is false there and
   * `applyDetailLod` returns before reading this.
   */
  /**
   * Declared **here**, above the detail LOD, and not beside its listeners.
   *
   * `applyDetailLod` reads it to exempt drive mode from the corridor withdrawal,
   * and the LOD is applied once up front — before the line this used to sit on.
   * A `let` is in its temporal dead zone until its declaration *executes*, so
   * the earlier position made the merged board throw a `ReferenceError` on the
   * first board it ever drew. TypeScript does not catch that: the binding is in
   * scope for the whole function and only the runtime knows the order.
   */
  let controlMode: CityControlMode = "overview";

  const detailLayers: THREE.Object3D[] = [];

  /**
   * The relief ramp: how much of the board's built exaggeration is in force
   * right now, as a scale on the ground group.
   *
   * ## What it is for
   *
   * `city.verticalExaggeration` is derived so the tallest blended peak fills a
   * fixed fraction of the board when the board is framed whole, and on a board
   * you can only look at from one distance that is the end of the matter. The
   * merged California can be flown from 1,551 km to 1.9 km, and the same 15x
   * that makes the Sierra read from orbit puts the camera **inside Twin Peaks**
   * at the FiDi rung. `nearVerticalExaggeration` is what the board wants when
   * you are in it — 5.78, San Francisco's own — and this interpolates.
   *
   * ## Why a scale and not a rebuild
   *
   * Because a rebuild is the expensive, dangerous answer to a question a
   * transform already answers. Every height in the ground group was derived
   * from the same `verticalExaggeration`, so multiplying the group restates all
   * of them consistently: a lot placed on a hillside is still on it, a bridge
   * deck still clears the water, a berth is still at the quay. The alternative
   * is re-deriving every placement against new ground, which is precisely the
   * blocker `TODO.md` names for the terrain quadtree — this sidesteps it rather
   * than solving it, and the difference is worth being clear about, because the
   * quadtree will still have to solve it.
   *
   * ## Why the trigger is stand-off and not altitude
   *
   * Altitude is measured against the ground, and this moves the ground. Driving
   * the ramp from the camera's height would be a feedback loop: flatten the
   * relief, the camera is higher, flatten less, the camera is lower. Stand-off
   * — the distance from the camera to what it is looking at — is a property of
   * the controls alone and does not move when the ground does, so the ramp is
   * stable by construction.
   *
   * ## The two ends, in true metres
   *
   * Full exaggeration from `RELIEF_FAR_M` out, which is comfortably wider than
   * any metro rung and just inside the state rungs, so the whole-board pose and
   * every capture guard aimed at it are untouched. Fully near by
   * `RELIEF_NEAR_M`, which is the closest a metro rung sits. Interpolated on
   * the **logarithm** of the stand-off between them, because the rungs
   * themselves are spaced logarithmically — 1,551, 501, 256, 95, 45, 24, 8.3,
   * 7.7 km — and a linear ramp spends almost all of its travel between the two
   * widest rungs and almost none across the eight that matter.
   */
  let arrival: { from: Pose; to: Pose; elapsed: number } | null = null;
  const builtExaggeration = city.verticalExaggeration;
  const nearExaggeration = city.nearVerticalExaggeration ?? builtExaggeration;
  /** Nothing to ramp when a pack asked for one exaggeration; the common case. */
  const reliefRamps = Math.abs(nearExaggeration - builtExaggeration) > 1e-6;
  let reliefScale = 1;
  function applyReliefRamp(): void {
    if (!reliefRamps) return;
    const standoff = kit.camera.position.distanceTo(kit.controls.target) * world.metresPerUnit;
    const wanted = reliefScaleAt(standoff, nearExaggeration, builtExaggeration);
    // A scale write is cheap but it dirties the world matrix of every descendant
    // of the ground group, which is most of the board. 0.2% is below anything
    // the eye resolves across a frame and keeps a still camera genuinely still.
    if (Math.abs(wanted - reliefScale) < 0.002) return;
    const factor = wanted / reliefScale;
    reliefScale = wanted;
    ground.scale.y = wanted;
    ground.updateMatrixWorld(true);
    /*
     * Camera and look-at live in world space; the city lives in the ground
     * group. Without this, a FiDi landing written against baked 15x ground
     * stares at a point 2.6x above the ramped city. Scale both Ys from the
     * sea-level pivot so height-above-surface stays authored and the target
     * stays on the surface.
     */
    kit.controls.target.y *= factor;
    kit.camera.position.y *= factor;
    kit.controls.update();
    if (arrival !== null) {
      arrival.from.position.y *= factor;
      arrival.from.target.y *= factor;
      arrival.to.position.y *= factor;
      arrival.to.target.y *= factor;
    }
  }

  /**
   * Layers that belong to the wide view and are **withdrawn** when the camera
   * comes in close — the mirror of `detailLayers`, and empty on every board that
   * is not the merged one.
   *
   * The corridor is the whole of it. `createFreewayWorld` draws US-101 and I-5
   * as a deliberate atlas glyph about 4.7 km across, because DRIVE mode has to
   * be able to drive down it on a board where a real freeway is a fifth of a
   * pixel — `main.ts` says so where it sets the corridor's altitude. On the
   * three separate boards that was invisible, because the only board with a
   * corridor was the one you never came close on.
   *
   * `cities/unify.ts` put cities on that board, and the first photograph of San
   * Francisco from 9 km showed what that means: two black ribbons four and a
   * half kilometres wide lying across the city, wider than the peninsula is at
   * the Golden Gate. It is the single most dominant thing in every close frame
   * of the merged board.
   *
   * **Atlas far, metre close.** The atlas glyph is withdrawn inside
   * `DETAIL_STANDOFF_M`; the true-width corridor (`widthM`, 44 m on US-101)
   * is shown instead, including in drive, which is the mode that needs a
   * road a car can be on. Roadside props on the metre corridor are placed
   * from `setbackM`, not from the slab.
   */
  const overviewLayers: THREE.Object3D[] = [];
  let detailShown = true;
  /**
   * The camera's frustum in the **ground group's own space**, which is the
   * space `createBlocks` placed its instances in.
   *
   * `ground.matrixWorld` is not the identity and cannot be assumed away: the
   * relief ramp writes `ground.scale.y` as the camera descends, and a frustum
   * built from the camera alone would be testing district spheres against a
   * board of a different height. Multiplying the view-projection by
   * `ground.matrixWorld` moves the planes into the group instead of moving
   * every sphere out of it.
   *
   * One frame late, deliberately: this runs before `kit.tick`, so these are
   * last frame's camera matrices. `DETAIL_FRUSTUM_PAD` in `blocks.ts` is what
   * covers the gap, and it is a fraction of the stand-off for exactly that
   * reason — how far the camera moved in a frame scales with how far away it
   * is standing.
   */
  const detailFrustum = new THREE.Frustum();
  const detailMatrix = new THREE.Matrix4();
  function applyDetailLod(cull = true): void {
    if (!hasDetail) return;
    const target = kit.controls.target;
    const standoff = kit.camera.position.distanceTo(target) * world.metresPerUnit;
    let want = standoff < DETAIL_STANDOFF_M;
    if (want) {
      const reach = detailReachUnits * detailReachUnits;
      want = detailCentres.some((c) => {
        const dx = target.x - c.x;
        const dz = target.z - c.z;
        return dx * dx + dz * dz < reach;
      });
    }
    /*
     * The buildings are re-packed by district rather than switched wholesale,
     * so driving into Los Angeles draws Los Angeles and not also San Francisco
     * four hundred kilometres behind the camera. `updateBlocksDetail` is a no-op
     * on any frame where the visible set has not changed, and on any board with
     * no detail districts at all.
     *
     * The frustum is the second half of the same idea and it is what makes a
     * close pose cheap: the reach test draws every district within 60 km of
     * what is being looked at, and standing 2.5 km over Los Angeles that is
     * 48,081 lots — 480,810 triangles against a 400,000 cap — of which two
     * thirds are behind the camera or off the sides.
     */
    // `updateWorldMatrix(true, false)`, not `updateMatrixWorld()`: the second
    // walks every descendant of the ground group — most of the board — and the
    // renderer already does that once a frame. This updates the group's own
    // matrix and its ancestors' and stops.
    ground.updateWorldMatrix(true, false);
    detailMatrix
      .multiplyMatrices(kit.camera.projectionMatrix, kit.camera.matrixWorldInverse)
      .multiply(ground.matrixWorld);
    detailFrustum.setFromProjectionMatrix(detailMatrix);
    updateBlocksDetail(
      blocks,
      target.x,
      target.z,
      want ? detailReachUnits : 0,
      cull ? detailFrustum : undefined,
      standoff / world.metresPerUnit,
    );
    const driving = controlMode === "drive";
    if (metreCorridor) {
      atlasCorridor.visible = !want && !driving;
      metreCorridor.visible = want || driving;
    }
    if (want === detailShown) return;
    detailShown = want;
    for (const child of detailLandmarks) child.visible = want;
    for (const layer of detailLayers) layer.visible = want;
    if (!metreCorridor) {
      for (const layer of overviewLayers) layer.visible = !want || driving;
    }
  }
  // Applied once up front so the opening frame is already correct rather than
  // correct one tick later, which is a frame the capture harness can catch.

  const bridgeGroup = createBridges(world);
  ground.add(bridgeGroup);
  // Airfields. Laid flush on the terrain rather than draped over it like a
  // road, which is why the packs no longer carry runways as `Road` records —
  // carrying both floats a dark stripe thirteen metres above every runway.
  const airportGroup = createAirports(world, city.airports ?? []);
  ground.add(airportGroup);

  /**
   * The city switching itself on after sunset. Built after `blocks` because it
   * patches the material that `createBlocks` made — order is load-bearing.
   */
  const nightLights: NightLights = createNightLights({ world, blocks });
  ground.add(nightLights.group);

  const clouds: CloudLayer = createCloudLayer(world, { span: boardSpan });
  clouds.setLighting(opening);
  scene.add(clouds.group);

  /**
   * Fire, when this deployment has a projection to draw. Built beside the
   * clouds because it is the same kind of thing — a weather layer sized by the
   * board and lit by the rig this scene was handed — and built after them so a
   * plume sorts against cloud rather than the other way round.
   */
  const fireLayer: FireLayer | null = options.fires
    ? options.fires(world, { span: boardSpan })
    : null;
  if (fireLayer) {
    fireLayer.setLighting(opening);
    ground.add(fireLayer.group);
  }

  /**
   * The port kit, the ships, the rain and the birds — each one built exactly the
   * way the fire layer above it is, and each one absent by default.
   *
   * Order is not arbitrary. Ports are ground and go under everything; hulls sit
   * on the sea beside the quays the ports just drew; the reflectivity sheet
   * hangs at cloud base and must sort against the clouds built above it; the
   * migration motes are the last thing in and the highest, so they draw over the
   * sheet rather than through it.
   */
  const portLayer: PortLayer | null = options.ports
    ? options.ports(world, { span: boardSpan })
    : null;
  if (portLayer) {
    portLayer.setLighting(opening);
    ground.add(portLayer.group);
  }

  const vesselLayer: VesselLayer | null = options.vessels
    ? options.vessels(world, { span: boardSpan })
    : null;
  if (vesselLayer) {
    vesselLayer.setLighting(opening);
    ground.add(vesselLayer.group);
  }

  /*
   * Now that every metro-scale layer exists, hand them to the detail LOD and
   * apply it once — up front, so the opening frame is already correct rather
   * than correct one tick later, which is a frame the capture harness can catch.
   */
  if (hasDetail) {
    detailLayers.push(bridgeGroup, airportGroup);
    overviewLayers.push(atlasCorridor);
    if (portLayer) detailLayers.push(portLayer.group);
    if (vesselLayer) detailLayers.push(vesselLayer.group);
    detailShown = true;
    /*
     * Without the frustum, for the one call that is not a frame: the camera has
     * not been placed for the opening shot yet, and a set culled against
     * whatever pose the kit was constructed at would open a district short and
     * fill it in one tick later — which is a frame the capture harness can
     * catch. Every subsequent call comes from the frame loop and does cull.
     */
    applyDetailLod(false);
  }
  // Once up front so the opening frame is already at the right relief. A board
  // that started flat and rose over the first second would read as the ground
  // inflating under the camera, and the capture harness would photograph it
  // mid-rise.
  applyReliefRamp();

  const precipLayer: PrecipLayer | null = options.precip
    ? options.precip(world, { span: boardSpan })
    : null;
  if (precipLayer) {
    precipLayer.setLighting(opening);
    scene.add(precipLayer.group);
  }

  const migrationLayer: MigrationLayer | null = options.migration
    ? options.migration(world, { span: boardSpan })
    : null;
  if (migrationLayer) {
    migrationLayer.setLighting(opening);
    scene.add(migrationLayer.group);
  }

  const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
  markerLayer.setMarkers(options.markers ?? []);
  ground.add(markerLayer.group);

  const roadTraffic: RoadTrafficLayer | null = options.roadTraffic
    ? createRoadTrafficLayer(world, kit.camera, kit.controls, options.roadTraffic)
    : null;
  if (roadTraffic) ground.add(roadTraffic.group);
  const actorAnchor = options.actorAnchor ?? city.center;
  const [actorX, actorZ] = world.project(actorAnchor.lat, actorAnchor.lng);
  const sceneActor = options.actor
    ? createSceneActor({
        ...options.actor,
        sceneUnitsPerMetre: options.actor.sceneUnitsPerMetre ?? 1 / world.metresPerUnit,
        visualSceneUnitsPerMetre: options.actor.visualSceneUnitsPerMetre ??
          (city.id === "california" ? 0.025 : 1 / world.metresPerUnit),
        sceneOrigin: options.actor.sceneOrigin ?? {
          x: actorX,
          y: world.groundAt(actorAnchor.lat, actorAnchor.lng),
          z: actorZ,
        },
      })
    : null;
  if (sceneActor) ground.add(sceneActor.root);
  const sceneAircraft = options.aircraft
    ? createSceneAircraft({
        ...options.aircraft,
        project: (lat, lng) => world.project(lat, lng),
        groundAt: (lat, lng) => surfaceWorldY(world.groundAt(lat, lng), reliefScale),
        altitudeSceneUnitsPerMetre: options.aircraft.altitudeSceneUnitsPerMetre ??
          1 / world.metresPerUnit,
      })
    : null;
  if (sceneAircraft) scene.add(sceneAircraft.root);
  // Dynamic on purpose: the offline/default entry must not download four peer
  // prototypes merely because `createScene` can optionally host them.
  let realtimePeers: ScenePeers | null = null;
  if (options.realtimePeers) {
    const { createScenePeers } = await import("../realtime/scenePeers.ts");
    realtimePeers = createScenePeers({
      ...options.realtimePeers,
      project: (lat, lng) => world.project(lat, lng),
      groundAt: (lat, lng) => world.groundAt(lat, lng),
      geographicSceneUnitsPerMetre:
        options.realtimePeers.geographicSceneUnitsPerMetre ?? 1 / world.metresPerUnit,
      geographicVisualScale:
        options.realtimePeers.geographicVisualScale ??
        (city.id === "california" ? 0.025 : 1 / world.metresPerUnit),
    });
    ground.add(realtimePeers.root);
  }

  let flightLayer: FlightLayer | null = null;
  let flightTimer = 0;
  /**
   * The last observation, by id, so a pick has something to hand back.
   *
   * The layer interpolates between observations and keeps no record a caller
   * could read; this is the record. Rebuilt wholesale on every poll rather than
   * merged, so an aeroplane that has left the region leaves this table with it
   * and a card cannot be opened on a track that is no longer in the sky.
   */
  const lastAircraft = new Map<string, Aircraft>();
  if (options.flights) {
    flightLayer = createFlightLayer(world);
    scene.add(flightLayer.group);
  }

  let satelliteLayer: SatelliteLayer | null = null;
  let starlinkMeshes: StarlinkMeshLayer | null = null;
  if (options.satellites) {
    satelliteLayer = createSatelliteLayer(boardRadius);
    scene.add(satelliteLayer.group);
    // Board radius, the same unit `createSatelliteLayer` takes above — the
    // mesh layer applies the dome factor itself. It used to take the *dome*
    // radius while its sibling took the *board* radius, with nothing in the
    // types to tell them apart, which is precisely the confusion that has
    // already produced one real bug in this file.
    starlinkMeshes = createStarlinkMeshLayer({ boardRadius });
    scene.add(starlinkMeshes.group);
  }

  /**
   * The instant the sky is drawn for, or `null` for the wall clock.
   *
   * Satellites are the one layer whose content is a function of *absolute* time
   * rather than of elapsed time, so `tick(dt)` cannot serve them: godmode scrubs
   * the clock to an arbitrary date and the sky has to follow it there.
   *
   * It holds the **override** rather than a resolved `Date`, which is the same
   * shape `main.ts` keeps its own clock in and is load-bearing here. A resolved
   * instant would have to be pushed in on a timer, and the only timer available
   * is `updateSun`'s — which runs about once a second, so the sky would advance
   * in one-second jumps while everything around it moved smoothly. Holding the
   * override means an unscrubbed scene reads the clock afresh every frame and a
   * scrubbed one is frozen exactly where it was put.
   */
  let skyOverride: Date | null = null;

  // ---- Chapters -----------------------------------------------------------

  const chapterById = Object.fromEntries(city.chapters.map((c) => [c.id, c]));
  const first = city.chapters[0];
  if (!first) throw new Error(`City "${city.id}" declares no chapters`);
  if (options.openingChapter !== undefined && chapterById[options.openingChapter] === undefined) {
    throw new Error(
      `openingChapter "${options.openingChapter}" is not a chapter of city "${city.id}"`,
    );
  }
  const landing = chapterById[options.openingChapter ?? first.id] ?? first;

  let currentChapter = landing.id;
  const chapterListeners: ((id: string) => void)[] = [];
  const controlModeListeners: ((mode: CityControlMode) => void)[] = [];

  function applyControlMode(requested: CityControlMode): CityControlMode {
    const next: CityControlMode =
      requested === "drive" && !roadTraffic ? "overview"
        : requested === "actor" && !sceneActor ? "overview"
          : requested === "aircraft" && !sceneAircraft ? "overview"
            : requested;
    const ownership = cityControlOwnership(next);
    sceneActor?.setActive(ownership.actor);
    sceneAircraft?.setActive(ownership.aircraft);
    roadTraffic?.setFollowing(ownership.drive);
    kit.controls.enabled = ownership.orbit;
    /*
     * Clip planes in metres for the modes that look at metre-scale bodies.
     * 0.1 scene units is 192 m on the merged board — farther than the car
     * is long, which is why drive could not see a true-scale Model X.
     * Overview keeps the authored 0.1: a 0.4 m near against a 2,200-unit
     * far plane is a depth ratio the 24-bit buffer will not survive.
     */
    const mpu = world.metresPerUnit;
    if (next === "drive") {
      kit.camera.near = 0.5 / mpu;
      kit.camera.far = Math.max(40_000 / mpu, boardSpan * 0.2);
    } else if (next === "actor") {
      kit.camera.near = 0.25 / mpu;
      kit.camera.far = boardSpan * 4;
    } else if (next === "aircraft") {
      kit.camera.near = 1 / mpu;
      kit.camera.far = boardSpan * 4;
    } else {
      kit.camera.near = 0.1;
      kit.camera.far = boardSpan * 4;
    }
    kit.controls.minDistance = next === "actor"
      ? 0.25 / mpu
      : next === "aircraft"
        ? 1 / mpu
        : orbitMinDistance;
    kit.camera.updateProjectionMatrix();
    if (next !== controlMode) {
      controlMode = next;
      for (const fn of controlModeListeners) fn(next);
    }
    return next;
  }

  function chapterPose(ch: Chapter): Pose {
    const [x, z] = world.project(ch.focus.lat, ch.focus.lng);
    const groundY = surfaceWorldY(world.groundAt(ch.focus.lat, ch.focus.lng), reliefScale);
    const scale = chapterFraming({
      aspect: kit.camera.aspect,
      reach: Math.hypot(ch.focus.distance, ch.focus.height),
      boardSpan,
      orbitMax: orbitMaxDistance,
    });
    return {
      target: new THREE.Vector3(x, groundY, z),
      position: new THREE.Vector3(
        x + Math.sin(ch.focus.rotation) * ch.focus.distance * scale,
        groundY + ch.focus.height * scale,
        z + Math.cos(ch.focus.rotation) * ch.focus.distance * scale,
      ),
    };
  }

  function flyTo(chapterId: string) {
    const ch = chapterById[chapterId];
    if (!ch) return;
    // Somebody chose a view. Whatever the opening move was still doing, it is
    // no longer what the camera is for.
    cancelArrival();
    // Named viewpoints are observe/vehicle destinations. Possessing an actor
    // is an explicit UI action, so a chapter selection always hands the camera
    // back before it moves anywhere else.
    const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId);
    if (route && roadTraffic) {
      roadTraffic.setRoute(route.id);
      applyControlMode("drive");
    } else {
      applyControlMode("overview");
      roadTraffic?.setVehicleActions({});
      kit.flyTo(chapterPose(ch));
    }
    if (currentChapter !== chapterId) {
      currentChapter = chapterId;
      for (const fn of chapterListeners) fn(chapterId);
    }
  }

  /**
   * The frame a stranger sees first.
   *
   * Two rules govern everything below and both were learned from a picture.
   *
   * **The board settles exactly where the pack said it would.** `openingPose`
   * is `chapterPose(landing)` — `chapters[0]` unless the caller named a
   * chapter, so a capture that does not pass `openingChapter` is the same
   * capture it was, and a bare URL that does can land on a city without
   * reordering the pack. The wow is the *approach*.
   *
   * **Under `prefers-reduced-motion` there is no move at all.** Not a shorter
   * one — none: the camera is placed on the resting pose and that is the whole
   * of it. That is the accessibility answer and it is also what keeps the
   * capture harness honest, because a screenshot of a board mid-flight is a
   * screenshot of a different board every time you take it.
   */
  const openingPose = chapterPose(landing);

  /**
   * Any input at all ends it, on the spot, wherever the camera has got to.
   *
   * `OrbitControls` fires `start` on the first pointer-down, the first wheel
   * notch and the first pinch, which is every way a visitor can say "I would
   * rather look at something else". A camera that finished its arc anyway would
   * be an interface arguing with somebody who has already begun using it. The
   * camera is left exactly where the move had reached — not snapped to either
   * end — because the drag that cancelled it is already in flight from there.
   */
  function cancelArrival() {
    arrival = null;
  }
  kit.controls.addEventListener("start", cancelArrival);

  kit.setPose(openingPose);

  // ---- Picking ------------------------------------------------------------

  /**
   * Two things on this board are worth pointing at, and both are resolved here.
   *
   * `markerLayer.pickables` and `flightLayer.pickables` are both mutated in
   * place by their layers, so neither array can simply be concatenated once —
   * the picking target list has to be a getter that reads both at the moment of
   * the test. A pin is an authored place; an aeroplane is an observation, and
   * `Pick` keeps them apart as a union rather than flattening both to a string,
   * because the aircraft card is five fields and a provenance line and the
   * moment it becomes a sentence it can never be anything else again.
   */
  const pickTargets = (): THREE.Object3D[] =>
    flightLayer === null
      ? markerLayer.pickables
      : [...markerLayer.pickables, ...flightLayer.pickables];

  kit.setPicking<Pick>({
    targets: pickTargets,
    resolve: (hit) => {
      const marker = hit.object.userData.marker as Marker | undefined;
      if (marker) return { kind: "marker", marker };
      const id = hit.object.userData.aircraftId as string | undefined;
      const aircraft = id === undefined ? undefined : lastAircraft.get(id);
      return aircraft ? { kind: "aircraft", aircraft } : null;
    },
    onChange: (picked) => {
      // Both callbacks fire on every change, including the change back to
      // `null`, so whichever card is up is retired by a pointer that leaves —
      // and by a pointer that moves from a pin straight onto an aeroplane.
      options.onMarkerPick?.(picked?.kind === "marker" ? picked.marker : null);
      options.onAircraftPick?.(picked?.kind === "aircraft" ? picked.aircraft : null);
    },
  });

  // ---- The scene, as the stage sees it ------------------------------------

  const stageScene: StageScene = {
    scene,
    camera: kit.camera,
    controls: kit.controls,
    // Leaving for an office should retire the hover with it; coming back to a
    // stale detail card for something the pointer is nowhere near reads as a
    // bug.
    onExit: () => kit.resetPick(),
    /*
     * Claim the environment on arrival.
     *
     * A board built with `present: false` was applied `offstage`, which means it
     * borrowed whatever probe was cached rather than building its own — right
     * while it was invisible, wrong the moment it is not. This is the same call
     * every board already makes; on a cache hit it costs no convolution, which
     * is the case a prefetched board arriving at the same hour always hits.
     *
     * It runs on every entry, not only the first, and that is deliberate: a
     * board returned to after an office visit or another board has been off
     * screen while the clock moved, and re-asserting is how it catches up.
     */
    onEnter: () => {
      presented = true;
      options.environment?.apply(scene, lighting, "city");
    },
    tick(dt) {
      // Exactly one subsystem owns the camera. In particular, OrbitControls
      // must stay disabled while the road layer writes its follow pose.
      const ownership = cityControlOwnership(controlMode);
      kit.controls.enabled = ownership.orbit;
      /**
       * The opening move, stepped before `kit.tick` so the damping and the
       * clamps `controls.update()` applies land on top of it rather than
       * underneath.
       *
       * `easeInOutCubic`, the same curve a chapter flight uses, because the
       * camera starts from a standstill: a curve that began at full speed would
       * read as a cut followed by a glide.
       */
      applyDetailLod();
      applyReliefRamp();
      if (arrival !== null) {
        arrival.elapsed += dt;
        const t = Math.min(1, arrival.elapsed / ARRIVAL_SECONDS);
        const e = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
        const at: Pose = {
          position: new THREE.Vector3().lerpVectors(
            arrival.from.position,
            arrival.to.position,
            e,
          ),
          target: new THREE.Vector3().lerpVectors(
            arrival.from.target,
            arrival.to.target,
            e,
          ),
        };
        kit.setPose(at);
        if (t >= 1) arrival = null;
      }
      kit.tick(dt);
      sceneActor?.tick(dt);
      if (ownership.actor && sceneActor) kit.setPose(sceneActor.followPose());
      sceneAircraft?.tick(dt);
      if (ownership.aircraft && sceneAircraft) kit.setPose(sceneAircraft.followPose());
      realtimePeers?.tick(Date.now());
      roadTraffic?.tick(dt);
      /**
       * Aerial fog, after every camera owner has written this frame's pose.
       *
       * `applyReliefRamp` above is the model: a camera-dependent term that
       * cannot live on OrbitControls `change`, because a follow camera never
       * fires it. Drive writes `camera.position` inside `roadTraffic.tick`;
       * crow and aircraft call `setPose` just above. Calling here means a
       * descent under any of them is a fog step on the same frame, not a 1 Hz
       * jump on the clock.
       */
      options.onCameraFrame?.();
      clouds.tick(dt);
      fireLayer?.tick(dt);
      vesselLayer?.tick(dt);
      precipLayer?.tick(dt);
      migrationLayer?.tick(dt);
      /**
       * The one number the aeroplane glyph clamp cannot reach on its own.
       *
       * `flights.ts` captures its camera inside `trailLine.onBeforeRender` and
       * `tick()` takes no arguments, so the layer has a camera and no controls
       * and cannot ask how far away the thing being looked *at* is. This file
       * holds both, and this is the frame that owns them — so the distance is
       * pushed rather than pulled, and the layer never acquires an
       * `OrbitControls` reference it has no business holding.
       *
       * It is scene units rather than metres, which is why `flights.ts` names
       * the parameter `distance`: the clamp compares it against the same
       * projected geometry the glyph is drawn in, so a conversion here would be
       * a conversion into the wrong space and back.
       */
      flightLayer?.setFocusDistance(kit.camera.position.distanceTo(kit.controls.target));
      if (options.flights && flightLayer) {
        flightTimer -= dt;
        if (flightTimer <= 0) {
          flightTimer = options.flights.interval;
          void Promise.resolve(options.flights.poll()).then((ac) => {
            lastAircraft.clear();
            for (const a of ac) lastAircraft.set(a.id, a);
            flightLayer?.update(ac);
          });
        }
      }
      // Every frame and on no timer of its own. The catalogue's sweep is
      // time-budgeted internally — see `SWEEP_BUDGET_MS` — so calling it more
      // often makes it walk the catalogue sooner, never makes it cost more.
      if (options.satellites && satelliteLayer) {
        /**
         * One instant, one sweep, shared.
         *
         * `fixes()` advances the catalogue's rolling propagation, so calling it
         * twice in a frame spends twice the budget for no new information — and
         * two different `when`s would put the near-field satellites' sun
         * attitude on a different clock from the sky they are in.
         *
         * The sun comes from `solar.ts` directly rather than from the rig,
         * because `atmosphere.ts` floors the light direction at
         * `shadowFloorDeg` to keep the shadow camera usable. That floor pins the
         * sun above the horizon, and a sun ten degrees *down* is precisely the
         * dusk geometry that lights a Starlink pass.
         */
        const when = skyOverride ?? new Date();
        const solar = solarPosition(city.center.lat, city.center.lng, when);
        /**
         * The sky's own brightness, and the fix for the worst thing on this
         * board at first load.
         *
         * Both satellite layers draw light *added* to the sky: the dot cloud is
         * `AdditiveBlending` and the near-field buses are unlit white. That is
         * exactly right against a night sky and is a hard white square against a
         * daytime one — which is what the California board showed at 15:55 with
         * the sun at +44°, scattered across the frame, reading as render
         * artefacts before anything else on the page registered.
         *
         * `nightFactor` is `atmosphere.ts`'s own dusk curve and is deliberately
         * the same one `nightlights.ts` switches the city on with, so the sky
         * does not empty at a different dusk from the one the windows light up
         * at. It is computed here rather than taken from the rig for the reason
         * the sun vector below is: `atmosphere.ts` floors the *rig's* light
         * direction at `shadowFloorDeg` to keep the shadow camera usable, and a
         * sun pinned above the horizon is precisely the wrong input for a
         * question about how dark it is.
         */
        const darkness = nightFactor(solar.elevation);
        satelliteLayer.setSkyDarkness(darkness);
        starlinkMeshes?.setSkyDarkness(darkness);
        const fixes = options.satellites.fixes(when);
        satelliteLayer.update(fixes);
        starlinkMeshes?.update(fixes, kit.camera, sunDirection(solar));
      }
    },
    dispose() {
      // Before anything else frees a texture: the rig holds this scene in a
      // ledger so a rebuilt environment can be pushed to every scene using it,
      // and a disposed city left in that ledger is the whole graph retained.
      options.environment?.release(scene);
      options.flights?.dispose?.();
      flightLayer?.dispose();
      satelliteLayer?.dispose();
      starlinkMeshes?.dispose();
      // Before the `scene.traverse` sweep below, and required rather than tidy:
      // the sweep reaches geometries and materials, and a `ShaderMaterial`'s
      // uniform textures are neither — the cloud texture is a canvas this layer
      // drew and only it can free.
      clouds.dispose();
      fireLayer?.dispose();
      portLayer?.dispose();
      vesselLayer?.dispose();
      precipLayer?.dispose();
      migrationLayer?.dispose();
      nightLights.dispose();
      markerLayer.dispose();
      roadTraffic?.dispose();
      sceneAircraft?.dispose();
      realtimePeers?.dispose();
      kit.dispose();
      /*
       * Recursive, which is what makes the terrain's eighteen objects free to
       * arrive: a `Group` is swept with its children, the one material they
       * share disposes idempotently, and the `position`/`color`/`normal`
       * attributes they *all* share are removed from `WebGLAttributes` by the
       * first chunk — `remove` is a no-op the second time and `update`
       * re-creates on demand, so the worst a repeat can do is re-upload, never
       * blank the board. The standing rule that comes with sharing them is in
       * `terrain.ts`: no chunk may be disposed independently of its siblings.
       */
      scene.traverse((obj) => {
        const mesh = obj as THREE.Mesh;
        mesh.geometry?.dispose();
        const mat = mesh.material;
        if (Array.isArray(mat)) mat.forEach((m) => m.dispose());
        else if (mat) (mat as THREE.Material).dispose();
      });
    },
  };
  // Construction implies presentation only while nobody says otherwise. See
  // `SceneOptions.present` for why that stopped being unconditional.
  if (options.present !== false) stage.setScene(stageScene);

  return {
    world,
    chapters: city.chapters,
    stage,
    stageScene,
    arrive() {
      if (prefersReducedMotion()) {
        arrival = null;
        kit.setPose(heroPose(openingPose, orbitMaxDistance));
        return;
      }
      const rest = heroPose(openingPose, orbitMaxDistance);
      const from = arrivalStart(rest, orbitMaxDistance);
      /*
       * **Record the move before making it.** `kit.setPose` drives
       * `OrbitControls`, which fires `change` *synchronously*, and `main.ts`
       * listens on that event to decide whether the camera has left this board.
       * Written the obvious way round — pose first, then bookkeeping — that
       * listener runs on the line between them, sees a camera 1.5x further out
       * than this board's own resting stand-off and an `arriving()` that is
       * still false, and hands the visitor back to the coarser tier before the
       * opening move has drawn a single frame.
       *
       * That is precisely what shipped: landing on the Bay Area bounced to the
       * state board with no input at all, and the guard written to prevent it
       * could not fire because the flag it reads was set one statement too late.
       * The trajectory log that found it showed the handover tick arriving
       * *before* the first `arriving=true` sample, at the same stand-off.
       *
       * So the assignment goes first. Nothing else in this function changed.
       */
      arrival = { from, to: rest, elapsed: 0 };
      kit.setPose(from);
    },
    setLighting: (state) => {
      lighting = state;
      kit.applyLighting(state);
      clouds.setLighting(state);
      fireLayer?.setLighting(state);
      portLayer?.setLighting(state);
      vesselLayer?.setLighting(state);
      precipLayer?.setLighting(state);
      migrationLayer?.setLighting(state);
      /**
       * Every lighting change, and it is cheap to do it every one.
       *
       * The rig fingerprints the state coarsely and rebuilds only when the
       * fingerprint moves, so an unchanged sky is a map lookup and an
       * assignment. Calling it here rather than on a timer of its own is what
       * keeps CONTRACT §4's single direction intact: `Atmosphere` decided this
       * rig, the scene is applying it, and the environment is derived from the
       * decision rather than being a second opinion about the light.
       */
      options.environment?.apply(scene, state, "city", { offstage: !presented });
    },
    setAerialFog: ({ near, far }) => {
      // The three fog owners on a city board and no more. `ports`, `vessels`,
      // `precip`, `migration` and every building on the board are stock
      // materials lit by `scene.fog`, which `kit` has just moved for them.
      kit.setFogDistances(near, far);
      clouds.setFogDistances(near, far);
      fireLayer?.setFogDistances(near, far);
    },
    setCloudCover: (fraction) => clouds.setCover(fraction),
    setWind: (kph, fromDeg) => {
      clouds.setWind(kph, fromDeg);
      // The same observation, and the same one the clouds drift on. A plume
      // that leaned on a different wind from the cloud beside it would be two
      // opinions about one sky.
      fireLayer?.setWind(kph, fromDeg);
    },
    setSolarElevation: (degrees) => {
      nightLights.setSolarElevation(degrees);
      fireLayer?.setSolarElevation(degrees);
      migrationLayer?.setSolarElevation(degrees);
    },
    setFires: (view) => fireLayer?.setFires(view),
    setFireSmoke: (visible) => fireLayer?.setSmokeVisible(visible),
    arriving() {
      return arrival !== null;
    },
    groundWorldY(lat: number, lng: number) {
      return surfaceWorldY(world.groundAt(lat, lng), reliefScale);
    },
    reliefScale() {
      return reliefScale;
    },
    cameraStandoffMetres() {
      // `metresPerUnit` and NOT `unitsToMetres`, which is the vertical
      // conversion and divides the exaggeration back out. A stand-off is a
      // distance across the board, and the board's horizontal scale is honest —
      // only its height is stretched. Dividing by 15 here would have reported
      // every pose as fifteen times closer than it is.
      return Math.max(0, kit.camera.position.distanceTo(kit.controls.target) * world.metresPerUnit);
    },
    cameraAltitudeMetres() {
      // Vertical exaggeration divides back out: the ground under the target is
      // drawn `exaggeration` times too high, so a camera fifty units above it is
      // not fifty units of real air above it. `world.unitsToMetres` is the inverse of
      // the same conversion `world.metres` applied on the way in.
      const above = kit.camera.position.y - kit.controls.target.y;
      return Math.max(0, world.unitsToMetres(above));
    },
    setVessels: (vessels) => vesselLayer?.setVessels(vessels),
    setPrecip: (field) => precipLayer?.setField(field),
    setMigration: (field) => migrationLayer?.setField(field),
    fireSmokeLoadAt: (lat, lng) => fireLayer?.smokeLoadAt(lat, lng) ?? 0,
    setSkyInstant: (when) => {
      skyOverride = when;
    },
    setSatellitesVisible: (visible) => satelliteLayer?.setVisible(visible),
    satelliteCounts: () => ({
      visible: options.satellites?.visibleCount ?? 0,
      total: options.satellites?.size ?? 0,
    }),
    flyTo,
    current: () => currentChapter,
    onChapterChange(fn) {
      chapterListeners.push(fn);
    },
    setControlMode: (mode) => { applyControlMode(mode); },
    controlMode: () => controlMode,
    onControlModeChange(fn) {
      controlModeListeners.push(fn);
    },
    setVehicleActions: (actions) => roadTraffic?.setVehicleActions(actions),
    vehicleState: () => roadTraffic?.hero() ?? null,
    setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
    vehicleCamera: () => roadTraffic?.cameraMode() ?? null,
    setActorActions: (actions) => { sceneActor?.setActions(actions); },
    actorState: () => sceneActor?.state() ?? null,
    setActorIdentity: (identity) => { sceneActor?.setIdentity(identity); },
    attachActorFaceTexture: (texture) => sceneActor?.attachFaceTexture(texture) ?? false,
    clearActorFaceTexture: () => { sceneActor?.clearFaceTexture(); },
    setActorActive(active) {
      if (active) applyControlMode("actor");
      else if (controlMode === "actor") applyControlMode("overview");
    },
    actorActive: () => sceneActor?.active() ?? false,
    setAircraftActions: (actions) => { sceneAircraft?.setActions(actions); },
    aircraftState: () => sceneAircraft?.state() ?? null,
    setAircraftActive(active) {
      if (active) applyControlMode("aircraft");
      else if (controlMode === "aircraft") applyControlMode("overview");
    },
    aircraftActive: () => sceneAircraft?.active() ?? false,
    upsertRemoteSnapshot: (snapshot) => {
      if (snapshot.pose.space === "local") {
        // Local office coordinates have no meaning on a geographic board.
        // Local city coordinates are accepted only for this detailed board;
        // California actors use geographic poses.
        const expected = city.id === "sf" ? "bay-area" : city.id === "socal" ? "socal" : null;
        if (snapshot.pose.cell.kind !== "city" || snapshot.pose.cell.cityId !== expected) return false;
      }
      return realtimePeers?.upsert(snapshot) ?? false;
    },
    removeRemoteEntity: (id) => realtimePeers?.remove(id) ?? false,
    clearRemoteEntities: () => { realtimePeers?.clear(); },
    remoteEntityCount: () => realtimePeers?.count() ?? 0,
    setMarkers(markers) {
      markerLayer.setMarkers(markers);
    },
    dispose() {
      sceneActor?.dispose();
      sceneAircraft?.dispose();
      realtimePeers?.dispose();
      /**
       * Off the stage, then released — and the stage itself is left running.
       *
       * The order is load-bearing and it used to be the other way round, with
       * a comment saying "Stage first, so nothing ticks a half-disposed scene".
       * The concern was real and the remedy was the bug: `stage.dispose()`
       * calls `renderer.dispose()`, which replaces the `properties` WeakMap,
       * and `stageScene.dispose()` then walks the scene disposing materials
       * that the renderer no longer has an entry for. `three` reads
       * `properties.get(material).programs`, finds `undefined`, and quietly
       * skips `gl.deleteProgram` for every one of them — so disposing in that
       * order freed nothing it was written to free.
       *
       * `setScene(null)` answers the ticking concern on its own and answers it
       * better: the loop drops this scene on the very next frame, and the
       * renderer keeps its bookkeeping so the disposals below actually land.
       */
      kit.controls.removeEventListener("start", cancelArrival);
      if (stage.current() === stageScene) stage.setScene(null);
      stageScene.dispose();
    },
  };
}

/**
 * The committed default rig: a late-afternoon sun from the west, which throws
 * the hills' shadows east across the flats.
 *
 * Not an `Atmosphere` and not a substitute for one — it computes nothing from
 * time or weather, it is a constant with the city's own sky colours poured in.
 * It exists so the engine renders with no server, no clock and no config, which
 * is the acceptance test the whole repo is held to.
 */
/**
 * How wide a screen a `Chapter.focus` was written for.
 *
 * Every pose in every pack was authored on one, and `fov: 42` is a **vertical**
 * field of view: the horizontal half-angle is `atan(tan(21°) × aspect)`, so how
 * much of a board is in frame sideways is entirely a fact about the shape of the
 * window. At 16:10 that half-angle is 31.6°. On a phone held upright — 390 × 844,
 * an aspect of 0.46 — it is 10.1°, a third as wide, and the pose that framed a
 * whole state on a laptop frames the middle third of it and runs the rest off
 * both sides of the screen.
 */
const AUTHORED_ASPECT = 1.6;

/**
 * Where a stand-off stops being a whole-board shot and starts being a place on
 * it, in board spans. See `chapterFraming`.
 */
const WHOLE_BOARD_FROM = 0.8;
const WHOLE_BOARD_TO = 1.2;

/**
 * How much further back to stand than the pose asked for, given the window it
 * is actually being looked at through.
 *
 * Returns a multiplier applied to `distance` **and** `height` together, so the
 * angle the board is seen from — which is most of the character of a pack's
 * opening shot — is exactly preserved and only the stand-off changes.
 *
 * Three things bound it, and the second and third were both learned from a
 * screenshot rather than from the arithmetic.
 *
 *   - **The aspect.** The correction wanted is `AUTHORED_ASPECT / aspect`,
 *     because the width in frame at a given distance is linear in the aspect.
 *     A window at least as wide as the authored one gets nothing at all.
 *   - **The orbit's own ceiling.** `setPose` hands the camera to
 *     `OrbitControls`, which clamps to `maxDistance` on its next update, so a
 *     correction that asks for more than that is not a correction — the pose
 *     quietly becomes something nobody wrote.
 *   - **Whether the pose was ever about the whole board.** Correcting every
 *     pose was tried and it ruins the close-ups: Southern California's opening
 *     shot stands off half a board span and is a dense city filling a tall
 *     screen to all four edges, and pulling it back three times put it in the
 *     top third of the frame over half a screen of empty ocean. Widening the
 *     frame is a correction for a shot that was trying to hold something and
 *     no longer can; on a shot that was deliberately *inside* its subject it is
 *     a different photograph. Below `WHOLE_BOARD_FROM` spans of stand-off the
 *     correction is off; above `WHOLE_BOARD_TO` it is fully on.
 */
export function chapterFraming(options: {
  aspect: number;
  reach: number;
  boardSpan: number;
  orbitMax: number;
}): number {
  const { aspect, reach, boardSpan, orbitMax } = options;
  if (!Number.isFinite(reach) || reach <= 0) return 1;
  const wanted =
    Number.isFinite(aspect) && aspect > 0 && aspect < AUTHORED_ASPECT
      ? AUTHORED_ASPECT / aspect
      : 1;
  const ceiling = Number.isFinite(orbitMax) && orbitMax > 0 ? orbitMax / reach : 1;
  const wide = Math.max(1, Math.min(wanted, ceiling));
  const spans = Number.isFinite(boardSpan) && boardSpan > 0 ? reach / boardSpan : 0;
  const share = Math.max(
    0,
    Math.min(1, (spans - WHOLE_BOARD_FROM) / (WHOLE_BOARD_TO - WHOLE_BOARD_FROM)),
  );
  return 1 + (wide - 1) * share;
}

/**
 * How long the opening move takes, in seconds.
 *
 * Longer than a chapter flight's 1.5 s, deliberately. A chapter flight is a
 * *response* — somebody clicked a name and is waiting to arrive — so it should
 * be brisk. This is the opposite: nobody asked for it, nothing is waiting
 * behind it, and its whole job is to be looked at.
 *
 * **This used to be 2.5, and the reason was the performance harness rather than
 * taste.** `scripts/performance-budget.mjs` waits `warmup-ms` (3,000) after the
 * board reports ready and then samples for eight seconds, and `arrive()` is
 * called in the same statement block that makes it report ready — so a move
 * longer than the warm-up was a *moving camera inside the sample window*: a
 * different frustum every frame, and triangle and draw counts that no longer
 * reproduce. The whole reason those cells are trustworthy on this box is that
 * geometry here is deterministic, and a longer arrival would have quietly spent
 * that, with the first symptom an unexplainable red cell blamed on GPU clocks.
 *
 * The coupling is gone because the harness now opens its context with
 * `reducedMotion: "reduce"`, under which this move collapses to a cut — the
 * same preference `look.mjs --reduced` uses to make an arrival frame
 * reproducible, and the same one a person who asked their operating system for
 * less motion gets. So the length is free to be the length the shot wants.
 *
 * If `reducedMotion` is ever dropped from that context, this number has to go
 * back under `warmup-ms` in the same commit. It is the only thing holding the
 * two apart.
 */
const ARRIVAL_SECONDS = 4.5;

/**
 * The hero seat, as multiples of the pack's own opening pose.
 *
 * `HERO_HEIGHT` is the one that matters and the reason this exists. Every one
 * of the three boards authors its whole-board shot between 32 and 41 degrees
 * above the ground — California at 40.8, San Francisco at 34.7, Southern
 * California at 32.1 — and from up there a board is a *map*: the far edge ends
 * in water, the sky is off the top of the frame, and the relief that took two
 * and a half seconds of heightfield to build is flattened into shading. Drop
 * the eye and the horizon arrives, the ranges get a skyline, and the same
 * geometry stops being a diagram and starts being a place.
 *
 * The target and the azimuth are left alone, so this is still the pack's
 * chapter 01 — the state, seen from where the pack pointed the camera — and
 * clicking `01` still flies to the authored seat exactly.
 */
const HERO_ELEVATION_DEG = 29;
const HERO_DISTANCE = 0.9;

/** How much further out the camera stands before the move, as a multiple. */
/**
 * The stand-off, in true metres, below which a merged board draws its metro
 * detail. Just outside the ladder's handover ceilings; see `applyDetailLod`.
 */
export const DETAIL_STANDOFF_M = 120_000;

/**
 * How near a detail district the camera's *target* must be before that detail
 * is drawn, in true metres.
 *
 * **60 km, measured down from 150.** At 150 km the test asked "are you in the
 * same half of the state as a city", which the corridor drive answers yes to
 * along most of its length: `california-drive` revealed every building, bridge,
 * airport and port on the board while the camera was a chase view on a freeway
 * eighty kilometres from any of them, and measured 315 draw calls against a 280
 * mobile cap.
 *
 * 60 km asks "are you looking at a city", which is the question this flag is
 * actually for. A metro on this board is roughly fifty kilometres across, so a
 * target anywhere in one is comfortably inside; and driving up US-101 the
 * Southland appears as you come into it rather than while you are still in the
 * Salinas Valley — which is the better behaviour as well as the cheaper one.
 *
 * Paired with `DETAIL_STANDOFF_M`: one asks "close enough to resolve a
 * building", the other "looking at somewhere that has any".
 */
export const DETAIL_REACH_M = 60_000;

export const ARRIVAL_STANDOFF = 1.5;
/** How much higher, as a multiple. Larger than the stand-off: the move descends. */
const ARRIVAL_LIFT = 2.2;
/** How far round the board it swings, in radians. Negative is anticlockwise. */
const ARRIVAL_YAW = -0.5;

/**
 * Rotate and scale the offset between a pose and its target.
 *
 * The one piece of arithmetic the two poses below share: both are described as
 * a departure from the authored shot rather than as coordinates, which is what
 * lets one implementation serve three boards and two studios that have each
 * already chosen the angle they look best from.
 *
 * `maxReach` is the orbit's own ceiling and is not optional. `setPose` hands
 * the camera to `OrbitControls`, which clamps to `maxDistance` on its next
 * update — so a pose beyond it is not a wider shot, it is a shorter move that
 * begins wherever the clamp happened to land. `chapterFraming` records the same
 * hazard one floor down.
 */
function offsetPose(
  rest: Pose,
  options: { standoff: number; lift: number; yaw: number; maxReach: number },
): Pose {
  const dx = rest.position.x - rest.target.x;
  const dy = rest.position.y - rest.target.y;
  const dz = rest.position.z - rest.target.z;
  const cos = Math.cos(options.yaw);
  const sin = Math.sin(options.yaw);
  let ox = (dx * cos - dz * sin) * options.standoff;
  let oz = (dx * sin + dz * cos) * options.standoff;
  let oy = dy * options.lift;
  const reach = Math.hypot(ox, oy, oz);
  if (Number.isFinite(options.maxReach) && options.maxReach > 0 && reach > options.maxReach) {
    // 0.995 rather than 1: landing exactly on the ceiling leaves the first
    // `controls.update()` free to shave a unit off it and start the move with a
    // visible twitch.
    const k = (options.maxReach * 0.995) / reach;
    ox *= k;
    oy *= k;
    oz *= k;
  }
  return {
    target: rest.target.clone(),
    position: new THREE.Vector3(rest.target.x + ox, rest.target.y + oy, rest.target.z + oz),
  };
}

/**
 * Where the opening move comes to rest: the pack's chapter 01, seen from
 * `HERO_ELEVATION_DEG` above the ground instead of from wherever it was
 * authored.
 *
 * An **angle**, not a multiplier, and that is the whole of the design. A
 * multiplier applied to the three boards' three different authored elevations
 * produces three different answers to a question that has one — California
 * would land at 30 degrees and Southern California at 13 off the same constant
 * — and the horizon either enters the frame or it does not. At this field of
 * view it enters just under 21, so 20 puts it a degree inside the top edge on
 * every board, which is what the number is for.
 *
 * `Math.min` rather than an assignment, and it is load-bearing: an authored
 * pose that is **already** lower than this is not raised. Studio viewpoints sit
 * at eye height inside a room, and a "hero" seat that lifted a camera standing
 * on a floor up to twenty degrees would be a ceiling shot of somebody's desk.
 * The move only ever brings a camera down.
 */
export function heroPose(authored: Pose, maxReach: number): Pose {
  const dx = authored.position.x - authored.target.x;
  const dy = authored.position.y - authored.target.y;
  const dz = authored.position.z - authored.target.z;
  const reach = Math.hypot(dx, dy, dz);
  if (reach <= 0) return { target: authored.target.clone(), position: authored.position.clone() };
  const elevation = Math.asin(Math.max(-1, Math.min(1, dy / reach)));
  const wanted = Math.min(elevation, (HERO_ELEVATION_DEG * Math.PI) / 180);
  const flat = Math.hypot(dx, dz);
  const azimuth = flat > 0 ? { x: dx / flat, z: dz / flat } : { x: 0, z: 1 };
  const heroReach = reach * HERO_DISTANCE;
  const capped =
    Number.isFinite(maxReach) && maxReach > 0 ? Math.min(heroReach, maxReach * 0.995) : heroReach;
  const horizontal = Math.cos(wanted) * capped;
  return {
    target: authored.target.clone(),
    position: new THREE.Vector3(
      authored.target.x + azimuth.x * horizontal,
      authored.target.y + Math.sin(wanted) * capped,
      authored.target.z + azimuth.z * horizontal,
    ),
  };
}

/**
 * Where the camera stands *before* the opening move.
 *
 * Further out, higher, and a little way round from where it will land, so the
 * move is a descending swing that closes that difference. All three numbers are
 * modest on purpose: enough that the board visibly grows, turns and settles,
 * and not so much that the opening frame is a different photograph from the one
 * it is arriving at.
 */
export function arrivalStart(rest: Pose, maxReach: number): Pose {
  return offsetPose(rest, {
    standoff: ARRIVAL_STANDOFF,
    lift: ARRIVAL_LIFT,
    yaw: ARRIVAL_YAW,
    maxReach,
  });
}

export function cityDaylight(palette: ScenePalette, boardSpan = 230): LightingState {
  return {
    sun: { direction: [-0.632, 0.717, 0.295], color: 0xfff3e0, intensity: 2.1 },
    hemisphere: { sky: 0xdcecf7, ground: 0x6b6f5e, intensity: 1.05 },
    ambient: { color: 0xffffff, intensity: 0.32 },
    sky: { top: palette.skyTop, horizon: palette.skyHorizon },
    // Scaled to the board for the same reason the camera is, and it was the same
    // bug: 210/460 were tuned when San Francisco's 230-unit board was the only
    // one, and on the Bay Area's 1003 units they close the fog well inside the
    // city. The camera sits about 0.6 spans out on a whole-board view, so a fog
    // that starts nearer than that is behind the viewer's own shoulder.
    //
    // The default keeps the original numbers exactly, for a caller that has a
    // palette but no world.
    fog: {
      color: palette.skyHorizon,
      near: boardSpan * 0.91,
      far: boardSpan * 2,
    },
  };
}
