feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul
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>
This commit is contained in:
@@ -68,8 +68,15 @@
|
||||
import * as THREE from "three";
|
||||
import { createSceneKit, type Pose } from "../engine/scenekit.ts";
|
||||
import type { StageScene } from "../engine/stage.ts";
|
||||
import type { LightingState, Pin, View } from "../engine/types.ts";
|
||||
import type { AssetRegistry } from "../assets/kit.ts";
|
||||
import type { Aircraft, FlightSource, LightingState, Pin, View } from "../engine/types.ts";
|
||||
import { airlinerGeometry } from "../engine/aircraftGeometry.ts";
|
||||
import type { EnvironmentRig } from "../engine/environmentRig.ts";
|
||||
import { createOfficeExterior, type OfficeExterior } from "../engine/officeExterior.ts";
|
||||
import { createDeviceLayer, type DeviceLayer } from "./devices.ts";
|
||||
import type { DeviceDeclaration, DeviceState } from "../devices/types.ts";
|
||||
import type { VehicleTelemetryState } from "../transport/vehicleTelemetry.ts";
|
||||
import type { ModelXDetail } from "../assets/vehicles/index.ts";
|
||||
import { kit as assetKit, type AssetRegistry } from "../assets/kit.ts";
|
||||
import { MaterialRegistry, type MaterialQuality } from "../assets/materials.ts";
|
||||
import type { InteriorPalette } from "../assets/palette.ts";
|
||||
// Importing the catalogue registers the built-in `tera:` assets into the shared
|
||||
@@ -140,6 +147,53 @@ const HORIZON_EXTENT = 12_000;
|
||||
*/
|
||||
const HORIZON_DARKEN = 0.5;
|
||||
|
||||
/**
|
||||
* How far out the overhead traffic dome sits, in metres, at most.
|
||||
*
|
||||
* The camera's far plane is `HORIZON_EXTENT * 0.7` — 8.4 km — so 3.6 km is
|
||||
* comfortably inside it with the horizon plane still behind. The number itself
|
||||
* carries no claim: an aeroplane on this dome is a **map symbol drawn in 3-D**,
|
||||
* placed at the bearing and elevation it is genuinely at and at a distance
|
||||
* chosen so it is visible, exactly as `aircraftGeometry.ts` argues for the
|
||||
* city's own traffic. Drawing airliners at true metre range would put most of
|
||||
* them past the far plane and the rest inside the fog.
|
||||
*/
|
||||
const OVERHEAD_MAX_RADIUS_M = 3_600;
|
||||
|
||||
/**
|
||||
* How large an aeroplane is drawn, as an angle at the eye.
|
||||
*
|
||||
* 0.012 rad is about 0.7 degrees — a little over the width of a fingernail at
|
||||
* arm's length, which is roughly what an airliner at cruise actually looks like
|
||||
* from directly beneath and is enough to read the sweep of a wing. It is an
|
||||
* angle rather than a length so the glyph does not have to be retuned if the
|
||||
* dome radius ever changes.
|
||||
*/
|
||||
const OVERHEAD_ANGULAR_SIZE = 0.012;
|
||||
|
||||
/** The bounding length of `airlinerGeometry()`, which the angular size divides. */
|
||||
const AIRLINER_LENGTH = 0.42;
|
||||
|
||||
/**
|
||||
* How low an aeroplane may be and still be drawn, in degrees above the horizon.
|
||||
*
|
||||
* Below this it is behind the ground plane from any viewpoint inside the
|
||||
* building, so drawing it is drawing an aeroplane through a floor. Five degrees
|
||||
* is also about where an airliner stops being distinguishable from the haze.
|
||||
*/
|
||||
const OVERHEAD_MIN_ELEVATION_DEG = 5;
|
||||
|
||||
/**
|
||||
* How many aeroplanes the dome can hold at once.
|
||||
*
|
||||
* One `InstancedMesh` and therefore one draw call at any occupancy, so the cost
|
||||
* of the ceiling is 32 unused matrices rather than 32 unused objects. The
|
||||
* godmode traffic dial can put four hundred aircraft over the city; a room's
|
||||
* sky wants the nearest few, and the nearest few is what a person looking up
|
||||
* would see anyway.
|
||||
*/
|
||||
const OVERHEAD_CAPACITY = 32;
|
||||
|
||||
export interface OfficeSceneOptions {
|
||||
/**
|
||||
* The renderer's canvas. Orbit input and pointer coordinates are read against
|
||||
@@ -236,6 +290,45 @@ export interface OfficeSceneOptions {
|
||||
*/
|
||||
background?: number | null;
|
||||
plan?: PlanOptions;
|
||||
/**
|
||||
* The page's one environment map, shared with the city.
|
||||
*
|
||||
* The same argument `scene.ts` makes: a `PMREMGenerator` and its targets
|
||||
* belong to the renderer, not to a scene, so one rig is built beside the
|
||||
* `Stage` and handed to both. It matters more indoors than out — `deviceMesh`,
|
||||
* `chairBase`, `metalTrim`, `glazingFrame` and the Model X's paint are all
|
||||
* metal or clearcoat, and metal with nothing to reflect is grey plastic.
|
||||
*
|
||||
* Absent, the office renders exactly as it did before the rig existed.
|
||||
*/
|
||||
environment?: EnvironmentRig;
|
||||
/**
|
||||
* Overhead traffic for a sited office's sky.
|
||||
*
|
||||
* The same `FlightSource` the city board is drawing, deliberately: a studio in
|
||||
* the Arts District and the SoCal board above it are one world, and an arena
|
||||
* that observes an overflight while the viewer standing in the room sees an
|
||||
* empty sky is two. Polled here and **never disposed** here — the source
|
||||
* belongs to whoever built it, which is the city.
|
||||
*
|
||||
* Ignored on a pack with no `site`: without a coordinate there is no bearing
|
||||
* to put an aeroplane on, and without a horizon there is no sky to put it in.
|
||||
*/
|
||||
flights?: FlightSource;
|
||||
/**
|
||||
* Park a Model X on the pack's arrival apron.
|
||||
*
|
||||
* Ignored unless `office.site.arrival` names a stall, which is a pack's own
|
||||
* decision — `ExteriorArrival` is optional and a floorplan with no outdoors
|
||||
* has nowhere to put a car.
|
||||
*
|
||||
* `detail` is a required choice by the exterior's own contract: `corridor` is
|
||||
* 33 draw calls and 4,098 triangles against `follow`'s 40 and 13,986, and the
|
||||
* difference a viewer can see at three metres is mirrors, glass frames and
|
||||
* brake calipers. `seed` makes the parking jitter and the paint a property of
|
||||
* the studio rather than of the page load.
|
||||
*/
|
||||
exteriorVehicle?: { detail: ModelXDetail; seed: number };
|
||||
}
|
||||
|
||||
export interface OfficeScene extends StageScene {
|
||||
@@ -281,6 +374,28 @@ export interface OfficeScene extends StageScene {
|
||||
*/
|
||||
setRobotsVisible(visible: boolean): void;
|
||||
setLighting(state: LightingState): void;
|
||||
/**
|
||||
* The hardware this pack declared, in the order it authored it.
|
||||
*
|
||||
* Authored, public and inert — a declaration says a microphone exists and what
|
||||
* it can be asked to do. It is on the handle so that the interface can build a
|
||||
* panel for a studio without reading the pack a second time, and it is the
|
||||
* **resolved** list: a declaration `Plan` dropped, because its anchor prop is
|
||||
* not on this level or did not survive this build's depth, is not here.
|
||||
*/
|
||||
devices: readonly DeviceDeclaration[];
|
||||
/**
|
||||
* Show these readings on the hardware. Cheap and idempotent; call it whenever
|
||||
* a feed publishes. A no-op for an office that declared no devices.
|
||||
*/
|
||||
setDeviceStates(states: readonly DeviceState[]): void;
|
||||
/**
|
||||
* Reflect one vehicle telemetry observation on the car outside.
|
||||
*
|
||||
* Signature-guarded downstream, so calling it every frame costs a comparison.
|
||||
* A no-op for a pack with no arrival stall, or when no exterior was asked for.
|
||||
*/
|
||||
setVehicleTelemetry(state: VehicleTelemetryState): void;
|
||||
/**
|
||||
* The sun's height, in degrees, from whatever clock the app is running.
|
||||
*
|
||||
@@ -402,7 +517,12 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
// reads as a stall.
|
||||
flightSpeed: 0.95,
|
||||
});
|
||||
kit.applyLighting(options.lighting ?? officeInterior());
|
||||
const openingLighting = options.lighting ?? officeInterior();
|
||||
kit.applyLighting(openingLighting);
|
||||
// Before a single surface is built, so the first frame already has a room to
|
||||
// reflect. The rig fingerprints the state and caches per kind, so this and
|
||||
// every later `setLighting` cost a map lookup unless the light actually moved.
|
||||
options.environment?.apply(scene, openingLighting, "office");
|
||||
|
||||
/**
|
||||
* The sky wins over the flat colour when there is one.
|
||||
@@ -562,6 +682,64 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
if (presence) scene.add(presence.group);
|
||||
shell.ceilings.visible = options.showCeilings ?? false;
|
||||
|
||||
// ---- The hardware on the furniture --------------------------------------
|
||||
//
|
||||
// Read off the floorplans rather than taken as an option, because a device is
|
||||
// part of a pack in exactly the way a desk is: `Floorplan.devices` says the
|
||||
// microphone exists and which prop it stands on, and `Plan` has already
|
||||
// decided which of those declarations survived this build's depth. Filtering
|
||||
// to what `Plan` accepted is what stops the interface offering a panel for a
|
||||
// device the room does not contain — the layer would have skipped it and the
|
||||
// panel would have shown a control that reaches nothing.
|
||||
const registry: AssetRegistry = options.registry ?? assetKit;
|
||||
const declared: DeviceDeclaration[] = [];
|
||||
for (const level of office.levels) {
|
||||
for (const declaration of level.floorplan.devices ?? []) declared.push(declaration);
|
||||
}
|
||||
const deviceDeclarations: readonly DeviceDeclaration[] = declared.filter(
|
||||
(declaration) => plan.device(declaration.id) !== null,
|
||||
);
|
||||
const deviceLayer: DeviceLayer | null =
|
||||
deviceDeclarations.length > 0
|
||||
? createDeviceLayer({ plan, declarations: deviceDeclarations, assets: registry, materials })
|
||||
: null;
|
||||
if (deviceLayer) scene.add(deviceLayer.object);
|
||||
|
||||
// ---- The car outside -----------------------------------------------------
|
||||
//
|
||||
// Guarded on the pack having authored a stall, which most will not: an
|
||||
// `ExteriorArrival` is optional and a floor plate with no outdoors has nowhere
|
||||
// to put one. The exterior positions everything in the pack's own metres from
|
||||
// the plan origin, so the only transform it needs is the storey its stall is
|
||||
// measured from — a podium deck at level 1 is 188 m off the street, and the
|
||||
// apron stands on the floor of `arrival.levelId` by the exterior's own wording.
|
||||
const arrivalStall = office.site?.arrival;
|
||||
let exterior: OfficeExterior | null = null;
|
||||
if (options.exteriorVehicle && office.site && arrivalStall) {
|
||||
exterior = createOfficeExterior({
|
||||
site: office.site,
|
||||
arrival: arrivalStall,
|
||||
assets: registry,
|
||||
materials,
|
||||
rand: mulberry32(options.exteriorVehicle.seed),
|
||||
detail: options.exteriorVehicle.detail,
|
||||
});
|
||||
exterior.object.position.y = plan.level(arrivalStall.levelId)?.floorY ?? 0;
|
||||
scene.add(exterior.object);
|
||||
}
|
||||
|
||||
// ---- The traffic overhead ------------------------------------------------
|
||||
const overhead: OverheadTraffic | null =
|
||||
options.flights && office.site && options.horizon
|
||||
? createOverheadTraffic({
|
||||
source: options.flights,
|
||||
site: office.site,
|
||||
centre: plan.bounds.center,
|
||||
radius: Math.min(far * 0.42, OVERHEAD_MAX_RADIUS_M),
|
||||
})
|
||||
: null;
|
||||
if (overhead) scene.add(overhead.group);
|
||||
|
||||
// ---- Viewpoints ---------------------------------------------------------
|
||||
|
||||
const viewpointById = new Map(plan.viewpoints.map((v) => [v.id, v]));
|
||||
@@ -818,6 +996,13 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
onViewChange(fn) {
|
||||
viewListeners.push(fn);
|
||||
},
|
||||
devices: deviceDeclarations,
|
||||
setDeviceStates(states) {
|
||||
deviceLayer?.apply(states);
|
||||
},
|
||||
setVehicleTelemetry(state) {
|
||||
exterior?.apply(state);
|
||||
},
|
||||
setPresence(people) {
|
||||
if (!presence) {
|
||||
// Once, not once per poll: an occupancy feed pointed at the public
|
||||
@@ -843,6 +1028,10 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
setLighting(state) {
|
||||
kit.applyLighting(state);
|
||||
paintHorizon(state);
|
||||
// One direction, still: `Atmosphere` decided this rig, `officeDaylight`
|
||||
// turned it into the building's frame, and the environment is derived
|
||||
// from the result rather than being a second opinion about the light.
|
||||
options.environment?.apply(scene, state, "office");
|
||||
},
|
||||
setSolarElevation(degrees) {
|
||||
luminaires.setSolarElevation(degrees);
|
||||
@@ -887,8 +1076,16 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
// *now*, not to where they were last frame.
|
||||
robots?.tick(dt);
|
||||
luminaires.tick(dt);
|
||||
overhead?.tick(dt);
|
||||
},
|
||||
dispose() {
|
||||
// First, because the rig keeps a ledger of every scene it has written to
|
||||
// so a rebuilt environment reaches all of them — and a disposed office
|
||||
// left in that ledger is a whole floor plate retained.
|
||||
options.environment?.release(scene);
|
||||
overhead?.dispose();
|
||||
exterior?.dispose();
|
||||
deviceLayer?.dispose();
|
||||
mediaSurfaces.dispose();
|
||||
officeWalker?.dispose();
|
||||
realtimePeers?.dispose();
|
||||
@@ -922,6 +1119,206 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A deterministic generator from one integer, so a studio's car is the same car
|
||||
* on every machine and on every reload.
|
||||
*
|
||||
* Mulberry32, four lines, no dependency. It is here rather than imported
|
||||
* because the only thing in this file that needs randomness is the parking
|
||||
* jitter, and `createOfficeExterior` takes a `() => number` precisely so that
|
||||
* the caller owns the reproducibility rather than the layer.
|
||||
*/
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Overhead traffic -----------------------------------------------------
|
||||
|
||||
interface OverheadTraffic {
|
||||
group: THREE.Group;
|
||||
tick(dt: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface OverheadTrafficOptions {
|
||||
source: FlightSource;
|
||||
site: NonNullable<Office["site"]>;
|
||||
/** The middle of the floor plate, in the pack's metres. The dome is centred here. */
|
||||
centre: { x: number; z: number };
|
||||
radius: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same aeroplanes the city board is drawing, seen from inside a building.
|
||||
*
|
||||
* ### Why a dome rather than a position
|
||||
*
|
||||
* An airliner over Los Angeles is ten kilometres up and twenty across. Placed at
|
||||
* true metre range in a scene whose far plane is 8.4 km it is clipped, and if
|
||||
* the far plane were moved out to reach it the fog — which saturates at 4.2 km,
|
||||
* because that is what makes the horizon a horizon — would have swallowed it
|
||||
* long before. So the *direction* is kept exactly and the *distance* is not:
|
||||
* every track is put on a fixed dome at the bearing and elevation it is really
|
||||
* at, sized by an angle rather than a length. That is the same bargain
|
||||
* `aircraftGeometry.ts` already makes for the city, written down again here
|
||||
* because the reason is different: the city trades scale for legibility, and
|
||||
* this trades range for a depth buffer that works.
|
||||
*
|
||||
* ### Why it is instanced
|
||||
*
|
||||
* One geometry, one material, one draw call whatever the occupancy — against
|
||||
* the city layer's mesh-per-track, which exists there because each aircraft
|
||||
* carries its own altitude-banded material and its own pick target. Neither is
|
||||
* wanted here: a room's sky is scenery, nothing in it is clickable, and the
|
||||
* office's draw-call budget is the one that has to hold an entire studio.
|
||||
*
|
||||
* ### One direction, again
|
||||
*
|
||||
* `site.heading` is the bearing the pack's −Z points along, so a compass bearing
|
||||
* becomes a building-frame yaw by subtracting it — the same rotation
|
||||
* `officeDaylight` applies to the sun, for the same reason and in the same
|
||||
* sense. Getting it backwards would put the afternoon traffic over the wrong
|
||||
* wall, which is exactly as wrong as putting the afternoon sun there.
|
||||
*/
|
||||
function createOverheadTraffic(options: OverheadTrafficOptions): OverheadTraffic {
|
||||
const { source, site, centre, radius } = options;
|
||||
const group = new THREE.Group();
|
||||
group.name = "overhead-traffic";
|
||||
|
||||
const geometry = airlinerGeometry();
|
||||
/**
|
||||
* Lit, with a small emissive floor, and out of the fog.
|
||||
*
|
||||
* The emissive is the city layer's number and is there for the city layer's
|
||||
* reason: after sunset the rig is a tenth of an intensity and a purely diffuse
|
||||
* dart simply vanishes, on the one evening sky worth looking at. `fog: false`
|
||||
* because the dome's radius is a drawing convention rather than a distance —
|
||||
* applying 3.6 km of haze to a symbol that stands for twenty kilometres is
|
||||
* fogging an arbitrary number.
|
||||
*/
|
||||
const material = new THREE.MeshLambertMaterial({
|
||||
color: 0xdfe7ef,
|
||||
emissive: 0xdfe7ef,
|
||||
emissiveIntensity: 0.35,
|
||||
fog: false,
|
||||
});
|
||||
const mesh = new THREE.InstancedMesh(geometry, material, OVERHEAD_CAPACITY);
|
||||
mesh.name = "overhead-traffic-instances";
|
||||
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
// The dome is centred on the building and always in frame; a bounding-sphere
|
||||
// test on something that can never be culled is pure cost.
|
||||
mesh.frustumCulled = false;
|
||||
mesh.count = 0;
|
||||
mesh.castShadow = false;
|
||||
mesh.receiveShadow = false;
|
||||
group.add(mesh);
|
||||
|
||||
const scale = (radius * OVERHEAD_ANGULAR_SIZE) / AIRLINER_LENGTH;
|
||||
const matrix = new THREE.Matrix4();
|
||||
const position = new THREE.Vector3();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const euler = new THREE.Euler(0, 0, 0, "YXZ");
|
||||
const scaleVector = new THREE.Vector3(scale, scale, scale);
|
||||
|
||||
const headingRad = (site.heading * Math.PI) / 180;
|
||||
const cosLat = Math.cos((site.lat * Math.PI) / 180);
|
||||
const minSinElevation = Math.sin((OVERHEAD_MIN_ELEVATION_DEG * Math.PI) / 180);
|
||||
|
||||
let timer = 0;
|
||||
let disposed = false;
|
||||
|
||||
function place(aircraft: readonly Aircraft[]): void {
|
||||
let count = 0;
|
||||
for (const a of aircraft) {
|
||||
if (count >= OVERHEAD_CAPACITY) break;
|
||||
/*
|
||||
* Equirectangular, not great-circle, and that is a decision rather than a
|
||||
* shortcut: an aeroplane still above five degrees from a building is at
|
||||
* most a couple of hundred kilometres away, where the cosine-corrected
|
||||
* flat approximation is wrong by metres in a bearing that is then drawn
|
||||
* on a dome anyway. A haversine here would be four transcendentals per
|
||||
* aeroplane per poll to move a symbol by less than its own width.
|
||||
*/
|
||||
const east = (a.lng - site.lng) * cosLat * METRES_PER_DEGREE;
|
||||
const north = (a.lat - site.lat) * METRES_PER_DEGREE;
|
||||
const ground = Math.hypot(east, north);
|
||||
const up = a.altitude - site.elevation;
|
||||
const slant = Math.hypot(ground, up);
|
||||
if (slant < 1) continue;
|
||||
const sinElevation = up / slant;
|
||||
if (sinElevation < minSinElevation) continue;
|
||||
|
||||
// Bearing clockwise from true north, turned into the building's frame by
|
||||
// subtracting the heading its own −Z points along.
|
||||
const bearing = Math.atan2(east, north) - headingRad;
|
||||
const cosElevation = Math.sqrt(Math.max(0, 1 - sinElevation * sinElevation));
|
||||
position.set(
|
||||
centre.x + Math.sin(bearing) * cosElevation * radius,
|
||||
sinElevation * radius,
|
||||
centre.z - Math.cos(bearing) * cosElevation * radius,
|
||||
);
|
||||
|
||||
// Nose along +Z and −Z is the building's own north, so a half turn less
|
||||
// the track's heading in this frame — the identical mapping `flights.ts`
|
||||
// uses, and the one whose inverse once flew every departure tail-first.
|
||||
euler.set(0, Math.PI - ((a.heading * Math.PI) / 180 - headingRad), 0);
|
||||
quaternion.setFromEuler(euler);
|
||||
matrix.compose(position, quaternion, scaleVector);
|
||||
mesh.setMatrixAt(count, matrix);
|
||||
count += 1;
|
||||
}
|
||||
mesh.count = count;
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
|
||||
return {
|
||||
group,
|
||||
tick(dt) {
|
||||
if (disposed) return;
|
||||
timer -= dt;
|
||||
if (timer > 0) return;
|
||||
timer = source.interval;
|
||||
/*
|
||||
* No interpolation, unlike the city layer, and the sky is why. A track on
|
||||
* this dome moves a few pixels between polls: at 3.6 km an airliner
|
||||
* covers about 0.24 degrees a second across the dome, which is a third of
|
||||
* its own drawn width, so tweening it would be machinery for motion
|
||||
* nobody can see. The city layer interpolates because there the same
|
||||
* aeroplane crosses a visible fraction of the board.
|
||||
*/
|
||||
void Promise.resolve(source.poll()).then((aircraft) => {
|
||||
if (!disposed) place(aircraft);
|
||||
});
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
// Never `source.dispose()`: this layer is a second reader of a feed the
|
||||
// city owns, and disposing it here would take the traffic off the board
|
||||
// the moment somebody stepped indoors.
|
||||
mesh.dispose();
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
group.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Metres per degree of latitude, and of longitude at the equator.
|
||||
*
|
||||
* The WGS-84 mean, which is the same constant `main.ts` uses to turn a scene
|
||||
* offset back into a coordinate. A degree of latitude varies by about half a
|
||||
* percent between the equator and the pole; on a bearing drawn as a symbol that
|
||||
* is nothing.
|
||||
*/
|
||||
const METRES_PER_DEGREE = 111_320;
|
||||
|
||||
function withoutPeerFactory(options: OfficeRealtimePeersOptions): Omit<ScenePeersOptions, "project" | "groundAt"> {
|
||||
const { create: _create, ...peerOptions } = options;
|
||||
return peerOptions;
|
||||
|
||||
Reference in New Issue
Block a user