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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user