f81d5218d4
**The aeroplanes were stuck because the wire could not describe motion.** `WireAircraft` carried position, altitude and heading and nothing else, so the client could only interpolate between the last two observations: every aircraft replayed a segment it had already flown, arrived at the newest known point, and sat still until the next poll landed five to fifteen seconds later. The feed had the missing numbers the whole time and the server threw them away. Sampled live from `api.adsb.lol/v2/point` while writing this — `gs` ground speed, `track`, `baro_rate`, plus `r` registration and `t` type designator. They are on the wire now in SI, aircraft dead-reckon along their own track and correct toward the truth when a fix lands, and the click card an anonymous visitor gets says "B739 · N68834". That last part is the enrichment FR24 was wanted for, obtained from an ODbL feed we may actually republish. **SFO and LAX exist.** A new `engine/airports.ts` composes an airport from runways, taxiways, aprons and terminal masses, with markings drawn on a canvas rather than modelled; the pattern of the runways is what the eye recognises from altitude, long before any building does. SFO is the two crossing pairs on the bay fill; LAX is the four parallels either side of the terminal horseshoe, plus the Southland fields under the traffic that actually flies there. **The Golden Gate and the Bay Bridge are those bridges.** One kit in `engine/bridges.ts`, because a suspension bridge is a repeated tower, a catenary main cable, a series of hangers and a deck — so both are configurations rather than two private implementations. The Bay Bridge carries the real 2013 topology: two suspension towers west of Yerba Buena, one east, then the piered causeway. The freeway stopped being a wireframe overlay and became a road, with shoulders, a median, and lane markings as texture. **And the board got faster while all of that landed.** California went from 728,744 triangles and 562 draw calls to 391,169 and 371 — headroom from 2.8% to 47.8%. The Bay Area board is 506,550 triangles lighter than before this work. Two things paid for it: - `transmission: 0.08` on the aircraft cockpit glass. three.js runs a full transmission backdrop pass whenever any rendered material has transmission above zero, re-drawing the entire opaque scene into a second target every frame — so the city was rendering terrain, every block and every freeway piece TWICE. Measured by patching only that number in a copy of the built bundle: 703,267 tris / 562 draws with it, 398,608 / 371 without. The material was already `transparent: true, opacity: 0.86`, so it was buying nothing. - Flatness-adaptive terrain LOD, which collapses runs of lattice cells wherever the height and colour agree with the quad replacing them. The coastline is provably untouched — a patch collapses only when every point is on land and agrees about `park` — and a test asserts the drawn footprint matches the cell-by-cell area to 1e-6. `createTerrain` got *faster*: the vertices it stops emitting cost more than the flatness scan costs to run. **The budget now watches the boards this was built on.** There was no `bay-area` or `socal` cell — so SFO, LAX and both bridges all landed in frames nothing measured, which is how a cap you do not have looks from the inside. Both are in the matrix now with caps set from measurement, and the rationale lives in the harness because JSON cannot hold a comment. Two known defects ship with this, both recorded in TODO.md rather than hidden: - `bay-area.desktop` drops about one frame in twenty (p50 16.7, p95 33.3). It is desktop-only and not fill rate — mobile runs the same 2.26 M triangles at a comparable pixel count and holds 16.7 flat — which points at the 2048 shadow map desktop uses against handheld's 1024. Measured at the commit before this work with the same harness: identical p95 33.3. Pre-existing, and invisible until the cell existed. - The aeroplane glyph is still about 1.5x the Golden Gate's main span at chapter zoom, down from 2.5x. `GLYPH_MAX_SCALE` is 52 because the raw scale at the far end of the California orbit is 51.0 at a 60-degree field of view, and 26 — tried first — put the glyph at 0.0123 of the frame against the 0.012 where the wings stop resolving. The real fix is to clamp against the camera's focus distance rather than the aircraft's, which is a signature change. Tests 1020 -> 1137. Typecheck, build, eight budget cells, no-binaries, provenance, zero-config boot, dependency licences, arena source hashes and the UI smoke across two viewports and two access tiers all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
976 lines
44 KiB
TypeScript
976 lines
44 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 } from "./atmosphere.ts";
|
||
import { createStarlinkMeshLayer, type StarlinkMeshLayer } from "./starlinkMesh.ts";
|
||
import {
|
||
createSatelliteLayer,
|
||
type SatelliteCatalogue,
|
||
type SatelliteLayer,
|
||
} from "./satellites.ts";
|
||
import { createSceneKit, type Pose } from "./scenekit.ts";
|
||
import type { EnvironmentRig } from "./environmentRig.ts";
|
||
import {
|
||
createRoadTrafficLayer,
|
||
type RoadTrafficLayer,
|
||
type RoadTrafficOptions,
|
||
type VehicleCameraMode,
|
||
} from "./roadTraffic.ts";
|
||
import type { Stage, StageScene } from "./stage.ts";
|
||
import type {
|
||
VehicleActionSnapshot,
|
||
VehicleControllerState,
|
||
} from "../transport/vehicleController.ts";
|
||
import { createAirports } from "./airports.ts";
|
||
import { createBridges, createFreewayWorld, createRoads } from "./structures.ts";
|
||
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
|
||
import type {
|
||
Aircraft,
|
||
Chapter,
|
||
City,
|
||
FlightSource,
|
||
LightingState,
|
||
Marker,
|
||
MarkerPalette,
|
||
ScenePalette,
|
||
} from "./types.ts";
|
||
import { World, type FieldProgress } from "./world.ts";
|
||
import { createSceneActor, type SceneActorOptions } from "../actors/sceneActor.ts";
|
||
import type {
|
||
ActorActionSnapshot,
|
||
ActorControllerSnapshot,
|
||
ActorIdentity,
|
||
} from "../actors/controller.ts";
|
||
import {
|
||
createSceneAircraft,
|
||
type SceneAircraftOptions,
|
||
} from "../aircraft/sceneAircraft.ts";
|
||
import type {
|
||
AircraftActionSnapshot,
|
||
AircraftControllerSnapshot,
|
||
} from "../aircraft/controller.ts";
|
||
import type { ScenePeers, ScenePeersOptions } from "../realtime/scenePeers.ts";
|
||
import type { EntityPoseSnapshot } from "../realtime/types.ts";
|
||
import { cityControlOwnership, type CityControlMode } from "../play/controlMode.ts";
|
||
|
||
export type CityRealtimePeersOptions = Omit<ScenePeersOptions, "project" | "groundAt">;
|
||
|
||
/** 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;
|
||
/** Fires on hover/click of a marker head. */
|
||
onMarkerPick?: (marker: Marker | null) => void;
|
||
/**
|
||
* Fires on hover of an aeroplane, and with `null` as the pointer leaves one.
|
||
*
|
||
* The same shape as `onMarkerPick` and for the same reason: `scenekit` reports
|
||
* picks by hover, so a click handler upstairs reads whatever the last hover
|
||
* resolved. What comes back is the engine's own `Aircraft` — a position and a
|
||
* callsign — and nothing about where it came from, because that is a question
|
||
* about the deployment and `adapters/http.ts` is the layer that can answer it.
|
||
*
|
||
* Not gated on anything. An ADS-B position is broadcast in clear to anybody
|
||
* with a receiver, so there is nothing here an account could grant; see
|
||
* `owner-decisions.md` and the note on `TrafficSource.detail`.
|
||
*/
|
||
onAircraftPick?: (aircraft: Aircraft | null) => void;
|
||
/**
|
||
* The shared environment map, when the page has one.
|
||
*
|
||
* Handed in rather than built here, and that is the whole of the wiring rule:
|
||
* a `PMREMGenerator` and its render targets belong to the **renderer**, which
|
||
* outlives every city on the page, so one rig is built beside the `Stage` and
|
||
* shared. A rig per `createScene` would allocate a fresh blur chain and a
|
||
* fresh target for every board and leak both on the next switch, which is
|
||
* exactly the arithmetic `stage.ts` records for the renderer itself.
|
||
*
|
||
* Absent, everything renders as it did before the rig existed: duller metal,
|
||
* no sky in the water, and no error.
|
||
*/
|
||
environment?: EnvironmentRig;
|
||
/**
|
||
* Opening light rig. Comes from an `Atmosphere` when there is one; without
|
||
* one the city gets `cityDaylight()`, because a scene that renders black
|
||
* until somebody wires up the sun is not a scene that boots with no config.
|
||
*/
|
||
lighting?: LightingState;
|
||
/**
|
||
* Fires while the heightfield builds, several times a second. The caller
|
||
* decides what to say about it; the engine only reports a phase and a
|
||
* fraction. See `FieldProgress`.
|
||
*/
|
||
onProgress?: (progress: FieldProgress) => void;
|
||
/**
|
||
* Abandons the build. `createScene` then resolves to `null` having allocated
|
||
* no geometry and having touched the stage not at all — the point of aborting
|
||
* is that the next city gets the machine to itself, and a half-built scene
|
||
* parked on the stage defeats that.
|
||
*/
|
||
signal?: AbortSignal;
|
||
}
|
||
|
||
export interface SceneHandle {
|
||
world: World;
|
||
chapters: Chapter[];
|
||
/**
|
||
* The stage this city was built on, which it uses and does not own. An office
|
||
* is swapped in with `stage.setScene(officeScene)` and this city back in the
|
||
* same way; the one that steps out is paused, not thrown away.
|
||
*
|
||
* `dispose()` below takes the city off it and leaves it running. Whoever
|
||
* built the stage disposes it, and on this page nobody does — it lives as
|
||
* long as the canvas does.
|
||
*/
|
||
stage: Stage;
|
||
/** This city, as the thing `stage.setScene` takes. */
|
||
stageScene: StageScene;
|
||
/** Applies a rig computed elsewhere. The scene never works one out itself. */
|
||
setLighting(state: LightingState): void;
|
||
/**
|
||
* Solar elevation in degrees, for the layers that need the sun's position
|
||
* rather than the rig it implies. `LightingState` deliberately carries no
|
||
* elevation, so the number has to arrive separately.
|
||
*/
|
||
setSolarElevation(degrees: number): void;
|
||
/**
|
||
* How much of the sky has cloud in it, 0..1 — `WeatherObservation.cloudCover`.
|
||
*
|
||
* Separate from `setLighting` for the same reason `setSolarElevation` is: a
|
||
* `LightingState` deliberately carries no cover, so the number has to arrive
|
||
* on its own rather than be reverse-engineered out of a rig.
|
||
*/
|
||
setCloudCover(fraction: number): void;
|
||
/** Wind as the observation reports it: km/h, and the bearing it blows *from*. */
|
||
setWind(kph: number | null, fromDeg: number | null): void;
|
||
/**
|
||
* Freeze the satellite sky at an instant, or pass `null` to follow the wall
|
||
* clock. Exactly the shape of `main.ts`'s own time override, deliberately.
|
||
*
|
||
* Separate from `setSolarElevation` even though both follow the same clock,
|
||
* because they need different things from it: the night-lights want a scalar
|
||
* the caller has already worked out, and SGP4 wants the date itself. Handing
|
||
* the layer an elevation would mean it could not propagate, and handing the
|
||
* lights a `Date` would mean two modules computing the sun.
|
||
*
|
||
* No-op on a deployment with no catalogue.
|
||
*/
|
||
setSkyInstant(when: Date | null): void;
|
||
/** Draw the satellite layer, or do not. No-op with no catalogue. */
|
||
setSatellitesVisible(visible: boolean): void;
|
||
/**
|
||
* How many objects were above the horizon at the end of the last complete
|
||
* propagation pass, and how many element sets this build could read at all.
|
||
* Both zero without a catalogue. For the godmode readout; nothing renders it.
|
||
*/
|
||
satelliteCounts(): { visible: number; total: number };
|
||
flyTo(chapterId: string): void;
|
||
current(): string;
|
||
onChapterChange(fn: (id: string) => void): void;
|
||
/** Atomically hands local input and follow-camera ownership to one subsystem. */
|
||
setControlMode(mode: CityControlMode): void;
|
||
controlMode(): CityControlMode;
|
||
onControlModeChange(fn: (mode: CityControlMode) => void): void;
|
||
/** Device-neutral input for the corridor hero; a no-op on boards without one. */
|
||
setVehicleActions(actions: Partial<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.
|
||
options.environment?.apply(scene, opening, "city");
|
||
|
||
scene.add(createWater(world));
|
||
scene.add(createShorePlates(world));
|
||
scene.add(createTerrain(world));
|
||
scene.add(options.roadTraffic
|
||
? createFreewayWorld(world, options.roadTraffic.pack)
|
||
: createRoads(world));
|
||
const buildingReservations: BuildingReservation[] = [];
|
||
for (const marker of options.markers ?? []) {
|
||
const glyph = marker.glyph;
|
||
if (marker.located === false || glyph?.kind !== "building") continue;
|
||
const [x, z] = world.project(marker.lat, marker.lng);
|
||
// Five metres of breathing room keeps an anonymous wall from sitting
|
||
// exactly on the authored façade after both reservation circles touch.
|
||
const radius = (Math.hypot(glyph.width, glyph.depth) / 2 + 5) / world.metresPerUnit;
|
||
buildingReservations.push({ x, z, radius });
|
||
}
|
||
const blocks = createBlocks(world, buildingReservations);
|
||
scene.add(blocks);
|
||
scene.add(createLandmarks(world, buildingReservations));
|
||
scene.add(createBridges(world));
|
||
// Airfields. Laid flush on the terrain rather than draped over it like a
|
||
// road, which is why the packs no longer carry runways as `Road` records —
|
||
// carrying both floats a dark stripe thirteen metres above every runway.
|
||
scene.add(createAirports(world, city.airports ?? []));
|
||
|
||
/**
|
||
* The city switching itself on after sunset. Built after `blocks` because it
|
||
* patches the material that `createBlocks` made — order is load-bearing.
|
||
*/
|
||
const nightLights: NightLights = createNightLights({ world, blocks });
|
||
scene.add(nightLights.group);
|
||
|
||
const clouds: CloudLayer = createCloudLayer(world, { span: boardSpan });
|
||
clouds.setLighting(opening);
|
||
scene.add(clouds.group);
|
||
|
||
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
|
||
markerLayer.setMarkers(options.markers ?? []);
|
||
scene.add(markerLayer.group);
|
||
|
||
const roadTraffic: RoadTrafficLayer | null = options.roadTraffic
|
||
? createRoadTrafficLayer(world, kit.camera, kit.controls, options.roadTraffic)
|
||
: null;
|
||
if (roadTraffic) scene.add(roadTraffic.group);
|
||
const actorAnchor = options.actorAnchor ?? city.center;
|
||
const [actorX, actorZ] = world.project(actorAnchor.lat, actorAnchor.lng);
|
||
const sceneActor = options.actor
|
||
? createSceneActor({
|
||
...options.actor,
|
||
sceneUnitsPerMetre: options.actor.sceneUnitsPerMetre ?? 1 / world.metresPerUnit,
|
||
visualSceneUnitsPerMetre: options.actor.visualSceneUnitsPerMetre ??
|
||
(city.id === "california" ? 0.025 : 1 / world.metresPerUnit),
|
||
sceneOrigin: options.actor.sceneOrigin ?? {
|
||
x: actorX,
|
||
y: world.groundAt(actorAnchor.lat, actorAnchor.lng),
|
||
z: actorZ,
|
||
},
|
||
})
|
||
: null;
|
||
if (sceneActor) scene.add(sceneActor.root);
|
||
const sceneAircraft = options.aircraft
|
||
? createSceneAircraft({
|
||
...options.aircraft,
|
||
project: (lat, lng) => world.project(lat, lng),
|
||
groundAt: (lat, lng) => world.groundAt(lat, lng),
|
||
altitudeSceneUnitsPerMetre: options.aircraft.altitudeSceneUnitsPerMetre ??
|
||
1 / world.metresPerUnit,
|
||
})
|
||
: null;
|
||
if (sceneAircraft) scene.add(sceneAircraft.root);
|
||
// Dynamic on purpose: the offline/default entry must not download four peer
|
||
// prototypes merely because `createScene` can optionally host them.
|
||
let realtimePeers: ScenePeers | null = null;
|
||
if (options.realtimePeers) {
|
||
const { createScenePeers } = await import("../realtime/scenePeers.ts");
|
||
realtimePeers = createScenePeers({
|
||
...options.realtimePeers,
|
||
project: (lat, lng) => world.project(lat, lng),
|
||
groundAt: (lat, lng) => world.groundAt(lat, lng),
|
||
geographicSceneUnitsPerMetre:
|
||
options.realtimePeers.geographicSceneUnitsPerMetre ?? 1 / world.metresPerUnit,
|
||
geographicVisualScale:
|
||
options.realtimePeers.geographicVisualScale ??
|
||
(city.id === "california" ? 0.025 : 1 / world.metresPerUnit),
|
||
});
|
||
scene.add(realtimePeers.root);
|
||
}
|
||
|
||
let flightLayer: FlightLayer | null = null;
|
||
let flightTimer = 0;
|
||
/**
|
||
* The last observation, by id, so a pick has something to hand back.
|
||
*
|
||
* The layer interpolates between observations and keeps no record a caller
|
||
* could read; this is the record. Rebuilt wholesale on every poll rather than
|
||
* merged, so an aeroplane that has left the region leaves this table with it
|
||
* and a card cannot be opened on a track that is no longer in the sky.
|
||
*/
|
||
const lastAircraft = new Map<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;
|
||
// Named viewpoints are observe/vehicle destinations. Possessing an actor
|
||
// is an explicit UI action, so a chapter selection always hands the camera
|
||
// back before it moves anywhere else.
|
||
const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId);
|
||
if (route && roadTraffic) {
|
||
roadTraffic.setRoute(route.id);
|
||
applyControlMode("drive");
|
||
} else {
|
||
applyControlMode("overview");
|
||
roadTraffic?.setVehicleActions({});
|
||
kit.flyTo(chapterPose(ch));
|
||
}
|
||
if (currentChapter !== chapterId) {
|
||
currentChapter = chapterId;
|
||
for (const fn of chapterListeners) fn(chapterId);
|
||
}
|
||
}
|
||
|
||
kit.setPose(chapterPose(first));
|
||
|
||
// ---- Picking ------------------------------------------------------------
|
||
|
||
/**
|
||
* Two things on this board are worth pointing at, and both are resolved here.
|
||
*
|
||
* `markerLayer.pickables` and `flightLayer.pickables` are both mutated in
|
||
* place by their layers, so neither array can simply be concatenated once —
|
||
* the picking target list has to be a getter that reads both at the moment of
|
||
* the test. A pin is an authored place; an aeroplane is an observation, and
|
||
* `Pick` keeps them apart as a union rather than flattening both to a string,
|
||
* because the aircraft card is five fields and a provenance line and the
|
||
* moment it becomes a sentence it can never be anything else again.
|
||
*/
|
||
const pickTargets = (): THREE.Object3D[] =>
|
||
flightLayer === null
|
||
? markerLayer.pickables
|
||
: [...markerLayer.pickables, ...flightLayer.pickables];
|
||
|
||
kit.setPicking<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(),
|
||
tick(dt) {
|
||
// Exactly one subsystem owns the camera. In particular, OrbitControls
|
||
// must stay disabled while the road layer writes its follow pose.
|
||
const ownership = cityControlOwnership(controlMode);
|
||
kit.controls.enabled = ownership.orbit;
|
||
kit.tick(dt);
|
||
sceneActor?.tick(dt);
|
||
if (ownership.actor && sceneActor) kit.setPose(sceneActor.followPose());
|
||
sceneAircraft?.tick(dt);
|
||
if (ownership.aircraft && sceneAircraft) kit.setPose(sceneAircraft.followPose());
|
||
realtimePeers?.tick(Date.now());
|
||
roadTraffic?.tick(dt);
|
||
clouds.tick(dt);
|
||
if (options.flights && flightLayer) {
|
||
flightTimer -= dt;
|
||
if (flightTimer <= 0) {
|
||
flightTimer = options.flights.interval;
|
||
void Promise.resolve(options.flights.poll()).then((ac) => {
|
||
lastAircraft.clear();
|
||
for (const a of ac) lastAircraft.set(a.id, a);
|
||
flightLayer?.update(ac);
|
||
});
|
||
}
|
||
}
|
||
// Every frame and on no timer of its own. The catalogue's sweep is
|
||
// time-budgeted internally — see `SWEEP_BUDGET_MS` — so calling it more
|
||
// often makes it walk the catalogue sooner, never makes it cost more.
|
||
if (options.satellites && satelliteLayer) {
|
||
/**
|
||
* One instant, one sweep, shared.
|
||
*
|
||
* `fixes()` advances the catalogue's rolling propagation, so calling it
|
||
* twice in a frame spends twice the budget for no new information — and
|
||
* two different `when`s would put the near-field satellites' sun
|
||
* attitude on a different clock from the sky they are in.
|
||
*
|
||
* The sun comes from `solar.ts` directly rather than from the rig,
|
||
* because `atmosphere.ts` floors the light direction at
|
||
* `shadowFloorDeg` to keep the shadow camera usable. That floor pins the
|
||
* sun above the horizon, and a sun ten degrees *down* is precisely the
|
||
* dusk geometry that lights a Starlink pass.
|
||
*/
|
||
const when = skyOverride ?? new Date();
|
||
const solar = solarPosition(city.center.lat, city.center.lng, when);
|
||
/**
|
||
* The sky's own brightness, and the fix for the worst thing on this
|
||
* board at first load.
|
||
*
|
||
* Both satellite layers draw light *added* to the sky: the dot cloud is
|
||
* `AdditiveBlending` and the near-field buses are unlit white. That is
|
||
* exactly right against a night sky and is a hard white square against a
|
||
* daytime one — which is what the California board showed at 15:55 with
|
||
* the sun at +44°, scattered across the frame, reading as render
|
||
* artefacts before anything else on the page registered.
|
||
*
|
||
* `nightFactor` is `atmosphere.ts`'s own dusk curve and is deliberately
|
||
* the same one `nightlights.ts` switches the city on with, so the sky
|
||
* does not empty at a different dusk from the one the windows light up
|
||
* at. It is computed here rather than taken from the rig for the reason
|
||
* the sun vector below is: `atmosphere.ts` floors the *rig's* light
|
||
* direction at `shadowFloorDeg` to keep the shadow camera usable, and a
|
||
* sun pinned above the horizon is precisely the wrong input for a
|
||
* question about how dark it is.
|
||
*/
|
||
const darkness = nightFactor(solar.elevation);
|
||
satelliteLayer.setSkyDarkness(darkness);
|
||
starlinkMeshes?.setSkyDarkness(darkness);
|
||
const fixes = options.satellites.fixes(when);
|
||
satelliteLayer.update(fixes);
|
||
starlinkMeshes?.update(fixes, kit.camera, sunDirection(solar));
|
||
}
|
||
},
|
||
dispose() {
|
||
// Before anything else frees a texture: the rig holds this scene in a
|
||
// ledger so a rebuilt environment can be pushed to every scene using it,
|
||
// and a disposed city left in that ledger is the whole graph retained.
|
||
options.environment?.release(scene);
|
||
options.flights?.dispose?.();
|
||
flightLayer?.dispose();
|
||
satelliteLayer?.dispose();
|
||
starlinkMeshes?.dispose();
|
||
// Before the `scene.traverse` sweep below, and required rather than tidy:
|
||
// the sweep reaches geometries and materials, and a `ShaderMaterial`'s
|
||
// uniform textures are neither — the cloud texture is a canvas this layer
|
||
// drew and only it can free.
|
||
clouds.dispose();
|
||
nightLights.dispose();
|
||
markerLayer.dispose();
|
||
roadTraffic?.dispose();
|
||
sceneAircraft?.dispose();
|
||
realtimePeers?.dispose();
|
||
kit.dispose();
|
||
scene.traverse((obj) => {
|
||
const mesh = obj as THREE.Mesh;
|
||
mesh.geometry?.dispose();
|
||
const mat = mesh.material;
|
||
if (Array.isArray(mat)) mat.forEach((m) => m.dispose());
|
||
else if (mat) (mat as THREE.Material).dispose();
|
||
});
|
||
},
|
||
};
|
||
stage.setScene(stageScene);
|
||
|
||
return {
|
||
world,
|
||
chapters: city.chapters,
|
||
stage,
|
||
stageScene,
|
||
setLighting: (state) => {
|
||
kit.applyLighting(state);
|
||
clouds.setLighting(state);
|
||
/**
|
||
* Every lighting change, and it is cheap to do it every one.
|
||
*
|
||
* The rig fingerprints the state coarsely and rebuilds only when the
|
||
* fingerprint moves, so an unchanged sky is a map lookup and an
|
||
* assignment. Calling it here rather than on a timer of its own is what
|
||
* keeps CONTRACT §4's single direction intact: `Atmosphere` decided this
|
||
* rig, the scene is applying it, and the environment is derived from the
|
||
* decision rather than being a second opinion about the light.
|
||
*/
|
||
options.environment?.apply(scene, state, "city");
|
||
},
|
||
setCloudCover: (fraction) => clouds.setCover(fraction),
|
||
setWind: (kph, fromDeg) => clouds.setWind(kph, fromDeg),
|
||
setSolarElevation: (degrees) => nightLights.setSolarElevation(degrees),
|
||
setSkyInstant: (when) => {
|
||
skyOverride = when;
|
||
},
|
||
setSatellitesVisible: (visible) => satelliteLayer?.setVisible(visible),
|
||
satelliteCounts: () => ({
|
||
visible: options.satellites?.visibleCount ?? 0,
|
||
total: options.satellites?.size ?? 0,
|
||
}),
|
||
flyTo,
|
||
current: () => currentChapter,
|
||
onChapterChange(fn) {
|
||
chapterListeners.push(fn);
|
||
},
|
||
setControlMode: (mode) => { applyControlMode(mode); },
|
||
controlMode: () => controlMode,
|
||
onControlModeChange(fn) {
|
||
controlModeListeners.push(fn);
|
||
},
|
||
setVehicleActions: (actions) => roadTraffic?.setVehicleActions(actions),
|
||
vehicleState: () => roadTraffic?.hero() ?? null,
|
||
setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
|
||
vehicleCamera: () => roadTraffic?.cameraMode() ?? null,
|
||
setActorActions: (actions) => { sceneActor?.setActions(actions); },
|
||
actorState: () => sceneActor?.state() ?? null,
|
||
setActorIdentity: (identity) => { sceneActor?.setIdentity(identity); },
|
||
attachActorFaceTexture: (texture) => sceneActor?.attachFaceTexture(texture) ?? false,
|
||
clearActorFaceTexture: () => { sceneActor?.clearFaceTexture(); },
|
||
setActorActive(active) {
|
||
if (active) applyControlMode("actor");
|
||
else if (controlMode === "actor") applyControlMode("overview");
|
||
},
|
||
actorActive: () => sceneActor?.active() ?? false,
|
||
setAircraftActions: (actions) => { sceneAircraft?.setActions(actions); },
|
||
aircraftState: () => sceneAircraft?.state() ?? null,
|
||
setAircraftActive(active) {
|
||
if (active) applyControlMode("aircraft");
|
||
else if (controlMode === "aircraft") applyControlMode("overview");
|
||
},
|
||
aircraftActive: () => sceneAircraft?.active() ?? false,
|
||
upsertRemoteSnapshot: (snapshot) => {
|
||
if (snapshot.pose.space === "local") {
|
||
// Local office coordinates have no meaning on a geographic board.
|
||
// Local city coordinates are accepted only for this detailed board;
|
||
// California actors use geographic poses.
|
||
const expected = city.id === "sf" ? "bay-area" : city.id === "socal" ? "socal" : null;
|
||
if (snapshot.pose.cell.kind !== "city" || snapshot.pose.cell.cityId !== expected) return false;
|
||
}
|
||
return realtimePeers?.upsert(snapshot) ?? false;
|
||
},
|
||
removeRemoteEntity: (id) => realtimePeers?.remove(id) ?? false,
|
||
clearRemoteEntities: () => { realtimePeers?.clear(); },
|
||
remoteEntityCount: () => realtimePeers?.count() ?? 0,
|
||
setMarkers(markers) {
|
||
markerLayer.setMarkers(markers);
|
||
},
|
||
dispose() {
|
||
sceneActor?.dispose();
|
||
sceneAircraft?.dispose();
|
||
realtimePeers?.dispose();
|
||
/**
|
||
* Off the stage, then released — and the stage itself is left running.
|
||
*
|
||
* The order is load-bearing and it used to be the other way round, with
|
||
* a comment saying "Stage first, so nothing ticks a half-disposed scene".
|
||
* The concern was real and the remedy was the bug: `stage.dispose()`
|
||
* calls `renderer.dispose()`, which replaces the `properties` WeakMap,
|
||
* and `stageScene.dispose()` then walks the scene disposing materials
|
||
* that the renderer no longer has an entry for. `three` reads
|
||
* `properties.get(material).programs`, finds `undefined`, and quietly
|
||
* skips `gl.deleteProgram` for every one of them — so disposing in that
|
||
* order freed nothing it was written to free.
|
||
*
|
||
* `setScene(null)` answers the ticking concern on its own and answers it
|
||
* better: the loop drops this scene on the very next frame, and the
|
||
* renderer keeps its bookkeeping so the disposals below actually land.
|
||
*/
|
||
if (stage.current() === stageScene) stage.setScene(null);
|
||
stageScene.dispose();
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The committed default rig: a late-afternoon sun from the west, which throws
|
||
* the hills' shadows east across the flats.
|
||
*
|
||
* Not an `Atmosphere` and not a substitute for one — it computes nothing from
|
||
* time or weather, it is a constant with the city's own sky colours poured in.
|
||
* It exists so the engine renders with no server, no clock and no config, which
|
||
* is the acceptance test the whole repo is held to.
|
||
*/
|
||
/**
|
||
* How wide a screen a `Chapter.focus` was written for.
|
||
*
|
||
* Every pose in every pack was authored on one, and `fov: 42` is a **vertical**
|
||
* field of view: the horizontal half-angle is `atan(tan(21°) × aspect)`, so how
|
||
* much of a board is in frame sideways is entirely a fact about the shape of the
|
||
* window. At 16:10 that half-angle is 31.6°. On a phone held upright — 390 × 844,
|
||
* an aspect of 0.46 — it is 10.1°, a third as wide, and the pose that framed a
|
||
* whole state on a laptop frames the middle third of it and runs the rest off
|
||
* both sides of the screen.
|
||
*/
|
||
const AUTHORED_ASPECT = 1.6;
|
||
|
||
/**
|
||
* Where a stand-off stops being a whole-board shot and starts being a place on
|
||
* it, in board spans. See `chapterFraming`.
|
||
*/
|
||
const WHOLE_BOARD_FROM = 0.8;
|
||
const WHOLE_BOARD_TO = 1.2;
|
||
|
||
/**
|
||
* How much further back to stand than the pose asked for, given the window it
|
||
* is actually being looked at through.
|
||
*
|
||
* Returns a multiplier applied to `distance` **and** `height` together, so the
|
||
* angle the board is seen from — which is most of the character of a pack's
|
||
* opening shot — is exactly preserved and only the stand-off changes.
|
||
*
|
||
* Three things bound it, and the second and third were both learned from a
|
||
* screenshot rather than from the arithmetic.
|
||
*
|
||
* - **The aspect.** The correction wanted is `AUTHORED_ASPECT / aspect`,
|
||
* because the width in frame at a given distance is linear in the aspect.
|
||
* A window at least as wide as the authored one gets nothing at all.
|
||
* - **The orbit's own ceiling.** `setPose` hands the camera to
|
||
* `OrbitControls`, which clamps to `maxDistance` on its next update, so a
|
||
* correction that asks for more than that is not a correction — the pose
|
||
* quietly becomes something nobody wrote.
|
||
* - **Whether the pose was ever about the whole board.** Correcting every
|
||
* pose was tried and it ruins the close-ups: Southern California's opening
|
||
* shot stands off half a board span and is a dense city filling a tall
|
||
* screen to all four edges, and pulling it back three times put it in the
|
||
* top third of the frame over half a screen of empty ocean. Widening the
|
||
* frame is a correction for a shot that was trying to hold something and
|
||
* no longer can; on a shot that was deliberately *inside* its subject it is
|
||
* a different photograph. Below `WHOLE_BOARD_FROM` spans of stand-off the
|
||
* correction is off; above `WHOLE_BOARD_TO` it is fully on.
|
||
*/
|
||
export function chapterFraming(options: {
|
||
aspect: number;
|
||
reach: number;
|
||
boardSpan: number;
|
||
orbitMax: number;
|
||
}): number {
|
||
const { aspect, reach, boardSpan, orbitMax } = options;
|
||
if (!Number.isFinite(reach) || reach <= 0) return 1;
|
||
const wanted =
|
||
Number.isFinite(aspect) && aspect > 0 && aspect < AUTHORED_ASPECT
|
||
? AUTHORED_ASPECT / aspect
|
||
: 1;
|
||
const ceiling = Number.isFinite(orbitMax) && orbitMax > 0 ? orbitMax / reach : 1;
|
||
const wide = Math.max(1, Math.min(wanted, ceiling));
|
||
const spans = Number.isFinite(boardSpan) && boardSpan > 0 ? reach / boardSpan : 0;
|
||
const share = Math.max(
|
||
0,
|
||
Math.min(1, (spans - WHOLE_BOARD_FROM) / (WHOLE_BOARD_TO - WHOLE_BOARD_FROM)),
|
||
);
|
||
return 1 + (wide - 1) * share;
|
||
}
|
||
|
||
export function cityDaylight(palette: ScenePalette, boardSpan = 230): LightingState {
|
||
return {
|
||
sun: { direction: [-0.632, 0.717, 0.295], color: 0xfff3e0, intensity: 2.1 },
|
||
hemisphere: { sky: 0xdcecf7, ground: 0x6b6f5e, intensity: 1.05 },
|
||
ambient: { color: 0xffffff, intensity: 0.32 },
|
||
sky: { top: palette.skyTop, horizon: palette.skyHorizon },
|
||
// Scaled to the board for the same reason the camera is, and it was the same
|
||
// bug: 210/460 were tuned when San Francisco's 230-unit board was the only
|
||
// one, and on the Bay Area's 1003 units they close the fog well inside the
|
||
// city. The camera sits about 0.6 spans out on a whole-board view, so a fog
|
||
// that starts nearer than that is behind the viewer's own shoulder.
|
||
//
|
||
// The default keeps the original numbers exactly, for a caller that has a
|
||
// palette but no world.
|
||
fog: {
|
||
color: palette.skyHorizon,
|
||
near: boardSpan * 0.91,
|
||
far: boardSpan * 2,
|
||
},
|
||
};
|
||
}
|