db074e9cf7
The build the studios needed, across eight workstreams and one strict file partition. **The render rig was the quality ceiling.** The renderer ran three's NoToneMapping default while atmosphere drove the sun to 2.35 and assets set emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is why walls blew out and every fitting looked like a white rectangle. ACES filmic tone mapping and an explicit output colour space land in `stage.ts`, and the atmosphere intensity table and palette headroom are re-tuned against the new curve rather than left tuned for the clipping we removed. `engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally, so nothing binary is committed. There was no environment map anywhere before, so every `metalness > 0` role had nothing to reflect and rendered dull grey — a defect the code already documented against itself in `office/optimus.ts`, where a whole material role was abandoned over it, and worked around in `modelX.ts` with a fake emissive that this change deletes. Atmosphere remains the sole light owner; the rig derives from the `LightingState` it already produced. **Studio hardware exists.** There was no device concept anywhere in the product: no type, no route, no state. `devices/types.ts` fixes a declaration/state/ capability/command contract that a smart light, a thermostat, a door sensor and a charger all fit without a schema change, and both studios now carry a desk mic and a computer speaker with deterministic simulated behaviour behind an adapter seam a real API can occupy later. Reads are the demo and are open; commands are a signed-in action and are kept off the read body entirely, because a shared cache replaying a GET that turned a microphone on is exactly what the fail-closed cache default exists to prevent. **The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the response was served publicly cacheable, and the attribution hardcoded adsb.lol regardless of where the endpoint pointed — one env var away from republishing non-redistributable data under an open-terms credit. The host is now allowlisted, the credit is derived from the host actually configured, public cacheability is conditional on redistributability, and a refused endpoint demotes to simulated flights and says so in `degraded[]`. The gate is on the source, not the feature: live aircraft and their detail cards stay open to anonymous visitors. **The LA studio was never the smaller pack** — 16 rooms and 248 props against SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were ceiling troffers, it bound no props to seats, placed none of the habitat kit, and 12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather than more instances, because `furnish.ts` draws once per kind and folds colour into the batch key, so repeat instances add nothing the eye can read. **The interface stops being forty imperative mutations.** Every visibility decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the chrome has coverage for the first time. Deleted: ~100 lines of CSS and two bindings targeting elements that no longer exist, and a `body:has()` rule that shifted the desktop layout by 160px for touch controls hidden there. Fixed: the office picker tabs that drew their label and their badge on top of each other. Added: a first-run flow, because the product is two verbs and neither was ever stated on screen. Mobile is designed on its own terms instead of being the desktop with things hidden — the plan view comes back, and the keyboard-only shortcuts button is replaced by touch controls. `arena/studioOps.ts` frames the whole thing as the multi-variable environment it is, wrapping the same simulators the renderer drives rather than a headless copy. Also removed `input/vehicle.ts`, which nothing but its own test imported. Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six matrix cells, no-binaries, provenance, dependency licences, zero-config boot and arena source hashes all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
886 lines
39 KiB
TypeScript
886 lines
39 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 { 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);
|
|
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: boardSpan * 2.0,
|
|
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));
|
|
|
|
/**
|
|
* 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);
|
|
return {
|
|
target: new THREE.Vector3(x, groundY, z),
|
|
position: new THREE.Vector3(
|
|
x + Math.sin(ch.focus.rotation) * ch.focus.distance,
|
|
groundY + ch.focus.height,
|
|
z + Math.cos(ch.focus.rotation) * ch.focus.distance,
|
|
),
|
|
};
|
|
}
|
|
|
|
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.
|
|
*/
|
|
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,
|
|
},
|
|
};
|
|
}
|