/** * 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, 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 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; /** * 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; /** 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; /** * 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; } 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 every orbit step past a 2% altitude threshold — a few dozen times in * one drag. 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; /** 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): void; /** Current playable corridor state, or null on a city-scale board. */ vehicleState(): Readonly | null; setVehicleCamera(mode: VehicleCameraMode): void; vehicleCamera(): VehicleCameraMode | null; setActorActions(actions: Partial): void; actorState(): Readonly | 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): void; aircraftState(): Readonly | 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 { 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), ); const orbitMinDistance = 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 }); scene.add(createWater(world)); scene.add(createShorePlates(world)); scene.add(createTerrain(world)); scene.add(options.roadTraffic ? createFreewayWorld(world, options.roadTraffic.pack) : createRoads(world)); 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); scene.add(blocks); const landmarkGroup = createLandmarks(world, buildingReservations); scene.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 baseCount = (blocks.userData.baseCount as number | undefined) ?? blocks.count; const detailLots = blocks.count - baseCount; const detailLandmarks = landmarkGroup.children.filter( (child) => child.userData.detail === true, ); const hasDetail = detailLots > 0 || 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. */ const detailLayers: THREE.Object3D[] = []; let detailShown = true; function applyDetailLod(): void { if (!hasDetail) return; const standoff = kit.camera.position.distanceTo(kit.controls.target) * world.metresPerUnit; let want = standoff < DETAIL_STANDOFF_M; if (want) { const target = kit.controls.target; 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; }); } if (want === detailShown) return; detailShown = want; blocks.count = want ? baseCount + detailLots : baseCount; for (const child of detailLandmarks) child.visible = want; for (const layer of detailLayers) layer.visible = want; } // 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); scene.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 ?? []); scene.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 }); scene.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); scene.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); scene.add(portLayer.group); } const vesselLayer: VesselLayer | null = options.vessels ? options.vessels(world, { span: boardSpan }) : null; if (vesselLayer) { vesselLayer.setLighting(opening); scene.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); if (portLayer) detailLayers.push(portLayer.group); if (vesselLayer) detailLayers.push(vesselLayer.group); detailShown = true; applyDetailLod(); } 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 ?? []); scene.add(markerLayer.group); const roadTraffic: RoadTrafficLayer | null = options.roadTraffic ? createRoadTrafficLayer(world, kit.camera, kit.controls, options.roadTraffic) : null; if (roadTraffic) scene.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) scene.add(sceneActor.root); const sceneAircraft = options.aircraft ? createSceneAircraft({ ...options.aircraft, project: (lat, lng) => world.project(lat, lng), groundAt: (lat, lng) => world.groundAt(lat, lng), 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), }); scene.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(); 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`); let currentChapter = first.id; const chapterListeners: ((id: string) => void)[] = []; let controlMode: CityControlMode = "overview"; 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; kit.camera.near = next === "actor" ? 0.001 : next === "aircraft" ? 0.01 : 0.1; kit.controls.minDistance = next === "actor" ? 0.001 : next === "aircraft" ? 0.01 : 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 = world.groundAt(ch.focus.lat, ch.focus.lng); 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(first)` unchanged, so the resting frame is the one the pack * authored, chapter 01 keeps meaning what it says, and a capture of this * board is the same capture it was before an arrival existed. The wow is the * *approach*; nothing about the destination is second-guessed here. * * **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(first); /** * The opening move, or `null` when there is not one running. * * Held here rather than in `SceneKit` because it is not a chapter flight: it * is slower, it is unrequested, and it must yield to the first thing the * visitor does. `kit.flyTo` is the right shape for "you clicked a name and * are waiting to arrive" and the wrong one for this. */ let arrival: { from: Pose; to: Pose; elapsed: number } | null = null; /** * 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({ 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(); 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); 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(); 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; }, 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, }, }; }