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:
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* The draw-call reclaim in `engine/structures.ts`.
|
||||
*
|
||||
* These are budget tests, and they are here because the budget is the reason
|
||||
* anything else in this build can be made to look better. The city measured 616
|
||||
* draw calls against a cap of 650 while the office spent 8% of its triangle
|
||||
* allowance: indoors quality is nearly free, outdoors it is not, and every call
|
||||
* this module gives back is one the exterior Model X and the aircraft get to
|
||||
* spend. `scripts/performance-budget.mjs` is the real gate, but it needs a
|
||||
* built bundle, a browser and eleven seconds a cell — these run in
|
||||
* milliseconds and fail on the line that caused the regression.
|
||||
*
|
||||
* Two invariants, and they are the two ways this file has gone wrong before:
|
||||
*
|
||||
* 1. **A material is per colour, not per call site.** `roadRibbon` used to
|
||||
* close over `new THREE.MeshLambertMaterial({ color })`, so twelve
|
||||
* identical asphalt decks were twelve materials — and two meshes that do
|
||||
* not share a material can never be merged, whatever else you do.
|
||||
* 2. **Geometry is merged per bucket.** A suspension bridge used to arrive as
|
||||
* about thirty-four meshes of one colour.
|
||||
*
|
||||
* There is a third thing the tests below quietly guard, and it is the one that
|
||||
* fails silently: `mergeGeometries` returns `null` when the attribute sets
|
||||
* disagree, so a ribbon without UVs sitting in a bucket beside a tube that has
|
||||
* them loses the whole bucket. Asserting on merged vertex counts is what catches
|
||||
* that, because a dropped bucket looks exactly like a very efficient one.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createBridge, createBridges, createRoads } from "../../engine/structures.ts";
|
||||
import type { Bridge, City, Road } from "../../engine/types.ts";
|
||||
import type { World } from "../../engine/world.ts";
|
||||
|
||||
/**
|
||||
* The smallest thing `structures.ts` will accept: a flat projection, ground at
|
||||
* zero, and metres straight through.
|
||||
*
|
||||
* A real `World` builds a heightfield, which is 0.53M lattice points and a
|
||||
* couple of seconds — none of which any assertion here depends on.
|
||||
*/
|
||||
function flatWorld(city: Partial<City>): World {
|
||||
return {
|
||||
city: { roads: [], bridges: [], inlandWater: [], ...city } as unknown as City,
|
||||
project(lat: number, lng: number): [number, number] {
|
||||
return [(lng + 122) * 20, -(lat - 37) * 20];
|
||||
},
|
||||
groundAt(): number {
|
||||
return 0;
|
||||
},
|
||||
metres(value: number): number {
|
||||
return value / 100;
|
||||
},
|
||||
} as unknown as World;
|
||||
}
|
||||
|
||||
const GOLDEN_GATE: Bridge = {
|
||||
name: "golden-gate",
|
||||
path: [
|
||||
[37.806, -122.4756],
|
||||
[37.8199, -122.4783],
|
||||
[37.8324, -122.4796],
|
||||
],
|
||||
towers: [
|
||||
[37.8104, -122.4767],
|
||||
[37.8249, -122.4787],
|
||||
],
|
||||
deckHeight: 67,
|
||||
towerHeight: 227,
|
||||
sag: 0.45,
|
||||
color: 0xc0553b,
|
||||
};
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function materialsIn(root: THREE.Object3D): Set<THREE.Material> {
|
||||
const set = new Set<THREE.Material>();
|
||||
for (const mesh of meshes(root)) {
|
||||
if (Array.isArray(mesh.material)) for (const material of mesh.material) set.add(material);
|
||||
else set.add(mesh.material);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
// ---- Bridges ---------------------------------------------------------------
|
||||
|
||||
test("a suspension bridge is one material and one draw call", () => {
|
||||
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
|
||||
|
||||
// The spec's number is six; a bridge is painted one colour throughout, so
|
||||
// anything above one is a part that was left out of the bucket.
|
||||
const distinct = materialsIn(bridge);
|
||||
assert.ok(distinct.size <= 6, `the bridge holds ${distinct.size} materials`);
|
||||
assert.equal(distinct.size, 1, `the bridge holds ${distinct.size} materials, not one`);
|
||||
assert.equal(meshes(bridge).length, 1, "the bridge did not merge into one mesh");
|
||||
});
|
||||
|
||||
test("merging kept every part of the bridge", () => {
|
||||
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
|
||||
const merged = meshes(bridge)[0];
|
||||
assert.ok(merged);
|
||||
|
||||
// The arithmetic, because a bucket that failed to merge comes out as one
|
||||
// *span* of geometry and otherwise looks entirely healthy: a 3-point deck tube
|
||||
// is 7 × 5 = 35 vertices, two towers and four braces are 24 each = 144, three
|
||||
// cable spans at 25 × 6 = 450, and the hangers are 24 boxes of 24 less
|
||||
// whichever ones the deck-clearance test culls — call it 1,000 at the floor.
|
||||
const vertices = merged.geometry.getAttribute("position").count;
|
||||
assert.ok(vertices > 1_000, `the bridge merged down to ${vertices} vertices`);
|
||||
|
||||
// The merge only happens because every part carries the same attributes.
|
||||
for (const name of ["position", "normal", "uv"]) {
|
||||
assert.ok(merged.geometry.getAttribute(name), `the merged bridge has no ${name}`);
|
||||
}
|
||||
assert.ok(merged.geometry.getIndex(), "the merged bridge lost its index");
|
||||
|
||||
// A 227 m tower is the tallest thing on the board; it has to cast.
|
||||
assert.equal(merged.castShadow, true);
|
||||
});
|
||||
|
||||
test("the bridge is still shaped like a bridge after the merge", () => {
|
||||
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
|
||||
const merged = meshes(bridge)[0];
|
||||
assert.ok(merged);
|
||||
merged.geometry.computeBoundingBox();
|
||||
const box = merged.geometry.boundingBox;
|
||||
assert.ok(box);
|
||||
|
||||
// Towers to 2.27 units, deck at 0.67, cables sagging between. Baking the
|
||||
// transforms into the geometry is where a merge goes wrong — a part that lost
|
||||
// its translation collapses onto the origin and the box stops matching.
|
||||
assert.ok(Math.abs(box.max.y - 2.27) < 0.05, `the towers top out at ${box.max.y.toFixed(2)}`);
|
||||
assert.ok(box.min.y > 0, "something sank below the water line");
|
||||
assert.ok(box.max.x - box.min.x > 0.4, "the bridge has no span");
|
||||
});
|
||||
|
||||
test("two bridges are two draw calls, not sixty-eight", () => {
|
||||
const second: Bridge = { ...GOLDEN_GATE, name: "bay-bridge", color: 0x9aa6ad };
|
||||
const group = createBridges(flatWorld({ bridges: [GOLDEN_GATE, second] }));
|
||||
assert.equal(meshes(group).length, 2);
|
||||
// Different colours, so genuinely two materials. Each bridge builds its own
|
||||
// batch, which is deliberate: the cache cannot outlive the build, because
|
||||
// `createScene().dispose()` walks the scene disposing every material it finds
|
||||
// and a shared cache would hand the next board a disposed one.
|
||||
assert.equal(materialsIn(group).size, 2);
|
||||
});
|
||||
|
||||
// ---- Roads -----------------------------------------------------------------
|
||||
|
||||
test("identical roads share one material and one mesh", () => {
|
||||
const street: Road = {
|
||||
kind: "street",
|
||||
width: 0.1,
|
||||
path: [
|
||||
[37.7, -122.4],
|
||||
[37.75, -122.42],
|
||||
[37.8, -122.45],
|
||||
],
|
||||
};
|
||||
const group = createRoads(flatWorld({ roads: [street, street, street] }));
|
||||
|
||||
// Three streets, one colour: one draw call. Before the cache this was three
|
||||
// materials and three meshes, and it scaled with the pack.
|
||||
assert.equal(materialsIn(group).size, 1);
|
||||
assert.equal(meshes(group).length, 1);
|
||||
|
||||
const merged = meshes(group)[0];
|
||||
assert.ok(merged);
|
||||
// All three really are in there — three drapes of the same path.
|
||||
const vertices = merged.geometry.getAttribute("position").count;
|
||||
assert.ok(vertices > 100, `three roads merged to ${vertices} vertices`);
|
||||
assert.ok(merged.geometry.getAttribute("uv"), "the road deck lost the UVs merging depends on");
|
||||
});
|
||||
|
||||
test("a freeway keeps its median stroke as a second material", () => {
|
||||
const freeway: Road = {
|
||||
kind: "freeway",
|
||||
width: 0.14,
|
||||
path: [
|
||||
[37.7, -122.4],
|
||||
[37.9, -122.45],
|
||||
],
|
||||
};
|
||||
const group = createRoads(flatWorld({ roads: [freeway] }));
|
||||
// Two colours is two calls, and that is the floor rather than a regression:
|
||||
// the stroke is a different colour from the deck it sits on.
|
||||
assert.equal(meshes(group).length, 2);
|
||||
assert.equal(materialsIn(group).size, 2);
|
||||
});
|
||||
Reference in New Issue
Block a user