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
+304
View File
@@ -0,0 +1,304 @@
/**
* The per-scene half of the renderer: camera, controls, the light rig, camera
* flights and picking.
*
* Everything here is per-scene rather than per-stage, because the city and an
* office want different answers to all of it — different near/far planes,
* different orbit limits, a fixed interior rig against a driven daylight one.
* `Stage` keeps the renderer and the loop; a `SceneKit` is what a `StageScene`
* is built out of. See CONTRACT.md §1.
*
* The kit *applies* a `LightingState`; it never works one out. Whoever owns
* the sun — `Atmosphere` for a city, a fixed constant for an office — computes
* the state and hands it over, and nothing writes back. That is the one
* direction CONTRACT.md §4 asks for.
*/
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import type { LightingState } from "./types.ts";
/** Where the camera sits and what it looks at. Scene units, whatever they mean. */
export interface Pose {
position: THREE.Vector3;
target: THREE.Vector3;
}
/**
* Picking, with the meaning left to the caller.
*
* `resolve` turns a raycast hit into whatever the caller considers picked — the
* kit never reads `userData` itself, because it has no idea what is in there.
*/
export interface PickOptions<T> {
/** A live array is fine; layers that rebuild theirs can pass a getter. */
targets: THREE.Object3D[] | (() => THREE.Object3D[]);
resolve(hit: THREE.Intersection): T | null;
/** Fires only on change, including the change back to `null`. */
onChange(picked: T | null): void;
}
export interface SceneKitOptions {
scene: THREE.Scene;
/** The element pointer coordinates are read against — the renderer's canvas. */
dom: HTMLElement;
fov?: number;
near?: number;
far?: number;
minDistance?: number;
maxDistance?: number;
maxPolarAngle?: number;
dampingFactor?: number;
/** Shadow-camera half-extent, in scene units. */
shadowExtent?: number;
shadowMapSize?: number;
shadowNear?: number;
shadowFar?: number;
shadowBias?: number;
/**
* How far along its direction the sun is placed. A `LightingState` carries a
* unit direction and no distance, because distance is a fact about the scale
* of the scene — 94 m per unit outdoors, 1 m per unit indoors — and not about
* where the sun is.
*/
sunDistance?: number;
/** Flight rate, in fractions of the flight per second. */
flightSpeed?: number;
/** Cursor while something is picked. */
hoverCursor?: string;
}
export interface SceneKit {
camera: THREE.PerspectiveCamera;
controls: OrbitControls;
sun: THREE.DirectionalLight;
hemisphere: THREE.HemisphereLight;
ambient: THREE.AmbientLight;
applyLighting(state: LightingState): void;
/** Jump. Used for the opening pose, where a flight from nowhere is nonsense. */
setPose(pose: Pose): void;
flyTo(pose: Pose): void;
flying(): boolean;
setPicking<T>(options: PickOptions<T>): void;
/** Forget what is under the pointer and say so. */
resetPick(): void;
tick(dt: number): void;
dispose(): void;
}
export function createSceneKit(options: SceneKitOptions): SceneKit {
const { scene, dom } = options;
const sunDistance = options.sunDistance ?? 240;
const flightSpeed = options.flightSpeed ?? 0.65;
const hoverCursor = options.hoverCursor ?? "pointer";
const camera = new THREE.PerspectiveCamera(
options.fov ?? 42,
dom.clientWidth / Math.max(1, dom.clientHeight),
options.near ?? 0.1,
options.far ?? 900,
);
const controls = new OrbitControls(camera, dom);
controls.enableDamping = true;
controls.dampingFactor = options.dampingFactor ?? 0.07;
controls.maxPolarAngle = options.maxPolarAngle ?? Math.PI / 2.12; // never dip under the ground plane
controls.minDistance = options.minDistance ?? 12;
controls.maxDistance = options.maxDistance ?? 340;
// ---- Light rig ----------------------------------------------------------
const sun = new THREE.DirectionalLight(0xffffff, 1);
sun.castShadow = true;
const mapSize = options.shadowMapSize ?? 2048;
sun.shadow.mapSize.set(mapSize, mapSize);
sun.shadow.camera.near = options.shadowNear ?? 10;
sun.shadow.camera.far = options.shadowFar ?? 520;
const extent = options.shadowExtent ?? 170;
sun.shadow.camera.left = -extent;
sun.shadow.camera.right = extent;
sun.shadow.camera.top = extent;
sun.shadow.camera.bottom = -extent;
sun.shadow.bias = options.shadowBias ?? -0.0012;
const hemisphere = new THREE.HemisphereLight(0xffffff, 0x808080, 1);
const ambient = new THREE.AmbientLight(0xffffff, 0.3);
scene.add(sun, hemisphere, ambient);
const sunDirection = new THREE.Vector3();
let sky: THREE.Texture | null = null;
let skyTop = -1;
let skyHorizon = -1;
function applyLighting(state: LightingState) {
const [dx, dy, dz] = state.sun.direction;
sunDirection.set(dx, dy, dz);
// A zero direction would put the sun inside the ground and black the scene
// out; leaving it where it was is the kinder failure.
if (sunDirection.lengthSq() > 0) {
sun.position.copy(sunDirection.normalize().multiplyScalar(sunDistance));
}
sun.color.setHex(state.sun.color);
sun.intensity = state.sun.intensity;
hemisphere.color.setHex(state.hemisphere.sky);
hemisphere.groundColor.setHex(state.hemisphere.ground);
hemisphere.intensity = state.hemisphere.intensity;
ambient.color.setHex(state.ambient.color);
ambient.intensity = state.ambient.intensity;
// A null sky leaves `scene.background` alone entirely, which is what an
// office wants: it has walls, and whatever is behind them is not sky.
if (state.sky && (state.sky.top !== skyTop || state.sky.horizon !== skyHorizon)) {
sky?.dispose();
sky = makeSkyTexture(state.sky.top, state.sky.horizon);
skyTop = state.sky.top;
skyHorizon = state.sky.horizon;
scene.background = sky;
}
if (!state.fog) {
scene.fog = null;
} else if (scene.fog instanceof THREE.Fog) {
scene.fog.color.setHex(state.fog.color);
scene.fog.near = state.fog.near;
scene.fog.far = state.fog.far;
} else {
scene.fog = new THREE.Fog(state.fog.color, state.fog.near, state.fog.far);
}
}
// ---- Camera flights -----------------------------------------------------
const from: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() };
const to: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() };
let flying = false;
let flightT = 0;
function setPose(pose: Pose) {
flying = false;
camera.position.copy(pose.position);
controls.target.copy(pose.target);
controls.update();
}
function flyTo(pose: Pose) {
from.position.copy(camera.position);
from.target.copy(controls.target);
to.position.copy(pose.position);
to.target.copy(pose.target);
flightT = 0;
flying = true;
}
// ---- Picking ------------------------------------------------------------
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let picking: PickOptions<unknown> | null = null;
let picked: unknown = null;
// The raycast runs at most once a frame, off the last pointer position,
// rather than once per `pointermove` — a fast drag across the canvas fires
// dozens of those between two frames and every one of them but the last is
// thrown away.
let pointerDirty = false;
function onPointerMove(event: PointerEvent) {
const rect = dom.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
pointerDirty = true;
}
dom.addEventListener("pointermove", onPointerMove);
function resetPick() {
pointerDirty = false;
if (picked === null) return;
const wasPicking = picking;
picked = null;
dom.style.cursor = "";
wasPicking?.onChange(null);
}
dom.addEventListener("pointerleave", resetPick);
function repick() {
if (!picking || !pointerDirty) return;
pointerDirty = false;
const targets = typeof picking.targets === "function" ? picking.targets() : picking.targets;
const hit = targets.length === 0 ? undefined : raycastFirst(targets);
const next = hit ? picking.resolve(hit) : null;
if (next === picked) return;
picked = next;
dom.style.cursor = next ? hoverCursor : "";
picking.onChange(next);
}
function raycastFirst(targets: THREE.Object3D[]): THREE.Intersection | undefined {
raycaster.setFromCamera(pointer, camera);
return raycaster.intersectObjects(targets, false)[0];
}
return {
camera,
controls,
sun,
hemisphere,
ambient,
applyLighting,
setPose,
flyTo,
flying: () => flying,
setPicking(pick) {
picking = pick as PickOptions<unknown>;
},
resetPick,
tick(dt) {
if (flying) {
flightT = Math.min(1, flightT + dt * flightSpeed);
// 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(from.position, to.position, e);
controls.target.lerpVectors(from.target, to.target, e);
if (flightT >= 1) flying = false;
// The pointer has not moved but the world under it has.
pointerDirty = true;
}
controls.update();
repick();
},
dispose() {
dom.removeEventListener("pointermove", onPointerMove);
dom.removeEventListener("pointerleave", resetPick);
dom.style.cursor = "";
picking = null;
controls.dispose();
scene.remove(sun, hemisphere, ambient);
sun.dispose();
hemisphere.dispose();
ambient.dispose();
sky?.dispose();
if (scene.background === sky) scene.background = null;
},
};
}
/**
* A two-pixel-wide vertical gradient. Cheap, and a `Scene.background` texture
* is stretched to fill regardless, so the width buys nothing.
*/
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;
}