/** * 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 } 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, Marker, MarkerPalette, ScenePalette, } 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; /** 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; /** 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; } 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; /** Applies a rig computed elsewhere. The scene never works one out itself. */ setLighting(state: LightingState): 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; /** * 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. options.environment?.apply(scene, opening, "city"); 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); scene.add(createLandmarks(world, buildingReservations)); scene.add(createBridges(world)); // 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. scene.add(createAirports(world, city.airports ?? [])); /** * 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); 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; // 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); } } kit.setPose(chapterPose(first)); // ---- 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(), 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; 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); 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(); 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(); }); }, }; stage.setScene(stageScene); return { world, chapters: city.chapters, stage, stageScene, setLighting: (state) => { kit.applyLighting(state); clouds.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"); }, setCloudCover: (fraction) => clouds.setCover(fraction), setWind: (kph, fromDeg) => clouds.setWind(kph, fromDeg), setSolarElevation: (degrees) => nightLights.setSolarElevation(degrees), 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. */ 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; } 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, }, }; }