1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/interiors/officeScene.ts
T

927 lines
41 KiB
TypeScript

/**
* 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 the Bay Area's 0.53M-point heightfield on the way back out costs
* about two and a half seconds, 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 remains the default navigation mode
*
* An optional `OfficeWalker` can temporarily possess a local actor and publish a
* chase-camera pose. It is inactive by default; without one, or while inactive,
* the ceilings, occlusion fading, named views and orbit controls behave exactly
* as before.
*
* ### Two depths, and the public one is the architecture without the people
*
* `depth: "public"` is the office an anonymous visitor gets, and the office is
* becoming a front door in its own right, so this is the majority case rather
* than a degraded one. It keeps the shell, the floor plan, the furniture, the
* lighting and every named `View`. It builds **no presence layer at all** — no
* occupants, no avatars, no seat states, nothing to hover that could name a
* person — and `Plan` has already dropped whatever the pack marked
* `audience: "private"` before this file sees it.
*
* The rule the two depths are written to is *build-time exclusion, never
* visibility toggling*. There is no `presence.group.visible = false` path here
* and there must not be one: a scene that constructs the private objects and
* then hides them still hands every one of them to `scene.traverse`, to the
* devtools scene graph and to anyone who types `scene.children` into a console.
* That is a data leak dressed as a privacy feature, and it is worse than not
* having the feature, because it looks like it works.
*
* **None of that is a security boundary.** The office pack is bundled into the
* static build, so its contents are public by construction whatever they are
* marked, and `lumbridge-hq.ts` is fabricated sample data besides. The only
* thing genuinely being withheld from an anonymous visitor is occupancy, and it
* is withheld because live `Presence` comes from the API and **the API is what
* refuses an anonymous caller** — not because this file declined to draw it. If
* a future deployment ever ships real occupant data, that server-side refusal is
* the fix; a `depth` argument in the browser is not, and never will be. See the
* note on `Audience` in `types.ts`.
*/
import * as THREE from "three";
import { createSceneKit, type Pose } from "../engine/scenekit.ts";
import type { StageScene } from "../engine/stage.ts";
import type { LightingState, Pin, 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 Depth, type PlanOptions } from "./plan.ts";
import { createPresenceLayer, type PresenceLayer, type PresencePalette } from "./presence.ts";
import { createShell, type Shell, type WallInfo } from "./shell.ts";
import { createLuminaires, type Luminaires, type Walker } from "./luminaires.ts";
import {
createOfficeWalker,
type OfficeWalker,
type OfficeWalkerOptions,
} from "./officeWalker.ts";
import {
createOfficeMediaPresentation,
type MediaSurfaceDescriptor,
type MediaSurfaceGrant,
type OfficeMediaPresentation,
} from "../media/presentation.ts";
import {
createRobotLayer,
type RobotLayer,
type RobotSpec,
type RobotView,
} from "./robots.ts";
/** Shared empty, so a pack with no robots does not allocate one per call. */
const NO_ROBOTS: readonly RobotView[] = [];
import type { Office, Point2, Presence, Viewpoint } from "./types.ts";
// Re-exported so a caller can name the tier it is asking for without importing
// the resolver. `Plan` is where depth is *applied*; this is where it is chosen.
export type { Depth } from "./plan.ts";
/**
* How wide the horizon plane is, in metres.
*
* Twelve kilometres across, which is far enough that the fog has long since
* saturated before its edge — so the plane never ends anywhere you can see, and
* the far plane never has to be honest about where the ground stops.
*/
const HORIZON_EXTENT = 12_000;
/**
* How much darker the ground is than the air in front of it.
*
* There has to be *some* difference or there is no horizon: paint the ground the
* fog colour exactly and the two meet invisibly, which at noon is a white void
* with a building in it. Half is enough to read as land under sky at every hour
* without ever reading as a painted floor — and because the fog then blends the
* two with distance, the line lands where the haze runs out rather than at an
* arbitrary radius.
*/
const HORIZON_DARKEN = 0.5;
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;
/**
* How much of the office to build. Defaults to `"full"`, which is every
* caller that existed before this option did.
*
* `"public"` is the not-signed-in building: same shell, same plan, same
* furniture, same lighting, same views, and no people. See the header for what
* that means and, more importantly, for what it does not mean.
*
* There is no way to change this after construction, on purpose. Signing in
* while standing in the public office is a `dispose()` and a second
* `createOfficeScene` at `"full"`, which is cheap if you hand both of them the
* same `materials` — the textures are the expensive part and they are drawn
* once per registry, not once per office.
*/
depth?: Depth;
/**
* Bring your own, to share one set of materials and textures across two
* offices — or across the same office reopened at another depth. 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;
/** Full depth only. At `"public"` there is no presence to pick. */
onPresencePick?: (presence: Presence | null) => void;
/**
* Public depth only: the pointer is over a desk, and here is what a stranger
* is allowed to be told about it.
*
* The public office is not a diorama — you can still hover the furniture — but
* what comes back is a `Pin` and never a `Presence`, and its label is
* `"Desk 14"`. It is a separate callback rather than a widened
* `onPresencePick` because the two carry different things: one says who is
* there, and this one says only that there is a there.
*/
onPlacePick?: (place: Pin | null) => void;
/**
* Overrides the fixed interior rig.
*
* It used to have to carry `sky: null` and `fog: null`, because a room has
* walls and no horizon. That is still true of a room with no `site` — but a
* pack that says where it stands gets a real sun, a sky behind the glazing and
* a fog that starts outside the building. See `horizon` below, and CONTRACT.md
* §4, which anticipated exactly this and called it a later refinement.
*
* A `fog` whose `near` is inside the building will fog the building. That is
* the one way to get this badly wrong, and it is the caller's job not to,
* because only the caller knows the scale it is working in.
*/
lighting?: LightingState;
/**
* Put the ground back, this many metres below the level-0 floor.
*
* Absent, and the office floats in a flat colour exactly as it always has.
* Present, and the scene gets one very large horizontal plane at `-drop` and
* the building reads as being *up* — which, for a floor plate two hundred
* metres in the air, is most of the point of siting it at all.
*
* One plane, not a city. A cropped piece of the real terrain was the obvious
* alternative and is a much bigger thing: `blocks.ts` bakes its lot size in
* scene units at the city's ~94 m per unit, so a crop cannot simply be
* rebuilt at an office's 1 m per unit — it has to be built at city scale and
* then scaled into the room, which is a project rather than a detail. A plane
* plus honest fog gets the horizon, the haze and the sense of height, and
* those are the three things you actually feel.
*/
horizon?: { drop: number };
/**
* Humanoids to walk about the floor, one entry per robot.
*
* **Not gated on `depth`, unlike `presence`, and that asymmetry is the point.**
* The build-time-exclusion rule in this file's header is about *occupancy* — a
* `Presence` names a person and comes from an authenticated API, so the public
* office must not construct one. A robot is nobody: it carries no id anybody
* issued, no seat binding, and no data from anywhere. There is nothing to
* withhold, so a stranger gets them too.
*/
robots?: readonly RobotSpec[];
/** Optional local walk actor. Constructed inactive unless `walker.active` says otherwise. */
walker?: OfficeWalkerOptions;
/** 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;
/**
* What this office actually is, so the caller can tell what it got rather than
* assuming it got what it asked for. The UI reads this to decide whether to
* print the "no presence" badge and whether to offer a sign-in.
*/
depth: Depth;
/** Local walk-mode actor, or null when this scene was built as dollhouse-only. */
walker: OfficeWalker | null;
/** Authored monitor/display props; contains no source locators. */
listMediaSurfaces(): MediaSurfaceDescriptor[];
/** Bind only a caller-owned texture plus the result of server authorization and opt-in. */
bindMediaSurface(screenId: string, grant: MediaSurfaceGrant, texture: THREE.VideoTexture): boolean;
clearMediaSurface(screenId: string): boolean;
/** 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.
*
* A no-op at public depth — there is no layer to put anybody in — and it warns
* once rather than silently accepting people it will not draw. A caller that
* finds itself needing that warning is asking an anonymous session for
* occupancy, which is a question the API should already have refused.
*/
setPresence(people: Presence[]): void;
/** Scene-space label anchors per presence id, for an HTML overlay. Empty at public depth. */
anchors: Map<string, THREE.Vector3>;
setCeilingsVisible(visible: boolean): void;
/**
* Draw the robots, or do not.
*
* Visibility only, deliberately. A hidden robot still walks and still moves
* the vectors the luminaires hold, so the fittings above it still come up —
* which is the useful half of the switch rather than a caveat: it is how you
* watch the ceiling respond without a figure in the way. Gating `tick` would
* freeze the building instead.
*/
setRobotsVisible(visible: boolean): void;
setLighting(state: LightingState): void;
/**
* The sun's height, in degrees, from whatever clock the app is running.
*
* This is what turns the lights on. It is a separate call from `setLighting`
* and not a field on `LightingState` for the reason `scene.ts` gives for the
* city's identical pair: a `LightingState` is a rig, and how far below the
* horizon the sun is is a fact about the sky that the rig has already spent.
*/
setSolarElevation(degrees: number): void;
/**
* How much of the building's own light is on, 0..1, after the last
* `setSolarElevation`. The caller adds it to the rig — see `withHouseLights`
* in `daylight.ts`, and CONTRACT.md §4 on why this file does not.
*/
houseLevel(): number;
/**
* Who is moving about the floor, so the fittings above them can come up.
* Cheap; call it every frame. An empty list is the normal state.
*/
setWalkers(walkers: readonly Walker[]): void;
/**
* The robots walking about the floor, live. Empty when the pack asked for
* none.
*
* The **same array** every call, holding vectors the layer mutates in place —
* take the reference once and read it, rather than polling for a snapshot.
* The plan panel and the ceiling lights both consume it that way.
*/
robots(): readonly RobotView[];
}
export function createOfficeScene(office: Office, options: OfficeSceneOptions): OfficeScene {
const depth: Depth = options.depth ?? "full";
// The scene's `depth` wins over anything `plan` carried. There is one tier per
// office and it is chosen here; a `PlanOptions.depth` that disagreed with the
// handle's would produce a scene whose `depth` field was a lie, which is the
// one field a caller has to be able to trust.
const plan = new Plan(office, { ...(options.plan ?? {}), depth });
const scene = new THREE.Scene();
// The public build says so in the scene graph, and the full one keeps the name
// it has always had. Whoever is reading `scene.name` in the devtools is the
// exact person who needs to know which of the two buildings they are looking
// at before they conclude anything from what is missing.
scene.name = depth === "full" ? `office:${office.id}` : `office:${office.id}:public`;
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);
/**
* The depth range, which the horizon changes and nothing else does.
*
* A room needs 5 cm to 300 m. A room with fifteen kilometres of ground under
* it needs the far plane out past the ground — and a 0.05 m near plane against
* an 8 km far plane is a depth ratio of 160,000, which spends the whole buffer
* on the first metre and z-fights every contact shadow in the building.
*
* So the near plane moves with the far one. 0.2 m is still well inside
* `minDistance` (1.2 m), so nothing the camera can actually reach is clipped,
* and the ratio comes back to 40,000 — which a 24-bit buffer holds without
* complaint. The fog saturates a long way before the plane's edge, so the far
* plane never has to be honest about where the ground stops.
*/
const far = options.horizon ? HORIZON_EXTENT * 0.7 : 300;
const near = options.horizon ? 0.2 : 0.05;
const kit = createSceneKit({
scene,
dom: options.dom,
fov: 50,
near,
far,
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),
/**
* The middle of the floor plate, which is nowhere near the origin.
*
* A pack's origin is the **north-west corner of its slab** — that is the
* frame `interiors/types.ts` defines and every pack is authored in. So a
* shadow box centred on the origin puts half of itself outside the building
* to the west and north: for `lumbridge-hq`, 14.4 m of a 48 m plate, about a
* third of the floor, fell outside the frustum and neither cast nor received
* a shadow. Invisible while three's default ±5 box made shadows useless
* everywhere, and obvious the moment they started working.
*
* `y = 0` deliberately: the slab is the receiving surface, and moving the
* target up and down only slides the box along the light's view axis.
*/
shadowTarget: new THREE.Vector3(plan.bounds.center.x, 0, plan.bounds.center.z),
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());
/**
* The sky wins over the flat colour when there is one.
*
* `applyLighting` writes `scene.background` itself when the state carries a
* non-null `sky`, so setting a colour here afterwards would overwrite the
* gradient it just built — the office would compute a sky and then paint over
* it, which is a bug that looks exactly like the sky not working.
*/
const hasSky = (options.lighting ?? officeInterior()).sky !== null;
if (options.background !== null && !hasSky) {
// 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);
}
/**
* The ground, a long way down.
*
* Deliberately vast and deliberately plain. Its job is to end the sky in a
* horizon line and to give the eye something that is obviously *below* the
* floor you are standing on; anything more detailed at this distance is
* detail the fog eats before it reaches the camera.
*
* `MeshBasicMaterial` rather than a lit one, because a plane this size lit by
* a directional sun bands horribly across its own width, and because what it
* should read as is the far ground already washed out by fifteen kilometres of
* air — which is a fog colour, not a surface colour. The fog does the work.
*/
let horizonPlane: THREE.Mesh | null = null;
if (options.horizon) {
const geometry = new THREE.PlaneGeometry(HORIZON_EXTENT, HORIZON_EXTENT);
geometry.rotateX(-Math.PI / 2);
const material = new THREE.MeshBasicMaterial({
// Recoloured on every `setLighting` — see `paintHorizon`. The value here is
// only what it looks like for the one frame before the first rig lands.
color: new THREE.Color(materials.palette.floorSlab).multiplyScalar(0.5),
// The one thing it must do: take the fog, so it fades into the sky at the
// horizon instead of ending in a hard edge halfway up the frame.
fog: true,
depthWrite: true,
});
horizonPlane = new THREE.Mesh(geometry, material);
horizonPlane.name = "horizon";
horizonPlane.position.y = -options.horizon.drop;
// Nothing casts onto it and it receives nothing — it is scenery, and a
// shadow map stretched over fifteen kilometres would resolve nothing anyway.
horizonPlane.receiveShadow = false;
horizonPlane.castShadow = false;
// Its bounding sphere is enormous and always in view; testing it every frame
// is pure cost.
horizonPlane.frustumCulled = false;
scene.add(horizonPlane);
}
/**
* Keep the ground the colour of the air in front of it.
*
* The ground below a tower is not a surface you see, it is fifteen kilometres
* of atmosphere you see *through*, and the colour of that is the fog colour —
* which tracks the clock, so this has to be repainted rather than picked once.
*
* It was picked once, from the floor slab, and the result was the bug this
* exists to fix: a pale concrete sheet twelve kilometres across, sitting 188 m
* below the camera and therefore **nearer than the fog begins**, so it arrived
* at full strength and filled the frame behind the building at midnight.
*
* Slightly darker than the fog rather than equal to it, so there is still a
* horizon: the ground reads as ground near the building and converges on the
* sky at the distance where the fog saturates, which is what distance actually
* looks like.
*/
function paintHorizon(state: LightingState) {
if (!horizonPlane) return;
const material = horizonPlane.material as THREE.MeshBasicMaterial;
const source = state.fog?.color ?? state.hemisphere.ground;
material.color.setHex(source).multiplyScalar(HORIZON_DARKEN);
}
paintHorizon(options.lighting ?? officeInterior());
const shell: Shell = createShell(plan, { materials });
const furnishings: Furnishings = createFurnishings(plan, {
materials,
...(options.registry ? { registry: options.registry } : {}),
...(options.colorFor ? { colorFor: options.colorFor } : {}),
});
const mediaSurfaces: OfficeMediaPresentation = createOfficeMediaPresentation(plan, furnishings.group);
scene.add(mediaSurfaces.group);
/**
* The ceiling, made switchable.
*
* Built from the furnishings rather than from the pack, because what a
* fitting *is* has already been resolved by then: an id has been through the
* registry's override table, and a self-hoster who pointed
* `tera:light.troffer` at their own asset gets their fitting switched on
* rather than a fitting nobody placed.
*
* Harmless on a pack with no fittings — the list is empty, `tick` does
* nothing, and `houseLevel` still reports the hour so the caller's rig can
* make its own decision.
*/
const luminaires: Luminaires = createLuminaires(furnishings.luminaires);
const robots: RobotLayer | null =
options.robots && options.robots.length > 0
? createRobotLayer(plan, { materials, robots: options.robots })
: null;
const officeWalker = options.walker ? createOfficeWalker(plan, options.walker) : null;
if (officeWalker) scene.add(officeWalker.root);
if (robots) {
scene.add(robots.group);
/**
* Once, not per frame.
*
* `robots()` hands back a stable array of stable `Vector3`s that the layer
* mutates in place, so the luminaires are reading this frame's positions
* through a reference taken at setup. Calling it every frame would allocate
* nothing extra but would imply the array were a snapshot, which it is not.
*/
}
if (robots || officeWalker) {
luminaires.setWalkers([
...(robots?.robots() ?? NO_ROBOTS),
...(officeWalker ? [officeWalker.view] : []),
]);
}
// A public office has no presence layer, rather than an empty one. The
// difference is not cosmetic: an empty `PresenceLayer` is a `THREE.Group`
// named "presence" hanging in the scene graph, a `setPresence` that works, and
// a pair of figure geometries one call away from being populated by any code
// that gets a handle on it. None of that should exist in the building a
// stranger is looking at. The layer is `null`, the group is never added, and
// every path that would have used it is written to cope with its absence
// rather than to hide it. See the header.
const presence: PresenceLayer | null =
depth === "full" ? createPresenceLayer(plan, options.presencePalette ?? {}) : null;
scene.add(shell.group, furnishings.group);
if (presence) scene.add(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;
/**
* The correction scales the whole offset from the target, not the ground
* distance alone, and the difference is the entire fix.
*
* Pushing the camera back while leaving `height` where it was does not step
* away from the shot, it *flattens* it: the reference pack's establishing
* view is 32 m out and 14 m up, a comfortable look down onto the floor, and
* multiplying only the 32 leaves the camera ninety-odd metres away and still
* fourteen up — a near-horizontal squint at the edge of a floor plate,
* stranded near the horizon with the bottom half of the frame empty. Tried
* it; it was worse than the bug. Scaling both preserves the elevation angle
* exactly, so the shot is the one the author framed, from further away.
*/
const k = widthCorrection();
const above = Math.max(focus.height, TARGET_Y + 0.3) - TARGET_Y;
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 * k,
floorY + TARGET_Y + above * k,
focus.at.z + Math.cos(focus.rotation) * focus.distance * k,
),
};
}
/**
* The aspect ratio a pack's `distance` was written against.
*
* Every viewpoint in `lumbridge-hq.ts` was framed by eye in a landscape
* browser, which makes 16:9 the honest reading of what those numbers mean —
* and this is the only place that reading is written down, so a pack author
* who wants to know what `distance: 32` promises can find out.
*/
const AUTHORED_ASPECT = 16 / 9;
/**
* How much further back a narrow viewport has to stand.
*
* `camera.fov` is *vertical*, so the width you can see is
* `distance * tan(fov / 2) * aspect` — and a phone held upright has an aspect
* near 0.5 against the 1.78 the pack was written for. At the same distance
* that is a third of the width, which is exactly what the arrival shot looked
* like: a thirty-four-metre floor plate shoved off the corner of the screen
* with the top of the frame full of empty sky. The pack was not wrong and the
* renderer was not wrong; the number simply meant something else on that
* screen.
*
* So the correction preserves the *width* the author framed, which is the
* thing they were actually choosing — "everything in this building is
* somewhere in this frame" is a statement about width, and the extra height a
* tall screen throws in for free costs nothing. It only ever pushes back,
* never pulls in: a viewport wider than 16:9 already shows more than was asked
* for, and creeping closer to trim it would crop an establishing shot on a
* desktop to make a rule tidy.
*/
function widthCorrection(): number {
const aspect = kit.camera.aspect;
if (!(aspect > 0) || !Number.isFinite(aspect)) return 1;
return Math.max(1, AUTHORED_ASPECT / aspect);
}
/**
* 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 ------------------------------------------------------------
/**
* At public depth, the desks are the pick surface and a desk is a number.
*
* Built once, up front, and handed out by reference — `SceneKit` decides
* whether the hover changed by comparing what `resolve` returned against what
* it returned last frame, so a fresh object literal per hit would fire
* `onChange` every frame the pointer sat still.
*
* The numbering is the point of the map. A desk's real address is its seat id,
* `eng-14`, and that string says which team sits there — it is the id a
* private occupancy API is keyed on precisely because it means something. A
* stranger gets `Desk 14`, numbered from one in plan order across the whole
* building, which says only that this office has at least fourteen desks. The
* bank ids, the seat ids and the station numbers stay on this side of the
* callback.
*/
const places: Map<string, Pin> | null = depth === "public" ? new Map() : null;
if (places) {
let n = 0;
for (const level of plan.levels) {
for (const prop of level.props) {
if (prop.source?.part !== "desk") continue;
n += 1;
places.set(prop.id, { id: `desk-${n}`, label: `Desk ${n}`, colorKey: "desk" });
}
}
}
if (presence) {
// `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),
});
} else if (places) {
// The furnishings are instanced, so the hit resolves in two steps: the
// instanced mesh plus the instance index gives a prop id, and only the prop
// ids that are in the map — the desks — resolve to anything at all. A chair,
// a plant or a light is not a place and comes back `null`.
kit.setPicking<Pin>({
targets: () => furnishings.pickables,
resolve: (hit) => {
const id = furnishings.propAt(hit.object, hit.instanceId);
return id === null ? null : (places.get(id) ?? null);
},
onChange: (place) => options.onPlacePick?.(place),
});
}
// ---- 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 ------------------------------------
/**
* Disposal is one-way and it is checked, because the reason this handle gets
* thrown away is usually that another one is being built to replace it.
*
* Signing in while standing in the public office disposes this scene and
* constructs a `"full"` one; the stage is mid-frame when that happens, and a
* `tick` arriving after `dispose` would drive an `OrbitControls` that has
* already released its listeners. Guarding here rather than asking every
* caller to sequence it correctly is the difference between a dispose you can
* rely on and one that mostly works.
*/
let disposed = false;
let warnedNoPresence = false;
return {
scene,
camera: kit.camera,
controls: kit.controls,
plan,
depth,
walker: officeWalker,
listMediaSurfaces: () => mediaSurfaces.list(),
bindMediaSurface: (screenId, grant, texture) => mediaSurfaces.bind(screenId, grant, texture),
clearMediaSurface: (screenId) => mediaSurfaces.clear(screenId),
views,
// A public office anchors nothing, because it has nobody to anchor. The
// empty map is this scene's own rather than a shared module-level one: an
// HTML overlay that writes into what it was handed should not be able to
// reach across into another office.
anchors: presence?.anchors ?? new Map<string, THREE.Vector3>(),
flyTo(viewId) {
officeWalker?.setActive(false);
kit.controls.enabled = true;
flyTo(viewId);
},
current: () => currentView,
onViewChange(fn) {
viewListeners.push(fn);
},
setPresence(people) {
if (!presence) {
// Once, not once per poll: an occupancy feed pointed at the public
// office will call this every few seconds, and the console is where the
// author of the caller finds out that nothing is happening.
if (!warnedNoPresence) {
warnedNoPresence = true;
console.warn(
`[tera/interiors] office "${office.id}" was built at depth "public"; ` +
`${people.length} presence record(s) ignored. Rebuild at "full" to show people.`,
);
}
return;
}
presence.setPresence(people);
},
setCeilingsVisible(visible) {
shell.ceilings.visible = visible;
},
setRobotsVisible(visible) {
if (robots) robots.group.visible = visible;
},
setLighting(state) {
kit.applyLighting(state);
paintHorizon(state);
},
setSolarElevation(degrees) {
luminaires.setSolarElevation(degrees);
},
houseLevel: () => luminaires.houseLevel(),
robots: () => robots?.robots() ?? NO_ROBOTS,
setWalkers(walkers) {
luminaires.setWalkers(walkers);
},
// 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) {
if (disposed) return;
const walking = officeWalker?.active() ?? false;
kit.controls.enabled = !walking;
kit.tick(dt);
officeWalker?.tick(dt);
if (walking && officeWalker) kit.setPose(officeWalker.followPose());
updateOcclusion();
// Robots first: the lights above them should respond to where they are
// *now*, not to where they were last frame.
robots?.tick(dt);
luminaires.tick(dt);
},
dispose() {
mediaSurfaces.dispose();
officeWalker?.dispose();
robots?.dispose();
luminaires.dispose();
if (horizonPlane) {
horizonPlane.geometry.dispose();
(horizonPlane.material as THREE.Material).dispose();
}
if (disposed) return;
disposed = true;
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();
// Three things the old version left behind, and all three matter when the
// reason for disposing is that a second office is about to be built: the
// background `Color`, the view listeners — whose closures reach back into
// whatever UI created this scene — and the desk table. None of them is
// large; all of them are held for as long as anything holds this handle,
// and a handle is exactly the sort of thing a `let office` keeps a stale
// copy of.
scene.background = null;
viewListeners.length = 0;
places?.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);
}