1
0

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:
2026-08-21 19:44:24 -07:00
parent 8738367258
commit db074e9cf7
150 changed files with 36237 additions and 2586 deletions
+202 -6
View File
@@ -2,12 +2,14 @@
* Aircraft over the city.
*
* The engine takes a `FlightSource` rather than talking to any particular
* service, because the obvious one cannot ship here. FlightRadar24's terms
* forbid scraping and forbid redistributing their data, so an Apache-2.0 repo
* containing an FR24 client would be publishing instructions for breaking a
* ToS and shipping data it has no right to relicense. Commercial sources are
* adapters in a private deployment; this file holds what we can actually give
* away. See ARCHITECTURE.md §4.
* service, because the obvious one cannot ship here. FlightRadar24's terms do
* not permit scraping and do not permit redistributing their data. An
* Apache-2.0 repo shipping such a client would not merely be breaking a ToS —
* it would be publishing instructions for doing so, alongside data it has no
* right to relicense. Commercial sources are adapters in a private deployment;
* this file holds what we can actually give away. See ARCHITECTURE.md §4, and
* `server/src/flights/licence.ts` for the allowlist that keeps the open lane
* open in practice rather than in principle.
*
* `SimulatedFlights` is the default and is genuinely enough for the map — what
* a city view wants is convincing motion in the right corridors, not a
@@ -345,6 +347,16 @@ const ADSB_HOLD_SECONDS = 60;
* licence problem. The best answer long-term is an RTL-SDR on a fleet box:
* first-party data, nothing to comply with.
*
* **`endpoint` is not free-form, even though its type is `string`.** The
* allowlist of feeds this project will fetch, and the credit line each of them
* is owed, live in `server/src/flights/licence.ts`, which is where the API's
* `TERA_ADSB_ENDPOINT` is validated before a request is made. A browser drawing
* a feed for itself is not republishing it and so is not the exposure that gate
* exists for — but a self-hoster who constructs this class with some other
* endpoint is choosing terms nobody here has read, and this is the sentence
* that says so. Nothing in this repo constructs it: the shipped path is
* `HttpFlights` against our own API.
*
* The region is required and has no default. It used to default to a point in
* San Francisco, which is a fine centre for one of the two cities in this build
* and a five-hundred-kilometre error for the other — and a wrong default is
@@ -445,10 +457,183 @@ interface RawAircraft {
track?: number;
}
// ---- Detail ---------------------------------------------------------------
/**
* One aircraft, described well enough to put on a card somebody clicked.
*
* The demo this project leads with is a signed-out visitor clicking a dart over
* a city they recognise and being told what it is, so this type is written for
* **anon** and carries nothing an account would be needed for. Everything in it
* is either broadcast unencrypted by the aircraft itself — ADS-B is receivable
* with a forty-dollar dongle — or arithmetic on top of that. There is no route,
* no registration and no operator here, because the open feeds do not carry
* them and inventing them would be the same class of lie `synthetic` exists to
* prevent. `owner-decisions.md` reserves those for an openly-licensed registry
* we have not wired.
*
* Two fields are about the *provenance* rather than the aeroplane, and they are
* the reason this is a type and not an object literal built in the UI:
* `observed` says whether anybody actually saw this, and `attribution` carries
* whatever the feed asks to be credited with **at the point the data is
* displayed**, which is what an ODbL notice is for. A card is a display. A
* corner label on the other side of the screen is not obviously one.
*/
export interface AircraftDetail {
/** The source's own id. The ICAO address for a live feed; a route name for the simulator. */
id: string;
/** Flight number or tail as broadcast, trimmed, or `null` when the feed said nothing. */
callsign: string | null;
/**
* The transponder's 24-bit ICAO address, lowercase hex, or `null`.
*
* `null` rather than a guess for anything that does not look like one — the
* simulator's ids are route names and a `~`-prefixed id on a real feed is a
* non-ICAO address (TIS-B and MLAT targets carry them), which is genuinely
* not an ICAO24 and must not be presented as one. Somebody can paste this
* into a registry lookup, so a wrong one sends them to another aircraft.
*/
icao24: string | null;
lat: number;
lng: number;
/** Barometric altitude, metres — the unit the wire and the engine both use. */
altitudeM: number;
/** The same altitude in feet, which is the unit aviation is actually read in. */
altitudeFt: number;
/** Degrees clockwise from true north. */
headingDeg: number;
/** The heading as a 16-point compass name, for a card a human reads. */
headingCompass: string;
/** Nautical miles from the board's centre, or `null` when no centre was given. */
distanceNm: number | null;
/**
* Did somebody observe this, or did this repo invent it?
*
* The same statement `TrafficSource.live()` makes about the whole feed, made
* about one aircraft, and it must travel with the aircraft: a card is read on
* its own, away from any corner label, and a fabricated flight number
* presented in the same frame as a real one is the confusion the `live` flag
* exists to prevent.
*/
observed: boolean;
/** Credit lines owed for this aircraft, to be shown on the card itself. */
attribution: string[];
}
/**
* An ICAO 24-bit address as the feeds write it: six hex digits, lowercase.
*
* Anchored, so `sim-BA286` fails and `~abc123` — the anonymous-address form
* both community feeds emit for targets whose real address is not known — fails
* too, which is the point. See `AircraftDetail.icao24`.
*/
const ICAO24 = /^[0-9a-f]{6}$/;
/** The sixteen names, in the order the compass runs. */
const COMPASS = [
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW",
];
/**
* A bearing as a compass point.
*
* Sixteen points rather than eight because the difference between "north-east"
* and "east-north-east" is the difference between two departure corridors, and
* rather than thirty-two because nobody reads "NNE by N" off a card. Negative
* and out-of-range degrees are wrapped rather than refused: a heading is an
* angle and every angle names a direction.
*/
export function compassPoint(degrees: number): string {
if (!Number.isFinite(degrees)) return "—";
const wrapped = ((degrees % 360) + 360) % 360;
return COMPASS[Math.round(wrapped / 22.5) % 16] ?? "N";
}
/** Metres to feet. The wire carries metres; aviation is read in feet. */
const FEET_PER_METRE = 3.280_84;
export interface AircraftDetailOptions {
/**
* The transponder address, when the caller was told one separately.
*
* `HttpFlights` is: `WireAircraft.icao24` is a field on the body and
* `Aircraft` has nowhere to put it, so the adapter keeps the wire record
* beside the position and hands it back here. Absent, the id is tested
* against `ICAO24` — which is right for every feed that keys on the hex, and
* correctly declines for the simulator.
*/
icao24?: string | null;
/** Whether these coordinates were observed. Defaults to `false`: invented until said otherwise. */
observed?: boolean;
/** Credit lines the feed asks for, shown on the card. */
attribution?: readonly string[];
/** Board centre, for the distance readout. Omit and `distanceNm` is `null`. */
from?: Place;
}
/**
* Turn an `Aircraft` into something a panel can render, without the panel
* knowing where aircraft come from.
*
* Pure, total and free of I/O, so the interface layer can call it on a click
* without awaiting anything, and so it can be tested without a network. It
* invents nothing: every field is a restatement, a unit conversion or a `null`.
*/
export function aircraftDetail(
aircraft: Aircraft,
options: AircraftDetailOptions = {},
): AircraftDetail {
const callsign = aircraft.callsign?.trim();
const declared = options.icao24?.trim().toLowerCase();
const fromId = aircraft.id.trim().toLowerCase();
const icao24 =
declared !== undefined && ICAO24.test(declared)
? declared
: ICAO24.test(fromId)
? fromId
: null;
return {
id: aircraft.id,
callsign: callsign === undefined || callsign === "" ? null : callsign,
icao24,
lat: aircraft.lat,
lng: aircraft.lng,
altitudeM: aircraft.altitude,
altitudeFt: Math.round(aircraft.altitude * FEET_PER_METRE),
headingDeg: aircraft.heading,
headingCompass: compassPoint(aircraft.heading),
distanceNm:
options.from === undefined
? null
: Math.round(distanceNm(options.from, { lat: aircraft.lat, lng: aircraft.lng }) * 10) / 10,
observed: options.observed === true,
attribution: [...(options.attribution ?? [])],
};
}
// ---- Rendering ------------------------------------------------------------
export interface FlightLayer {
group: THREE.Group;
/**
* The aircraft meshes currently in the sky, as a **live** array, each
* carrying `userData.aircraftId`.
*
* Here rather than on the caller because only this layer knows which mesh is
* which track: the map from id to mesh is private and the group's child order
* is an artefact of when each aircraft appeared. It is the same shape
* `MarkerLayer.pickables` publishes and it exists for the same reason — a
* pick is resolved from the object that was hit, and something has to say
* what the object stands for.
*
* `owner-decisions.md` is why this is not gated on anything: an ADS-B
* position is broadcast unencrypted to anybody with a receiver, so the card
* it opens is available to an anonymous visitor and the picking that reaches
* it must be too.
*/
pickables: THREE.Object3D[];
/**
* Hand over a fresh observation. Called on the source's own timer, which is
* once a second for the simulator and once every several seconds for a real
@@ -716,6 +901,9 @@ interface Track {
export function createFlightLayer(world: World): FlightLayer {
const group = new THREE.Group();
group.name = "flights";
// Mutated in place as tracks appear and expire, so `setPicking` can hold the
// array itself as its target list rather than re-reading it every pointer move.
const pickables: THREE.Object3D[] = [];
const geo = airlinerGeometry();
const materials = new Map<number, THREE.MeshLambertMaterial>();
@@ -799,7 +987,11 @@ export function createFlightLayer(world: World): FlightLayer {
// Yaw then pitch, because the heading is about the world's vertical and
// the climb angle is about the aircraft's own wing.
mesh.rotation.order = "YXZ";
// The id, on the object, so a raycast hit resolves to an aeroplane
// without this layer having to expose its private track table.
mesh.userData.aircraftId = a.id;
group.add(mesh);
pickables.push(mesh);
track = {
mesh,
samples: [],
@@ -921,6 +1113,8 @@ export function createFlightLayer(world: World): FlightLayer {
if (track.missingSince === 0) track.missingSince = now;
if (now - track.missingSince < TRACK_GRACE_SECONDS) continue;
group.remove(track.mesh);
const at = pickables.indexOf(track.mesh);
if (at >= 0) pickables.splice(at, 1);
tracks.delete(id);
}
@@ -1139,6 +1333,7 @@ export function createFlightLayer(world: World): FlightLayer {
return {
group,
pickables,
update,
tick,
dispose() {
@@ -1148,6 +1343,7 @@ export function createFlightLayer(world: World): FlightLayer {
trailGeo.dispose();
trailMat.dispose();
tracks.clear();
pickables.length = 0;
group.clear();
},
};