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
karti 8fb85cd2e5 feat: give the boards a horizon, a sea that reflects, and a state worth flying over
The wide shot, which is what an anonymous visitor actually lands on.

**The sea was `MeshLambertMaterial`** — a material with no specular term at all,
by construction — on a board where water is half the frame. It is now a
low-roughness dielectric that reads `scene.environment`, with a runtime-generated
tiling swell normal map sampled twice per fragment at two scales and two
headings, so the sun breaks into a moving glitter path instead of a mirror point.
An `onBeforeCompile` patch takes the body colour toward the deep value looking
straight down and leaves it to the reflection at grazing, and walks roughness up
past 1.6 board spans so the far water cannot shimmer.

The swell spectrum is 1/k^2 and not 1/k because the first attempt was
photographed: at 1/k every component carries the same slope, the shortest wave
wins, and the sea renders as hard diagonal corduroy. A test holds it now.

**The board no longer ends in a diamond.** The sea plane went from 1.8 board
spans to 18, past the fog's far plane from anywhere the orbit reaches, and the
sky is a world-space dome rather than a screen-space gradient. That gradient was
wrong in a way dusk made obvious: the sunset band was painted along the *bottom*
of the picture, under the board, while the true horizon at the top of frame stayed
zenith blue. `daylight.ts` pinning the horizon stop to the fog colour to hide the
seam was a symptom of it.

**Terrain casts shadows.** Left off before because double-sided terrain against a
~16 m-per-texel shadow map gives acne; `shadowSide = BackSide` is the cure, shot
at four sun elevations down to +0.0 degrees to confirm no stippling. The caster is
a stride-2 decimation appended to the same index buffer and swapped in by
`onBeforeShadow`/`onAfterShadow` via `drawRange`: no extra draw call, a quarter of
the depth cost, and indistinguishable from the full-resolution caster in a
side-by-side crop. Stride 1 was measured at +65,566 triangles and would have
missed the budget by ~47,000, so it was not shipped.

**California reads as California.** It was a beige kite: the eastern edge one
ruled line for five degrees of latitude, the south closing in a diagonal V, the
whole south-east a featureless tan wedge. Now the coast runs to the Mexican
border with San Diego on it, the eastern edge follows the Colorado and the Nevada
diagonal, and the south-east is the Basin and Range — forty parallel desert ridges
throwing shadows east, Death Valley as a white pan between the Panamints and the
Black Mountains, the Salton Sea the one cool value for two hundred kilometres.
The opening pose is retuned to the bigger board; the old 452/392 stand-off left a
slab of empty ocean where the state should be.

**The aircraft were six pixels.** Measured, by enlarging a screenshot 200% to
find one at all — indistinguishable from a dead pixel, on a board whose entire
claim is that the sky is live. They are airliners now, with planform and trail,
and clicking one raises its card for a signed-out visitor.

**The Model X is off the wall.** It stood at floor level outside a studio 188 m up
a Transbay tower, reading as a car balanced on a parapet. The apron is now chosen
from `site.elevation`, which the pack already carries — not from an office id,
which is the bug class this repo already hit once when a door marker gated on
`id === "sf"` and would have pinned the Los Angeles building to San Francisco.

Also fixed, and nearly shipped: sea z-fighting dithered every flat piece of ground
on the Bay Area and SoCal boards. And one test asserted an exact source line for
the water material, so the better multi-line implementation failed it — it now
asserts the property (dielectric, metalness 0, low roughness) rather than the
author's first guess at formatting.

Tests 964 -> 1015. California desktop 562/650 draw calls and 728,744/750,000
triangles — 2.8% of triangle headroom left, which is the number the next person
should check first. No budget was raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 23:43:14 -07:00

1373 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { Aircraft, FlightSource, LightingState, Pin, View } from "../engine/types.ts";
import { airlinerGeometry } from "../engine/aircraftGeometry.ts";
import type { EnvironmentRig } from "../engine/environmentRig.ts";
import { createOfficeExterior, type OfficeExterior } from "../engine/officeExterior.ts";
import { createDeviceLayer, type DeviceLayer } from "./devices.ts";
import type { DeviceDeclaration, DeviceState } from "../devices/types.ts";
import type { VehicleTelemetryState } from "../transport/vehicleTelemetry.ts";
import type { ModelXDetail } from "../assets/vehicles/index.ts";
import { kit as assetKit, 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 RobotView,
} from "./robots.ts";
import type { RobotOperationsDefinition } from "./robotOperations.ts";
import type {
ScenePeers,
ScenePeersOptions,
} from "../realtime/scenePeers.ts";
import type { EntityPoseSnapshot } from "../realtime/types.ts";
export type CreateOfficeScenePeers = (options: ScenePeersOptions) => ScenePeers;
export type OfficeRealtimePeersOptions = Omit<ScenePeersOptions, "project" | "groundAt"> & {
/** Injected by an authenticated caller so anonymous offices never download peer assets. */
create: CreateOfficeScenePeers;
};
/** 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;
/**
* How far out the overhead traffic dome sits, in metres, at most.
*
* The camera's far plane is `HORIZON_EXTENT * 0.7` — 8.4 km — so 3.6 km is
* comfortably inside it with the horizon plane still behind. The number itself
* carries no claim: an aeroplane on this dome is a **map symbol drawn in 3-D**,
* placed at the bearing and elevation it is genuinely at and at a distance
* chosen so it is visible, exactly as `aircraftGeometry.ts` argues for the
* city's own traffic. Drawing airliners at true metre range would put most of
* them past the far plane and the rest inside the fog.
*/
const OVERHEAD_MAX_RADIUS_M = 3_600;
/**
* How large an aeroplane is drawn, as an angle at the eye.
*
* 0.012 rad is about 0.7 degrees — a little over the width of a fingernail at
* arm's length, which is roughly what an airliner at cruise actually looks like
* from directly beneath and is enough to read the sweep of a wing. It is an
* angle rather than a length so the glyph does not have to be retuned if the
* dome radius ever changes.
*/
const OVERHEAD_ANGULAR_SIZE = 0.012;
/** The bounding length of `airlinerGeometry()`, which the angular size divides. */
const AIRLINER_LENGTH = 0.42;
/**
* How low an aeroplane may be and still be drawn, in degrees above the horizon.
*
* Below this it is behind the ground plane from any viewpoint inside the
* building, so drawing it is drawing an aeroplane through a floor. Five degrees
* is also about where an airliner stops being distinguishable from the haze.
*/
const OVERHEAD_MIN_ELEVATION_DEG = 5;
/**
* How many aeroplanes the dome can hold at once.
*
* One `InstancedMesh` and therefore one draw call at any occupancy, so the cost
* of the ceiling is 32 unused matrices rather than 32 unused objects. The
* godmode traffic dial can put four hundred aircraft over the city; a room's
* sky wants the nearest few, and the nearest few is what a person looking up
* would see anyway.
*/
const OVERHEAD_CAPACITY = 32;
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 };
/** Authored seeded simulation; omitted means this office has no robot activity. */
robotOperations?: RobotOperationsDefinition;
/** Optional local walk actor. Constructed inactive unless `walker.active` says otherwise. */
walker?: OfficeWalkerOptions;
/** Full-depth-only authoritative remote actors/vehicles in local metre coordinates. */
realtimePeers?: OfficeRealtimePeersOptions;
/** 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;
/**
* The page's one environment map, shared with the city.
*
* The same argument `scene.ts` makes: a `PMREMGenerator` and its targets
* belong to the renderer, not to a scene, so one rig is built beside the
* `Stage` and handed to both. It matters more indoors than out — `deviceMesh`,
* `chairBase`, `metalTrim`, `glazingFrame` and the Model X's paint are all
* metal or clearcoat, and metal with nothing to reflect is grey plastic.
*
* Absent, the office renders exactly as it did before the rig existed.
*/
environment?: EnvironmentRig;
/**
* Overhead traffic for a sited office's sky.
*
* The same `FlightSource` the city board is drawing, deliberately: a studio in
* the Arts District and the SoCal board above it are one world, and an arena
* that observes an overflight while the viewer standing in the room sees an
* empty sky is two. Polled here and **never disposed** here — the source
* belongs to whoever built it, which is the city.
*
* Ignored on a pack with no `site`: without a coordinate there is no bearing
* to put an aeroplane on, and without a horizon there is no sky to put it in.
*/
flights?: FlightSource;
/**
* Park a Model X on the pack's arrival apron.
*
* Ignored unless `office.site.arrival` names a stall, which is a pack's own
* decision — `ExteriorArrival` is optional and a floorplan with no outdoors
* has nowhere to put a car.
*
* `detail` is a required choice by the exterior's own contract: `corridor` is
* 33 draw calls and 4,098 triangles against `follow`'s 40 and 13,986, and the
* difference a viewer can see at three metres is mirrors, glass frames and
* brake calipers. `seed` makes the parking jitter and the paint a property of
* the studio rather than of the page load.
*/
exteriorVehicle?: { detail: ModelXDetail; seed: number };
}
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 hardware this pack declared, in the order it authored it.
*
* Authored, public and inert — a declaration says a microphone exists and what
* it can be asked to do. It is on the handle so that the interface can build a
* panel for a studio without reading the pack a second time, and it is the
* **resolved** list: a declaration `Plan` dropped, because its anchor prop is
* not on this level or did not survive this build's depth, is not here.
*/
devices: readonly DeviceDeclaration[];
/**
* Show these readings on the hardware. Cheap and idempotent; call it whenever
* a feed publishes. A no-op for an office that declared no devices.
*/
setDeviceStates(states: readonly DeviceState[]): void;
/**
* Reflect one vehicle telemetry observation on the car outside.
*
* Signature-guarded downstream, so calling it every frame costs a comparison.
* A no-op for a pack with no arrival stall, or when no exterior was asked for.
*/
setVehicleTelemetry(state: VehicleTelemetryState): 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[];
/** Honest provenance for UI and diagnostics; null when no simulation is authored. */
robotActivityInfo(): { operationsId: string; disclosure: string; simulated: true } | null;
upsertRemoteSnapshot(snapshot: EntityPoseSnapshot): boolean;
removeRemoteEntity(id: string): boolean;
clearRemoteEntities(): void;
remoteEntityCount(): number;
}
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,
});
const openingLighting = options.lighting ?? officeInterior();
kit.applyLighting(openingLighting);
// Before a single surface is built, so the first frame already has a room to
// reflect. The rig fingerprints the state and caches per kind, so this and
// every later `setLighting` cost a map lookup unless the light actually moved.
options.environment?.apply(scene, openingLighting, "office");
/**
* 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.robotOperations && options.robotOperations.robots.length > 0
? createRobotLayer(plan, { materials, operations: options.robotOperations })
: null;
const officeWalker = options.walker ? createOfficeWalker(plan, options.walker) : null;
if (officeWalker) scene.add(officeWalker.root);
// Live occupancy is excluded from the public-depth build rather than hidden.
// Geographic callbacks are required by the generic adapter but harmless here:
// an office subscription sends validated local poses in metre coordinates.
const realtimePeers = depth === "full" && options.realtimePeers
? options.realtimePeers.create({
...withoutPeerFactory(options.realtimePeers),
project: () => [0, 0],
groundAt: () => 0,
localSceneUnitsPerMetre: options.realtimePeers.localSceneUnitsPerMetre ?? 1,
})
: null;
if (realtimePeers) scene.add(realtimePeers.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;
// ---- The hardware on the furniture --------------------------------------
//
// Read off the floorplans rather than taken as an option, because a device is
// part of a pack in exactly the way a desk is: `Floorplan.devices` says the
// microphone exists and which prop it stands on, and `Plan` has already
// decided which of those declarations survived this build's depth. Filtering
// to what `Plan` accepted is what stops the interface offering a panel for a
// device the room does not contain — the layer would have skipped it and the
// panel would have shown a control that reaches nothing.
const registry: AssetRegistry = options.registry ?? assetKit;
const declared: DeviceDeclaration[] = [];
for (const level of office.levels) {
for (const declaration of level.floorplan.devices ?? []) declared.push(declaration);
}
const deviceDeclarations: readonly DeviceDeclaration[] = declared.filter(
(declaration) => plan.device(declaration.id) !== null,
);
const deviceLayer: DeviceLayer | null =
deviceDeclarations.length > 0
? createDeviceLayer({ plan, declarations: deviceDeclarations, assets: registry, materials })
: null;
if (deviceLayer) scene.add(deviceLayer.object);
// ---- The car outside -----------------------------------------------------
//
// Guarded on the pack having authored a stall, which most will not: an
// `ExteriorArrival` is optional and a floor plate with no outdoors has nowhere
// to put one. The exterior positions everything in the pack's own metres from
// the plan origin, so the only transform it needs is the storey its stall is
// measured from — a stall on an upper storey is measured from that storey's
// floor, and the apron stands on it. A site with no ground outside gets an
// inert exterior instead; see `ArrivalGround` in transport/exteriorVehicle.ts.
const arrivalStall = office.site?.arrival;
let exterior: OfficeExterior | null = null;
if (options.exteriorVehicle && office.site && arrivalStall) {
exterior = createOfficeExterior({
site: office.site,
arrival: arrivalStall,
assets: registry,
materials,
rand: mulberry32(options.exteriorVehicle.seed),
detail: options.exteriorVehicle.detail,
});
exterior.object.position.y = plan.level(arrivalStall.levelId)?.floorY ?? 0;
scene.add(exterior.object);
}
// ---- The traffic overhead ------------------------------------------------
const overhead: OverheadTraffic | null =
options.flights && office.site && options.horizon
? createOverheadTraffic({
source: options.flights,
site: office.site,
centre: plan.bounds.center,
radius: Math.min(far * 0.42, OVERHEAD_MAX_RADIUS_M),
})
: null;
if (overhead) scene.add(overhead.group);
// ---- 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);
},
devices: deviceDeclarations,
setDeviceStates(states) {
deviceLayer?.apply(states);
},
setVehicleTelemetry(state) {
exterior?.apply(state);
},
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);
// One direction, still: `Atmosphere` decided this rig, `officeDaylight`
// turned it into the building's frame, and the environment is derived
// from the result rather than being a second opinion about the light.
options.environment?.apply(scene, state, "office");
},
setSolarElevation(degrees) {
luminaires.setSolarElevation(degrees);
},
houseLevel: () => luminaires.houseLevel(),
robots: () => robots?.robots() ?? NO_ROBOTS,
robotActivityInfo: () => robots
? {
operationsId: options.robotOperations!.id,
disclosure: robots.disclosure,
simulated: true,
}
: null,
setWalkers(walkers) {
luminaires.setWalkers(walkers);
},
upsertRemoteSnapshot: (snapshot) => {
// The generic peer adapter can also project geographic poses, but an
// office must never render a stale city stream or another office's local
// coordinates at its origin during an interest handoff.
if (snapshot.pose.space !== "local") return false;
const cell = snapshot.pose.cell;
if (!("officeId" in cell) || cell.officeId !== office.id) return false;
return realtimePeers?.upsert(snapshot) ?? false;
},
removeRemoteEntity: (id) => realtimePeers?.remove(id) ?? false,
clearRemoteEntities: () => { realtimePeers?.clear(); },
remoteEntityCount: () => realtimePeers?.count() ?? 0,
// 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());
realtimePeers?.tick(Date.now());
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);
overhead?.tick(dt);
},
dispose() {
// First, because the rig keeps a ledger of every scene it has written to
// so a rebuilt environment reaches all of them — and a disposed office
// left in that ledger is a whole floor plate retained.
options.environment?.release(scene);
overhead?.dispose();
exterior?.dispose();
deviceLayer?.dispose();
mediaSurfaces.dispose();
officeWalker?.dispose();
realtimePeers?.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();
},
};
}
/**
* A deterministic generator from one integer, so a studio's car is the same car
* on every machine and on every reload.
*
* Mulberry32, four lines, no dependency. It is here rather than imported
* because the only thing in this file that needs randomness is the parking
* jitter, and `createOfficeExterior` takes a `() => number` precisely so that
* the caller owns the reproducibility rather than the layer.
*/
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ---- Overhead traffic -----------------------------------------------------
interface OverheadTraffic {
group: THREE.Group;
tick(dt: number): void;
dispose(): void;
}
interface OverheadTrafficOptions {
source: FlightSource;
site: NonNullable<Office["site"]>;
/** The middle of the floor plate, in the pack's metres. The dome is centred here. */
centre: { x: number; z: number };
radius: number;
}
/**
* The same aeroplanes the city board is drawing, seen from inside a building.
*
* ### Why a dome rather than a position
*
* An airliner over Los Angeles is ten kilometres up and twenty across. Placed at
* true metre range in a scene whose far plane is 8.4 km it is clipped, and if
* the far plane were moved out to reach it the fog — which saturates at 4.2 km,
* because that is what makes the horizon a horizon — would have swallowed it
* long before. So the *direction* is kept exactly and the *distance* is not:
* every track is put on a fixed dome at the bearing and elevation it is really
* at, sized by an angle rather than a length. That is the same bargain
* `aircraftGeometry.ts` already makes for the city, written down again here
* because the reason is different: the city trades scale for legibility, and
* this trades range for a depth buffer that works.
*
* ### Why it is instanced
*
* One geometry, one material, one draw call whatever the occupancy — against
* the city layer's mesh-per-track, which exists there because each aircraft
* carries its own altitude-banded material and its own pick target. Neither is
* wanted here: a room's sky is scenery, nothing in it is clickable, and the
* office's draw-call budget is the one that has to hold an entire studio.
*
* ### One direction, again
*
* `site.heading` is the bearing the pack's Z points along, so a compass bearing
* becomes a building-frame yaw by subtracting it — the same rotation
* `officeDaylight` applies to the sun, for the same reason and in the same
* sense. Getting it backwards would put the afternoon traffic over the wrong
* wall, which is exactly as wrong as putting the afternoon sun there.
*/
function createOverheadTraffic(options: OverheadTrafficOptions): OverheadTraffic {
const { source, site, centre, radius } = options;
const group = new THREE.Group();
group.name = "overhead-traffic";
const geometry = airlinerGeometry();
/**
* Lit, with a small emissive floor, and out of the fog.
*
* The emissive is the city layer's number and is there for the city layer's
* reason: after sunset the rig is a tenth of an intensity and a purely diffuse
* dart simply vanishes, on the one evening sky worth looking at. `fog: false`
* because the dome's radius is a drawing convention rather than a distance —
* applying 3.6 km of haze to a symbol that stands for twenty kilometres is
* fogging an arbitrary number.
*/
const material = new THREE.MeshLambertMaterial({
color: 0xdfe7ef,
emissive: 0xdfe7ef,
emissiveIntensity: 0.35,
fog: false,
});
const mesh = new THREE.InstancedMesh(geometry, material, OVERHEAD_CAPACITY);
mesh.name = "overhead-traffic-instances";
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
// The dome is centred on the building and always in frame; a bounding-sphere
// test on something that can never be culled is pure cost.
mesh.frustumCulled = false;
mesh.count = 0;
mesh.castShadow = false;
mesh.receiveShadow = false;
group.add(mesh);
const scale = (radius * OVERHEAD_ANGULAR_SIZE) / AIRLINER_LENGTH;
const matrix = new THREE.Matrix4();
const position = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const euler = new THREE.Euler(0, 0, 0, "YXZ");
const scaleVector = new THREE.Vector3(scale, scale, scale);
const headingRad = (site.heading * Math.PI) / 180;
const cosLat = Math.cos((site.lat * Math.PI) / 180);
const minSinElevation = Math.sin((OVERHEAD_MIN_ELEVATION_DEG * Math.PI) / 180);
let timer = 0;
let disposed = false;
function place(aircraft: readonly Aircraft[]): void {
let count = 0;
for (const a of aircraft) {
if (count >= OVERHEAD_CAPACITY) break;
/*
* Equirectangular, not great-circle, and that is a decision rather than a
* shortcut: an aeroplane still above five degrees from a building is at
* most a couple of hundred kilometres away, where the cosine-corrected
* flat approximation is wrong by metres in a bearing that is then drawn
* on a dome anyway. A haversine here would be four transcendentals per
* aeroplane per poll to move a symbol by less than its own width.
*/
const east = (a.lng - site.lng) * cosLat * METRES_PER_DEGREE;
const north = (a.lat - site.lat) * METRES_PER_DEGREE;
const ground = Math.hypot(east, north);
const up = a.altitude - site.elevation;
const slant = Math.hypot(ground, up);
if (slant < 1) continue;
const sinElevation = up / slant;
if (sinElevation < minSinElevation) continue;
// Bearing clockwise from true north, turned into the building's frame by
// subtracting the heading its own Z points along.
const bearing = Math.atan2(east, north) - headingRad;
const cosElevation = Math.sqrt(Math.max(0, 1 - sinElevation * sinElevation));
position.set(
centre.x + Math.sin(bearing) * cosElevation * radius,
sinElevation * radius,
centre.z - Math.cos(bearing) * cosElevation * radius,
);
// Nose along +Z and Z is the building's own north, so a half turn less
// the track's heading in this frame — the identical mapping `flights.ts`
// uses, and the one whose inverse once flew every departure tail-first.
euler.set(0, Math.PI - ((a.heading * Math.PI) / 180 - headingRad), 0);
quaternion.setFromEuler(euler);
matrix.compose(position, quaternion, scaleVector);
mesh.setMatrixAt(count, matrix);
count += 1;
}
mesh.count = count;
mesh.instanceMatrix.needsUpdate = true;
}
return {
group,
tick(dt) {
if (disposed) return;
timer -= dt;
if (timer > 0) return;
timer = source.interval;
/*
* No interpolation, unlike the city layer, and the sky is why. A track on
* this dome moves a few pixels between polls: at 3.6 km an airliner
* covers about 0.24 degrees a second across the dome, which is a third of
* its own drawn width, so tweening it would be machinery for motion
* nobody can see. The city layer interpolates because there the same
* aeroplane crosses a visible fraction of the board.
*/
void Promise.resolve(source.poll()).then((aircraft) => {
if (!disposed) place(aircraft);
});
},
dispose() {
disposed = true;
// Never `source.dispose()`: this layer is a second reader of a feed the
// city owns, and disposing it here would take the traffic off the board
// the moment somebody stepped indoors.
mesh.dispose();
geometry.dispose();
material.dispose();
group.clear();
},
};
}
/**
* Metres per degree of latitude, and of longitude at the equator.
*
* The WGS-84 mean, which is the same constant `main.ts` uses to turn a scene
* offset back into a coordinate. A degree of latitude varies by about half a
* percent between the equator and the pole; on a bearing drawn as a symbol that
* is nothing.
*/
const METRES_PER_DEGREE = 111_320;
function withoutPeerFactory(options: OfficeRealtimePeersOptions): Omit<ScenePeersOptions, "project" | "groundAt"> {
const { create: _create, ...peerOptions } = options;
return peerOptions;
}
/**
* 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);
}