64618cb47d
Closing the merged board's red budget cells, and one of the two fixes turned out to be a win for every board in the product rather than for this one. **Every lake on a board is now one mesh.** `createWater` built a `Mesh` and a `MeshStandardMaterial` per inland-water polygon, so a board paid a draw call per lake. It went unnoticed while the boards were separate — California draws one, the Salton Sea — and a scene census on a phone found it the moment `unify.ts` folded three packs together: **water=20 against water=1**, nineteen of the fifty-eight draw calls that put the merged board over the mobile cap. They differ in nothing but shape, so the geometries merge and the material is shared. The 0.05 lift is baked in before the merge, since afterwards there is no per-lake mesh to carry it. Measured on the shipping default, unrelated to any flag: socal 218 -> 205 draw calls, bay-area 208 -> 202, california 374 -> 373, california-drive 233 -> 232. It was never free anywhere. **The metro-scale layers now follow the same reveal as the buildings.** Bridges, airports, ports and vessels are handed to `applyDetailLod` and hidden with the detail districts. That every one of them is metro detail on a merged board is measured rather than assumed: the same census showed all seven named bridges, all eight airport layers and every port mesh going from *zero* on the state pack to one on the merged board — `california.ts` authors none of them. Together they were the remaining 27 of those 58 draws, for structures sub-pixel at 1,919 m to the unit. Result on the merged board: california desktop 348,271 triangles of 440,000 and 372 draws of 415 on mobile, both green where mobile was 428 before. **`DETAIL_REACH_M` is 60 km, 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. 60 km asks "are you looking at a city", which is what the flag is for — and driving up US-101 the Southland now appears as you come into it rather than while you are still in the Salinas Valley. **The metro chapters were merged, photographed and reverted, and the reason is recorded rather than the attempt deleted.** Converting a metro rung onto this board is easy and was done correctly — `focus.lat/lng` is absolute, `distance` is horizontal in the owning board's units and `height` has already been through that board's exaggeration, so each keeps its true metres. The camera arrives exactly where it should. What is *at* that range is the problem: two scene units from the target on a 551-unit board, and the corridor there is a deliberate 4.7 km-wide atlas glyph, because DRIVE mode has to be able to drive down it on a board where a real freeway is a fifth of a pixel. The delivered frame is a black slab across San Francisco. A rung that flies you into that is worse than no rung. They come back when the corridor is drawn at true width, which is `createFreewayWorld` and the `roads` rule, not `unify.ts`. `airportsAndCard.test.ts` asserted a literal one-liner that is now two statements — the airport group is captured so the LOD can hide it. Split into the two facts it was ever about: the `?? []` call exists, and the result is added. 1,701 tests pass. The full budget passes on the shipping default. Still open on the merged board: `california-drive` mobile peaks at 315 draws against 280. The steady state is +3 drawables over the state board; the peak is the car driving into a metro and revealing it, against a cap set when the corridor had no cities on it. That is a cap to re-derive for a board that now has them, and it is an owner's call rather than a number to quietly raise — which is why this is still behind `?one=1`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1980 lines
89 KiB
TypeScript
1980 lines
89 KiB
TypeScript
/**
|
||
* 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<ScenePeersOptions, "project" | "groundAt">;
|
||
|
||
/**
|
||
* One promoted wildfire, as the renderer receives it.
|
||
*
|
||
* **Everything here is public.** The authority on this shape is `promote()` in
|
||
* `src/server/fires.ts`, which returns a `FirePromotion` that is assignable to
|
||
* `FireView` below; this is the same shape restated as the minimum a *renderer*
|
||
* needs, not a second opinion about it. `promote()` is also the only place a
|
||
* home-relative column could enter, and the place that must never let one —
|
||
* `observations.distance_km`, `bearing_deg` and `threat`, and
|
||
* `detections.distance_km`, are computed against the owner's house and invert to
|
||
* a circle around it. `threat` is the subtle one: it is
|
||
* `(16/distance)^2 x log10(acres) x momentum x containment x wind`, so with
|
||
* `acres` and `pctContained` on the wire it *solves for the distance*. The
|
||
* cloud-1 projection is what makes that structurally impossible; these types are
|
||
* what make it obvious.
|
||
*
|
||
* Declared here rather than imported on purpose, and it is not duplication for
|
||
* its own sake. `scene.ts` only *forwards* these values to a layer it did not
|
||
* build, this repo is structurally typed, and keeping the minimum on this side
|
||
* means the engine never takes a dependency on a wire module — the same rule
|
||
* that keeps `Marker` in `engine/types.ts` and the marker row on the server.
|
||
* The two are checked against each other at the one place they meet,
|
||
* `SceneOptions.fires`, and neither module has to exist for the other to
|
||
* compile.
|
||
*/
|
||
export interface DrawnFireMark {
|
||
id: string;
|
||
/** `null` where the agency published none. Never defaulted to the id. */
|
||
name: string | null;
|
||
lat: number;
|
||
/** `lon`, not `lng` — the wire's spelling, kept so `promote()` output flows in. */
|
||
lon: number;
|
||
/** Burned area. A `number`, not `number | null`: past the gate, acreage is a fact. */
|
||
acres: number;
|
||
/** `null` means "the agency has not said", which is not zero. */
|
||
pctContained: number | null;
|
||
/** 1 = a mark. 2 = a mark with a plume. `promote()` decides; no renderer re-derives it. */
|
||
tier: 1 | 2;
|
||
/** ISO-8601 of the observation `acres` came from. */
|
||
observedAt: string | null;
|
||
}
|
||
|
||
/**
|
||
* One satellite thermal detection — **evidence, not an incident.**
|
||
*
|
||
* There is a permanent industrial heat source in the store that appears on every
|
||
* pass with no matching incident, 4.7 km from the owner's house. Detections are
|
||
* therefore drawn as a separate, visually weaker layer and are never promoted
|
||
* into a fire client-side; `persistent` is the endpoint's own learned
|
||
* ignore-list for that furniture. `confidence` carries two incompatible scales
|
||
* in one field — MODIS is an integer 0-100, VIIRS is `low`/`nominal`/`high` — so
|
||
* anything reading it must branch on `sat` first, or use
|
||
* `detectionConfidence()` in `src/server/fires.ts`, which does the branch once.
|
||
*/
|
||
export interface FireDetectionMark {
|
||
/** `MODIS`, `VIIRS-NOAA20`, `VIIRS-SNPP` — the instrument, verbatim. */
|
||
sat: string;
|
||
lat: number;
|
||
lon: number;
|
||
/** Fire radiative power, MW. `null` where the product did not report one. */
|
||
frp: number | null;
|
||
confidence: string | null;
|
||
/** True when this cell is known furniture: a flare stack, a kiln, a landfill. */
|
||
persistent: boolean;
|
||
acquiredAt: string;
|
||
}
|
||
|
||
/**
|
||
* The whole promoted set for one board — and everything it needs to explain an
|
||
* *empty* one.
|
||
*
|
||
* `ageMs` is load-bearing rather than decorative. On a quiet day an empty board
|
||
* is the correct and common answer: the gate drops twenty-two nameless LA County
|
||
* dispatch numbers with no acreage between them. A silent board and a dead feed
|
||
* are indistinguishable without an age beside them, which is the same argument
|
||
* `HealthBody.degraded` makes, applied to a picture instead of a log.
|
||
*/
|
||
export interface FireView {
|
||
/** Fires inside this board's bounds, worst first. */
|
||
readonly drawn: readonly DrawnFireMark[];
|
||
/** Hot pixels inside the bounds that are not known furniture. */
|
||
readonly detections: readonly FireDetectionMark[];
|
||
/** ISO-8601 of the last **successful** upstream fetch. Epoch zero when never. */
|
||
readonly fetchedAt: string;
|
||
/** Milliseconds since `fetchedAt`, or `null` when nothing has ever answered. */
|
||
readonly ageMs: number | null;
|
||
}
|
||
|
||
/**
|
||
* The fire layer, as `scene.ts` uses it.
|
||
*
|
||
* Deliberately the smallest surface that lets this file own the wiring: the
|
||
* board's bounds, the rig, the sun and the wind all arrive here already and
|
||
* have to reach the layer, and nothing else about fire belongs in a city.
|
||
*
|
||
* `smokeLoadAt` is the one read-back, and it is what couples the LA courtyard
|
||
* to the real sky: a fire sixty kilometres up the San Gabriels is not a flame
|
||
* seen from a courtyard, it is a brown horizon and a dimmed sun.
|
||
*/
|
||
export interface FireLayer {
|
||
group: THREE.Object3D;
|
||
/** Replace the drawn set. `null` clears it — nothing has answered yet. */
|
||
setFires(view: FireView | null): void;
|
||
/** Draw the plumes, or do not. The marks stay either way. */
|
||
setSmokeVisible(visible: boolean): void;
|
||
setLighting(state: LightingState): void;
|
||
/** The fog distances alone. See `SceneHandle.setAerialFog`. */
|
||
setFogDistances(near: number, far: number): void;
|
||
setSolarElevation(degrees: number): void;
|
||
/** Wind as the observation reports it: km/h, and the bearing it blows *from*. */
|
||
setWind(kph: number | null, fromDeg: number | null): void;
|
||
tick(dt: number): void;
|
||
/** 0..1 smoke load at a coordinate, for haze somewhere else. */
|
||
smokeLoadAt(lat: number, lng: number): number;
|
||
dispose(): void;
|
||
}
|
||
|
||
/**
|
||
* How a fire layer is built. The same shape `createCloudLayer` has, so the
|
||
* layer sizes itself from the board rather than from a constant.
|
||
*/
|
||
export type FireLayerFactory = (
|
||
world: World,
|
||
options: { span: number },
|
||
) => FireLayer;
|
||
|
||
/**
|
||
* The four layers this build added, and the one shape they all share.
|
||
*
|
||
* Every one of them is a **factory in `SceneOptions`, not a layer**, and every
|
||
* factory has exactly `FireLayerFactory`'s signature — `(world, { span })`. That
|
||
* is deliberate to the point of being copied rather than generalised. The layer
|
||
* needs the `World` that `createScene` is in the middle of building, so it cannot
|
||
* be handed in already made; and it is sized from the board rather than from a
|
||
* constant, so it needs the span. Absent costs exactly nothing: no geometry, no
|
||
* material, no draw call, and the corresponding setter becomes a no-op. That is
|
||
* not an optimisation, it is the empty state — a quiet day is the commonest
|
||
* correct answer all four of these will ever give, and a layer that is not
|
||
* visited at all is cheaper and more honest than one drawing nothing.
|
||
*
|
||
* `scene.ts` declares the minimum a *renderer* needs and imports none of the
|
||
* modules that implement them, exactly as it does for `FireLayer`. The engine
|
||
* never takes a dependency on a wire module, and neither side has to exist for
|
||
* the other to compile.
|
||
*/
|
||
export interface PortLayer {
|
||
group: THREE.Object3D;
|
||
setLighting(state: LightingState): void;
|
||
dispose(): void;
|
||
}
|
||
|
||
export type PortLayerFactory = (
|
||
world: World,
|
||
options: { span: number },
|
||
) => PortLayer;
|
||
|
||
/**
|
||
* Hulls and their wakes.
|
||
*
|
||
* `setVessels(null)` and `setVessels([])` are the same picture and a different
|
||
* sentence, the same distinction `setFires` draws: `null` is "nothing has
|
||
* answered", an empty array is "the feed answered and nothing is on this board".
|
||
*
|
||
* `tick` exists because a ship under way is dead-reckoned **along its reported
|
||
* course at its reported speed** between fixes, and its wake is advanced with
|
||
* it. It is never a spline between two fixes: upstream listens for thirty
|
||
* seconds every fifteen minutes, and the chord between two samples is not a path
|
||
* anything took.
|
||
*/
|
||
export interface VesselLayer {
|
||
group: THREE.Object3D;
|
||
setVessels(vessels: readonly Vessel[] | null): void;
|
||
setLighting(state: LightingState): void;
|
||
tick(dt: number): void;
|
||
dispose(): void;
|
||
}
|
||
|
||
export type VesselLayerFactory = (
|
||
world: World,
|
||
options: { span: number },
|
||
) => VesselLayer;
|
||
|
||
/**
|
||
* The reflectivity sheet: one quad at cloud base, one `DataTexture`.
|
||
*
|
||
* Built only on a board coarse enough to carry a 0.25-degree cell. 27.8 km is a
|
||
* quarter of the SoCal board and a third of the Bay Area board, so on the fine
|
||
* boards the honest answer is to build no layer at all rather than to draw four
|
||
* texels over a city.
|
||
*/
|
||
export interface PrecipLayer {
|
||
group: THREE.Object3D;
|
||
/** Replace the raster. `null` clears it — nothing has answered yet. */
|
||
setField(field: RadarField | null): void;
|
||
setLighting(state: LightingState): void;
|
||
/** Crossfades between the two newest frames across the ten-minute step. */
|
||
tick(dt: number): void;
|
||
dispose(): void;
|
||
}
|
||
|
||
export type PrecipLayerFactory = (
|
||
world: World,
|
||
options: { span: number },
|
||
) => PrecipLayer;
|
||
|
||
/**
|
||
* Nocturnal migration as one `THREE.Points` drift field.
|
||
*
|
||
* `setSolarElevation` rather than a visibility flag, because the layer's own
|
||
* subject is nocturnal: BirdCast measures only after dark, and whether anything
|
||
* is aloft is a fact about the sun, not a preference. The same seam
|
||
* `nightlights.ts` and `FireLayer` already take.
|
||
*/
|
||
export interface MigrationLayer {
|
||
group: THREE.Object3D;
|
||
setField(field: MigrationField | null): void;
|
||
setLighting(state: LightingState): void;
|
||
setSolarElevation(degrees: number): void;
|
||
tick(dt: number): void;
|
||
dispose(): void;
|
||
}
|
||
|
||
export type MigrationLayerFactory = (
|
||
world: World,
|
||
options: { span: number },
|
||
) => MigrationLayer;
|
||
|
||
/**
|
||
* Whether this visitor has asked the platform for less movement.
|
||
*
|
||
* Read at the moment it is needed rather than cached, because the only caller
|
||
* asks once per board and the query is a property read. `scenekit.ts` keeps a
|
||
* live subscription for the same preference; it needs one because a chapter
|
||
* flight can be in the air when the setting changes, and the opening move
|
||
* cannot — it is started or it is not.
|
||
*/
|
||
function prefersReducedMotion(): boolean {
|
||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
}
|
||
|
||
/** What the pointer is over: an authored place, or an observed aeroplane. */
|
||
type Pick =
|
||
| { kind: "marker"; marker: Marker }
|
||
| { kind: "aircraft"; aircraft: Aircraft };
|
||
|
||
export interface SceneOptions {
|
||
city: City;
|
||
markerPalette?: MarkerPalette;
|
||
/**
|
||
* Markers available at construction time.
|
||
*
|
||
* Building glyphs in this first set reserve their footprints in the
|
||
* anonymous block scatter. Later `setMarkers()` calls remain cheap and do
|
||
* not rebuild a city, so callers should put stable destinations here and use
|
||
* updates for genuinely live marker feeds.
|
||
*/
|
||
markers?: Marker[];
|
||
/** Optional deterministic road traffic for a state/corridor-scale board. */
|
||
roadTraffic?: RoadTrafficOptions;
|
||
/** Optional possessed procedural actor for play mode; inactive by default. */
|
||
actor?: SceneActorOptions;
|
||
/** Geographic spawn anchor for that actor; defaults to the board centre. */
|
||
actorAnchor?: { lat: number; lng: number };
|
||
/** Optional possessed fixed-wing aircraft; the city supplies its projection and terrain. */
|
||
aircraft?: Omit<SceneAircraftOptions, "project" | "groundAt">;
|
||
/** Optional remote authoritative entities. Nothing is imported or allocated when absent. */
|
||
realtimePeers?: CityRealtimePeersOptions;
|
||
flights?: FlightSource;
|
||
/**
|
||
* Element sets to propagate, if this deployment has any.
|
||
*
|
||
* A catalogue rather than a source, and the asymmetry with `flights` is the
|
||
* point: a `FlightSource` is polled because there is no closed form for where
|
||
* aircraft are, and a `SatelliteCatalogue` is *evaluated* because a TLE is
|
||
* exactly that closed form. Nothing here is ever fetched on a timer.
|
||
*/
|
||
satellites?: SatelliteCatalogue;
|
||
/**
|
||
* How to build this board's fire layer, or nothing at all.
|
||
*
|
||
* A factory rather than a layer, because the layer needs the `World` this
|
||
* function is in the middle of building. Absent on every board that has no
|
||
* fire projection behind it, and absent costs exactly nothing: no geometry,
|
||
* no material, no draw call, and `setFires` becomes a no-op.
|
||
*/
|
||
fires?: FireLayerFactory;
|
||
/**
|
||
* How to build this board's port kit, or nothing at all.
|
||
*
|
||
* The ports themselves are `city.ports` and are plain authored data; this is
|
||
* only the renderer for them, kept optional and injected for the same reason
|
||
* `fires` is — so a board with no port allocates nothing and so `engine/ports.ts`
|
||
* is never in the import graph of a build that does not draw one.
|
||
*/
|
||
ports?: PortLayerFactory;
|
||
/** How to build this board's vessel layer, or nothing at all. */
|
||
vessels?: VesselLayerFactory;
|
||
/** How to build this board's reflectivity sheet, or nothing at all. */
|
||
precip?: PrecipLayerFactory;
|
||
/** How to build this board's migration field, or nothing at all. */
|
||
migration?: MigrationLayerFactory;
|
||
/** Fires on hover/click of a marker head. */
|
||
onMarkerPick?: (marker: Marker | null) => void;
|
||
/**
|
||
* Fires on hover of an aeroplane, and with `null` as the pointer leaves one.
|
||
*
|
||
* The same shape as `onMarkerPick` and for the same reason: `scenekit` reports
|
||
* picks by hover, so a click handler upstairs reads whatever the last hover
|
||
* resolved. What comes back is the engine's own `Aircraft` — a position and a
|
||
* callsign — and nothing about where it came from, because that is a question
|
||
* about the deployment and `adapters/http.ts` is the layer that can answer it.
|
||
*
|
||
* Not gated on anything. An ADS-B position is broadcast in clear to anybody
|
||
* with a receiver, so there is nothing here an account could grant; see
|
||
* `owner-decisions.md` and the note on `TrafficSource.detail`.
|
||
*/
|
||
onAircraftPick?: (aircraft: Aircraft | null) => void;
|
||
/**
|
||
* 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<VehicleActionSnapshot>): void;
|
||
/** Current playable corridor state, or null on a city-scale board. */
|
||
vehicleState(): Readonly<VehicleControllerState> | null;
|
||
setVehicleCamera(mode: VehicleCameraMode): void;
|
||
vehicleCamera(): VehicleCameraMode | null;
|
||
setActorActions(actions: Partial<ActorActionSnapshot>): void;
|
||
actorState(): Readonly<ActorControllerSnapshot> | null;
|
||
/** Replace the visible local actor profile without disturbing its position or controls. */
|
||
setActorIdentity(identity: ActorIdentity): void;
|
||
/** Attach a caller-owned face texture to the local humanoid, if one is active. */
|
||
attachActorFaceTexture(texture: THREE.Texture): boolean;
|
||
/** Detach the current local face texture without disposing caller ownership. */
|
||
clearActorFaceTexture(): void;
|
||
setActorActive(active: boolean): void;
|
||
actorActive(): boolean;
|
||
setAircraftActions(actions: Partial<AircraftActionSnapshot>): void;
|
||
aircraftState(): Readonly<AircraftControllerSnapshot> | null;
|
||
setAircraftActive(active: boolean): void;
|
||
aircraftActive(): boolean;
|
||
/** Canonical remote snapshots only; a no-op when realtime peers were not configured. */
|
||
upsertRemoteSnapshot(snapshot: EntityPoseSnapshot): boolean;
|
||
removeRemoteEntity(id: string): boolean;
|
||
clearRemoteEntities(): void;
|
||
remoteEntityCount(): number;
|
||
setMarkers(markers: Marker[]): void;
|
||
/** Take this city off the stage and release everything it built. */
|
||
dispose(): void;
|
||
}
|
||
|
||
/**
|
||
* Build a city on a stage. Resolves to `null` if the build was abandoned.
|
||
*
|
||
* ## Why this is async, and why the handle is not
|
||
*
|
||
* `SceneHandle` is unchanged: every method on it is synchronous and every field
|
||
* on it is real by the time you hold one. Only getting one takes a moment.
|
||
*
|
||
* The alternative was tried on paper and is worse. Handing back a handle
|
||
* immediately means handing back a handle whose `stageScene` holds an empty
|
||
* scene, whose `flyTo` cannot know where the ground is, and whose `world`
|
||
* answers `groundAt` by building the heightfield on the main thread — the exact
|
||
* block this change exists to remove, reintroduced by the first caller who
|
||
* forgets to wait. Every one of those methods would need a "not yet" branch and
|
||
* a queue, and the queue would be the real API.
|
||
*
|
||
* So the wait is where the wait actually is. The cost is that it ripples: the
|
||
* app has to `await mountCity`, and `createMinimap` has to be constructed after
|
||
* this resolves rather than alongside it. That is a handful of `await`s in
|
||
* `main.ts` against an engine that cannot lie about whether its ground exists.
|
||
*/
|
||
export async function createScene(
|
||
stage: Stage,
|
||
options: SceneOptions,
|
||
): Promise<SceneHandle | null> {
|
||
const { city } = options;
|
||
const world = new World(city);
|
||
// First, so an abandoned build has nothing to tear down: everything below
|
||
// this line allocates, and a city that is no longer wanted should not have
|
||
// built a single buffer.
|
||
const ready = await world.ready({ signal: options.signal, onProgress: options.onProgress });
|
||
if (!ready) return null;
|
||
|
||
const pal = paletteFor(world);
|
||
|
||
const scene = new THREE.Scene();
|
||
|
||
/**
|
||
* Every camera limit is derived from how big this city's board actually is.
|
||
*
|
||
* These were constants tuned for San Francisco — `maxDistance: 340`,
|
||
* `far: 900`, a 170-unit shadow box. That silently made board size a fixed
|
||
* property of the engine rather than of a city: expanding the pack from San
|
||
* Francisco to the whole Bay Area took the board from 230 units across to
|
||
* 1003, and the camera physically could not retreat far enough to frame it.
|
||
* You got a close-up of the peninsula with everything else off-screen, and
|
||
* nothing in the types said why.
|
||
*
|
||
* A city pack now chooses its own `latScale` freely and the camera follows.
|
||
*/
|
||
const [westX, northZ] = world.project(city.bounds.maxLat, city.bounds.minLng);
|
||
const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng);
|
||
const boardSpan = Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ));
|
||
|
||
/**
|
||
* How far the board reaches **from the scene origin**, which is not the same
|
||
* as how big it is.
|
||
*
|
||
* Scene space is centred on `city.center` — the city — and the Bay Area board
|
||
* runs forty kilometres down the peninsula from there, so the origin is
|
||
* nowhere near the middle of it. The furthest corner is 0.94 spans out where
|
||
* the half-diagonal is only 0.65, and anything sized off the half-diagonal is
|
||
* therefore too small by half.
|
||
*
|
||
* The satellite dome is centred on the origin, because that is where its look
|
||
* angles were computed for, so this is the radius it has to clear.
|
||
*/
|
||
const boardRadius = Math.max(
|
||
Math.hypot(westX, northZ),
|
||
Math.hypot(eastX, northZ),
|
||
Math.hypot(westX, southZ),
|
||
Math.hypot(eastX, southZ),
|
||
);
|
||
|
||
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<string, Aircraft>();
|
||
if (options.flights) {
|
||
flightLayer = createFlightLayer(world);
|
||
scene.add(flightLayer.group);
|
||
}
|
||
|
||
let satelliteLayer: SatelliteLayer | null = null;
|
||
let starlinkMeshes: StarlinkMeshLayer | null = null;
|
||
if (options.satellites) {
|
||
satelliteLayer = createSatelliteLayer(boardRadius);
|
||
scene.add(satelliteLayer.group);
|
||
// Board radius, the same unit `createSatelliteLayer` takes above — the
|
||
// mesh layer applies the dome factor itself. It used to take the *dome*
|
||
// radius while its sibling took the *board* radius, with nothing in the
|
||
// types to tell them apart, which is precisely the confusion that has
|
||
// already produced one real bug in this file.
|
||
starlinkMeshes = createStarlinkMeshLayer({ boardRadius });
|
||
scene.add(starlinkMeshes.group);
|
||
}
|
||
|
||
/**
|
||
* The instant the sky is drawn for, or `null` for the wall clock.
|
||
*
|
||
* Satellites are the one layer whose content is a function of *absolute* time
|
||
* rather than of elapsed time, so `tick(dt)` cannot serve them: godmode scrubs
|
||
* the clock to an arbitrary date and the sky has to follow it there.
|
||
*
|
||
* It holds the **override** rather than a resolved `Date`, which is the same
|
||
* shape `main.ts` keeps its own clock in and is load-bearing here. A resolved
|
||
* instant would have to be pushed in on a timer, and the only timer available
|
||
* is `updateSun`'s — which runs about once a second, so the sky would advance
|
||
* in one-second jumps while everything around it moved smoothly. Holding the
|
||
* override means an unscrubbed scene reads the clock afresh every frame and a
|
||
* scrubbed one is frozen exactly where it was put.
|
||
*/
|
||
let skyOverride: Date | null = null;
|
||
|
||
// ---- Chapters -----------------------------------------------------------
|
||
|
||
const chapterById = Object.fromEntries(city.chapters.map((c) => [c.id, c]));
|
||
const first = city.chapters[0];
|
||
if (!first) throw new Error(`City "${city.id}" declares no chapters`);
|
||
|
||
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<Pick>({
|
||
targets: pickTargets,
|
||
resolve: (hit) => {
|
||
const marker = hit.object.userData.marker as Marker | undefined;
|
||
if (marker) return { kind: "marker", marker };
|
||
const id = hit.object.userData.aircraftId as string | undefined;
|
||
const aircraft = id === undefined ? undefined : lastAircraft.get(id);
|
||
return aircraft ? { kind: "aircraft", aircraft } : null;
|
||
},
|
||
onChange: (picked) => {
|
||
// Both callbacks fire on every change, including the change back to
|
||
// `null`, so whichever card is up is retired by a pointer that leaves —
|
||
// and by a pointer that moves from a pin straight onto an aeroplane.
|
||
options.onMarkerPick?.(picked?.kind === "marker" ? picked.marker : null);
|
||
options.onAircraftPick?.(picked?.kind === "aircraft" ? picked.aircraft : null);
|
||
},
|
||
});
|
||
|
||
// ---- The scene, as the stage sees it ------------------------------------
|
||
|
||
const stageScene: StageScene = {
|
||
scene,
|
||
camera: kit.camera,
|
||
controls: kit.controls,
|
||
// Leaving for an office should retire the hover with it; coming back to a
|
||
// stale detail card for something the pointer is nowhere near reads as a
|
||
// bug.
|
||
onExit: () => kit.resetPick(),
|
||
/*
|
||
* Claim the environment on arrival.
|
||
*
|
||
* A board built with `present: false` was applied `offstage`, which means it
|
||
* borrowed whatever probe was cached rather than building its own — right
|
||
* while it was invisible, wrong the moment it is not. This is the same call
|
||
* every board already makes; on a cache hit it costs no convolution, which
|
||
* is the case a prefetched board arriving at the same hour always hits.
|
||
*
|
||
* It runs on every entry, not only the first, and that is deliberate: a
|
||
* board returned to after an office visit or another board has been off
|
||
* screen while the clock moved, and re-asserting is how it catches up.
|
||
*/
|
||
onEnter: () => {
|
||
presented = true;
|
||
options.environment?.apply(scene, lighting, "city");
|
||
},
|
||
tick(dt) {
|
||
// Exactly one subsystem owns the camera. In particular, OrbitControls
|
||
// must stay disabled while the road layer writes its follow pose.
|
||
const ownership = cityControlOwnership(controlMode);
|
||
kit.controls.enabled = ownership.orbit;
|
||
/**
|
||
* The opening move, stepped before `kit.tick` so the damping and the
|
||
* clamps `controls.update()` applies land on top of it rather than
|
||
* underneath.
|
||
*
|
||
* `easeInOutCubic`, the same curve a chapter flight uses, because the
|
||
* camera starts from a standstill: a curve that began at full speed would
|
||
* read as a cut followed by a glide.
|
||
*/
|
||
applyDetailLod();
|
||
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,
|
||
},
|
||
};
|
||
}
|