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:
+125
-12
@@ -34,6 +34,7 @@ import { createFlightLayer, type FlightLayer } from "./flights.ts";
|
||||
import { createCloudLayer, type CloudLayer } from "./clouds.ts";
|
||||
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
|
||||
import { solarPosition, sunDirection } from "./solar.ts";
|
||||
import { nightFactor } from "./atmosphere.ts";
|
||||
import { createStarlinkMeshLayer, type StarlinkMeshLayer } from "./starlinkMesh.ts";
|
||||
import {
|
||||
createSatelliteLayer,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
type SatelliteLayer,
|
||||
} from "./satellites.ts";
|
||||
import { createSceneKit, type Pose } from "./scenekit.ts";
|
||||
import type { EnvironmentRig } from "./environmentRig.ts";
|
||||
import {
|
||||
createRoadTrafficLayer,
|
||||
type RoadTrafficLayer,
|
||||
@@ -55,6 +57,7 @@ import type {
|
||||
import { createBridges, createFreewayWorld, createRoads } from "./structures.ts";
|
||||
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
|
||||
import type {
|
||||
Aircraft,
|
||||
Chapter,
|
||||
City,
|
||||
FlightSource,
|
||||
@@ -84,6 +87,11 @@ import { cityControlOwnership, type CityControlMode } from "../play/controlMode.
|
||||
|
||||
export type CityRealtimePeersOptions = Omit<ScenePeersOptions, "project" | "groundAt">;
|
||||
|
||||
/** What the pointer is over: an authored place, or an observed aeroplane. */
|
||||
type Pick =
|
||||
| { kind: "marker"; marker: Marker }
|
||||
| { kind: "aircraft"; aircraft: Aircraft };
|
||||
|
||||
export interface SceneOptions {
|
||||
city: City;
|
||||
markerPalette?: MarkerPalette;
|
||||
@@ -118,6 +126,34 @@ export interface SceneOptions {
|
||||
satellites?: SatelliteCatalogue;
|
||||
/** Fires on hover/click of a marker head. */
|
||||
onMarkerPick?: (marker: Marker | null) => void;
|
||||
/**
|
||||
* Fires on hover of an aeroplane, and with `null` as the pointer leaves one.
|
||||
*
|
||||
* The same shape as `onMarkerPick` and for the same reason: `scenekit` reports
|
||||
* picks by hover, so a click handler upstairs reads whatever the last hover
|
||||
* resolved. What comes back is the engine's own `Aircraft` — a position and a
|
||||
* callsign — and nothing about where it came from, because that is a question
|
||||
* about the deployment and `adapters/http.ts` is the layer that can answer it.
|
||||
*
|
||||
* Not gated on anything. An ADS-B position is broadcast in clear to anybody
|
||||
* with a receiver, so there is nothing here an account could grant; see
|
||||
* `owner-decisions.md` and the note on `TrafficSource.detail`.
|
||||
*/
|
||||
onAircraftPick?: (aircraft: Aircraft | null) => void;
|
||||
/**
|
||||
* The shared environment map, when the page has one.
|
||||
*
|
||||
* Handed in rather than built here, and that is the whole of the wiring rule:
|
||||
* a `PMREMGenerator` and its render targets belong to the **renderer**, which
|
||||
* outlives every city on the page, so one rig is built beside the `Stage` and
|
||||
* shared. A rig per `createScene` would allocate a fresh blur chain and a
|
||||
* fresh target for every board and leak both on the next switch, which is
|
||||
* exactly the arithmetic `stage.ts` records for the renderer itself.
|
||||
*
|
||||
* Absent, everything renders as it did before the rig existed: duller metal,
|
||||
* no sky in the water, and no error.
|
||||
*/
|
||||
environment?: EnvironmentRig;
|
||||
/**
|
||||
* Opening light rig. Comes from an `Atmosphere` when there is one; without
|
||||
* one the city gets `cityDaylight()`, because a scene that renders black
|
||||
@@ -356,6 +392,9 @@ export async function createScene(
|
||||
// two disagree for the one frame before the app's first `setLighting`.
|
||||
const opening = options.lighting ?? cityDaylight(pal, boardSpan);
|
||||
kit.applyLighting(opening);
|
||||
// The environment before the first layer is added, so the very first frame
|
||||
// has a sky to reflect rather than acquiring one a `setLighting` later.
|
||||
options.environment?.apply(scene, opening, "city");
|
||||
|
||||
scene.add(createWater(world));
|
||||
scene.add(createShorePlates(world));
|
||||
@@ -443,6 +482,15 @@ export async function createScene(
|
||||
|
||||
let flightLayer: FlightLayer | null = null;
|
||||
let flightTimer = 0;
|
||||
/**
|
||||
* The last observation, by id, so a pick has something to hand back.
|
||||
*
|
||||
* The layer interpolates between observations and keeps no record a caller
|
||||
* could read; this is the record. Rebuilt wholesale on every poll rather than
|
||||
* merged, so an aeroplane that has left the region leaves this table with it
|
||||
* and a card cannot be opened on a track that is no longer in the sky.
|
||||
*/
|
||||
const lastAircraft = new Map<string, Aircraft>();
|
||||
if (options.flights) {
|
||||
flightLayer = createFlightLayer(world);
|
||||
scene.add(flightLayer.group);
|
||||
@@ -553,12 +601,38 @@ export async function createScene(
|
||||
|
||||
// ---- Picking ------------------------------------------------------------
|
||||
|
||||
// `pickables` is mutated in place by the layer, so the array itself is the
|
||||
// live target list.
|
||||
kit.setPicking<Marker>({
|
||||
targets: markerLayer.pickables,
|
||||
resolve: (hit) => (hit.object.userData.marker as Marker | undefined) ?? null,
|
||||
onChange: (marker) => options.onMarkerPick?.(marker),
|
||||
/**
|
||||
* Two things on this board are worth pointing at, and both are resolved here.
|
||||
*
|
||||
* `markerLayer.pickables` and `flightLayer.pickables` are both mutated in
|
||||
* place by their layers, so neither array can simply be concatenated once —
|
||||
* the picking target list has to be a getter that reads both at the moment of
|
||||
* the test. A pin is an authored place; an aeroplane is an observation, and
|
||||
* `Pick` keeps them apart as a union rather than flattening both to a string,
|
||||
* because the aircraft card is five fields and a provenance line and the
|
||||
* moment it becomes a sentence it can never be anything else again.
|
||||
*/
|
||||
const pickTargets = (): THREE.Object3D[] =>
|
||||
flightLayer === null
|
||||
? markerLayer.pickables
|
||||
: [...markerLayer.pickables, ...flightLayer.pickables];
|
||||
|
||||
kit.setPicking<Pick>({
|
||||
targets: pickTargets,
|
||||
resolve: (hit) => {
|
||||
const marker = hit.object.userData.marker as Marker | undefined;
|
||||
if (marker) return { kind: "marker", marker };
|
||||
const id = hit.object.userData.aircraftId as string | undefined;
|
||||
const aircraft = id === undefined ? undefined : lastAircraft.get(id);
|
||||
return aircraft ? { kind: "aircraft", aircraft } : null;
|
||||
},
|
||||
onChange: (picked) => {
|
||||
// Both callbacks fire on every change, including the change back to
|
||||
// `null`, so whichever card is up is retired by a pointer that leaves —
|
||||
// and by a pointer that moves from a pin straight onto an aeroplane.
|
||||
options.onMarkerPick?.(picked?.kind === "marker" ? picked.marker : null);
|
||||
options.onAircraftPick?.(picked?.kind === "aircraft" ? picked.aircraft : null);
|
||||
},
|
||||
});
|
||||
|
||||
// ---- The scene, as the stage sees it ------------------------------------
|
||||
@@ -588,7 +662,11 @@ export async function createScene(
|
||||
flightTimer -= dt;
|
||||
if (flightTimer <= 0) {
|
||||
flightTimer = options.flights.interval;
|
||||
void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac));
|
||||
void Promise.resolve(options.flights.poll()).then((ac) => {
|
||||
lastAircraft.clear();
|
||||
for (const a of ac) lastAircraft.set(a.id, a);
|
||||
flightLayer?.update(ac);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Every frame and on no timer of its own. The catalogue's sweep is
|
||||
@@ -610,16 +688,40 @@ export async function createScene(
|
||||
* dusk geometry that lights a Starlink pass.
|
||||
*/
|
||||
const when = skyOverride ?? new Date();
|
||||
const solar = solarPosition(city.center.lat, city.center.lng, when);
|
||||
/**
|
||||
* The sky's own brightness, and the fix for the worst thing on this
|
||||
* board at first load.
|
||||
*
|
||||
* Both satellite layers draw light *added* to the sky: the dot cloud is
|
||||
* `AdditiveBlending` and the near-field buses are unlit white. That is
|
||||
* exactly right against a night sky and is a hard white square against a
|
||||
* daytime one — which is what the California board showed at 15:55 with
|
||||
* the sun at +44°, scattered across the frame, reading as render
|
||||
* artefacts before anything else on the page registered.
|
||||
*
|
||||
* `nightFactor` is `atmosphere.ts`'s own dusk curve and is deliberately
|
||||
* the same one `nightlights.ts` switches the city on with, so the sky
|
||||
* does not empty at a different dusk from the one the windows light up
|
||||
* at. It is computed here rather than taken from the rig for the reason
|
||||
* the sun vector below is: `atmosphere.ts` floors the *rig's* light
|
||||
* direction at `shadowFloorDeg` to keep the shadow camera usable, and a
|
||||
* sun pinned above the horizon is precisely the wrong input for a
|
||||
* question about how dark it is.
|
||||
*/
|
||||
const darkness = nightFactor(solar.elevation);
|
||||
satelliteLayer.setSkyDarkness(darkness);
|
||||
starlinkMeshes?.setSkyDarkness(darkness);
|
||||
const fixes = options.satellites.fixes(when);
|
||||
satelliteLayer.update(fixes);
|
||||
starlinkMeshes?.update(
|
||||
fixes,
|
||||
kit.camera,
|
||||
sunDirection(solarPosition(city.center.lat, city.center.lng, when)),
|
||||
);
|
||||
starlinkMeshes?.update(fixes, kit.camera, sunDirection(solar));
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
// Before anything else frees a texture: the rig holds this scene in a
|
||||
// ledger so a rebuilt environment can be pushed to every scene using it,
|
||||
// and a disposed city left in that ledger is the whole graph retained.
|
||||
options.environment?.release(scene);
|
||||
options.flights?.dispose?.();
|
||||
flightLayer?.dispose();
|
||||
satelliteLayer?.dispose();
|
||||
@@ -654,6 +756,17 @@ export async function createScene(
|
||||
setLighting: (state) => {
|
||||
kit.applyLighting(state);
|
||||
clouds.setLighting(state);
|
||||
/**
|
||||
* Every lighting change, and it is cheap to do it every one.
|
||||
*
|
||||
* The rig fingerprints the state coarsely and rebuilds only when the
|
||||
* fingerprint moves, so an unchanged sky is a map lookup and an
|
||||
* assignment. Calling it here rather than on a timer of its own is what
|
||||
* keeps CONTRACT §4's single direction intact: `Atmosphere` decided this
|
||||
* rig, the scene is applying it, and the environment is derived from the
|
||||
* decision rather than being a second opinion about the light.
|
||||
*/
|
||||
options.environment?.apply(scene, state, "city");
|
||||
},
|
||||
setCloudCover: (fraction) => clouds.setCover(fraction),
|
||||
setWind: (kph, fromDeg) => clouds.setWind(kph, fromDeg),
|
||||
|
||||
Reference in New Issue
Block a user