d464459838
Ten agents wrote this in parallel against CONTRACT.md, which exists because the five design agents before them collided on fifteen blocking points — four files specified twice with incompatible contents, three separate backends for one box, and `Environment` exported twice meaning different things. What landed: a Stage owning only the renderer and the loop, with the city and an office as two scenes over it. They cannot share one — San Francisco is ~94 m per scene unit with 3.6x vertical exaggeration and an office is 1 unit = 1 m — and the city is paused rather than disposed on the way in, because rebuilding its 336,864-point heightfield costs about a second on the way back out. Offices are data. `src/offices/lumbridge-hq.ts` is fifteen rooms and seventy-six seats, and it is the file a self-hoster copies. Walls are a segment list with 1-D openings, so doors and windows are holes punched in a wall rather than placed objects, and the pass that splits a wall around its openings hands the walk-mode collider its segments for free. The sun is real. `solar.ts` is a NOAA/Meeus implementation with no imports at all — not even three.js — so time of day keeps working on a laptop in a field. Verified against known values: 75.45 degrees at the June solstice in SF, 28.79 at December, sunset at 03:15Z. The first screenshot after wiring it was a black rectangle, which turned out to be correct: it was midnight in San Francisco. Presence binds to a seat id and never to a coordinate. The pack knows where `eng-04` is; who is sitting in it is private data behind an API. Same shape as the marker rule, one level in. Two corrections to ARCHITECTURE.md are in here. Containment does not discharge ODbL — publishing OSM-derived coordinates is Public Use of a Derivative Database wherever the rows live, so the rule is about the geocoder (US Census, public domain) and not the storage. And a person at a desk is not a Marker; markers are geographic. One contract gap surfaced only in a screenshot: two agents read `height` on a viewpoint differently, so the establishing shot aimed at empty air fourteen metres above the roof. It now means what the same field means for a city. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
128 lines
4.3 KiB
TypeScript
128 lines
4.3 KiB
TypeScript
/**
|
|
* The stage: one renderer, one loop, one canvas, and a scene you can swap.
|
|
*
|
|
* Stage owns *only* the WebGL renderer, the RAF loop and resize. It has no
|
|
* camera, no lights and no picking — those are per-scene and live in
|
|
* `SceneKit`, because a city and an office cannot share a `THREE.Scene` at all:
|
|
* SF's `latScale` puts one scene unit at ~94 m with 3.6x vertical
|
|
* exaggeration, and an office renders at 1 unit = 1 m.
|
|
*
|
|
* The swap **retains and pauses** the outgoing scene rather than disposing it.
|
|
* That is a measured choice, not a preference: SF's heightfield is 484 x 696
|
|
* lattice points and `world.ts` records a ~1.0 s build, so throwing the city
|
|
* away every time somebody steps into an office means paying a second of
|
|
* rebuild on the way back out. Stage therefore disposes nothing it did not
|
|
* create — whoever built a `StageScene` disposes it, when they actually mean
|
|
* to be rid of it. See CONTRACT.md §1.
|
|
*/
|
|
|
|
import * as THREE from "three";
|
|
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
|
|
export interface StageScene {
|
|
scene: THREE.Scene;
|
|
camera: THREE.PerspectiveCamera;
|
|
controls: OrbitControls;
|
|
onEnter?(): void;
|
|
onExit?(): void;
|
|
tick(dt: number, elapsed: number): void;
|
|
dispose(): void;
|
|
}
|
|
|
|
export interface Stage {
|
|
renderer: THREE.WebGLRenderer;
|
|
setScene(s: StageScene): void;
|
|
current(): StageScene | null;
|
|
dispose(): void;
|
|
}
|
|
|
|
export interface StageOptions {
|
|
antialias?: boolean;
|
|
/** Device pixel ratio ceiling. Above 2 the cost is real and the gain is not. */
|
|
maxPixelRatio?: number;
|
|
shadows?: boolean;
|
|
}
|
|
|
|
export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {}): Stage {
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: options.antialias ?? true });
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, options.maxPixelRatio ?? 2));
|
|
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
|
|
if (options.shadows ?? true) {
|
|
renderer.shadowMap.enabled = true;
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
}
|
|
|
|
let currentScene: StageScene | null = null;
|
|
|
|
// Elapsed time is tracked per scene rather than per stage. A scene that sat
|
|
// paused for forty seconds should come back where it left off, not jump
|
|
// forty seconds into whatever it animates.
|
|
const elapsedByScene = new WeakMap<StageScene, number>();
|
|
|
|
// Compared against CSS pixels, because `canvas.width` is in device pixels and
|
|
// differs from `clientWidth` on every retina display — checking it would call
|
|
// `setSize` on every single frame.
|
|
let lastWidth = 0;
|
|
let lastHeight = 0;
|
|
|
|
function applyViewport(target: StageScene) {
|
|
const w = canvas.clientWidth;
|
|
const h = canvas.clientHeight;
|
|
if (w === 0 || h === 0) return;
|
|
target.camera.aspect = w / h;
|
|
target.camera.updateProjectionMatrix();
|
|
}
|
|
|
|
function resize() {
|
|
const w = canvas.clientWidth;
|
|
const h = canvas.clientHeight;
|
|
if (w === 0 || h === 0) return;
|
|
if (w === lastWidth && h === lastHeight) return;
|
|
lastWidth = w;
|
|
lastHeight = h;
|
|
renderer.setSize(w, h, false);
|
|
if (currentScene) applyViewport(currentScene);
|
|
}
|
|
|
|
const clock = new THREE.Clock();
|
|
let raf = 0;
|
|
|
|
function tick() {
|
|
raf = requestAnimationFrame(tick);
|
|
// Clamped, so a backgrounded tab returning does not advance every animation
|
|
// by however long it was gone.
|
|
const dt = Math.min(clock.getDelta(), 0.05);
|
|
resize();
|
|
const active = currentScene;
|
|
if (!active) return;
|
|
const elapsed = (elapsedByScene.get(active) ?? 0) + dt;
|
|
elapsedByScene.set(active, elapsed);
|
|
active.tick(dt, elapsed);
|
|
renderer.render(active.scene, active.camera);
|
|
}
|
|
tick();
|
|
|
|
const onWindowResize = () => resize();
|
|
window.addEventListener("resize", onWindowResize);
|
|
|
|
return {
|
|
renderer,
|
|
setScene(s) {
|
|
if (s === currentScene) return;
|
|
currentScene?.onExit?.();
|
|
currentScene = s;
|
|
// The incoming camera may never have seen this canvas, and the canvas may
|
|
// have been resized while the scene was paused.
|
|
applyViewport(s);
|
|
s.onEnter?.();
|
|
},
|
|
current: () => currentScene,
|
|
dispose() {
|
|
cancelAnimationFrame(raf);
|
|
window.removeEventListener("resize", onWindowResize);
|
|
currentScene = null;
|
|
renderer.dispose();
|
|
},
|
|
};
|
|
}
|