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:
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* The building itself: walls, floor slabs, ceilings, and the frames and glazing
|
||||
* that line the holes in the walls.
|
||||
*
|
||||
* Everything here comes out of a `Plan` and nothing here reads an `Office`. The
|
||||
* wall pass has already happened — a run is a solid piece of wall with its
|
||||
* openings taken out of it, and its numbers are already in office-world metres
|
||||
* with the level's elevation baked in — so this file is the arithmetic-free half
|
||||
* of the job: place a `wallRun` part per run, triangulate a polygon per room,
|
||||
* and merge.
|
||||
*
|
||||
* ### One mesh per wall, and why not fewer
|
||||
*
|
||||
* Merging every wall on a floor into one buffer would be one draw call instead
|
||||
* of forty, and it is the wrong trade. The occlusion fade — the walls between
|
||||
* the camera and what you are looking at going translucent, so the floorplan
|
||||
* stays readable from outside — swaps a *material* on a whole object, and an
|
||||
* object has to be one wall for that to mean anything. Forty extra draw calls is
|
||||
* a rounding error next to the ~1,200 objects `parts.ts` was written to
|
||||
* collapse; losing the ability to fade one wall is not.
|
||||
*
|
||||
* Each wall mesh therefore carries its 2-D segment and its top height in
|
||||
* `userData.wall`, which is everything the fade needs to decide without walking
|
||||
* geometry, and `setGhosted` is the swap. See CONTRACT.md §3, which is where
|
||||
* `ghostOf()` landed on the material registry for exactly this.
|
||||
*
|
||||
* ### Ceilings are a group, not a clip plane
|
||||
*
|
||||
* Orbit mode hides them wholesale (`shell.ceilings.visible = false`) and that is
|
||||
* the entire mechanism. No CSG, no clipping planes, no per-camera cutaway: a
|
||||
* dollhouse is a room with its lid off, and a lid is a thing you can take off.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { MaterialRegistry, SurfaceRole } from "../assets/materials.ts";
|
||||
import { MeshBin, parts as sharedParts, type PartBin } from "../assets/parts.ts";
|
||||
import { TEXTURE_TILE_METRES } from "../assets/textures.ts";
|
||||
import type { LevelPlan, Plan, ResolvedOpening, ResolvedRoom, WallRun } from "./plan.ts";
|
||||
import type { Outline, Point2 } from "./types.ts";
|
||||
|
||||
/** Jamb and head width on an opening's lining, in metres. */
|
||||
const FRAME_WIDTH = 0.045;
|
||||
/** How far a lining stands proud of its wall on each face, so it reads as a reveal. */
|
||||
const FRAME_PROUD = 0.008;
|
||||
/** Depth of a window's sill board past the wall face, per side. */
|
||||
const SILL_PROUD = 0.03;
|
||||
|
||||
export interface ShellOptions {
|
||||
materials: MaterialRegistry;
|
||||
/** Defaults to the shared bin, which is what everything else uses. */
|
||||
parts?: PartBin;
|
||||
/** Which levels to build. Defaults to every level in the plan. */
|
||||
levelIds?: readonly string[];
|
||||
/** Line the openings with frames and glaze the windows. Defaults to true. */
|
||||
openings?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a wall mesh knows about itself, stamped on `userData.wall`.
|
||||
*
|
||||
* The segment is the wall's centreline in plan, which is what an occlusion test
|
||||
* wants: a camera-to-target ray crossing this line is looking through this wall.
|
||||
* `top` is there so a knee-high partition is never faded — you can see over it,
|
||||
* so it is not in the way.
|
||||
*/
|
||||
export interface WallInfo {
|
||||
wallId: string;
|
||||
levelId: string;
|
||||
from: Point2;
|
||||
to: Point2;
|
||||
/** Office-world metres. */
|
||||
bottom: number;
|
||||
top: number;
|
||||
role: SurfaceRole;
|
||||
}
|
||||
|
||||
export interface Shell {
|
||||
/** Everything below, as one object to add to a scene. */
|
||||
group: THREE.Group;
|
||||
walls: THREE.Group;
|
||||
floors: THREE.Group;
|
||||
/** Hide this to get the dollhouse. */
|
||||
ceilings: THREE.Group;
|
||||
/** Frames and glazing. Separate because glass must not cast a shadow. */
|
||||
openings: THREE.Group;
|
||||
/** Every wall mesh, each carrying a `WallInfo` on `userData.wall`. */
|
||||
wallMeshes: readonly THREE.Mesh[];
|
||||
/** Swap one wall between its own finish and the translucent copy of it. */
|
||||
setGhosted(mesh: THREE.Mesh, ghosted: boolean): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function createShell(plan: Plan, options: ShellOptions): Shell {
|
||||
const { materials } = options;
|
||||
const parts = options.parts ?? sharedParts;
|
||||
const drawOpenings = options.openings ?? true;
|
||||
|
||||
const group = new THREE.Group();
|
||||
group.name = "shell";
|
||||
const walls = new THREE.Group();
|
||||
walls.name = "walls";
|
||||
const floors = new THREE.Group();
|
||||
floors.name = "floors";
|
||||
const ceilings = new THREE.Group();
|
||||
ceilings.name = "ceilings";
|
||||
const openings = new THREE.Group();
|
||||
openings.name = "openings";
|
||||
group.add(walls, floors, ceilings, openings);
|
||||
|
||||
const wallMeshes: THREE.Mesh[] = [];
|
||||
// Every geometry this file makes is a merge or a triangulation it owns
|
||||
// outright, so disposal is a list rather than a traversal. The materials
|
||||
// belong to the registry and are emphatically not ours to dispose.
|
||||
const owned: THREE.BufferGeometry[] = [];
|
||||
|
||||
const levels = options.levelIds
|
||||
? options.levelIds.map((id) => plan.level(id)).filter((l): l is LevelPlan => l !== null)
|
||||
: plan.levels;
|
||||
|
||||
// Frames and glazing are merged across the whole shell rather than per level:
|
||||
// nothing ever fades or hides one on its own, so there is no reason to pay for
|
||||
// the addressability.
|
||||
const frameBin = new MeshBin();
|
||||
const glassBin = new MeshBin();
|
||||
|
||||
for (const level of levels) {
|
||||
const holesByWall = groupBy(level.openings, (o) => o.wallId);
|
||||
for (const [wallId, runs] of groupBy(level.runs, (r) => r.wallId)) {
|
||||
buildWall(level.id, wallId, runs, holesByWall.get(wallId) ?? []);
|
||||
}
|
||||
for (const room of level.rooms) {
|
||||
buildFloor(room);
|
||||
buildCeiling(room);
|
||||
}
|
||||
if (drawOpenings) {
|
||||
for (const opening of level.openings) lineOpening(opening);
|
||||
}
|
||||
}
|
||||
|
||||
if (drawOpenings) {
|
||||
for (const mesh of frameBin.build("openings").children) openings.add(mesh);
|
||||
// Glass casts no shadow and receives none. A shadow-casting pane makes a
|
||||
// window read as a solid panel, which is the one thing a window must not do.
|
||||
for (const mesh of glassBin
|
||||
.build("glazing", { castShadow: false, receiveShadow: false })
|
||||
.children) {
|
||||
// Drawn after the opaque shell, since the material writes no depth and
|
||||
// cannot sort itself against the room behind it.
|
||||
mesh.renderOrder = 1;
|
||||
openings.add(mesh);
|
||||
}
|
||||
for (const mesh of openings.children) {
|
||||
const geo = (mesh as THREE.Mesh).geometry;
|
||||
if (geo) owned.push(geo);
|
||||
}
|
||||
}
|
||||
|
||||
function buildWall(
|
||||
levelId: string,
|
||||
wallId: string,
|
||||
runs: WallRun[],
|
||||
holes: readonly ResolvedOpening[],
|
||||
): void {
|
||||
const first = runs[0];
|
||||
if (!first) return;
|
||||
// Every run of a wall carries the same surface — it is resolved from the
|
||||
// wall, or from the level, and never per run — so a wall is one material and
|
||||
// therefore one mesh. The loop below still handles a group of them, because
|
||||
// a `Shell` that silently drew three quarters of a wall would be worse than
|
||||
// one that drew an unexpected extra mesh.
|
||||
const role = materials.resolve(first.surface, "plaster");
|
||||
const material = materials.get(role);
|
||||
|
||||
const bin = new MeshBin();
|
||||
let bottom = Infinity;
|
||||
let top = -Infinity;
|
||||
for (const run of runs) {
|
||||
const height = run.top - run.bottom;
|
||||
if (height <= 0) continue;
|
||||
bin.add(parts.wallRun(run.length, height, run.thickness), material, {
|
||||
x: run.center.x,
|
||||
y: run.bottom,
|
||||
z: run.center.z,
|
||||
yaw: run.yaw,
|
||||
});
|
||||
bottom = Math.min(bottom, run.bottom);
|
||||
top = Math.max(top, run.top);
|
||||
}
|
||||
if (!Number.isFinite(top)) return;
|
||||
|
||||
const info: WallInfo = {
|
||||
wallId,
|
||||
levelId,
|
||||
...extentOf([...runs, ...holes]),
|
||||
bottom,
|
||||
top,
|
||||
role,
|
||||
};
|
||||
|
||||
for (const child of [...bin.build(`wall:${wallId}`).children]) {
|
||||
const mesh = child as THREE.Mesh;
|
||||
mesh.userData.wall = info;
|
||||
owned.push(mesh.geometry);
|
||||
wallMeshes.push(mesh);
|
||||
walls.add(mesh);
|
||||
}
|
||||
}
|
||||
|
||||
function buildFloor(room: ResolvedRoom): void {
|
||||
const geometry = slabGeometry(room.outline, room.y, true);
|
||||
if (!geometry) return;
|
||||
owned.push(geometry);
|
||||
const mesh = new THREE.Mesh(geometry, materials.forSurface(room.floor, "carpet"));
|
||||
mesh.name = `floor:${room.id}`;
|
||||
mesh.receiveShadow = true;
|
||||
// A floor slab casts nothing — there is nothing under it, and asking the
|
||||
// shadow camera to render the largest polygon in the office for no result is
|
||||
// a straight waste of its budget.
|
||||
mesh.castShadow = false;
|
||||
mesh.userData.roomId = room.id;
|
||||
floors.add(mesh);
|
||||
}
|
||||
|
||||
function buildCeiling(room: ResolvedRoom): void {
|
||||
const ceiling = room.ceiling;
|
||||
if (!ceiling) return;
|
||||
const geometry = slabGeometry(room.outline, ceiling.height, false);
|
||||
if (!geometry) return;
|
||||
owned.push(geometry);
|
||||
const mesh = new THREE.Mesh(geometry, materials.forSurface(ceiling.surface, "ceilingTile"));
|
||||
mesh.name = `ceiling:${room.id}`;
|
||||
// A ceiling that casts a shadow puts the whole room in shade, because the
|
||||
// rig's sun is above it. The room is lit by the rig, not through the slab.
|
||||
mesh.castShadow = false;
|
||||
mesh.receiveShadow = true;
|
||||
mesh.userData.roomId = room.id;
|
||||
ceilings.add(mesh);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lining of one hole: two jambs and a head, a sill board under a window,
|
||||
* and a pane in it.
|
||||
*
|
||||
* A door gets a frame and no leaf. A leaf either stands open — and then it is
|
||||
* a prop in the way of the dollhouse view — or stands shut, and then the room
|
||||
* behind it is invisible from every angle. The collider already has the gap;
|
||||
* the eye should have it too.
|
||||
*/
|
||||
function lineOpening(opening: ResolvedOpening): void {
|
||||
const height = opening.head - opening.sill;
|
||||
if (height <= 0 || opening.width <= 0) return;
|
||||
|
||||
// Windows are trimmed in the glazing frame's finish, doors and arches in the
|
||||
// door's. Same geometry, and the difference is the one a joiner would make.
|
||||
const trim = materials.get(opening.kind === "window" ? "glazingFrame" : "doorLeaf");
|
||||
const depth = opening.thickness + FRAME_PROUD * 2;
|
||||
const half = opening.width / 2;
|
||||
|
||||
for (const side of [-1, 1]) {
|
||||
const at = along(opening.center, opening.yaw, side * (half - FRAME_WIDTH / 2));
|
||||
frameBin.add(parts.box(), trim, {
|
||||
x: at.x,
|
||||
y: opening.sill,
|
||||
z: at.z,
|
||||
size: [FRAME_WIDTH, height, depth],
|
||||
yaw: opening.yaw,
|
||||
});
|
||||
}
|
||||
frameBin.add(parts.box(), trim, {
|
||||
x: opening.center.x,
|
||||
y: opening.head - FRAME_WIDTH,
|
||||
z: opening.center.z,
|
||||
size: [opening.width, FRAME_WIDTH, depth],
|
||||
yaw: opening.yaw,
|
||||
});
|
||||
|
||||
if (opening.kind !== "window") return;
|
||||
|
||||
frameBin.add(parts.box(), trim, {
|
||||
x: opening.center.x,
|
||||
y: opening.sill - 0.03,
|
||||
z: opening.center.z,
|
||||
size: [opening.width + FRAME_WIDTH, 0.03, opening.thickness + SILL_PROUD * 2],
|
||||
yaw: opening.yaw,
|
||||
});
|
||||
glassBin.add(parts.box(), materials.get("glazing"), {
|
||||
x: opening.center.x,
|
||||
y: opening.sill + 0.005,
|
||||
z: opening.center.z,
|
||||
size: [opening.width - FRAME_WIDTH, height - FRAME_WIDTH, 0.012],
|
||||
yaw: opening.yaw,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
group,
|
||||
walls,
|
||||
floors,
|
||||
ceilings,
|
||||
openings,
|
||||
wallMeshes,
|
||||
setGhosted(mesh, ghosted) {
|
||||
if (Boolean(mesh.userData.ghosted) === ghosted) return;
|
||||
const info = mesh.userData.wall as WallInfo | undefined;
|
||||
if (!info) return;
|
||||
mesh.userData.ghosted = ghosted;
|
||||
mesh.material = ghosted ? materials.ghostOf(info.role) : materials.get(info.role);
|
||||
// A ghost that still casts a solid shadow gives itself away instantly.
|
||||
mesh.castShadow = !ghosted;
|
||||
},
|
||||
dispose() {
|
||||
for (const geo of owned) geo.dispose();
|
||||
owned.length = 0;
|
||||
wallMeshes.length = 0;
|
||||
group.clear();
|
||||
walls.clear();
|
||||
floors.clear();
|
||||
ceilings.clear();
|
||||
openings.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Geometry -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A room's polygon as a flat slab at `y`, facing up for a floor and down for a
|
||||
* ceiling.
|
||||
*
|
||||
* It is a surface and not a box. Nothing is ever underneath a floor or above a
|
||||
* ceiling in an office, and the only place the missing thickness would show is
|
||||
* the outer edge of the building seen from below, which the orbit limits do not
|
||||
* let you get to.
|
||||
*
|
||||
* **UVs are the room's own world coordinates in metres**, not a 0..1 unwrap.
|
||||
* Carpet in one room therefore lines up with carpet in the room next door
|
||||
* exactly as laid carpet does, and a 3 m booth and a 30 m floor plate show the
|
||||
* same size of loop. `parts.metricQuad` does this for rectangles; a room is a
|
||||
* polygon, which is why this lives here.
|
||||
*/
|
||||
function slabGeometry(outline: Outline, y: number, up: boolean): THREE.BufferGeometry | null {
|
||||
const count = outline.length;
|
||||
if (count < 3) return null;
|
||||
|
||||
const contour = outline.map((p) => new THREE.Vector2(p.x, p.z));
|
||||
const faces = THREE.ShapeUtils.triangulateShape(contour, []);
|
||||
if (faces.length === 0) return null;
|
||||
|
||||
const position = new Float32Array(count * 3);
|
||||
const normal = new Float32Array(count * 3);
|
||||
const uv = new Float32Array(count * 2);
|
||||
const ny = up ? 1 : -1;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = outline[i];
|
||||
if (!p) continue;
|
||||
position[i * 3] = p.x;
|
||||
position[i * 3 + 1] = y;
|
||||
position[i * 3 + 2] = p.z;
|
||||
normal[i * 3 + 1] = ny;
|
||||
uv[i * 2] = p.x / TEXTURE_TILE_METRES;
|
||||
uv[i * 2 + 1] = p.z / TEXTURE_TILE_METRES;
|
||||
}
|
||||
|
||||
// `Plan` hands over a known winding, but the triangulator's output order is
|
||||
// its own business and a back-facing floor is invisible rather than wrong-
|
||||
// looking. Each triangle is oriented from its own cross product, which costs
|
||||
// three subtractions and cannot be got wrong by a later change of convention.
|
||||
const index: number[] = [];
|
||||
for (const face of faces) {
|
||||
const a = face[0];
|
||||
const b = face[1];
|
||||
const c = face[2];
|
||||
if (a === undefined || b === undefined || c === undefined) continue;
|
||||
const pa = outline[a];
|
||||
const pb = outline[b];
|
||||
const pc = outline[c];
|
||||
if (!pa || !pb || !pc) continue;
|
||||
const facing = (pb.z - pa.z) * (pc.x - pa.x) - (pb.x - pa.x) * (pc.z - pa.z);
|
||||
if (facing * ny > 0) index.push(a, b, c);
|
||||
else index.push(a, c, b);
|
||||
}
|
||||
if (index.length === 0) return null;
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(position, 3));
|
||||
geometry.setAttribute("normal", new THREE.BufferAttribute(normal, 3));
|
||||
geometry.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
|
||||
geometry.setIndex(index);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/** A point `d` metres along a wall of yaw `yaw` from its centre. */
|
||||
function along(center: Point2, yaw: number, d: number): Point2 {
|
||||
// A run's mesh lies along its local +X, which for yaw φ points at
|
||||
// (cos φ, -sin φ) — the same derivation `Plan` uses to place its runs. The
|
||||
// `+ 0` normalises IEEE negative zero for the same reason `Plan` does it: a
|
||||
// north-south wall otherwise reports an x of `-0`, which renders identically
|
||||
// and looks like a bug in every diff.
|
||||
return { x: center.x + Math.cos(yaw) * d + 0, z: center.z - Math.sin(yaw) * d + 0 };
|
||||
}
|
||||
|
||||
/** Anything that knows where it sits along its wall. Runs and openings both do. */
|
||||
interface Interval {
|
||||
center: Point2;
|
||||
yaw: number;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoints of the wall a set of runs and openings came from.
|
||||
*
|
||||
* A run knows where its own centre is and how far along the wall it starts and
|
||||
* ends, which is enough to recover the wall's origin and therefore both of its
|
||||
* ends. Doing it this way rather than reading `Wall.from`/`Wall.to` off the pack
|
||||
* means the segment stamped on the mesh is the segment that was actually drawn,
|
||||
* and a wall `Plan` repaired stays consistent with itself.
|
||||
*
|
||||
* The openings are in the list because a full-height door at the very end of a
|
||||
* wall leaves no run out there — no apron under it, no lintel over it — and the
|
||||
* segment would come up short by the width of the door.
|
||||
*/
|
||||
function extentOf(intervals: readonly Interval[]): { from: Point2; to: Point2 } {
|
||||
const first = intervals[0];
|
||||
if (!first) return { from: { x: 0, z: 0 }, to: { x: 0, z: 0 } };
|
||||
const mid = (first.start + first.end) / 2;
|
||||
const origin = along(first.center, first.yaw, -mid);
|
||||
let start = first.start;
|
||||
let end = first.end;
|
||||
for (const interval of intervals) {
|
||||
start = Math.min(start, interval.start);
|
||||
end = Math.max(end, interval.end);
|
||||
}
|
||||
return {
|
||||
from: along(origin, first.yaw, start),
|
||||
to: along(origin, first.yaw, end),
|
||||
};
|
||||
}
|
||||
|
||||
function groupBy<T, K>(items: readonly T[], key: (item: T) => K): Map<K, T[]> {
|
||||
const out = new Map<K, T[]>();
|
||||
for (const item of items) {
|
||||
const k = key(item);
|
||||
const list = out.get(k);
|
||||
if (list) list.push(item);
|
||||
else out.set(k, [item]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user