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
Binary file not shown.
+383
View File
@@ -0,0 +1,383 @@
/**
* An `Office` as a `StageScene`: the shell, the furniture, the people, a camera
* at office scale and a fixed interior light rig.
*
* This is `scene.ts`'s opposite number and it is deliberately the same shape.
* The renderer and the loop live in `Stage`; the camera, controls, flights and
* picking live in a `SceneKit`; what is left here is the office itself. Swap it
* in with `stage.setScene(office)` and the city is *paused, not disposed* —
* rebuilding SF's 336,864-point heightfield on the way back out costs about a
* second, which is the measurement CONTRACT.md §1 is built on.
*
* Whoever builds one of these disposes it. `Stage` disposes nothing it did not
* create, and the city handle's `dispose()` does not reach in here.
*
* ### One unit is one metre, and the camera has to know
*
* Nothing here is shared with the city's camera settings, because none of them
* transfer: SF puts a scene unit at ~94 m and clips at 900, and using those
* numbers indoors gives you a near plane thicker than a desk. An office runs
* near 0.05, far 300, and orbits between about a metre and the width of the
* building.
*
* ### No Atmosphere
*
* CONTRACT.md §4: an office gets `fog: null`, no `scene.background` drive and
* its own fixed rig. Daylight through the windows is a later refinement and
* explicitly not a v1 coupling — an office that dims at dusk because a weather
* station said so is a nice idea and a bad dependency for a room that has to
* render with no network at all.
*
* ### Orbit dollhouse is the only navigation mode
*
* Walk mode is not built here. `Plan` already produces the collision segments it
* will need, which is the point of doing the wall split once, but v1 orbits: the
* ceilings come off, the walls between you and what you are looking at go
* translucent, and the existing camera, flight and picking machinery is reused
* verbatim.
*/
import * as THREE from "three";
import { createSceneKit, type Pose } from "../engine/scenekit.ts";
import type { StageScene } from "../engine/stage.ts";
import type { LightingState, View } from "../engine/types.ts";
import type { AssetRegistry } from "../assets/kit.ts";
import { MaterialRegistry, type MaterialQuality } from "../assets/materials.ts";
import type { InteriorPalette } from "../assets/palette.ts";
// Importing the catalogue registers the built-in `tera:` assets into the shared
// `kit`. A caller who passes their own registry is left alone with it — theirs
// is theirs, and re-registering ours over the top would clobber a deliberate
// replacement of a built-in id.
import "../assets/office/index.ts";
import { createFurnishings, type Furnishings } from "./furnish.ts";
import { Plan, type PlanOptions } from "./plan.ts";
import { createPresenceLayer, type PresenceLayer, type PresencePalette } from "./presence.ts";
import { createShell, type Shell, type WallInfo } from "./shell.ts";
import type { Office, Point2, Presence, Viewpoint } from "./types.ts";
export interface OfficeSceneOptions {
/**
* The renderer's canvas. Orbit input and pointer coordinates are read against
* it, so this is `stage.renderer.domElement` — the office shares the city's
* renderer and has its own everything else.
*/
dom: HTMLElement;
/**
* Bring your own, to share one set of materials and textures across two
* offices. Made here otherwise, and disposed here only if it was made here.
*/
materials?: MaterialRegistry;
quality?: MaterialQuality;
palette?: InteriorPalette;
/** Defaults to the shared `kit`. */
registry?: AssetRegistry;
/** Resolves a `Prop.colorKey` to a colour. Opaque to everything in here. */
colorFor?: (key: string) => number | undefined;
/** Resolves a `Presence.colorKey` to a colour. Also opaque. */
presencePalette?: PresencePalette;
onPresencePick?: (presence: Presence | null) => void;
/** Overrides the fixed interior rig. Must carry `sky: null` and `fog: null`. */
lighting?: LightingState;
/** Defaults to false — the lid comes off, because that is the whole view. */
showCeilings?: boolean;
/** Fade the walls you are looking through. Defaults to true. */
occlusionFade?: boolean;
/**
* Flat colour behind the building. `null` leaves `scene.background` alone,
* which shows the page through the canvas.
*/
background?: number | null;
plan?: PlanOptions;
}
export interface OfficeScene extends StageScene {
plan: Plan;
/** The pack's viewpoints, as the thing a legend prints and `flyTo` is keyed on. */
views: View[];
flyTo(viewId: string): void;
current(): string | null;
onViewChange(fn: (id: string) => void): void;
/** Occupancy, bound by seat id. Safe to call before the scene is shown. */
setPresence(people: Presence[]): void;
/** Scene-space label anchors per presence id, for an HTML overlay. */
anchors: Map<string, THREE.Vector3>;
setCeilingsVisible(visible: boolean): void;
setLighting(state: LightingState): void;
}
export function createOfficeScene(office: Office, options: OfficeSceneOptions): OfficeScene {
const plan = new Plan(office, options.plan ?? {});
const scene = new THREE.Scene();
scene.name = `office:${office.id}`;
const ownsMaterials = options.materials === undefined;
const materials =
options.materials ??
new MaterialRegistry({
quality: options.quality ?? "high",
...(options.palette ? { palette: options.palette } : {}),
});
// The building's own size decides the camera limits, the shadow extent and how
// far away to put the sun. A 12 m studio and a 60 m floor plate want different
// answers to all three, and none of them is a constant anybody should be
// tuning by hand per pack.
const span = Math.max(plan.bounds.width, plan.bounds.depth, 8);
const kit = createSceneKit({
scene,
dom: options.dom,
fov: 50,
near: 0.05,
far: 300,
minDistance: 1.2,
maxDistance: span * 1.8,
// Just short of horizontal, so the camera cannot get under the floor slab
// and look up at the building's unlit underside.
maxPolarAngle: Math.PI / 2.04,
dampingFactor: 0.08,
shadowExtent: Math.max(8, span * 0.7),
shadowMapSize: 2048,
shadowNear: 0.5,
shadowFar: span * 4,
// An office is a hundredth of the city's scale, and the default bias is
// tuned for the city: at 1 unit = 1 m it detaches every contact shadow.
shadowBias: -0.0004,
sunDistance: Math.max(24, span * 1.4),
// Offices are small and a flight across one is short. At the city's rate it
// reads as a stall.
flightSpeed: 0.95,
});
kit.applyLighting(options.lighting ?? officeInterior());
if (options.background !== null) {
// A room has walls and no horizon, so nothing here computes a sky
// (CONTRACT.md §4) — but with the ceilings off you are looking at the
// building from outside it, and the outside cannot be nothing. One flat
// colour, set once, derived from the floor so it belongs to the palette
// rather than being picked.
scene.background =
options.background !== undefined
? new THREE.Color(options.background)
: new THREE.Color(materials.palette.floorSlab).multiplyScalar(0.45);
}
const shell: Shell = createShell(plan, { materials });
const furnishings: Furnishings = createFurnishings(plan, {
materials,
...(options.registry ? { registry: options.registry } : {}),
...(options.colorFor ? { colorFor: options.colorFor } : {}),
});
const presence: PresenceLayer = createPresenceLayer(plan, options.presencePalette ?? {});
scene.add(shell.group, furnishings.group, presence.group);
shell.ceilings.visible = options.showCeilings ?? false;
// ---- Viewpoints ---------------------------------------------------------
const viewpointById = new Map(plan.viewpoints.map((v) => [v.id, v]));
const views: View[] = [...plan.viewpoints];
let currentView: string | null = plan.arrival()?.id ?? null;
const viewListeners: ((id: string) => void)[] = [];
/**
* `height` raises the CAMERA above the floor; the target stays down near it.
*
* This is the city's meaning of the same two field names — read
* `chapterPose` in engine/scene.ts, where the target sits on the ground and
* only the camera is lifted — and matching it matters more than any argument
* for the alternative. An earlier version put target *and* camera at
* `floorY + height`, i.e. a horizontal look from that altitude, and the
* reference pack's establishing shot (`height: 14`, a building with 2.8 m
* ceilings) aimed the camera at empty air fourteen metres above the roof with
* the office out of frame below. Two readings of one field, and the pack
* author's was the reasonable one.
*
* `TARGET_Y` is a little above the floor rather than on it so an eye-level
* viewpoint looks at a room instead of at people's shoes.
*/
function poseFor(viewpoint: Viewpoint): Pose {
const floorY = plan.level(viewpoint.levelId)?.floorY ?? 0;
const focus = viewpoint.focus;
const TARGET_Y = 1.2;
return {
target: new THREE.Vector3(focus.at.x, floorY + TARGET_Y, focus.at.z),
position: new THREE.Vector3(
focus.at.x + Math.sin(focus.rotation) * focus.distance,
floorY + Math.max(focus.height, TARGET_Y + 0.3),
focus.at.z + Math.cos(focus.rotation) * focus.distance,
),
};
}
/**
* Where you arrive when the pack declares no viewpoints at all.
*
* Deliberately a dollhouse rather than an eye-level shot: with nothing
* authored there is no first impression to honour, and the useful default is
* the one that shows you what you have got.
*/
function overview(): Pose {
const floorY = plan.levels[0]?.floorY ?? 0;
const c = plan.bounds.center;
return {
target: new THREE.Vector3(c.x, floorY + 1, c.z),
position: new THREE.Vector3(c.x, floorY + span * 0.75, c.z + span * 0.85),
};
}
const arrival = plan.arrival();
kit.setPose(arrival ? poseFor(arrival) : overview());
function flyTo(viewId: string) {
const viewpoint = viewpointById.get(viewId);
if (!viewpoint) return;
kit.flyTo(poseFor(viewpoint));
if (currentView !== viewId) {
currentView = viewId;
for (const fn of viewListeners) fn(viewId);
}
}
// ---- Picking ------------------------------------------------------------
// `pickables` is rebuilt in place whenever occupancy changes, so the getter
// rather than the array: the office outlives any one set of people in it.
kit.setPicking<Presence>({
targets: () => presence.pickables,
resolve: (hit) => (hit.object.userData.presence as Presence | undefined) ?? null,
onChange: (person) => options.onPresencePick?.(person),
});
// ---- Occlusion fade -----------------------------------------------------
const fade = options.occlusionFade ?? true;
const lastEye = new THREE.Vector3(NaN, NaN, NaN);
const lastTarget = new THREE.Vector3(NaN, NaN, NaN);
const eye: Point2 = { x: 0, z: 0 };
const look: Point2 = { x: 0, z: 0 };
/**
* Walls between the camera and what it is looking at go translucent.
*
* A 2-D crossing test against each wall's centreline, which is why `shell.ts`
* stamps the segment on the mesh — the alternative is a raycast per wall per
* frame against geometry that has already been merged past recognition. Walls
* whose top is below the target are left alone: you can see over a 1.4 m
* partition, so it is not in the way, and fading it only makes the floor look
* unfinished.
*
* Recomputed only when the camera has actually moved. Orbit damping means it
* settles within a few frames of the pointer stopping, and then this costs
* nothing at all.
*/
function updateOcclusion() {
if (!fade) return;
const camera = kit.camera;
const target = kit.controls.target;
if (camera.position.distanceToSquared(lastEye) < 4e-4 && target.distanceToSquared(lastTarget) < 4e-4) {
return;
}
lastEye.copy(camera.position);
lastTarget.copy(target);
eye.x = camera.position.x;
eye.z = camera.position.z;
look.x = target.x;
look.z = target.z;
for (const mesh of shell.wallMeshes) {
const info = mesh.userData.wall as WallInfo | undefined;
if (!info) continue;
const blocking = info.top > target.y + 0.25 && segmentsCross(eye, look, info.from, info.to);
shell.setGhosted(mesh, blocking);
}
}
updateOcclusion();
// ---- The scene, as the stage sees it ------------------------------------
return {
scene,
camera: kit.camera,
controls: kit.controls,
plan,
views,
anchors: presence.anchors,
flyTo,
current: () => currentView,
onViewChange(fn) {
viewListeners.push(fn);
},
setPresence(people) {
presence.setPresence(people);
},
setCeilingsVisible(visible) {
shell.ceilings.visible = visible;
},
setLighting(state) {
kit.applyLighting(state);
},
// Stepping back out to the city should retire the hover with it, or the
// detail card for whoever the pointer was over survives the journey.
onExit: () => kit.resetPick(),
tick(dt) {
kit.tick(dt);
updateOcclusion();
},
dispose() {
presence.dispose();
furnishings.dispose();
shell.dispose();
kit.dispose();
// The shared `PartBin` is never disposed — it is module-level and every
// other asset in the page is still using it.
if (ownsMaterials) materials.dispose();
scene.clear();
},
};
}
/**
* The fixed interior rig: a soft high sun, a strong hemisphere for the bounce a
* real room has and a real-time renderer does not, no sky and no fog.
*
* It computes nothing. There is no `Atmosphere` indoors, on purpose
* (CONTRACT.md §4) — the numbers below are a lighting designer's, not a
* physicist's, and their job is that a room looks like a room with no server, no
* clock and no configuration, which is the acceptance test the whole repo is
* held to.
*
* The ambient term is high by outdoor standards and has to be: a directional
* light and a hemisphere between them put nothing at all on the underside of a
* desk, and with the ceilings off there is no surface left to bounce from.
*/
export function officeInterior(): LightingState {
return {
// Steeply down and a little to one side. A low interior sun rakes across the
// floor and throws desk shadows halfway across the room, which reads as late
// afternoon through a window that has not been built yet.
sun: { direction: [0.32, 0.89, 0.32], color: 0xfff4e6, intensity: 1.15 },
hemisphere: { sky: 0xf3f6f9, ground: 0x70737a, intensity: 1.45 },
ambient: { color: 0xffffff, intensity: 0.42 },
sky: null,
fog: null,
};
}
/**
* Whether two segments properly cross in plan.
*
* The same test `Plan` uses on outlines, which does not export it — six lines
* duplicated rather than a query added to `Plan` for something that is a fact
* about two segments and not about an office.
*/
function segmentsCross(a1: Point2, a2: Point2, b1: Point2, b2: Point2): boolean {
const d1 = cross(a1, a2, b1);
const d2 = cross(a1, a2, b2);
const d3 = cross(b1, b2, a1);
const d4 = cross(b1, b2, a2);
return d1 * d2 < 0 && d3 * d4 < 0;
}
function cross(o: Point2, a: Point2, b: Point2): number {
return (a.x - o.x) * (b.z - o.z) - (a.z - o.z) * (b.x - o.x);
}
File diff suppressed because it is too large Load Diff
+246
View File
@@ -0,0 +1,246 @@
/**
* People at seats.
*
* This is `markers.ts` one level in, and it is deliberately just as ignorant. It
* renders `Presence[]`, looks colours up by `colorKey` in a palette the caller
* supplies, and does not know that a presence is a person, that a colour means
* "in today", or that anybody works anywhere. That mapping belongs to the
* adapter in the consuming app. See ARCHITECTURE.md §3.3.
*
* ### Bound to a seat id, never to a coordinate
*
* A `Presence` carries `seatId` and no position, and this file is where that
* pays off: the office pack knows where `eng-04` is, a private API knows who is
* in it, and neither one has to know the other. Publishing the geometry and
* publishing the people are therefore two separate acts, which is what lets the
* first happen at all. A presence whose seat is not in the plan is dropped —
* there is nowhere to put it, and inventing a spot on the floor would quietly
* turn a private id into a public coordinate, which is the exact thing the split
* exists to prevent.
*
* ### Figures
*
* Two poses, one merged geometry each, one material per colour, one mesh per
* person. Low-poly on purpose: at fifty people that is fifty draw calls, which
* is the same order as the whole rest of the office, and a room reads as
* occupied from the silhouette long before anyone counts the polygons.
*/
import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import {
parts as sharedParts,
placementMatrix,
type PartBin,
type Placement,
} from "../assets/parts.ts";
import type { Plan } from "./plan.ts";
import type { Presence, SeatPose } from "./types.ts";
/**
* Caller-supplied `colorKey` -> colour. The same shape as the city's
* `MarkerPalette` and named separately on purpose: they are two palettes with
* two sets of keys, and one type exported twice meaning two things is the
* failure CONTRACT.md exists to stop.
*/
export type PresencePalette = Record<string, number>;
const FALLBACK_COLOR = 0x9aa4ad;
/** How far above the crown an HTML label should hang, in metres. */
const LABEL_LIFT = 0.16;
export interface PresenceLayer {
group: THREE.Group;
/** Raycast targets, for hover and click. One per person. */
pickables: THREE.Object3D[];
/** Scene-space head position per presence id, for the HTML label layer. */
anchors: Map<string, THREE.Vector3>;
setPresence(people: Presence[]): void;
dispose(): void;
}
export interface PresenceOptions {
parts?: PartBin;
}
export function createPresenceLayer(
plan: Plan,
palette: PresencePalette,
options: PresenceOptions = {},
): PresenceLayer {
const parts = options.parts ?? sharedParts;
const group = new THREE.Group();
group.name = "presence";
const pickables: THREE.Object3D[] = [];
const anchors = new Map<string, THREE.Vector3>();
// Built on first use rather than up front: an office with nobody in it should
// not pay for two figures it never draws.
const figures = new Map<SeatPose, { geometry: THREE.BufferGeometry; crown: number }>();
const materials = new Map<string, THREE.Material>();
const warned = new Set<string>();
function figureFor(pose: SeatPose) {
const hit = figures.get(pose);
if (hit) return hit;
const made = buildFigure(parts, pose);
figures.set(pose, made);
return made;
}
function materialFor(key: string): THREE.Material {
const hit = materials.get(key);
if (hit) return hit;
// Standard rather than Lambert, unlike the city's pins: an office is lit by
// a physically-shaded rig and a Lambert figure in it reads as a cardboard
// cutout. No map — a `colorKey` is a colour and nothing else.
const made = new THREE.MeshStandardMaterial({
color: palette[key] ?? FALLBACK_COLOR,
roughness: 0.72,
metalness: 0.02,
});
made.name = `presence:${key}`;
materials.set(key, made);
return made;
}
function clear() {
for (const child of [...group.children]) group.remove(child);
pickables.length = 0;
anchors.clear();
}
function setPresence(people: Presence[]) {
clear();
for (const person of people) {
const seat = plan.seat(person.seatId);
if (!seat) {
// Once per seat id, not once per update: a feed pointed at last
// quarter's floorplan should say so, not fill the console.
if (!warned.has(person.seatId)) {
warned.add(person.seatId);
console.warn(`[tera/interiors] presence "${person.id}" sits at unknown seat "${person.seatId}"`);
}
continue;
}
const figure = figureFor(seat.pose);
const mesh = new THREE.Mesh(figure.geometry, materialFor(person.colorKey));
mesh.name = `presence:${person.id}`;
mesh.position.set(seat.position.x, seat.y, seat.position.z);
// The seat's own facing, unconverted. A seat, the chair at it and the
// person in it all carry one rotation — see `Yaw` in `types.ts`.
mesh.rotation.y = seat.facing;
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData.presence = person;
group.add(mesh);
pickables.push(mesh);
anchors.set(
person.id,
new THREE.Vector3(seat.position.x, seat.y + figure.crown + LABEL_LIFT, seat.position.z),
);
}
}
return {
group,
pickables,
anchors,
setPresence,
dispose() {
clear();
for (const figure of figures.values()) figure.geometry.dispose();
figures.clear();
for (const material of materials.values()) material.dispose();
materials.clear();
},
};
}
// ---- Figures --------------------------------------------------------------
/**
* One person, as eleven primitives merged into one buffer.
*
* Built out of the shared `PartBin` rather than hand-rolled cylinders for the
* same reason every asset is: a limb that is the same `rod()` as a chair leg
* looks like it belongs in the same world. The proportions are a 1.72 m adult;
* the seated pose puts the hips at a 460 mm seat pan, which is the height
* `tera:seat.task-chair` is authored at.
*
* A figure faces **-Z** at yaw zero, which is what a seat's `facing` means and
* which is why an occupant, their chair and their desk all take one rotation.
* Knees therefore run toward -Z and the backrest is behind the figure at +Z.
*
* Returns the crown height as well, because the label anchor wants it and
* measuring a merged buffer afterwards to find out how tall a person is would be
* silly.
*/
function buildFigure(
parts: PartBin,
pose: SeatPose,
): { geometry: THREE.BufferGeometry; crown: number } {
const pieces: { geometry: THREE.BufferGeometry; place: Placement }[] = [];
const add = (geometry: THREE.BufferGeometry, place: Placement) => {
pieces.push({ geometry, place });
};
const limb = parts.cylinder(8);
const torso = parts.cylinder(10);
const head = parts.sphere(10);
const hipSpan = 0.11;
const armSpan = 0.21;
let crown: number;
if (pose === "stand") {
const hip = 0.86;
const shoulder = 1.4;
for (const side of [-1, 1]) {
add(limb, { x: side * hipSpan, y: 0, size: [0.15, hip, 0.16] });
add(limb, { x: side * armSpan, y: shoulder - 0.46, size: [0.11, 0.46, 0.12] });
}
add(torso, { y: hip, size: [0.42, shoulder - hip, 0.26] });
add(limb, { y: shoulder, size: [0.11, 0.07, 0.11] });
add(head, { y: shoulder + 0.05, size: [0.22, 0.24, 0.23] });
crown = shoulder + 0.29;
} else {
// Seated: shins down in front of the seat, thighs forward at pan height,
// torso up from the hips. A thigh is a limb pitched by -pi/2, which sends
// the unit cylinder's +Y along -Z — out in front of the occupant.
const pan = 0.46;
const knee = 0.3;
const shoulder = pan + 0.5;
for (const side of [-1, 1]) {
add(limb, { x: side * hipSpan, y: 0, z: -knee, size: [0.14, pan - 0.02, 0.15] });
add(limb, {
x: side * hipSpan,
y: pan - 0.02,
z: 0.02,
size: [0.15, knee + 0.06, 0.16],
pitch: -Math.PI / 2,
});
add(limb, { x: side * armSpan, y: shoulder - 0.42, size: [0.1, 0.42, 0.11] });
}
add(torso, { y: pan, size: [0.4, shoulder - pan, 0.26] });
add(limb, { y: shoulder, size: [0.1, 0.06, 0.1] });
add(head, { y: shoulder + 0.04, size: [0.21, 0.23, 0.22] });
crown = shoulder + 0.27;
}
const matrix = new THREE.Matrix4();
const transformed = pieces.map((piece) => {
const clone = piece.geometry.clone();
clone.applyMatrix4(placementMatrix(piece.place, matrix));
return clone;
});
// Every part here is an indexed primitive from the same bin, so the merge
// cannot hit the indexed/non-indexed refusal documented in `assets/office/
// common.ts`. If a future figure grows a `roundedBox` shoulder, it will.
const merged = mergeGeometries(transformed, false);
for (const geometry of transformed) geometry.dispose();
if (!merged) throw new Error("presence: could not merge the figure geometry");
merged.name = `presence:${pose}`;
return { geometry: merged, crown };
}
+449
View File
@@ -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;
}
+481
View File
@@ -0,0 +1,481 @@
/**
* The office contract — what an office pack is allowed to say.
*
* This is the interiors half of `engine/types.ts`, and it obeys the same rule:
* the engine renders what an `Office` describes and takes no position on what it
* means. It does not know that a room is a room because people meet in it, that
* `zone: "eng"` is a team, or that anybody is sitting anywhere. See
* ARCHITECTURE.md §3.3 and CONTRACT.md §2.
*
* **Everything here is strictly JSON-serialisable.** No functions, no classes,
* no getters, no `THREE` types, no `Date`. A pack hand-written as a `.ts` module
* and a pack arriving as a `.json` body over HTTP have to be literally the same
* thing — the moment one of them can carry a callback, the other stops being a
* pack and starts being a second format nobody maintains.
*
* `Plan` (`src/interiors/plan.ts`) is the only thing that turns an `Office` into
* geometry. Its output — wall runs, collision segments, resolved placements — is
* a build product and is deliberately not authorable here.
*
* ### Coordinates and units
*
* Offices are authored in **metres, 1 unit = 1 m**, which is also how assets are
* authored (CONTRACT.md §3). This is not the city's scale and cannot be: SF puts
* one scene unit at ~94 m with 3.6x vertical exaggeration, which is why an
* office gets its own `THREE.Scene`.
*
* The floor is the **XZ plane** with **+Y up**, three.js's convention. Plan view
* throughout this file means looking down at that plane with +X to the right and
* +Z down the page.
*/
import type { Pin, View } from "../engine/types.ts";
// ---- Geometry -------------------------------------------------------------
/**
* A point on the floor plane, in metres.
*
* Named fields rather than a `[number, number]` tuple — unlike the city's
* `LatLng`, where the pair has an obvious reading, `[4, 6]` in an office gives a
* reader no way to tell whether the second number is depth or height. The field
* is called `z` precisely so that the answer is on the page.
*/
export interface Point2 {
x: number;
z: number;
}
/**
* A polygon, in plan.
*
* Do **not** repeat the first point at the end; the outline is implicitly
* closed. Author them counter-clockwise in plan view. `Plan` re-winds anything
* that arrives the other way round rather than rendering a black hole, so this
* is a style rule and not a trap.
*/
export type Outline = Point2[];
/**
* Yaw about the +Y axis, in radians. Zero faces **-Z**, and the angle increases
* counter-clockwise seen from above.
*
* That is exactly three.js's `object.rotation.y`, and it is stated in those
* terms on purpose. A plan-space "degrees clockwise from north" angle — which is
* what `District.gridAngle` and `Aircraft.heading` use, because for a city it
* reads better — would need a sign flip on the way into the scene, and a sign
* flip that lives in one place is a sign flip that eventually gets applied
* twice. Nothing converts this.
*/
export type Yaw = number;
// ---- Identifiers ----------------------------------------------------------
/**
* A namespaced asset id, like `"tera:desk.workstation"`.
*
* **`src/assets/kit.ts` is the authority** on what ids exist and what they
* build; this alias exists so that the office contract does not import the mesh
* library. It is the same type by the same name in both places — one string, one
* meaning — not two ideas that collided. Interiors is data; assets is code that
* turns data into geometry, and data should not depend on it to be parsed,
* validated or stored.
*
* The `tera:` namespace is the one this repo ships. A self-hoster registers
* `acme:desk.standing` with `overrides: "tera:desk.workstation"` and reskins the
* reference office without forking it. An id with no registration resolves to a
* placeholder box rather than throwing, because an office pack with one typo in
* it should still open.
*/
export type AssetId = string;
/**
* A material id for a floor, wall or ceiling finish, like `"tera:carpet.loop"`.
*
* Same arrangement as `AssetId`, one level down: `src/assets/materials.ts` holds
* the `MaterialRegistry` and the closed `SurfaceRole` union it is keyed on, and
* this alias is the loose string an authored pack carries. The name differs from
* `SurfaceRole` deliberately — a type name exported twice meaning two different
* things is the exact failure CONTRACT.md §4 was written to stop.
*
* There is no per-instance tint here, unlike `Prop.colorKey`. One blue meeting
* room is a *material* — register `acme:paint.blue` and point the wall at it —
* whereas one red chair in a row of grey ones is genuinely an instance. Giving
* surfaces a tint key as well would duplicate the override mechanism that
* already exists and leave two ways to answer the same question.
*/
export type SurfaceId = string;
// ---- The office -----------------------------------------------------------
/**
* One building's interior: the whole authored pack, and the thing a self-hoster
* copies to make their own.
*
* An office contains no people. `Presence` is runtime data that arrives
* separately and binds by seat id — see the note on that type, which is the
* single most important paragraph in this file.
*/
export interface Office {
id: string;
name: string;
/**
* Ground floor first. An office with one storey declares one level; nothing
* else in the format changes.
*/
levels: Level[];
/**
* Named camera poses. `viewpoints[0]` is where you arrive, so put reception —
* or whatever the pack wants a first impression to be — at the front.
*/
viewpoints: Viewpoint[];
meta?: OfficeMeta;
}
/**
* Provenance for a pack, and nothing the renderer reads.
*
* Optional, but a pack meant to be shared should fill in `author` and `license`.
* The art in this repo is Apache-2.0 with the artistic output additionally
* dedicated under CC0-1.0 (CONTRACT.md §3.1); a pack built elsewhere is under
* whatever its author says here, and saying nothing helps nobody.
*/
export interface OfficeMeta {
description?: string;
author?: string;
/** SPDX identifier where there is one, e.g. `"CC0-1.0"`. */
license?: string;
version?: string;
/** ISO-8601 date string. A string, not a `Date` — this has to survive JSON. */
updated?: string;
}
/**
* One storey.
*
* The three `wall*` fields are the storey's defaults, not a constraint: an
* individual `Wall` overrides any of them. They live here because a floor of
* forty walls that are all 3 m of painted plasterboard should say so once, and
* the interesting wall — the 1.4 m partition around the desk bay — should be the
* one that stands out in the source.
*/
export interface Level {
id: string;
name: string;
/** Floor slab height above the office origin, in metres. Ground is `0`. */
elevation: number;
/** Storey height: the default top of a wall, measured from this floor. */
wallHeight: number;
/** Default wall thickness in metres. `Plan` uses 0.12 when this is absent. */
wallThickness?: number;
/** Default wall finish. */
wallSurface?: SurfaceId;
floorplan: Floorplan;
}
/**
* Everything on one storey.
*
* `rooms` and `walls` are required because a level without them is not a level;
* the rest are optional because a bare lobby genuinely has no desk banks, and a
* pack arriving over HTTP will drop empty arrays. Consumers read the optional
* ones as `?? []`.
*/
export interface Floorplan {
rooms: Room[];
walls: Wall[];
props?: Prop[];
deskBanks?: DeskBank[];
seats?: Seat[];
zones?: Zone[];
}
// ---- Rooms ----------------------------------------------------------------
/**
* A floor slab with a name.
*
* **A room implies no walls.** This is the load-bearing half of CONTRACT.md §2:
* rooms are surfaces, walls are a separate explicit list, and the two are not
* derived from each other. Deriving walls from shared room edges sounds tidy
* until it needs float-equality dedup to decide whether two rooms touch, and
* then it is a source of gaps that only appear in one build out of ten.
*
* Rooms may overlap and may leave gaps. An open-plan floor is one big room with
* a handful of walls standing on it.
*/
export interface Room {
id: string;
name: string;
outline: Outline;
floor: SurfaceId;
/**
* Omit for the level default. Explicit `null` means **no ceiling at all** —
* an atrium, a double-height void, or a cutaway you want to look down into.
*/
ceiling?: RoomCeiling | null;
}
/** A ceiling override for one room. Both fields fall back to the level. */
export interface RoomCeiling {
/** Metres above this level's floor. */
height?: number;
surface?: SurfaceId;
}
// ---- Walls and openings ---------------------------------------------------
/**
* A single straight wall segment, from `from` to `to`, centred on that line.
*
* Walls are an explicit list rather than something inferred from room edges, and
* this is the decision the rest of the interiors code is built on. The pass that
* splits a wall around its openings has to run anyway to produce the solid runs
* you can see; running it once produces the **walk-mode collision segments for
* free**, with the gaps in exactly the places you can walk through. Any other
* arrangement keeps two lists in sync by hand.
*
* A wall belongs to no room. It stands where it is put.
*/
export interface Wall {
id: string;
from: Point2;
to: Point2;
/** Metres. Falls back to the level's `wallThickness`. */
thickness?: number;
/** Metres above this level's floor. Falls back to the level's `wallHeight`. */
height?: number;
surface?: SurfaceId;
/** Doors, windows and arches punched out of this wall. Order is irrelevant. */
openings?: Opening[];
}
export type OpeningKind = "door" | "window" | "arch";
/**
* A hole in a wall, described as a 1-D interval along it.
*
* `start` is measured **from the wall's `from` end**, along the wall, in metres;
* `width` runs on from there. That is the whole of the horizontal placement —
* an opening has no position of its own and cannot drift off its wall, which is
* the point of expressing it this way.
*
* Doors and windows are openings, never placeable assets. There is no
* `tera:shell.door`: shipping both a door prop and a door-shaped hole would put
* every opening in the scene twice, or — worse, because it is invisible until
* somebody walks through a wall — leave the collider with no gap where the door
* is. `Plan` hands each solid run to a parameterised `wallRun` part, and the
* frame, leaf or glazing is drawn by the opening itself.
*
* Typical values, in metres: a door is `sill: 0, head: 2.1`; a window is
* `sill: 0.9, head: 2.2`; an arch is `sill: 0, head: 2.4`. They are required
* rather than defaulted by kind, because a data contract with hidden per-kind
* defaults is one where the numbers you read are not the numbers you get.
*/
export interface Opening {
kind: OpeningKind;
/** Metres along the wall from the `from` end to the near edge of the hole. */
start: number;
/** Metres. Must be positive, and must fit inside the wall. */
width: number;
/** Bottom of the hole, metres above this level's floor. */
sill: number;
/** Top of the hole, metres above this level's floor. */
head: number;
}
// ---- Props ----------------------------------------------------------------
/**
* One instance of one asset, placed.
*
* `kind` is an `AssetId` because the prop registry and the asset registry are
* the same registry (CONTRACT.md §3) — there is no separate table of things you
* are allowed to put in a room.
*/
export interface Prop {
id: string;
kind: AssetId;
/** Where it stands, on the floor plane. */
position: Point2;
/** See `Yaw`. */
rotation: Yaw;
/**
* Metres above this level's floor. Omitted means standing on it, which is
* true of nearly everything; a wall-mounted screen or a monitor on a desktop
* says so here.
*/
elevation?: number;
/**
* Uniform scale, or per-axis. Use sparingly — an asset that is wanted at
* another size is usually better registered as its own id.
*/
scale?: number | [number, number, number];
/**
* An opaque palette key, resolved by the caller's palette exactly as
* `Pin.colorKey` is. The engine will not learn that `"focus"` means a quiet
* booth or that red means anything at all.
*/
colorKey?: string;
/**
* The id of a `Seat` this prop belongs to — the chair pulled up to `eng-04`.
*
* Purely an address. The prop is still positioned by its own `position`;
* binding it to a seat is what lets an occupancy layer dim the empty chairs
* without knowing which mesh is which.
*/
seat?: string;
}
/**
* A row or grid of identical desks, declared once.
*
* `Plan` expands one of these into props and seats. It exists for a plain
* reason: the reference office is about 180 props, and 180 hand-written literals
* is not a file anybody edits twice. A bank of twelve is six lines here.
*
* The grid is laid out in the bank's own frame — `columns` run along its local
* +X, `rows` step along its local +Z — and then rotated by `rotation` about
* `origin`, which is the centre of station (1, 1).
*
* ### Generated ids
*
* These are part of the contract, because a `Presence` binds to a seat id and a
* pack author has to be able to predict what it will be without running the
* expansion. Stations are numbered from 1, along each row and then down the
* rows, and the number is zero-padded to two digits:
*
* - seat `${seatPrefix ?? id}-01`, `-02`, …
* - desk prop `${id}-desk-01`, chair prop `${id}-chair-01`
*
* So a bank with `id: "eng"` and no `seatPrefix` gives you `eng-04` as the
* fourth seat, which is the id the private occupancy API is expected to know.
*/
export interface DeskBank {
id: string;
/** The asset placed at every station. */
desk: AssetId;
/** Placed at every seat, if given. */
chair?: AssetId;
/** Centre of the first station, before rotation. */
origin: Point2;
/** Orientation of the whole bank. See `Yaw`. */
rotation: Yaw;
/** Stations across, along the bank's local +X. At least 1. */
columns: number;
/** Rows deep, along the bank's local +Z. At least 1. */
rows: number;
/** Centre-to-centre spacing between columns, in metres. */
pitch: number;
/** Centre-to-centre spacing between rows, in metres. Defaults to `pitch`. */
rowPitch?: number;
/**
* When true, consecutive rows face each other rather than all facing the same
* way — bench seating, where two rows share a run of desktop. This is the
* difference between an office that looks laid out and one that looks like a
* spreadsheet, which is why it is here rather than left to the author to fake
* with two banks.
*/
facingRows?: boolean;
/** Metres from the desk centre to the seat, on the seated side. */
seatOffset?: number;
/** Pose for every seat in the bank. Defaults to `"sit"`. */
pose?: SeatPose;
/** Overrides the bank `id` as the seat-id prefix. */
seatPrefix?: string;
}
// ---- Seats and zones ------------------------------------------------------
export type SeatPose = "sit" | "stand";
/**
* A place a person can be.
*
* **Seats are addresses.** A seat is not a chair and not a person; it is a
* stable name for a spot on the floor, so that something outside this repo can
* say "eng-04" and mean somewhere without ever being told a coordinate. Ids
* should be stable across pack edits for the same reason street numbers are.
*/
export interface Seat {
id: string;
position: Point2;
/** Which way an occupant looks. See `Yaw`. */
facing: Yaw;
pose: SeatPose;
}
/**
* A named region of floor.
*
* A zone has no behaviour and no effect on geometry. It is a label on an area —
* a team's corner, a quiet zone, a phone-booth cluster — that a consuming app
* can highlight, filter or count against. Whether membership of a zone *means*
* anything is not the engine's business, which is why `colorKey` is opaque and
* there is no `kind` field.
*/
export interface Zone {
id: string;
name: string;
outline: Outline;
/** Opaque palette key, resolved by the caller. */
colorKey?: string;
}
// ---- Viewpoints -----------------------------------------------------------
/**
* A named camera pose — the office analogue of a city `Chapter`.
*
* It shares `View` with the city rather than redeclaring it, because the half
* that the interface cares about — what the legend prints, what `flyTo` is keyed
* on — is identical, and only the pose differs: a chapter focuses on a latitude
* and longitude, a viewpoint focuses on a point in metres on a particular level.
*/
export interface Viewpoint extends View {
levelId: string;
focus: {
/** What the camera looks at, on the floor plane. */
at: Point2;
/** Metres from the target to the camera. */
distance: number;
/** Metres above this level's floor, for both target and camera height. */
height: number;
/** Camera azimuth about the target. See `Yaw`. */
rotation: Yaw;
};
}
// ---- Presence -------------------------------------------------------------
/**
* Somebody at a seat.
*
* **A `Presence` binds to a `seatId` and never to a coordinate, and it never
* appears in an office pack.** This is the whole trick, and it is the marker
* rule one level in.
*
* The pack knows where seat `eng-04` is. A private API knows who is sitting in
* it. Neither knows the other, so occupancy can be private data behind
* authentication — names, faces, who is in today — while the office geometry
* stays public, open-source and copyable by anyone. If a presence carried an
* `{x, z}`, then publishing the geometry and publishing the people would be the
* same act, and one of them could never be published at all.
*
* It extends `Pin` for the same reason `Marker` does: a thing worth pointing at,
* with a label and an opaque colour key, that a detail card can render without
* caring whether it was placed by latitude or by seat id.
*/
export interface Presence extends Pin {
seatId: string;
}