db074e9cf7
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>
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
/**
|
|
* The environment rig: lifecycle, caching and the contract it must not break.
|
|
*
|
|
* There is no GL context under `node --test`, so the renderer here is a fake —
|
|
* but a fake of a very specific kind. `PMREMGenerator` never touches WebGL
|
|
* directly: it allocates plain `WebGLRenderTarget` objects, builds plain
|
|
* meshes, and reaches the GPU only through `renderer.render`,
|
|
* `renderer.setRenderTarget` and a handful of state accessors. Stubbing those
|
|
* out runs the *real* generator, the real target allocation and the real
|
|
* blur chain, and leaves only the pixels unwritten. So these tests exercise the
|
|
* actual code path the browser takes, which is the difference between testing
|
|
* the rig and testing a mock of it.
|
|
*
|
|
* The one assertion that needs the fake to be more than a no-op is the dispose
|
|
* check: the render targets the rig allocates are private to it, so they are
|
|
* captured on their way through `setRenderTarget` and identified afterwards by
|
|
* the texture the scene ended up holding.
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import test from "node:test";
|
|
import * as THREE from "three";
|
|
|
|
import { createEnvironmentRig } from "../../engine/environmentRig.ts";
|
|
import type { LightingState } from "../../engine/types.ts";
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
|
|
// ---- A renderer that allocates but does not draw ---------------------------
|
|
|
|
interface FakeRenderer {
|
|
renders: number;
|
|
targets: Set<THREE.WebGLRenderTarget>;
|
|
failNext: boolean;
|
|
as(): THREE.WebGLRenderer;
|
|
}
|
|
|
|
function fakeRenderer(): FakeRenderer {
|
|
const targets = new Set<THREE.WebGLRenderTarget>();
|
|
const state = {
|
|
renders: 0,
|
|
targets,
|
|
failNext: false,
|
|
as(): THREE.WebGLRenderer {
|
|
return stub as unknown as THREE.WebGLRenderer;
|
|
},
|
|
};
|
|
|
|
const stub = {
|
|
autoClear: true,
|
|
toneMapping: THREE.NoToneMapping,
|
|
xr: { enabled: false },
|
|
state: { buffers: { depth: { getReversed: () => false } } },
|
|
getRenderTarget: () => null,
|
|
getActiveCubeFace: () => 0,
|
|
getActiveMipmapLevel: () => 0,
|
|
getClearColor: (target: THREE.Color) => target,
|
|
getClearAlpha: () => 1,
|
|
setClearColor: () => {},
|
|
setClearAlpha: () => {},
|
|
clearDepth: () => {},
|
|
compile: () => {},
|
|
setRenderTarget(target: THREE.WebGLRenderTarget | null) {
|
|
if (target) targets.add(target);
|
|
},
|
|
render() {
|
|
if (state.failNext) {
|
|
state.failNext = false;
|
|
throw new Error("simulated context loss");
|
|
}
|
|
state.renders++;
|
|
},
|
|
};
|
|
|
|
return state;
|
|
}
|
|
|
|
// ---- Lighting states -------------------------------------------------------
|
|
|
|
function lightingState(overrides: Partial<LightingState> = {}): LightingState {
|
|
return {
|
|
sun: { direction: [0.31, 0.86, 0.4], color: 0xfff3e0, intensity: 2.35 },
|
|
hemisphere: { sky: 0xdcecf7, ground: 0x6b6f5e, intensity: 0.92 },
|
|
ambient: { color: 0xffffff, intensity: 0.24 },
|
|
sky: { top: 0x8fb8d8, horizon: 0xd9e6ee },
|
|
fog: { color: 0xd9e6ee, near: 210, far: 460 },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** The render target the scene's environment texture came out of. */
|
|
function targetOf(fake: FakeRenderer, scene: THREE.Scene): THREE.WebGLRenderTarget {
|
|
for (const target of fake.targets) {
|
|
if (target.texture === scene.environment) return target;
|
|
}
|
|
throw new Error("no allocated render target owns the scene's environment texture");
|
|
}
|
|
|
|
// ---- Lifecycle -------------------------------------------------------------
|
|
|
|
for (const kind of ["office", "city"] as const) {
|
|
test(`apply(..., "${kind}") leaves a texture on the scene and dispose frees it`, () => {
|
|
const fake = fakeRenderer();
|
|
const rig = createEnvironmentRig(fake.as());
|
|
const scene = new THREE.Scene();
|
|
|
|
rig.apply(scene, lightingState(), kind);
|
|
|
|
assert.ok(
|
|
scene.environment instanceof THREE.Texture,
|
|
"apply must leave a real texture on the scene, not a placeholder",
|
|
);
|
|
assert.equal(scene.environmentIntensity, 1);
|
|
assert.ok(fake.renders > 0, "the PMREM chain never rendered");
|
|
|
|
// The PMREM target, identified by the texture the scene is holding.
|
|
const target = targetOf(fake, scene);
|
|
let freed = false;
|
|
target.addEventListener("dispose", () => {
|
|
freed = true;
|
|
});
|
|
|
|
rig.dispose();
|
|
|
|
assert.equal(scene.environment, null, "dispose must take the environment back off the scene");
|
|
assert.ok(freed, "dispose must free the PMREM render target, not just drop the reference");
|
|
});
|
|
}
|
|
|
|
test("apply adds nothing to the scene graph", () => {
|
|
const fake = fakeRenderer();
|
|
const rig = createEnvironmentRig(fake.as());
|
|
const scene = new THREE.Scene();
|
|
scene.add(new THREE.Object3D());
|
|
|
|
rig.apply(scene, lightingState(), "office");
|
|
rig.apply(scene, lightingState(), "city");
|
|
|
|
// The environment is a property, not a child. A rig that parented its probe
|
|
// room into the caller's scene would light the office with a nine-quad box
|
|
// floating inside it.
|
|
assert.equal(scene.children.length, 1);
|
|
rig.dispose();
|
|
});
|
|
|
|
test("dispose clears every scene the rig ever wrote to", () => {
|
|
const fake = fakeRenderer();
|
|
const rig = createEnvironmentRig(fake.as());
|
|
const a = new THREE.Scene();
|
|
const b = new THREE.Scene();
|
|
|
|
rig.apply(a, lightingState(), "city");
|
|
rig.apply(b, lightingState(), "city");
|
|
assert.ok(a.environment);
|
|
assert.ok(b.environment);
|
|
|
|
rig.dispose();
|
|
assert.equal(a.environment, null);
|
|
assert.equal(b.environment, null);
|
|
});
|
|
|
|
test("apply after dispose is inert rather than fatal", () => {
|
|
const fake = fakeRenderer();
|
|
const rig = createEnvironmentRig(fake.as());
|
|
const scene = new THREE.Scene();
|
|
rig.dispose();
|
|
|
|
rig.apply(scene, lightingState(), "city");
|
|
assert.equal(scene.environment, null);
|
|
rig.dispose();
|
|
});
|
|
|
|
// ---- Caching ---------------------------------------------------------------
|
|
|
|
test("an unchanged lighting state does not rebuild", () => {
|
|
const fake = fakeRenderer();
|
|
const rig = createEnvironmentRig(fake.as());
|
|
const scene = new THREE.Scene();
|
|
|
|
rig.apply(scene, lightingState(), "city");
|
|
const first = scene.environment;
|
|
const after = fake.renders;
|
|
|
|
for (let i = 0; i < 20; i++) rig.apply(scene, lightingState(), "city");
|
|
|
|
assert.equal(scene.environment, first, "the environment texture was replaced for no reason");
|
|
assert.equal(fake.renders, after, "the PMREM chain ran again for an unchanged sky");
|
|
rig.dispose();
|
|
});
|
|
|
|
test("a change below the quantisation step does not rebuild", () => {
|
|
const fake = fakeRenderer();
|
|
const rig = createEnvironmentRig(fake.as());
|
|
const scene = new THREE.Scene();
|
|
|
|
rig.apply(scene, lightingState(), "city");
|
|
const first = scene.environment;
|
|
|
|
// `Atmosphere` interpolates continuously, so every field of a LightingState
|
|
// moves a fraction every frame. Rebuilding on that would run the whole chain
|
|
// sixty times a second to produce sixty indistinguishable environments.
|
|
rig.apply(
|
|
scene,
|
|
lightingState({
|
|
sun: { direction: [0.3104, 0.8601, 0.4002], color: 0xfff3e1, intensity: 2.352 },
|
|
}),
|
|
"city",
|
|
);
|
|
|
|
assert.equal(scene.environment, first);
|
|
rig.dispose();
|
|
});
|
|
|
|
test("a real change of hour rebuilds, and frees what it replaced", () => {
|
|
const fake = fakeRenderer();
|
|
const rig = createEnvironmentRig(fake.as());
|
|
const scene = new THREE.Scene();
|
|
|
|
rig.apply(scene, lightingState(), "city");
|
|
const noon = targetOf(fake, scene);
|
|
let freed = false;
|
|
noon.addEventListener("dispose", () => {
|
|
freed = true;
|
|
});
|
|
|
|
rig.apply(
|
|
scene,
|
|
lightingState({
|
|
sun: { direction: [0.86, 0.06, -0.5], color: 0xc2795c, intensity: 0.58 },
|
|
sky: { top: 0x2a4275, horizon: 0x9a6a63 },
|
|
}),
|
|
"city",
|
|
);
|
|
|
|
assert.notEqual(scene.environment, noon.texture, "sunset must not reflect noon's sky");
|
|
assert.ok(freed, "the replaced PMREM target leaked");
|
|
rig.dispose();
|
|
});
|
|
|
|
test("city and office are cached separately and do not evict each other", () => {
|
|
const fake = fakeRenderer();
|
|
const rig = createEnvironmentRig(fake.as());
|
|
const city = new THREE.Scene();
|
|
const office = new THREE.Scene();
|
|
|
|
rig.apply(city, lightingState(), "city");
|
|
const cityEnv = city.environment;
|
|
rig.apply(office, lightingState(), "office");
|
|
const officeEnv = office.environment;
|
|
|
|
assert.notEqual(cityEnv, officeEnv, "a room and a sky must not be the same environment");
|
|
|
|
const before = fake.renders;
|
|
rig.apply(city, lightingState(), "city");
|
|
assert.equal(city.environment, cityEnv);
|
|
assert.equal(fake.renders, before, "the office build evicted the city's cached environment");
|
|
rig.dispose();
|
|
});
|
|
|
|
test("rebuilding one kind does not repoint scenes on the other", () => {
|
|
// A page holds both at once — CONTRACT.md §1 keeps the city alive and paused
|
|
// while an office is on screen — so the loop that repoints scenes after a
|
|
// rebuild has to know which kind each scene is on. Getting this wrong lights
|
|
// an office through a wall with the city's sunset.
|
|
const fake = fakeRenderer();
|
|
const rig = createEnvironmentRig(fake.as());
|
|
const city = new THREE.Scene();
|
|
const office = new THREE.Scene();
|
|
|
|
rig.apply(city, lightingState(), "city");
|
|
rig.apply(office, lightingState(), "office");
|
|
const officeEnv = office.environment;
|
|
|
|
rig.apply(
|
|
city,
|
|
lightingState({
|
|
sun: { direction: [0.86, 0.06, -0.5], color: 0xc2795c, intensity: 0.58 },
|
|
sky: { top: 0x2a4275, horizon: 0x9a6a63 },
|
|
}),
|
|
"city",
|
|
);
|
|
|
|
assert.equal(office.environment, officeEnv, "the office was handed the city's new sky");
|
|
assert.notEqual(city.environment, officeEnv);
|
|
rig.dispose();
|
|
});
|
|
|
|
test("a scene left holding a freed target is repointed at the replacement", () => {
|
|
// The other half of the same loop: two city scenes, one rebuild, and the one
|
|
// that did not ask must not be left pointing at a disposed render target.
|
|
const fake = fakeRenderer();
|
|
const rig = createEnvironmentRig(fake.as());
|
|
const a = new THREE.Scene();
|
|
const b = new THREE.Scene();
|
|
|
|
rig.apply(a, lightingState(), "city");
|
|
rig.apply(b, lightingState(), "city");
|
|
|
|
rig.apply(
|
|
a,
|
|
lightingState({ sun: { direction: [0.86, 0.06, -0.5], color: 0xc2795c, intensity: 0.58 } }),
|
|
"city",
|
|
);
|
|
|
|
assert.equal(b.environment, a.environment, "the second city scene kept a freed texture");
|
|
rig.dispose();
|
|
});
|
|
|
|
// ---- Degradation -----------------------------------------------------------
|
|
|
|
test("a renderer that throws costs the environment, not the frame", () => {
|
|
const fake = fakeRenderer();
|
|
const rig = createEnvironmentRig(fake.as());
|
|
const scene = new THREE.Scene();
|
|
|
|
const warnings: unknown[] = [];
|
|
const warn = console.warn;
|
|
console.warn = (...args: unknown[]) => warnings.push(args);
|
|
try {
|
|
fake.failNext = true;
|
|
rig.apply(scene, lightingState(), "city");
|
|
} finally {
|
|
console.warn = warn;
|
|
}
|
|
|
|
assert.equal(scene.environment, null, "a failed build must not leave a half-made environment");
|
|
assert.equal(warnings.length, 1, "a missing environment is undiagnosable from the picture alone");
|
|
|
|
// And it recovers: the next apply tries again rather than latching off.
|
|
rig.apply(scene, lightingState(), "city");
|
|
const recovered: unknown = scene.environment;
|
|
assert.ok(recovered instanceof THREE.Texture);
|
|
rig.dispose();
|
|
});
|
|
|
|
// ---- CONTRACT §4 -----------------------------------------------------------
|
|
|
|
test("the rig constructs no light of any kind", () => {
|
|
// The literal form of the rule, checked against the source, because the whole
|
|
// value of CONTRACT.md §4 is that there is exactly one light owner and this is
|
|
// the module most likely to be tempted into becoming a second one.
|
|
const source = readFileSync(path.join(ROOT, "src/engine/environmentRig.ts"), "utf8");
|
|
const lights = source.match(
|
|
/new\s+THREE\.(Directional|Point|Spot|Rect(Area)?|Hemisphere|Ambient|Light\b)/g,
|
|
);
|
|
assert.equal(lights, null, `environmentRig constructs lights: ${lights?.join(", ")}`);
|
|
// And no import of three's fixed studio room either — the office probe has to
|
|
// stay derived from the LightingState, or the reflections stop knowing what
|
|
// time it is. (The prose above the code discusses `RoomEnvironment` by name,
|
|
// hence matching the import statement rather than the word.)
|
|
assert.doesNotMatch(source, /^\s*import[^\n]*RoomEnvironment/m);
|
|
});
|