/** * 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"; 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 & { /** 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; 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; /** 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; } 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; 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[]; 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, }); 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); // 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; // ---- 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 | 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({ 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({ 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(), 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); }, 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); }, 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(); }, }; } function withoutPeerFactory(options: OfficeRealtimePeersOptions): Omit { 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); }