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:
+393
-4
@@ -36,7 +36,7 @@ import {
|
||||
type Atmosphere,
|
||||
type WeatherObservation,
|
||||
} from "./engine/atmosphere.ts";
|
||||
import { officeDaylight, withHouseLights } from "./interiors/daylight.ts";
|
||||
import { officeDaylight, smokeCaption, withHouseLights } from "./interiors/daylight.ts";
|
||||
import { createScene, type SceneHandle } from "./engine/scene.ts";
|
||||
import { createEnvironmentRig } from "./engine/environmentRig.ts";
|
||||
import {
|
||||
@@ -67,10 +67,15 @@ import {
|
||||
} from "./input/play.ts";
|
||||
import {
|
||||
createTeraClient,
|
||||
type FireWatch,
|
||||
type PresenceWatch,
|
||||
type TrafficSource,
|
||||
type WeatherWatch,
|
||||
} from "./adapters/http.ts";
|
||||
import { promote, type FireBounds } from "./server/fires.ts";
|
||||
import type { FiresBody } from "./server/wire.ts";
|
||||
import { createFireLayer } from "./engine/fires.ts";
|
||||
import { mountFirePanel, type FirePanelHandle } from "./ui/firePanel.ts";
|
||||
import {
|
||||
SAMPLE_MARKERS,
|
||||
SAMPLE_PALETTE,
|
||||
@@ -110,7 +115,7 @@ import {
|
||||
type WebcamFaceTextureAdapter,
|
||||
} from "./profile/index.ts";
|
||||
import type { ActorIdentity } from "./actors/controller.ts";
|
||||
import { SRGBColorSpace, VideoTexture } from "three";
|
||||
import { SRGBColorSpace, Vector3, VideoTexture } from "three";
|
||||
import {
|
||||
CALIFORNIA_AIR_ROUTE,
|
||||
createAircraftPoseSnapshot,
|
||||
@@ -452,6 +457,34 @@ let weatherWatch: WeatherWatch | null = null;
|
||||
* which is never live and does not need asking.
|
||||
*/
|
||||
let cityFlights: TrafficSource | null = null;
|
||||
/**
|
||||
* What is burning, polled while a board that draws fire is on screen.
|
||||
*
|
||||
* A board watch like the weather's, and stopped in the same place for a
|
||||
* different reason. The body is not per-region — one statewide answer, one
|
||||
* cache key, every viewer — so a late arrival cannot describe the wrong place
|
||||
* the way a stale `/weather` can. What it can do is arrive for a board that has
|
||||
* been torn down, and `promote()` needs *bounds* to say anything at all.
|
||||
*
|
||||
* `null` on the Bay Area board and on every keyless clone. See `firesFor`.
|
||||
*/
|
||||
let fireWatch: FireWatch | null = null;
|
||||
/** The panel that says what the board is showing, and what it is not. */
|
||||
let firePanel: FirePanelHandle | null = null;
|
||||
/** The board's own rectangle, held so a poll landing later can be clipped to it. */
|
||||
let fireBounds: FireBounds | null = null;
|
||||
/**
|
||||
* The last body, kept for the whole page rather than for the board.
|
||||
*
|
||||
* This is the one place the "not per-region" property of `/fires` pays a
|
||||
* visible dividend. The same statewide body answers for every board, so a
|
||||
* visitor who switches from California to the Southland can be shown the
|
||||
* correct — and correctly *empty* — answer in the same frame as the switch,
|
||||
* re-clipped to the new rectangle, instead of reading "nothing has answered"
|
||||
* for as long as a fresh request takes. `undefined` means no watch has ever
|
||||
* settled; `null` means one settled and the feed refused.
|
||||
*/
|
||||
let firesBody: FiresBody | null | undefined;
|
||||
/**
|
||||
* The satellite element sets, fetched once for the page rather than once per city.
|
||||
*
|
||||
@@ -822,7 +855,22 @@ function officeLighting(site: NonNullable<Office["site"]>) {
|
||||
*/
|
||||
office?.setSolarElevation(houseElevation(env.sun.elevation));
|
||||
const house = office?.houseLevel() ?? houseLevelFor(houseElevation(env.sun.elevation));
|
||||
return withHouseLights(officeDaylight(state, site), house);
|
||||
/**
|
||||
* And the smoke, which is the third parameter and the only one that comes
|
||||
* from another building's problem.
|
||||
*
|
||||
* It is adapted here, once, for the reason CONTRACT §4 gives: `Atmosphere`
|
||||
* owns the rig and `daylight.ts` adapts what it returned, so a room may not
|
||||
* reach into the fire layer and form a second opinion about its own sky. The
|
||||
* scalar is the drawn set weighted by acreage, distance and wind alignment —
|
||||
* `smokeLoadAt` in `engine/fires.ts` — and it is zero whenever nothing is
|
||||
* burning, which is most days and is today on the Southland board.
|
||||
*
|
||||
* Costs no geometry and no draw call: it moves the haze colour, the haze
|
||||
* near-distance and the sun's tint, three numbers `officeDaylight` already
|
||||
* computed.
|
||||
*/
|
||||
return withHouseLights(officeDaylight(state, site, smokeLoadAtSite(site)), house);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -888,6 +936,125 @@ function updateSun() {
|
||||
renderChrome();
|
||||
}
|
||||
|
||||
// ---- Fire -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The boards that draw fire.
|
||||
*
|
||||
* Named rather than derived, and deliberately not "every board": the Bay Area
|
||||
* rectangle has never contained a live incident, so a layer there would be a
|
||||
* frame-time allowance spent on an empty view every night. California and the
|
||||
* Southland are the two rectangles the upstream store actually populates.
|
||||
*/
|
||||
const FIRE_BOARDS: ReadonlySet<string> = new Set(["california", "socal"]);
|
||||
|
||||
function drawsFire(id: string): boolean {
|
||||
return FIRE_BOARDS.has(id) && access.feeds?.fires === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One body, turned into the three things that read it.
|
||||
*
|
||||
* **The gate is not here and must never be copied here.** `promote()` in
|
||||
* `src/server/fires.ts` is the only place that decides what is drawn — the ten
|
||||
* acres, the eighty per cent, the prescribed burns, and the twenty-one stale
|
||||
* rows the collector never deletes — and this function's whole job is to hand
|
||||
* its answer to three consumers unchanged. On today's live store that answer is
|
||||
* five marks on California and *nothing at all* on the Southland, from the same
|
||||
* body on the same second, and both are correct.
|
||||
*
|
||||
* `null` is a fourth state and it is the one that has to survive: nothing has
|
||||
* answered. The panel says so out loud rather than showing an all-clear, which
|
||||
* is the same argument `health.ts` makes for `degraded[]` — a board with no feed
|
||||
* behind it and a board with nothing burning on it are indistinguishable without
|
||||
* a timestamp.
|
||||
*/
|
||||
function applyFires(body: FiresBody | null): void {
|
||||
firesBody = body;
|
||||
if (fireBounds === null) return;
|
||||
const promotion = promote(body, fireBounds);
|
||||
city?.setFires(promotion);
|
||||
firePanel?.apply(body === null ? null : promotion);
|
||||
// The courtyard the fires are actually over. Cheap — `smokeLoadAt` reads the
|
||||
// drawn set, which is at most sixty-four rows and is usually none — and only
|
||||
// ever a repaint of a rig that was going to be recomputed on the next clock
|
||||
// tick anyway.
|
||||
if (inside) updateSun();
|
||||
renderChrome();
|
||||
}
|
||||
|
||||
/**
|
||||
* How much smoke is in the air at a building, from the fires on the board.
|
||||
*
|
||||
* Zero with no fire layer, zero with nothing drawn, and zero on a board that
|
||||
* does not draw fire at all — so the courtyard is bit-identical to today
|
||||
* everywhere except under a real fire, which is the property
|
||||
* `interiors/daylight.ts` was given a third argument for.
|
||||
*/
|
||||
function smokeLoadAtSite(site: NonNullable<Office["site"]>): number {
|
||||
return city?.fireSmokeLoadAt(site.lat, site.lng) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The load below which the building says nothing about smoke.
|
||||
*
|
||||
* `smokeLoadAt` is continuous and its floor is a real zero, so "greater than
|
||||
* zero" looked like the honest test and is not. On today's live board the two
|
||||
* ten-acre fires in San Bernardino are 101 km from Mateo Court — inside the
|
||||
* layer's 120 km reach — and contribute a load of about **0.0025**: a quarter of
|
||||
* one per cent of a haze change, which is nothing anybody can see, under a
|
||||
* sentence that would have read "Thin smoke". That is a caption describing
|
||||
* something the picture does not contain, which is the failure this whole round
|
||||
* is arranged against.
|
||||
*
|
||||
* So the **rig** still gets the true scalar — continuous, no pop, and at 0.0025
|
||||
* indistinguishable from zero — and the **words** wait until there is something
|
||||
* to say. Calibrated from the layer's own model rather than by eye: a
|
||||
* 500-acre fire 60 km away lands at 0.14 and a 3,600-acre fire 20 km away at
|
||||
* 0.60, so this threshold sits an order of magnitude under the smallest fire
|
||||
* anybody would call smoke and an order of magnitude over the largest one
|
||||
* nobody would.
|
||||
*/
|
||||
const SMOKE_STATED_LOAD = 0.08;
|
||||
|
||||
/** The room's smoke disclosure, or `null` on a day the room cannot tell. */
|
||||
function officeSmokeNote(): string | null {
|
||||
const site = officePack?.site;
|
||||
if (site === undefined) return null;
|
||||
const load = smokeLoadAtSite(site);
|
||||
return load >= SMOKE_STATED_LOAD ? smokeCaption(load) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the board's fire panel is on screen right now.
|
||||
*
|
||||
* Two rules, and the second one is a judgement made from a picture. Outside a
|
||||
* building the panel is always up on a board that draws fire, because "nothing
|
||||
* is burning here" is a fact about that board and the whole point is that it is
|
||||
* *stated* rather than inferred from an absence.
|
||||
*
|
||||
* **Inside a building it appears only when the fires are actually in that
|
||||
* building's sky.** A five-item list of incidents three hundred kilometres away
|
||||
* pushed "Back to the city" below the fold of the LA office's panel on the first
|
||||
* frame a visitor sees of the room — a board-level instrument crowding out the
|
||||
* room's own controls. When the courtyard really does go brown, the list is the
|
||||
* explanation for what is on screen and it belongs there; `smokeCaption` in the
|
||||
* office note says the same thing in one sentence either way.
|
||||
*
|
||||
* `#fire-section` is this file's element, not `mount.ts`'s. See where it is
|
||||
* mounted.
|
||||
*/
|
||||
function showFireSection(): void {
|
||||
const section = document.querySelector<HTMLElement>("#fire-section");
|
||||
if (section === null) return;
|
||||
const site = officePack?.site;
|
||||
const wanted =
|
||||
firePanel !== null &&
|
||||
(!inside || (site !== undefined && smokeLoadAtSite(site) >= SMOKE_STATED_LOAD));
|
||||
if (section.hidden === !wanted) return;
|
||||
section.hidden = !wanted;
|
||||
}
|
||||
|
||||
// ---- Cities ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -917,6 +1084,17 @@ async function mountCity(id: string) {
|
||||
wantedCity = id;
|
||||
weatherWatch?.stop();
|
||||
weatherWatch = null;
|
||||
// The fire watch and its panel belong to the board for the same reason the
|
||||
// weather does — and the panel harder, because it is mounted into chrome this
|
||||
// file does not own and would otherwise accumulate one copy per board the
|
||||
// visitor has looked at.
|
||||
fireWatch?.stop();
|
||||
fireWatch = null;
|
||||
firePanel?.dispose();
|
||||
firePanel = null;
|
||||
fireBounds = null;
|
||||
// `firesBody` deliberately survives: it is one statewide answer for every
|
||||
// board, so the next one can be drawn correctly in the frame it is built in.
|
||||
poseEditor?.destroy();
|
||||
poseEditor = null;
|
||||
clearPublishedPlayInput();
|
||||
@@ -1072,6 +1250,22 @@ async function mountCity(id: string) {
|
||||
: {}),
|
||||
flights: dial.source,
|
||||
...(catalogue ? { satellites: catalogue } : {}),
|
||||
/**
|
||||
* Fire, on the two boards where fire happens and nowhere else.
|
||||
*
|
||||
* Two gates and, unusually here, neither is about the visitor. `drawsFire`
|
||||
* is about the *map*: the Bay Area rectangle has held zero incidents on
|
||||
* every day this store has existed, and it is the board already spending
|
||||
* the largest frame-time allowance in the product, so it does not pay for a
|
||||
* layer to draw nothing. `feeds.fires` is about the *deployment*: with no
|
||||
* projection configured there is no body to draw and no honest caption to
|
||||
* write, and a layer with an empty view is a layer allocated for nothing.
|
||||
*
|
||||
* Withheld rather than passed-and-emptied, because `scene.ts` builds no
|
||||
* group, no material and no draw call when this is absent — see
|
||||
* `SceneOptions.fires`.
|
||||
*/
|
||||
...(drawsFire(id) ? { fires: createFireLayer } : {}),
|
||||
/**
|
||||
* A pin is a hover *and* a click, and an office pin is a door.
|
||||
*
|
||||
@@ -1146,6 +1340,36 @@ async function mountCity(id: string) {
|
||||
})
|
||||
: null;
|
||||
|
||||
/**
|
||||
* And the fire, started the same way and for the same reason.
|
||||
*
|
||||
* The panel is mounted first and applied with `null` immediately, so the very
|
||||
* first frame of a fire board already carries a sentence — "no fire feed has
|
||||
* answered" rather than a blank space that a viewer would read as an
|
||||
* all-clear. The watch then replaces it within a request.
|
||||
*
|
||||
* `entry.city.bounds` is handed to both halves and they use it for different
|
||||
* things: `promote()` clips the drawn set to the frame, and the panel turns an
|
||||
* off-board fire into "342 km north of this frame" instead of "somewhere
|
||||
* else". One rectangle, so the picture and the caption cannot disagree.
|
||||
*/
|
||||
// `#fire-section` and `#fire-host` are this file's, not `mount.ts`'s — the
|
||||
// same arrangement `#presence-host` has, and the reason the header's rule
|
||||
// about not writing to the chrome's DOM is not broken here. `showFireSection`
|
||||
// owns the visibility and runs on every `renderChrome`.
|
||||
if (drawsFire(id)) {
|
||||
fireBounds = entry.city.bounds;
|
||||
const fireHost = document.querySelector<HTMLElement>("#fire-host");
|
||||
firePanel = fireHost === null ? null : mountFirePanel(fireHost, { bounds: fireBounds });
|
||||
// Whatever the page already knows, re-clipped to this board. `undefined` is
|
||||
// the first board of the session and is the only case that shows the fault
|
||||
// sentence, which is correct: nothing has answered yet.
|
||||
if (firesBody === undefined) firePanel?.apply(null);
|
||||
else applyFires(firesBody);
|
||||
fireWatch = tera.watchFires((body) => applyFires(body));
|
||||
}
|
||||
showFireSection();
|
||||
|
||||
// Fog distances are scene units, so they have to follow the board — 210/460
|
||||
// was tuned for a 230-unit San Francisco and fogs out most of a 1000-unit
|
||||
// Bay Area. They also have to clear the CAMERA, which sits about 0.6 spans
|
||||
@@ -1247,6 +1471,133 @@ async function mountCity(id: string) {
|
||||
|
||||
updateSun();
|
||||
renderChrome();
|
||||
|
||||
/**
|
||||
* Last, and only now: the opening move.
|
||||
*
|
||||
* After `updateSun`, so the first frame of the arrival is lit by the real sky
|
||||
* rather than by the opening climatology the scene was built with — a shot
|
||||
* whose whole job is to be looked at should not spend its first second in the
|
||||
* wrong light. After `showPlan` and `renderChrome` for the same reason: the
|
||||
* interface around the board is up, so the move plays over a finished page
|
||||
* instead of racing the panel into frame.
|
||||
*
|
||||
* Every mount, not just the first. Switching boards is arriving somewhere,
|
||||
* and a stranger who clicks "SoCal" is being shown that board for the first
|
||||
* time exactly as much as the visitor who landed on California was. It costs
|
||||
* nothing to be wrong about that: the first drag, wheel or pinch ends it.
|
||||
*/
|
||||
city.arrive();
|
||||
}
|
||||
|
||||
/**
|
||||
* The opening move for a **room**, and the one place the app owns a camera.
|
||||
*
|
||||
* The city's arrival lives in `scene.ts`, where the scene owns its own kit. An
|
||||
* office has no equivalent seam — `OfficeScene` hands out `camera` and
|
||||
* `controls` and offers `flyTo(viewId)` and nothing that takes a pose — so the
|
||||
* move is driven from here, off the pump this file already runs.
|
||||
*
|
||||
* Two rules, and both are about not breaking something that works:
|
||||
*
|
||||
* **The resting pose is whatever the room already chose.** It is read off the
|
||||
* camera at the moment the door opens rather than computed, so it is the pack's
|
||||
* `viewpoints[0]` on a first entry and *where you left it* on a second. A
|
||||
* viewpoint's `focus.at` is also the walk spawn — `startDeviceFeed`'s
|
||||
* neighbour above reads it to place a walker — and the LA office has already
|
||||
* been bitten by an arrival shot that stood the camera inside a three-metre
|
||||
* entry passage framing two brick walls. The target is not touched here. Only
|
||||
* the camera moves, and only away from a pose somebody already checked.
|
||||
*
|
||||
* **A low pose has nowhere to fly from.** Below `OFFICE_ARRIVAL_MIN_ELEVATION`
|
||||
* the viewpoint is an eye-level shot standing in a room, and lifting a camera
|
||||
* that is inside a building puts the first second of the arrival inside the
|
||||
* ceiling slab. Those rooms simply do not get one.
|
||||
*/
|
||||
let officeArrival: { from: Pose; to: Pose; elapsed: number } | null = null;
|
||||
|
||||
/**
|
||||
* Seconds. The same length as a board's, so the two doors feel like one product
|
||||
* — and bounded by the same thing: `scripts/performance-budget.mjs` measures the
|
||||
* `office` scene from a three-second warm-up, and a move still running inside
|
||||
* the sample window makes its geometry counts stop reproducing. See
|
||||
* `ARRIVAL_SECONDS` in `scene.ts` for the argument in full.
|
||||
*/
|
||||
const OFFICE_ARRIVAL_SECONDS = 2.5;
|
||||
/** Below this, in radians, the viewpoint is inside a room and gets no move. */
|
||||
const OFFICE_ARRIVAL_MIN_ELEVATION = (15 * Math.PI) / 180;
|
||||
/** How much higher the camera starts, in radians, and the ceiling on where that lands. */
|
||||
const OFFICE_ARRIVAL_RISE = (11 * Math.PI) / 180;
|
||||
const OFFICE_ARRIVAL_MAX_ELEVATION = (58 * Math.PI) / 180;
|
||||
/** How much further out it starts, as a multiple of the resting stand-off. */
|
||||
const OFFICE_ARRIVAL_STANDOFF = 1.32;
|
||||
/** How far round the room it swings, in radians. */
|
||||
const OFFICE_ARRIVAL_YAW = -0.4;
|
||||
|
||||
function beginOfficeArrival(scene: OfficeScene): void {
|
||||
officeArrival = null;
|
||||
if (prefersReducedMotion()) return;
|
||||
const target = scene.controls.target.clone();
|
||||
const rest = { position: scene.camera.position.clone(), target };
|
||||
const dx = rest.position.x - target.x;
|
||||
const dy = rest.position.y - target.y;
|
||||
const dz = rest.position.z - target.z;
|
||||
const reach = Math.hypot(dx, dy, dz);
|
||||
if (reach <= 0) return;
|
||||
const elevation = Math.asin(Math.max(-1, Math.min(1, dy / reach)));
|
||||
if (elevation < OFFICE_ARRIVAL_MIN_ELEVATION) return;
|
||||
const raised = Math.min(elevation + OFFICE_ARRIVAL_RISE, OFFICE_ARRIVAL_MAX_ELEVATION);
|
||||
const flat = Math.hypot(dx, dz);
|
||||
const cos = Math.cos(OFFICE_ARRIVAL_YAW);
|
||||
const sin = Math.sin(OFFICE_ARRIVAL_YAW);
|
||||
const ax = flat > 0 ? (dx * cos - dz * sin) / flat : 0;
|
||||
const az = flat > 0 ? (dx * sin + dz * cos) / flat : 1;
|
||||
const startReach = Math.min(reach * OFFICE_ARRIVAL_STANDOFF, scene.controls.maxDistance * 0.995);
|
||||
const horizontal = Math.cos(raised) * startReach;
|
||||
const from: Pose = {
|
||||
target: target.clone(),
|
||||
position: new Vector3(
|
||||
target.x + ax * horizontal,
|
||||
target.y + Math.sin(raised) * startReach,
|
||||
target.z + az * horizontal,
|
||||
),
|
||||
};
|
||||
scene.camera.position.copy(from.position);
|
||||
scene.controls.target.copy(from.target);
|
||||
scene.controls.update();
|
||||
officeArrival = { from, to: rest, elapsed: 0 };
|
||||
}
|
||||
|
||||
/** Any input, any view, any change of mode: the move is over. */
|
||||
function cancelOfficeArrival(): void {
|
||||
officeArrival = null;
|
||||
}
|
||||
|
||||
function stepOfficeArrival(dt: number): void {
|
||||
if (officeArrival === null) return;
|
||||
if (!inside || !office || controlModeState.mode !== "office-overview") {
|
||||
officeArrival = null;
|
||||
return;
|
||||
}
|
||||
officeArrival.elapsed += dt;
|
||||
const t = Math.min(1, officeArrival.elapsed / OFFICE_ARRIVAL_SECONDS);
|
||||
const e = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
|
||||
office.camera.position.lerpVectors(officeArrival.from.position, officeArrival.to.position, e);
|
||||
office.controls.target.lerpVectors(officeArrival.from.target, officeArrival.to.target, e);
|
||||
office.controls.update();
|
||||
if (t >= 1) officeArrival = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this visitor has asked the platform for less movement.
|
||||
*
|
||||
* The same question `scene.ts` and `scenekit.ts` ask, asked here because this
|
||||
* file drives a camera of its own. Read at the moment it is needed rather than
|
||||
* cached: the only callers ask once per door, and the query is a property read.
|
||||
*/
|
||||
function prefersReducedMotion(): boolean {
|
||||
if (typeof window.matchMedia !== "function") return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1282,6 +1633,7 @@ requestAnimationFrame(function pump(now) {
|
||||
lastPumpAt = now;
|
||||
|
||||
syncJourneyVehicle(now);
|
||||
stepOfficeArrival(dt);
|
||||
updateLocalPlayerMaps();
|
||||
// Only the mounted one. The other is still constructed and still holds a live
|
||||
// camera, but its canvas is out of the document, so `clientWidth` is 0, every
|
||||
@@ -1545,6 +1897,12 @@ async function enterOffice() {
|
||||
});
|
||||
builtOfficeId = officeId;
|
||||
office.onViewChange(() => renderChrome());
|
||||
// Registered here rather than on every entry, because this block runs once
|
||||
// per office and the scene is retained between visits. The first drag,
|
||||
// wheel or pinch ends the opening move wherever it has got to — a camera
|
||||
// that finished its arc anyway would be arguing with somebody who has
|
||||
// already started using it.
|
||||
office.controls.addEventListener("start", cancelOfficeArrival);
|
||||
officePlan = buildOfficePlan(createOfficeMinimap, office);
|
||||
startDeviceFeed(built.createDeviceSource, office.devices);
|
||||
startVehicleTelemetry(built.createSimulatedVehicleTelemetry);
|
||||
@@ -1565,6 +1923,9 @@ async function enterOffice() {
|
||||
showDetail(null);
|
||||
refreshGodmodePlace();
|
||||
renderChrome();
|
||||
// Once the room is up and the panel beside it is drawn. See
|
||||
// `beginOfficeArrival` for the two rules it obeys.
|
||||
beginOfficeArrival(office);
|
||||
// After the room is on screen, not before. The first ask is a second request
|
||||
// and the building is worth looking at while it is in flight; awaiting it here
|
||||
// would hold the door shut on a network round trip to draw people into a scene
|
||||
@@ -1748,6 +2109,17 @@ function seedForOffice(id: string): number {
|
||||
* weather and markers: a box with no device source answers with an empty body,
|
||||
* and asking it once per TTL per tab forever to be told nothing is a request
|
||||
* nobody needs.
|
||||
*
|
||||
* **It is two questions and it used to ask only one.** `access.feeds.devices`
|
||||
* says the deployment has a source; `access.can.liveDevices` says this viewer
|
||||
* may read it. On cloud-2 the first is true and, for the visitor this product
|
||||
* is designed for, the second is false — the route is members-only and answers
|
||||
* 401 — so passing the deployment's answer alone sent every anonymous viewer
|
||||
* down the API strategy to be refused. `apiSource` then renders `atRest()`
|
||||
* forever: a rack of powered-off instruments, in a room the panel beside them
|
||||
* describes as running, with an exponential back-off quietly retrying a request
|
||||
* that cannot succeed. Both halves, and the fallback is the simulator that was
|
||||
* always meant to serve this case.
|
||||
*/
|
||||
function startDeviceFeed(
|
||||
create: typeof import("./devices/adapter.ts").createDeviceSource,
|
||||
@@ -1763,7 +2135,18 @@ function startDeviceFeed(
|
||||
declarations,
|
||||
client: tera,
|
||||
officeId: officePack?.id ?? officeId,
|
||||
serverHasDevices: access.feeds?.devices !== false,
|
||||
serverHasDevices: access.feeds?.devices !== false && access.can.liveDevices,
|
||||
/**
|
||||
* And the tier itself, which is belt and braces on purpose.
|
||||
*
|
||||
* `can.liveDevices` above is *this app's* answer to "may this viewer read
|
||||
* the route", and it is the one CONTRACT §6 names. `viewerTier` is the
|
||||
* *fact*, and `adapter.ts` gates on it independently, so a future call site
|
||||
* that forgets the capability still cannot ship an anonymous visitor a rack
|
||||
* of dead instruments. Two gates for one decision is the right number when
|
||||
* one of them is a bug that already shipped once.
|
||||
*/
|
||||
viewerTier: access.tier,
|
||||
seed: seedForOffice(officeId),
|
||||
onReading: (reading) => {
|
||||
deviceStates = reading.states;
|
||||
@@ -2376,6 +2759,10 @@ function chromeInputs(): ChromeInputs {
|
||||
mediaSurfaceCount: mediaSurfaces.length,
|
||||
mediaSurfaceActiveCount: mediaSurfaces.filter((surface) => surface.bound).length,
|
||||
robotDisclosure: robotActivity?.disclosure ?? null,
|
||||
// Recomputed here rather than cached beside the promotion, because the
|
||||
// load is a function of *where the building is* as well as what is
|
||||
// burning, and this is the one place that knows both.
|
||||
smokeNote: officeSmokeNote(),
|
||||
walkable: office?.walker != null,
|
||||
},
|
||||
cameraLive: webcamCapture?.status() === "active",
|
||||
@@ -2422,6 +2809,7 @@ function renderChrome(): void {
|
||||
}
|
||||
}
|
||||
chrome?.apply(chromeState(chromeInputs()));
|
||||
showFireSection();
|
||||
// Each plan rings the entry its own legend is showing as current. Both are
|
||||
// updated whichever one is mounted, so the hidden one is already right when it
|
||||
// comes back rather than correcting itself a frame after it appears.
|
||||
@@ -3266,6 +3654,7 @@ function flyToIndex(index: number) {
|
||||
if (!view) return;
|
||||
if (inside && office) {
|
||||
requestControlMode("office-overview");
|
||||
cancelOfficeArrival();
|
||||
office.flyTo(view.id);
|
||||
renderChrome();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user