feat: real fire on the boards, the LA office as a twin, and a night sky worth reading
The world stops being a simulation of California and starts being California. **THE PROMOTION GATE WAS THE FIRST COMMIT, BEFORE ANY ORANGE PIXEL EXISTED.** On today's live store the SoCal board contains 22 incidents. Every one has NULL acreage and fifteen are nameless LA County dispatch numbers. Drawn naively that is 22 orange marks over Los Angeles on a day nothing is burning — in a frame that contains no other warm colour, so one glyph would be the most salient object on the board and twenty-two would spend its credibility permanently. `acres >= 10 AND contained < 80 AND type != 'RX' AND last_seen = max(last_seen)` returns 0 on SoCal, exactly 5 on California, 0 on the Bay — same body, same day, three correct answers. The empty board is a deliverable, not a fallback: it says "No active fire on this board — CAL FIRE and WFIGS, just now", states that 21 records were gated and why, lists the largest fires burning OUTSIDE the frame with distances, and counts the hot pixels it is deliberately not drawing. **The privacy leak is structurally impossible rather than carefully avoided.** cloud-1 serves a projection; the four home-relative columns never leave that box. `observations.threat` was the one that nearly got through — it is `(16/distance)^2 x log10(acres) x momentum x containment x wind-alignment`, so with acreage and containment public it inverts to a distance circle around a house and three fires give an intersection. A grep of the built bundle for distance_km, bearing_deg, threat, 7762 and the street name returns nothing. **Deliberately not used, and both would have produced a confident wrong answer:** the store's `air` table retains only the last parameter of each poll, so all 78 rows read "Good" while the live feed reports ozone 101 "Unhealthy for Sensitive Groups" — haze driven off it would clear the sky during a smoke event. And `weather` is written only inside the NWS alerts loop, so a quiet day stores no wind at all. Tera's own per-region NWS wind is already correct and already what the clouds drift on. Satellite detections are drawn as evidence and never as incidents. The permanent industrial heat source 4.7 km from the owner's house is flagged persistent and dropped, asserted by a test that first proves it is present in the fixture. MODIS integer confidence and VIIRS string confidence are branched on `sat`. **The LA office is a twin.** Its entire authored second storey — Model Loft, Model Bay, The Materials Room, 430 lines nobody had ever stood in — is reachable on foot: a walker crosses level-1 to level-2 in 73 fixed steps, floorY 0 to 5, verified against the real pack rather than a synthetic plan. Its two studio devices read real hardware through a field-allowlisted bridge: mute, volume and reachability only. Never level, because there is no passive level upstream and obtaining one would record a room with people in it. Never dB, because upstream is gainPct across four different native scales. The bridge refuses all writes. Fixed at its root: an anonymous visitor was getting permanently at-rest instruments backing off against a 401. The tier moves into `createDeviceSource`, so anon gets the living simulator three file headers already promised. **Item 8 is closed, not fixed, and the correction is the point.** The Bay Area "stutter" was GPU power management — the card sat at 500 MHz of 2725 through every run that reproduced it, 4096/2048/1024/256 shadow maps all render in 1.21-1.31 ms, and two consecutive runs over a byte-identical dist gave 33.4 then 16.7. The allowance is removed and the cell is back to 16.7. Geometry is the gate; frame time is advisory. Item 7 was re-scoped after measuring: 1,069,006 of the Bay Area's 2,265,056 triangles were the second submission of the same buildings into the shadow pass. Mobile now has its own triangle caps and bay-area mobile draws 1,266,096. Also: bridges and the freeway corridor light up at night as emission, not lights — 1,614 deck lamps and 18 tower heads on the Bay in two draw calls. The single change that made US-101 legible was moving its edge lines from the lit material to the unlit one: retroreflective paint, the argument the SFO night frame already makes. California went 21,991 lamps to 4,051, clustered at the 17 town districts, because a rural interurban corridor genuinely is unlit. Tests 1137 -> 1340, server 280. All ten budget cells pass on first attempt with no cap raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+480
-3
@@ -88,6 +88,144 @@ import { cityControlOwnership, type CityControlMode } from "../play/controlMode.
|
||||
|
||||
export type CityRealtimePeersOptions = Omit<ScenePeersOptions, "project" | "groundAt">;
|
||||
|
||||
/**
|
||||
* One promoted wildfire, as the renderer receives it.
|
||||
*
|
||||
* **Everything here is public.** The authority on this shape is `promote()` in
|
||||
* `src/server/fires.ts`, which returns a `FirePromotion` that is assignable to
|
||||
* `FireView` below; this is the same shape restated as the minimum a *renderer*
|
||||
* needs, not a second opinion about it. `promote()` is also the only place a
|
||||
* home-relative column could enter, and the place that must never let one —
|
||||
* `observations.distance_km`, `bearing_deg` and `threat`, and
|
||||
* `detections.distance_km`, are computed against the owner's house and invert to
|
||||
* a circle around it. `threat` is the subtle one: it is
|
||||
* `(16/distance)^2 x log10(acres) x momentum x containment x wind`, so with
|
||||
* `acres` and `pctContained` on the wire it *solves for the distance*. The
|
||||
* cloud-1 projection is what makes that structurally impossible; these types are
|
||||
* what make it obvious.
|
||||
*
|
||||
* Declared here rather than imported on purpose, and it is not duplication for
|
||||
* its own sake. `scene.ts` only *forwards* these values to a layer it did not
|
||||
* build, this repo is structurally typed, and keeping the minimum on this side
|
||||
* means the engine never takes a dependency on a wire module — the same rule
|
||||
* that keeps `Marker` in `engine/types.ts` and the marker row on the server.
|
||||
* The two are checked against each other at the one place they meet,
|
||||
* `SceneOptions.fires`, and neither module has to exist for the other to
|
||||
* compile.
|
||||
*/
|
||||
export interface DrawnFireMark {
|
||||
id: string;
|
||||
/** `null` where the agency published none. Never defaulted to the id. */
|
||||
name: string | null;
|
||||
lat: number;
|
||||
/** `lon`, not `lng` — the wire's spelling, kept so `promote()` output flows in. */
|
||||
lon: number;
|
||||
/** Burned area. A `number`, not `number | null`: past the gate, acreage is a fact. */
|
||||
acres: number;
|
||||
/** `null` means "the agency has not said", which is not zero. */
|
||||
pctContained: number | null;
|
||||
/** 1 = a mark. 2 = a mark with a plume. `promote()` decides; no renderer re-derives it. */
|
||||
tier: 1 | 2;
|
||||
/** ISO-8601 of the observation `acres` came from. */
|
||||
observedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One satellite thermal detection — **evidence, not an incident.**
|
||||
*
|
||||
* There is a permanent industrial heat source in the store that appears on every
|
||||
* pass with no matching incident, 4.7 km from the owner's house. Detections are
|
||||
* therefore drawn as a separate, visually weaker layer and are never promoted
|
||||
* into a fire client-side; `persistent` is the endpoint's own learned
|
||||
* ignore-list for that furniture. `confidence` carries two incompatible scales
|
||||
* in one field — MODIS is an integer 0-100, VIIRS is `low`/`nominal`/`high` — so
|
||||
* anything reading it must branch on `sat` first, or use
|
||||
* `detectionConfidence()` in `src/server/fires.ts`, which does the branch once.
|
||||
*/
|
||||
export interface FireDetectionMark {
|
||||
/** `MODIS`, `VIIRS-NOAA20`, `VIIRS-SNPP` — the instrument, verbatim. */
|
||||
sat: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
/** Fire radiative power, MW. `null` where the product did not report one. */
|
||||
frp: number | null;
|
||||
confidence: string | null;
|
||||
/** True when this cell is known furniture: a flare stack, a kiln, a landfill. */
|
||||
persistent: boolean;
|
||||
acquiredAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole promoted set for one board — and everything it needs to explain an
|
||||
* *empty* one.
|
||||
*
|
||||
* `ageMs` is load-bearing rather than decorative. On a quiet day an empty board
|
||||
* is the correct and common answer: the gate drops twenty-two nameless LA County
|
||||
* dispatch numbers with no acreage between them. A silent board and a dead feed
|
||||
* are indistinguishable without an age beside them, which is the same argument
|
||||
* `HealthBody.degraded` makes, applied to a picture instead of a log.
|
||||
*/
|
||||
export interface FireView {
|
||||
/** Fires inside this board's bounds, worst first. */
|
||||
readonly drawn: readonly DrawnFireMark[];
|
||||
/** Hot pixels inside the bounds that are not known furniture. */
|
||||
readonly detections: readonly FireDetectionMark[];
|
||||
/** ISO-8601 of the last **successful** upstream fetch. Epoch zero when never. */
|
||||
readonly fetchedAt: string;
|
||||
/** Milliseconds since `fetchedAt`, or `null` when nothing has ever answered. */
|
||||
readonly ageMs: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fire layer, as `scene.ts` uses it.
|
||||
*
|
||||
* Deliberately the smallest surface that lets this file own the wiring: the
|
||||
* board's bounds, the rig, the sun and the wind all arrive here already and
|
||||
* have to reach the layer, and nothing else about fire belongs in a city.
|
||||
*
|
||||
* `smokeLoadAt` is the one read-back, and it is what couples the LA courtyard
|
||||
* to the real sky: a fire sixty kilometres up the San Gabriels is not a flame
|
||||
* seen from a courtyard, it is a brown horizon and a dimmed sun.
|
||||
*/
|
||||
export interface FireLayer {
|
||||
group: THREE.Object3D;
|
||||
/** Replace the drawn set. `null` clears it — nothing has answered yet. */
|
||||
setFires(view: FireView | null): void;
|
||||
/** Draw the plumes, or do not. The marks stay either way. */
|
||||
setSmokeVisible(visible: boolean): void;
|
||||
setLighting(state: LightingState): void;
|
||||
setSolarElevation(degrees: number): void;
|
||||
/** Wind as the observation reports it: km/h, and the bearing it blows *from*. */
|
||||
setWind(kph: number | null, fromDeg: number | null): void;
|
||||
tick(dt: number): void;
|
||||
/** 0..1 smoke load at a coordinate, for haze somewhere else. */
|
||||
smokeLoadAt(lat: number, lng: number): number;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a fire layer is built. The same shape `createCloudLayer` has, so the
|
||||
* layer sizes itself from the board rather than from a constant.
|
||||
*/
|
||||
export type FireLayerFactory = (
|
||||
world: World,
|
||||
options: { span: number },
|
||||
) => FireLayer;
|
||||
|
||||
/**
|
||||
* Whether this visitor has asked the platform for less movement.
|
||||
*
|
||||
* Read at the moment it is needed rather than cached, because the only caller
|
||||
* asks once per board and the query is a property read. `scenekit.ts` keeps a
|
||||
* live subscription for the same preference; it needs one because a chapter
|
||||
* flight can be in the air when the setting changes, and the opening move
|
||||
* cannot — it is started or it is not.
|
||||
*/
|
||||
function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
/** What the pointer is over: an authored place, or an observed aeroplane. */
|
||||
type Pick =
|
||||
| { kind: "marker"; marker: Marker }
|
||||
@@ -125,6 +263,15 @@ export interface SceneOptions {
|
||||
* exactly that closed form. Nothing here is ever fetched on a timer.
|
||||
*/
|
||||
satellites?: SatelliteCatalogue;
|
||||
/**
|
||||
* How to build this board's fire layer, or nothing at all.
|
||||
*
|
||||
* A factory rather than a layer, because the layer needs the `World` this
|
||||
* function is in the middle of building. Absent on every board that has no
|
||||
* fire projection behind it, and absent costs exactly nothing: no geometry,
|
||||
* no material, no draw call, and `setFires` becomes a no-op.
|
||||
*/
|
||||
fires?: FireLayerFactory;
|
||||
/** Fires on hover/click of a marker head. */
|
||||
onMarkerPick?: (marker: Marker | null) => void;
|
||||
/**
|
||||
@@ -191,6 +338,22 @@ export interface SceneHandle {
|
||||
stage: Stage;
|
||||
/** This city, as the thing `stage.setScene` takes. */
|
||||
stageScene: StageScene;
|
||||
/**
|
||||
* Play the opening move: stand off the authored opening shot, then settle
|
||||
* onto it.
|
||||
*
|
||||
* Called by the app once the board is on screen, rather than run from
|
||||
* `createScene`, because only the app knows whether this is an arrival at
|
||||
* all — a board built behind a progress card is not being looked at yet.
|
||||
*
|
||||
* Idempotent-ish and cheap to get wrong in the safe direction: calling it
|
||||
* twice restarts the move, and calling it after the visitor has already
|
||||
* touched the board is the one thing it must not do, so the app calls it
|
||||
* exactly once per mount and any input cancels it. Under
|
||||
* `prefers-reduced-motion` it places the camera on the resting pose and
|
||||
* returns, which is also what makes a capture of this board reproducible.
|
||||
*/
|
||||
arrive(): void;
|
||||
/** Applies a rig computed elsewhere. The scene never works one out itself. */
|
||||
setLighting(state: LightingState): void;
|
||||
/**
|
||||
@@ -209,6 +372,29 @@ export interface SceneHandle {
|
||||
setCloudCover(fraction: number): void;
|
||||
/** Wind as the observation reports it: km/h, and the bearing it blows *from*. */
|
||||
setWind(kph: number | null, fromDeg: number | null): void;
|
||||
/**
|
||||
* The fires this board should be drawing, or `null` for none.
|
||||
*
|
||||
* `null` and an empty `incidents` array are the same picture and a different
|
||||
* sentence, which is why both exist: `null` is "nothing has answered", an
|
||||
* empty set is "the projection answered and nothing on this board qualifies".
|
||||
* On a quiet day the second is the correct and common case — the promotion
|
||||
* gate drops twenty-two nameless LA County dispatch numbers with no acreage
|
||||
* between them — and a board that says so with a fetch age is honest, where a
|
||||
* board that draws them is not.
|
||||
*
|
||||
* A no-op on a build with no fire layer, exactly like `setSatellitesVisible`.
|
||||
*/
|
||||
setFires(view: FireView | null): void;
|
||||
/** Draw the smoke plumes, or do not. The marks are unaffected. */
|
||||
setFireSmoke(visible: boolean): void;
|
||||
/**
|
||||
* 0..1 smoke load at a coordinate — how much of the drawn fire set is
|
||||
* upwind of it and near enough to matter. Zero with no fire layer.
|
||||
*
|
||||
* Read by the app to haze an *office* whose courtyard is open to this sky.
|
||||
*/
|
||||
fireSmokeLoadAt(lat: number, lng: number): number;
|
||||
/**
|
||||
* Freeze the satellite sky at an instant, or pass `null` to follow the wall
|
||||
* clock. Exactly the shape of `main.ts`'s own time override, deliberately.
|
||||
@@ -442,6 +628,20 @@ export async function createScene(
|
||||
clouds.setLighting(opening);
|
||||
scene.add(clouds.group);
|
||||
|
||||
/**
|
||||
* Fire, when this deployment has a projection to draw. Built beside the
|
||||
* clouds because it is the same kind of thing — a weather layer sized by the
|
||||
* board and lit by the rig this scene was handed — and built after them so a
|
||||
* plume sorts against cloud rather than the other way round.
|
||||
*/
|
||||
const fireLayer: FireLayer | null = options.fires
|
||||
? options.fires(world, { span: boardSpan })
|
||||
: null;
|
||||
if (fireLayer) {
|
||||
fireLayer.setLighting(opening);
|
||||
scene.add(fireLayer.group);
|
||||
}
|
||||
|
||||
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
|
||||
markerLayer.setMarkers(options.markers ?? []);
|
||||
scene.add(markerLayer.group);
|
||||
@@ -599,6 +799,9 @@ export async function createScene(
|
||||
function flyTo(chapterId: string) {
|
||||
const ch = chapterById[chapterId];
|
||||
if (!ch) return;
|
||||
// Somebody chose a view. Whatever the opening move was still doing, it is
|
||||
// no longer what the camera is for.
|
||||
cancelArrival();
|
||||
// Named viewpoints are observe/vehicle destinations. Possessing an actor
|
||||
// is an explicit UI action, so a chapter selection always hands the camera
|
||||
// back before it moves anywhere else.
|
||||
@@ -617,7 +820,51 @@ export async function createScene(
|
||||
}
|
||||
}
|
||||
|
||||
kit.setPose(chapterPose(first));
|
||||
/**
|
||||
* The frame a stranger sees first.
|
||||
*
|
||||
* Two rules govern everything below and both were learned from a picture.
|
||||
*
|
||||
* **The board settles exactly where the pack said it would.** `openingPose`
|
||||
* is `chapterPose(first)` unchanged, so the resting frame is the one the pack
|
||||
* authored, chapter 01 keeps meaning what it says, and a capture of this
|
||||
* board is the same capture it was before an arrival existed. The wow is the
|
||||
* *approach*; nothing about the destination is second-guessed here.
|
||||
*
|
||||
* **Under `prefers-reduced-motion` there is no move at all.** Not a shorter
|
||||
* one — none: the camera is placed on the resting pose and that is the whole
|
||||
* of it. That is the accessibility answer and it is also what keeps the
|
||||
* capture harness honest, because a screenshot of a board mid-flight is a
|
||||
* screenshot of a different board every time you take it.
|
||||
*/
|
||||
const openingPose = chapterPose(first);
|
||||
|
||||
/**
|
||||
* The opening move, or `null` when there is not one running.
|
||||
*
|
||||
* Held here rather than in `SceneKit` because it is not a chapter flight: it
|
||||
* is slower, it is unrequested, and it must yield to the first thing the
|
||||
* visitor does. `kit.flyTo` is the right shape for "you clicked a name and
|
||||
* are waiting to arrive" and the wrong one for this.
|
||||
*/
|
||||
let arrival: { from: Pose; to: Pose; elapsed: number } | null = null;
|
||||
|
||||
/**
|
||||
* Any input at all ends it, on the spot, wherever the camera has got to.
|
||||
*
|
||||
* `OrbitControls` fires `start` on the first pointer-down, the first wheel
|
||||
* notch and the first pinch, which is every way a visitor can say "I would
|
||||
* rather look at something else". A camera that finished its arc anyway would
|
||||
* be an interface arguing with somebody who has already begun using it. The
|
||||
* camera is left exactly where the move had reached — not snapped to either
|
||||
* end — because the drag that cancelled it is already in flight from there.
|
||||
*/
|
||||
function cancelArrival() {
|
||||
arrival = null;
|
||||
}
|
||||
kit.controls.addEventListener("start", cancelArrival);
|
||||
|
||||
kit.setPose(openingPose);
|
||||
|
||||
// ---- Picking ------------------------------------------------------------
|
||||
|
||||
@@ -670,6 +917,34 @@ export async function createScene(
|
||||
// must stay disabled while the road layer writes its follow pose.
|
||||
const ownership = cityControlOwnership(controlMode);
|
||||
kit.controls.enabled = ownership.orbit;
|
||||
/**
|
||||
* The opening move, stepped before `kit.tick` so the damping and the
|
||||
* clamps `controls.update()` applies land on top of it rather than
|
||||
* underneath.
|
||||
*
|
||||
* `easeInOutCubic`, the same curve a chapter flight uses, because the
|
||||
* camera starts from a standstill: a curve that began at full speed would
|
||||
* read as a cut followed by a glide.
|
||||
*/
|
||||
if (arrival !== null) {
|
||||
arrival.elapsed += dt;
|
||||
const t = Math.min(1, arrival.elapsed / ARRIVAL_SECONDS);
|
||||
const e = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
|
||||
const at: Pose = {
|
||||
position: new THREE.Vector3().lerpVectors(
|
||||
arrival.from.position,
|
||||
arrival.to.position,
|
||||
e,
|
||||
),
|
||||
target: new THREE.Vector3().lerpVectors(
|
||||
arrival.from.target,
|
||||
arrival.to.target,
|
||||
e,
|
||||
),
|
||||
};
|
||||
kit.setPose(at);
|
||||
if (t >= 1) arrival = null;
|
||||
}
|
||||
kit.tick(dt);
|
||||
sceneActor?.tick(dt);
|
||||
if (ownership.actor && sceneActor) kit.setPose(sceneActor.followPose());
|
||||
@@ -678,6 +953,23 @@ export async function createScene(
|
||||
realtimePeers?.tick(Date.now());
|
||||
roadTraffic?.tick(dt);
|
||||
clouds.tick(dt);
|
||||
fireLayer?.tick(dt);
|
||||
/**
|
||||
* The one number the aeroplane glyph clamp cannot reach on its own.
|
||||
*
|
||||
* `flights.ts` captures its camera inside `trailLine.onBeforeRender` and
|
||||
* `tick()` takes no arguments, so the layer has a camera and no controls
|
||||
* and cannot ask how far away the thing being looked *at* is. This file
|
||||
* holds both, and this is the frame that owns them — so the distance is
|
||||
* pushed rather than pulled, and the layer never acquires an
|
||||
* `OrbitControls` reference it has no business holding.
|
||||
*
|
||||
* It is scene units rather than metres, which is why `flights.ts` names
|
||||
* the parameter `distance`: the clamp compares it against the same
|
||||
* projected geometry the glyph is drawn in, so a conversion here would be
|
||||
* a conversion into the wrong space and back.
|
||||
*/
|
||||
flightLayer?.setFocusDistance(kit.camera.position.distanceTo(kit.controls.target));
|
||||
if (options.flights && flightLayer) {
|
||||
flightTimer -= dt;
|
||||
if (flightTimer <= 0) {
|
||||
@@ -751,6 +1043,7 @@ export async function createScene(
|
||||
// uniform textures are neither — the cloud texture is a canvas this layer
|
||||
// drew and only it can free.
|
||||
clouds.dispose();
|
||||
fireLayer?.dispose();
|
||||
nightLights.dispose();
|
||||
markerLayer.dispose();
|
||||
roadTraffic?.dispose();
|
||||
@@ -773,9 +1066,21 @@ export async function createScene(
|
||||
chapters: city.chapters,
|
||||
stage,
|
||||
stageScene,
|
||||
arrive() {
|
||||
if (prefersReducedMotion()) {
|
||||
arrival = null;
|
||||
kit.setPose(heroPose(openingPose, orbitMaxDistance));
|
||||
return;
|
||||
}
|
||||
const rest = heroPose(openingPose, orbitMaxDistance);
|
||||
const from = arrivalStart(rest, orbitMaxDistance);
|
||||
kit.setPose(from);
|
||||
arrival = { from, to: rest, elapsed: 0 };
|
||||
},
|
||||
setLighting: (state) => {
|
||||
kit.applyLighting(state);
|
||||
clouds.setLighting(state);
|
||||
fireLayer?.setLighting(state);
|
||||
/**
|
||||
* Every lighting change, and it is cheap to do it every one.
|
||||
*
|
||||
@@ -789,8 +1094,20 @@ export async function createScene(
|
||||
options.environment?.apply(scene, state, "city");
|
||||
},
|
||||
setCloudCover: (fraction) => clouds.setCover(fraction),
|
||||
setWind: (kph, fromDeg) => clouds.setWind(kph, fromDeg),
|
||||
setSolarElevation: (degrees) => nightLights.setSolarElevation(degrees),
|
||||
setWind: (kph, fromDeg) => {
|
||||
clouds.setWind(kph, fromDeg);
|
||||
// The same observation, and the same one the clouds drift on. A plume
|
||||
// that leaned on a different wind from the cloud beside it would be two
|
||||
// opinions about one sky.
|
||||
fireLayer?.setWind(kph, fromDeg);
|
||||
},
|
||||
setSolarElevation: (degrees) => {
|
||||
nightLights.setSolarElevation(degrees);
|
||||
fireLayer?.setSolarElevation(degrees);
|
||||
},
|
||||
setFires: (view) => fireLayer?.setFires(view),
|
||||
setFireSmoke: (visible) => fireLayer?.setSmokeVisible(visible),
|
||||
fireSmokeLoadAt: (lat, lng) => fireLayer?.smokeLoadAt(lat, lng) ?? 0,
|
||||
setSkyInstant: (when) => {
|
||||
skyOverride = when;
|
||||
},
|
||||
@@ -867,6 +1184,7 @@ export async function createScene(
|
||||
* better: the loop drops this scene on the very next frame, and the
|
||||
* renderer keeps its bookkeeping so the disposals below actually land.
|
||||
*/
|
||||
kit.controls.removeEventListener("start", cancelArrival);
|
||||
if (stage.current() === stageScene) stage.setScene(null);
|
||||
stageScene.dispose();
|
||||
},
|
||||
@@ -952,6 +1270,165 @@ export function chapterFraming(options: {
|
||||
return 1 + (wide - 1) * share;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the opening move takes, in seconds.
|
||||
*
|
||||
* Longer than a chapter flight's 1.5 s, deliberately. A chapter flight is a
|
||||
* *response* — somebody clicked a name and is waiting to arrive — so it should
|
||||
* be brisk. This is the opposite: nobody asked for it, nothing is waiting
|
||||
* behind it, and its whole job is to be looked at.
|
||||
*
|
||||
* **This used to be 2.5, and the reason was the performance harness rather than
|
||||
* taste.** `scripts/performance-budget.mjs` waits `warmup-ms` (3,000) after the
|
||||
* board reports ready and then samples for eight seconds, and `arrive()` is
|
||||
* called in the same statement block that makes it report ready — so a move
|
||||
* longer than the warm-up was a *moving camera inside the sample window*: a
|
||||
* different frustum every frame, and triangle and draw counts that no longer
|
||||
* reproduce. The whole reason those cells are trustworthy on this box is that
|
||||
* geometry here is deterministic, and a longer arrival would have quietly spent
|
||||
* that, with the first symptom an unexplainable red cell blamed on GPU clocks.
|
||||
*
|
||||
* The coupling is gone because the harness now opens its context with
|
||||
* `reducedMotion: "reduce"`, under which this move collapses to a cut — the
|
||||
* same preference `look.mjs --reduced` uses to make an arrival frame
|
||||
* reproducible, and the same one a person who asked their operating system for
|
||||
* less motion gets. So the length is free to be the length the shot wants.
|
||||
*
|
||||
* If `reducedMotion` is ever dropped from that context, this number has to go
|
||||
* back under `warmup-ms` in the same commit. It is the only thing holding the
|
||||
* two apart.
|
||||
*/
|
||||
const ARRIVAL_SECONDS = 4.5;
|
||||
|
||||
/**
|
||||
* The hero seat, as multiples of the pack's own opening pose.
|
||||
*
|
||||
* `HERO_HEIGHT` is the one that matters and the reason this exists. Every one
|
||||
* of the three boards authors its whole-board shot between 32 and 41 degrees
|
||||
* above the ground — California at 40.8, San Francisco at 34.7, Southern
|
||||
* California at 32.1 — and from up there a board is a *map*: the far edge ends
|
||||
* in water, the sky is off the top of the frame, and the relief that took two
|
||||
* and a half seconds of heightfield to build is flattened into shading. Drop
|
||||
* the eye and the horizon arrives, the ranges get a skyline, and the same
|
||||
* geometry stops being a diagram and starts being a place.
|
||||
*
|
||||
* The target and the azimuth are left alone, so this is still the pack's
|
||||
* chapter 01 — the state, seen from where the pack pointed the camera — and
|
||||
* clicking `01` still flies to the authored seat exactly.
|
||||
*/
|
||||
const HERO_ELEVATION_DEG = 29;
|
||||
const HERO_DISTANCE = 0.9;
|
||||
|
||||
/** How much further out the camera stands before the move, as a multiple. */
|
||||
const ARRIVAL_STANDOFF = 1.5;
|
||||
/** How much higher, as a multiple. Larger than the stand-off: the move descends. */
|
||||
const ARRIVAL_LIFT = 2.2;
|
||||
/** How far round the board it swings, in radians. Negative is anticlockwise. */
|
||||
const ARRIVAL_YAW = -0.5;
|
||||
|
||||
/**
|
||||
* Rotate and scale the offset between a pose and its target.
|
||||
*
|
||||
* The one piece of arithmetic the two poses below share: both are described as
|
||||
* a departure from the authored shot rather than as coordinates, which is what
|
||||
* lets one implementation serve three boards and two studios that have each
|
||||
* already chosen the angle they look best from.
|
||||
*
|
||||
* `maxReach` is the orbit's own ceiling and is not optional. `setPose` hands
|
||||
* the camera to `OrbitControls`, which clamps to `maxDistance` on its next
|
||||
* update — so a pose beyond it is not a wider shot, it is a shorter move that
|
||||
* begins wherever the clamp happened to land. `chapterFraming` records the same
|
||||
* hazard one floor down.
|
||||
*/
|
||||
function offsetPose(
|
||||
rest: Pose,
|
||||
options: { standoff: number; lift: number; yaw: number; maxReach: number },
|
||||
): Pose {
|
||||
const dx = rest.position.x - rest.target.x;
|
||||
const dy = rest.position.y - rest.target.y;
|
||||
const dz = rest.position.z - rest.target.z;
|
||||
const cos = Math.cos(options.yaw);
|
||||
const sin = Math.sin(options.yaw);
|
||||
let ox = (dx * cos - dz * sin) * options.standoff;
|
||||
let oz = (dx * sin + dz * cos) * options.standoff;
|
||||
let oy = dy * options.lift;
|
||||
const reach = Math.hypot(ox, oy, oz);
|
||||
if (Number.isFinite(options.maxReach) && options.maxReach > 0 && reach > options.maxReach) {
|
||||
// 0.995 rather than 1: landing exactly on the ceiling leaves the first
|
||||
// `controls.update()` free to shave a unit off it and start the move with a
|
||||
// visible twitch.
|
||||
const k = (options.maxReach * 0.995) / reach;
|
||||
ox *= k;
|
||||
oy *= k;
|
||||
oz *= k;
|
||||
}
|
||||
return {
|
||||
target: rest.target.clone(),
|
||||
position: new THREE.Vector3(rest.target.x + ox, rest.target.y + oy, rest.target.z + oz),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the opening move comes to rest: the pack's chapter 01, seen from
|
||||
* `HERO_ELEVATION_DEG` above the ground instead of from wherever it was
|
||||
* authored.
|
||||
*
|
||||
* An **angle**, not a multiplier, and that is the whole of the design. A
|
||||
* multiplier applied to the three boards' three different authored elevations
|
||||
* produces three different answers to a question that has one — California
|
||||
* would land at 30 degrees and Southern California at 13 off the same constant
|
||||
* — and the horizon either enters the frame or it does not. At this field of
|
||||
* view it enters just under 21, so 20 puts it a degree inside the top edge on
|
||||
* every board, which is what the number is for.
|
||||
*
|
||||
* `Math.min` rather than an assignment, and it is load-bearing: an authored
|
||||
* pose that is **already** lower than this is not raised. Studio viewpoints sit
|
||||
* at eye height inside a room, and a "hero" seat that lifted a camera standing
|
||||
* on a floor up to twenty degrees would be a ceiling shot of somebody's desk.
|
||||
* The move only ever brings a camera down.
|
||||
*/
|
||||
export function heroPose(authored: Pose, maxReach: number): Pose {
|
||||
const dx = authored.position.x - authored.target.x;
|
||||
const dy = authored.position.y - authored.target.y;
|
||||
const dz = authored.position.z - authored.target.z;
|
||||
const reach = Math.hypot(dx, dy, dz);
|
||||
if (reach <= 0) return { target: authored.target.clone(), position: authored.position.clone() };
|
||||
const elevation = Math.asin(Math.max(-1, Math.min(1, dy / reach)));
|
||||
const wanted = Math.min(elevation, (HERO_ELEVATION_DEG * Math.PI) / 180);
|
||||
const flat = Math.hypot(dx, dz);
|
||||
const azimuth = flat > 0 ? { x: dx / flat, z: dz / flat } : { x: 0, z: 1 };
|
||||
const heroReach = reach * HERO_DISTANCE;
|
||||
const capped =
|
||||
Number.isFinite(maxReach) && maxReach > 0 ? Math.min(heroReach, maxReach * 0.995) : heroReach;
|
||||
const horizontal = Math.cos(wanted) * capped;
|
||||
return {
|
||||
target: authored.target.clone(),
|
||||
position: new THREE.Vector3(
|
||||
authored.target.x + azimuth.x * horizontal,
|
||||
authored.target.y + Math.sin(wanted) * capped,
|
||||
authored.target.z + azimuth.z * horizontal,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the camera stands *before* the opening move.
|
||||
*
|
||||
* Further out, higher, and a little way round from where it will land, so the
|
||||
* move is a descending swing that closes that difference. All three numbers are
|
||||
* modest on purpose: enough that the board visibly grows, turns and settles,
|
||||
* and not so much that the opening frame is a different photograph from the one
|
||||
* it is arriving at.
|
||||
*/
|
||||
export function arrivalStart(rest: Pose, maxReach: number): Pose {
|
||||
return offsetPose(rest, {
|
||||
standoff: ARRIVAL_STANDOFF,
|
||||
lift: ARRIVAL_LIFT,
|
||||
yaw: ARRIVAL_YAW,
|
||||
maxReach,
|
||||
});
|
||||
}
|
||||
|
||||
export function cityDaylight(palette: ScenePalette, boardSpan = 230): LightingState {
|
||||
return {
|
||||
sun: { direction: [-0.632, 0.717, 0.295], color: 0xfff3e0, intensity: 2.1 },
|
||||
|
||||
Reference in New Issue
Block a user