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
+312
View File
@@ -0,0 +1,312 @@
/**
* Every surface in the library, keyed on what it *is* rather than what colour
* it happens to be.
*
* `SurfaceRole` is a closed union on purpose. A registry keyed on strings would
* let an asset ask for `"grey"` and get one, and then the day somebody wants a
* warm office there would be forty files to edit and no list of what to edit.
* Keyed on roles, the whole appearance of the world is `palette.ts` plus the
* table below, and an asset that asks for `deskSurface` gets whatever a desk
* surface is *here* — which is what makes eighteen independently-written asset
* builders look like one library.
*
* The roles are named after the object, never after the finish: `partitionFabric`
* and not `blueFelt`, `screenDisplay` and not `black`. Same rule as
* `Marker.colorKey` one level in (ARCHITECTURE.md §3.3) — this module renders
* surfaces and takes no position on what they mean.
*
* There is exactly one `THREE.Material` per role per registry, and every mesh in
* the office shares it. That sharing is half of the draw-call budget; the other
* half is `parts.ts` merging geometry per material.
*/
import * as THREE from "three";
import { DEFAULT_INTERIOR_PALETTE, type InteriorPalette } from "./palette.ts";
import { TextureBin, type TextureKind } from "./textures.ts";
/**
* The closed set of surfaces this library knows how to be.
*
* Adding a role is a three-line change — here, in `ROLE_SPECS`, and in
* `ROLE_SHIFTS` in `palette.ts` — and the compiler will not let you forget the
* third.
*/
export type SurfaceRole =
// Floors
| "floorSlab"
| "carpet"
| "carpetAccent"
| "woodFloor"
| "polishedConcrete"
| "tile"
// Ceilings
| "ceilingTile"
| "ceilingBaffle"
// The vertical shell
| "plaster"
| "plasterAccent"
| "skirting"
| "glazing"
| "glazingFrame"
| "doorLeaf"
// Partitions
| "partitionFabric"
| "partitionFrame"
// Furniture
| "deskSurface"
| "deskFrame"
| "tableTop"
| "cabinet"
| "shelf"
| "chairShell"
| "chairFabric"
| "chairBase"
| "upholstery"
// Fittings
| "metalTrim"
| "screenBezel"
| "screenDisplay"
| "lightHousing"
| "lightDiffuser"
| "whiteboard"
// Objects
| "foliage"
| "planter"
| "paper"
| "accent";
/**
* `low` is flat Lambert with no maps — the same material class the city uses,
* and the setting that makes an office open on an integrated GPU. `medium` and
* `high` are physically-shaded and differ only in texture resolution.
*/
export type MaterialQuality = "low" | "medium" | "high";
export type SurfaceMaterial = THREE.MeshStandardMaterial | THREE.MeshLambertMaterial;
interface RoleSpec {
/** 0 = mirror, 1 = chalk. Ignored at `low` quality. */
roughness: number;
/** Ignored at `low` quality. */
metalness: number;
texture?: TextureKind;
/** Fraction of the role's own colour emitted. Screens and diffusers only. */
glow?: number;
/** Opacity below 1 makes the material transparent. */
opacity?: number;
/** Leaf cards and glass want both faces. */
doubleSided?: boolean;
}
const ROLE_SPECS: Record<SurfaceRole, RoleSpec> = {
floorSlab: { roughness: 0.9, metalness: 0, texture: "polishedConcrete" },
carpet: { roughness: 0.98, metalness: 0, texture: "carpetLoop" },
carpetAccent: { roughness: 0.98, metalness: 0, texture: "carpetLoop" },
woodFloor: { roughness: 0.55, metalness: 0, texture: "woodPlank" },
polishedConcrete: { roughness: 0.4, metalness: 0.05, texture: "polishedConcrete" },
tile: { roughness: 0.3, metalness: 0, texture: "tileGrid" },
ceilingTile: { roughness: 0.95, metalness: 0, texture: "ceilingTile" },
ceilingBaffle: { roughness: 0.9, metalness: 0, texture: "fabricWeave" },
plaster: { roughness: 0.92, metalness: 0, texture: "plasterPaint" },
plasterAccent: { roughness: 0.92, metalness: 0, texture: "plasterPaint" },
skirting: { roughness: 0.6, metalness: 0 },
// Glass writes no depth. With it on, anything behind a window disappears
// depending on which mesh the sorter happens to draw first, and a meeting
// room made of glass is exactly the case where that is most visible.
glazing: { roughness: 0.05, metalness: 0.1, opacity: 0.22, doubleSided: true },
glazingFrame: { roughness: 0.35, metalness: 0.7 },
doorLeaf: { roughness: 0.6, metalness: 0 },
partitionFabric: { roughness: 0.95, metalness: 0, texture: "fabricWeave" },
partitionFrame: { roughness: 0.4, metalness: 0.6 },
deskSurface: { roughness: 0.45, metalness: 0, texture: "woodPlank" },
deskFrame: { roughness: 0.4, metalness: 0.65 },
tableTop: { roughness: 0.4, metalness: 0, texture: "woodPlank" },
cabinet: { roughness: 0.6, metalness: 0.05 },
shelf: { roughness: 0.55, metalness: 0, texture: "woodPlank" },
chairShell: { roughness: 0.55, metalness: 0.05 },
chairFabric: { roughness: 0.95, metalness: 0, texture: "fabricWeave" },
chairBase: { roughness: 0.35, metalness: 0.75 },
upholstery: { roughness: 0.92, metalness: 0, texture: "fabricWeave" },
metalTrim: { roughness: 0.3, metalness: 0.85 },
screenBezel: { roughness: 0.5, metalness: 0.2 },
screenDisplay: { roughness: 0.2, metalness: 0, glow: 0.4 },
lightHousing: { roughness: 0.4, metalness: 0.5 },
lightDiffuser: { roughness: 0.9, metalness: 0, glow: 0.85 },
whiteboard: { roughness: 0.15, metalness: 0, texture: "whiteboard" },
foliage: { roughness: 0.8, metalness: 0, doubleSided: true },
planter: { roughness: 0.7, metalness: 0 },
paper: { roughness: 0.9, metalness: 0 },
accent: { roughness: 0.6, metalness: 0.1 },
};
/**
* Authored `SurfaceId` strings to roles.
*
* An office pack carries `SurfaceId` — a loose namespaced string like
* `"tera:carpet.loop"` — because a pack is data and must not depend on this
* module to be parsed or stored (`interiors/types.ts`). Resolution happens here,
* once, and unknown ids fall back rather than throwing: a pack with one typo in
* it should still open, the same way an unregistered `AssetId` gets a
* placeholder box.
*
* The general rule is that the first dot-segment after the namespace is the
* role, so `tera:carpet.loop`, `tera:carpet.broadloom` and a self-hoster's
* `acme:carpet.whatever` all land on `carpet` for free. This table is only for
* the names where that reads badly.
*/
const SURFACE_ALIASES: Record<string, SurfaceRole> = {
paint: "plaster",
plasterboard: "plaster",
wall: "plaster",
wood: "woodFloor",
timber: "woodFloor",
concrete: "polishedConcrete",
glass: "glazing",
ceiling: "ceilingTile",
felt: "partitionFabric",
fabric: "partitionFabric",
laminate: "deskSurface",
steel: "metalTrim",
metal: "metalTrim",
aluminium: "metalTrim",
screen: "screenDisplay",
plant: "foliage",
};
const ROLE_NAMES = new Set(Object.keys(ROLE_SPECS));
export interface MaterialRegistryOptions {
palette?: InteriorPalette;
quality?: MaterialQuality;
/**
* Share a bin with another registry — two registries in one page (an office
* being previewed beside the one you are in) should not draw the carpet
* twice. The registry disposes only a bin it made itself.
*/
textures?: TextureBin;
}
export class MaterialRegistry {
readonly palette: InteriorPalette;
readonly quality: MaterialQuality;
readonly textures: TextureBin;
private readonly ownsTextures: boolean;
private readonly base = new Map<SurfaceRole, SurfaceMaterial>();
private readonly ghosts = new Map<SurfaceRole, SurfaceMaterial>();
private readonly tints = new Map<string, SurfaceMaterial>();
constructor(options: MaterialRegistryOptions = {}) {
this.palette = options.palette ?? DEFAULT_INTERIOR_PALETTE;
this.quality = options.quality ?? "high";
this.ownsTextures = options.textures === undefined;
this.textures = options.textures ?? new TextureBin(this.quality);
}
/** The one shared material for a role. Do not mutate it. */
get(role: SurfaceRole): SurfaceMaterial {
const hit = this.base.get(role);
if (hit) return hit;
const made = this.create(role, this.palette[role]);
made.name = role;
this.base.set(role, made);
return made;
}
/**
* A translucent copy of a role, for the wall-occlusion fade — the walls
* between the camera and where you are looking go ghost rather than being
* hidden, so the floorplan stays readable from outside.
*
* The map is dropped deliberately: carpet grain at 18% opacity is visual
* noise on top of whatever it is supposed to be letting you see. Depth
* writing goes with it, for the same reason glazing does not write depth.
*/
ghostOf(role: SurfaceRole): SurfaceMaterial {
const hit = this.ghosts.get(role);
if (hit) return hit;
const ghost = this.get(role).clone();
ghost.name = `${role}:ghost`;
ghost.map = null;
ghost.transparent = true;
ghost.opacity = 0.18;
ghost.depthWrite = false;
ghost.side = THREE.FrontSide;
this.ghosts.set(role, ghost);
return ghost;
}
/**
* A role recoloured for one instance — what an asset calls once the caller's
* palette has turned a `Prop.colorKey` into a number. Cached, because a
* hundred chairs in three colours should still be three materials.
*/
tinted(role: SurfaceRole, color: number): SurfaceMaterial {
const key = `${role}:${color.toString(16)}`;
const hit = this.tints.get(key);
if (hit) return hit;
const made = this.create(role, color);
made.name = key;
this.tints.set(key, made);
return made;
}
/**
* Turn an authored `SurfaceId` into a role. Unknown ids give `fallback`.
*
* `undefined` in gives `fallback` too, so a caller can pass an optional field
* straight through: `materials.resolve(room.floor, "carpet")`.
*/
resolve(surface: string | undefined, fallback: SurfaceRole): SurfaceRole {
if (!surface) return fallback;
const local = surface.includes(":") ? surface.slice(surface.indexOf(":") + 1) : surface;
const head = local.split(".")[0] ?? "";
if (ROLE_NAMES.has(head)) return head as SurfaceRole;
return SURFACE_ALIASES[head] ?? fallback;
}
/** Convenience for the common `resolve` then `get`. */
forSurface(surface: string | undefined, fallback: SurfaceRole): SurfaceMaterial {
return this.get(this.resolve(surface, fallback));
}
private create(role: SurfaceRole, color: number): SurfaceMaterial {
const spec = ROLE_SPECS[role];
const map = spec.texture ? this.textures.get(spec.texture) : null;
const transparent = spec.opacity !== undefined && spec.opacity < 1;
const shared = {
color,
map,
side: spec.doubleSided ? THREE.DoubleSide : THREE.FrontSide,
transparent,
opacity: spec.opacity ?? 1,
depthWrite: !transparent,
emissive: spec.glow ? color : 0x000000,
emissiveIntensity: spec.glow ?? 0,
};
if (this.quality === "low") return new THREE.MeshLambertMaterial(shared);
return new THREE.MeshStandardMaterial({
...shared,
roughness: spec.roughness,
metalness: spec.metalness,
});
}
dispose(): void {
for (const m of this.base.values()) m.dispose();
for (const m of this.ghosts.values()) m.dispose();
for (const m of this.tints.values()) m.dispose();
this.base.clear();
this.ghosts.clear();
this.tints.clear();
if (this.ownsTextures) this.textures.dispose();
}
}