1
0

Spaces: the inside of the world, and a sun that is actually where it should be

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>
This commit is contained in:
Karti Tripathi
2026-08-05 00:11:01 -07:00
parent 36471bbad7
commit d464459838
77 changed files with 14266 additions and 216 deletions
+118 -154
View File
@@ -1,21 +1,39 @@
/**
* The scene: lights, sky, layers, camera flights, render loop.
* The city scene: layers, chapter flights, markers, and the handle the app
* drives it all through.
*
* `createScene` owns a canvas and a `City` and nothing else. It knows nothing
* about React, about any API, or about what the markers mean — the caller hands
* it data and gets back a small imperative handle. That boundary is what lets
* one renderer serve a private map coloured by pipeline state and a public one
* coloured by sector without either being a fork.
*
* The renderer and the loop live in `Stage`; the camera, lights, flights and
* picking live in a `SceneKit`. What is left here — and it is the only thing
* that ought to be here — is the city itself: which layers go in the scene,
* where a chapter puts the camera, and what a pick means. An office builds the
* same two pieces with its own answers and swaps in on the same `Stage`, which
* keeps this city alive and paused rather than rebuilding its ~1.0 s
* heightfield on the way back. See CONTRACT.md §1.
*/
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { createBlocks, createLandmarks } from "./blocks.ts";
import { createFlightLayer, type FlightLayer } from "./flights.ts";
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
import { createSceneKit, type Pose } from "./scenekit.ts";
import { createStage, type Stage, type StageScene } from "./stage.ts";
import { createBridges, createRoads } from "./structures.ts";
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
import type { Chapter, City, FlightSource, Marker, MarkerPalette } from "./types.ts";
import type {
Chapter,
City,
FlightSource,
LightingState,
Marker,
MarkerPalette,
ScenePalette,
} from "./types.ts";
import { World } from "./world.ts";
export interface SceneOptions {
@@ -24,11 +42,27 @@ export interface SceneOptions {
flights?: FlightSource;
/** Fires on hover/click of a marker head. */
onMarkerPick?: (marker: Marker | null) => void;
/**
* Opening light rig. Comes from an `Atmosphere` when there is one; without
* one the city gets `cityDaylight()`, because a scene that renders black
* until somebody wires up the sun is not a scene that boots with no config.
*/
lighting?: LightingState;
}
export interface SceneHandle {
world: World;
chapters: Chapter[];
/**
* The renderer and the loop. An office is swapped in with
* `stage.setScene(officeScene)` and this city back in the same way; the one
* that steps out is paused, not thrown away.
*/
stage: Stage;
/** This city, as the thing `stage.setScene` takes. */
stageScene: StageScene;
/** Applies a rig computed elsewhere. The scene never works one out itself. */
setLighting(state: LightingState): void;
flyTo(chapterId: string): void;
current(): string;
onChapterChange(fn: (id: string) => void): void;
@@ -41,47 +75,21 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
const world = new World(city);
const pal = paletteFor(world);
const stage = createStage(canvas);
const scene = new THREE.Scene();
scene.background = makeSkyTexture(pal.skyTop, pal.skyHorizon);
scene.fog = new THREE.Fog(pal.skyHorizon, 210, 460);
const camera = new THREE.PerspectiveCamera(
42,
canvas.clientWidth / Math.max(1, canvas.clientHeight),
0.1,
900,
);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.07;
controls.maxPolarAngle = Math.PI / 2.12; // never dip under the ground plane
controls.minDistance = 12;
controls.maxDistance = 340;
// Late-afternoon sun from the west, which throws the hills' shadows east
// across the flats.
const sun = new THREE.DirectionalLight(0xfff3e0, 2.1);
sun.position.set(-150, 170, 70);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 10;
sun.shadow.camera.far = 520;
const extent = 170;
sun.shadow.camera.left = -extent;
sun.shadow.camera.right = extent;
sun.shadow.camera.top = extent;
sun.shadow.camera.bottom = -extent;
sun.shadow.bias = -0.0012;
scene.add(sun);
scene.add(new THREE.HemisphereLight(0xdcecf7, 0x6b6f5e, 1.05));
scene.add(new THREE.AmbientLight(0xffffff, 0.32));
const kit = createSceneKit({
scene,
dom: stage.renderer.domElement,
fov: 42,
near: 0.1,
far: 900,
minDistance: 12,
maxDistance: 340,
shadowExtent: 170,
shadowFar: 520,
});
kit.applyLighting(options.lighting ?? cityDaylight(pal));
scene.add(createWater(world));
scene.add(createShorePlates(world));
@@ -101,26 +109,21 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
scene.add(flightLayer.group);
}
// ---- Camera flights -----------------------------------------------------
// ---- Chapters -----------------------------------------------------------
const chapterById = Object.fromEntries(city.chapters.map((c) => [c.id, c]));
const first = city.chapters[0];
if (!first) throw new Error(`City "${city.id}" declares no chapters`);
const desiredTarget = new THREE.Vector3();
const desiredPosition = new THREE.Vector3();
const flightFrom = { pos: new THREE.Vector3(), target: new THREE.Vector3() };
let flying = false;
let flightT = 0;
let currentChapter = first.id;
const chapterListeners: ((id: string) => void)[] = [];
function chapterPose(ch: Chapter) {
function chapterPose(ch: Chapter): Pose {
const [x, z] = world.project(ch.focus.lat, ch.focus.lng);
const groundY = world.groundAt(ch.focus.lat, ch.focus.lng);
return {
target: new THREE.Vector3(x, groundY, z),
pos: new THREE.Vector3(
position: new THREE.Vector3(
x + Math.sin(ch.focus.rotation) * ch.focus.distance,
groundY + ch.focus.height,
z + Math.cos(ch.focus.rotation) * ch.focus.distance,
@@ -131,96 +134,67 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
function flyTo(chapterId: string) {
const ch = chapterById[chapterId];
if (!ch) return;
const pose = chapterPose(ch);
flightFrom.pos.copy(camera.position);
flightFrom.target.copy(controls.target);
desiredPosition.copy(pose.pos);
desiredTarget.copy(pose.target);
flightT = 0;
flying = true;
kit.flyTo(chapterPose(ch));
if (currentChapter !== chapterId) {
currentChapter = chapterId;
for (const fn of chapterListeners) fn(chapterId);
}
}
{
const pose = chapterPose(first);
camera.position.copy(pose.pos);
controls.target.copy(pose.target);
controls.update();
}
kit.setPose(chapterPose(first));
// ---- Picking ------------------------------------------------------------
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let hovered: Marker | null = null;
// `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),
});
function onPointerMove(event: PointerEvent) {
const rect = canvas.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(markerLayer.pickables, false)[0];
const marker = (hit?.object.userData.marker as Marker | undefined) ?? null;
if (marker !== hovered) {
hovered = marker;
canvas.style.cursor = marker ? "pointer" : "";
options.onMarkerPick?.(marker);
}
}
canvas.addEventListener("pointermove", onPointerMove);
// ---- The scene, as the stage sees it ------------------------------------
// ---- Loop ---------------------------------------------------------------
const clock = new THREE.Clock();
let raf = 0;
function resize() {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
if (w === 0 || h === 0) return;
if (canvas.width !== w || canvas.height !== h) {
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
}
function tick() {
raf = requestAnimationFrame(tick);
const dt = Math.min(clock.getDelta(), 0.05);
resize();
if (flying) {
flightT = Math.min(1, flightT + dt * 0.65);
// easeInOutCubic — a flight that starts and lands gently
const e = flightT < 0.5 ? 4 * flightT ** 3 : 1 - (-2 * flightT + 2) ** 3 / 2;
camera.position.lerpVectors(flightFrom.pos, desiredPosition, e);
controls.target.lerpVectors(flightFrom.target, desiredTarget, e);
if (flightT >= 1) flying = false;
}
if (options.flights && flightLayer) {
flightTimer -= dt;
if (flightTimer <= 0) {
flightTimer = options.flights.interval;
void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac));
const stageScene: StageScene = {
scene,
camera: kit.camera,
controls: kit.controls,
// Leaving for an office should retire the hover with it; coming back to a
// stale detail card for something the pointer is nowhere near reads as a
// bug.
onExit: () => kit.resetPick(),
tick(dt) {
kit.tick(dt);
if (options.flights && flightLayer) {
flightTimer -= dt;
if (flightTimer <= 0) {
flightTimer = options.flights.interval;
void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac));
}
}
}
controls.update();
renderer.render(scene, camera);
}
tick();
const onWindowResize = () => resize();
window.addEventListener("resize", onWindowResize);
},
dispose() {
options.flights?.dispose?.();
flightLayer?.dispose();
markerLayer.dispose();
kit.dispose();
scene.traverse((obj) => {
const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose();
const mat = mesh.material;
if (Array.isArray(mat)) mat.forEach((m) => m.dispose());
else if (mat) (mat as THREE.Material).dispose();
});
},
};
stage.setScene(stageScene);
return {
world,
chapters: city.chapters,
stage,
stageScene,
setLighting: (state) => kit.applyLighting(state),
flyTo,
current: () => currentChapter,
onChapterChange(fn) {
@@ -230,38 +204,28 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
markerLayer.setMarkers(markers);
},
dispose() {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onWindowResize);
canvas.removeEventListener("pointermove", onPointerMove);
options.flights?.dispose?.();
flightLayer?.dispose();
markerLayer.dispose();
controls.dispose();
scene.traverse((obj) => {
const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose();
const mat = mesh.material;
if (Array.isArray(mat)) mat.forEach((m) => m.dispose());
else if (mat) (mat as THREE.Material).dispose();
});
renderer.dispose();
// Stage first, so nothing ticks a half-disposed scene.
stage.dispose();
stageScene.dispose();
},
};
}
function makeSkyTexture(top: number, horizon: number): THREE.Texture {
const canvas = document.createElement("canvas");
canvas.width = 2;
canvas.height = 256;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D canvas context unavailable");
const grad = ctx.createLinearGradient(0, 0, 0, 256);
grad.addColorStop(0, `#${top.toString(16).padStart(6, "0")}`);
grad.addColorStop(1, `#${horizon.toString(16).padStart(6, "0")}`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 2, 256);
const tex = new THREE.CanvasTexture(canvas);
tex.magFilter = THREE.LinearFilter;
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
/**
* The committed default rig: a late-afternoon sun from the west, which throws
* the hills' shadows east across the flats.
*
* Not an `Atmosphere` and not a substitute for one — it computes nothing from
* time or weather, it is a constant with the city's own sky colours poured in.
* It exists so the engine renders with no server, no clock and no config, which
* is the acceptance test the whole repo is held to.
*/
export function cityDaylight(palette: ScenePalette): LightingState {
return {
sun: { direction: [-0.632, 0.717, 0.295], color: 0xfff3e0, intensity: 2.1 },
hemisphere: { sky: 0xdcecf7, ground: 0x6b6f5e, intensity: 1.05 },
ambient: { color: 0xffffff, intensity: 0.32 },
sky: { top: palette.skyTop, horizon: palette.skyHorizon },
fog: { color: palette.skyHorizon, near: 210, far: 460 },
};
}