975 lines
43 KiB
TypeScript
975 lines
43 KiB
TypeScript
/**
|
|
* An `Office` as a `StageScene`: the shell, the furniture, the people, a camera
|
|
* at office scale and a fixed interior light rig.
|
|
*
|
|
* This is `scene.ts`'s opposite number and it is deliberately the same shape.
|
|
* The renderer and the loop live in `Stage`; the camera, controls, flights and
|
|
* picking live in a `SceneKit`; what is left here is the office itself. Swap it
|
|
* in with `stage.setScene(office)` and the city is *paused, not disposed* —
|
|
* rebuilding the Bay Area's 0.53M-point heightfield on the way back out costs
|
|
* about two and a half seconds, which is the measurement CONTRACT.md §1 is
|
|
* built on.
|
|
*
|
|
* Whoever builds one of these disposes it. `Stage` disposes nothing it did not
|
|
* create, and the city handle's `dispose()` does not reach in here.
|
|
*
|
|
* ### One unit is one metre, and the camera has to know
|
|
*
|
|
* Nothing here is shared with the city's camera settings, because none of them
|
|
* transfer: SF puts a scene unit at ~94 m and clips at 900, and using those
|
|
* numbers indoors gives you a near plane thicker than a desk. An office runs
|
|
* near 0.05, far 300, and orbits between about a metre and the width of the
|
|
* building.
|
|
*
|
|
* ### No Atmosphere
|
|
*
|
|
* CONTRACT.md §4: an office gets `fog: null`, no `scene.background` drive and
|
|
* its own fixed rig. Daylight through the windows is a later refinement and
|
|
* explicitly not a v1 coupling — an office that dims at dusk because a weather
|
|
* station said so is a nice idea and a bad dependency for a room that has to
|
|
* render with no network at all.
|
|
*
|
|
* ### Orbit dollhouse remains the default navigation mode
|
|
*
|
|
* An optional `OfficeWalker` can temporarily possess a local actor and publish a
|
|
* chase-camera pose. It is inactive by default; without one, or while inactive,
|
|
* the ceilings, occlusion fading, named views and orbit controls behave exactly
|
|
* as before.
|
|
*
|
|
* ### Two depths, and the public one is the architecture without the people
|
|
*
|
|
* `depth: "public"` is the office an anonymous visitor gets, and the office is
|
|
* becoming a front door in its own right, so this is the majority case rather
|
|
* than a degraded one. It keeps the shell, the floor plan, the furniture, the
|
|
* lighting and every named `View`. It builds **no presence layer at all** — no
|
|
* occupants, no avatars, no seat states, nothing to hover that could name a
|
|
* person — and `Plan` has already dropped whatever the pack marked
|
|
* `audience: "private"` before this file sees it.
|
|
*
|
|
* The rule the two depths are written to is *build-time exclusion, never
|
|
* visibility toggling*. There is no `presence.group.visible = false` path here
|
|
* and there must not be one: a scene that constructs the private objects and
|
|
* then hides them still hands every one of them to `scene.traverse`, to the
|
|
* devtools scene graph and to anyone who types `scene.children` into a console.
|
|
* That is a data leak dressed as a privacy feature, and it is worse than not
|
|
* having the feature, because it looks like it works.
|
|
*
|
|
* **None of that is a security boundary.** The office pack is bundled into the
|
|
* static build, so its contents are public by construction whatever they are
|
|
* marked, and `lumbridge-hq.ts` is fabricated sample data besides. The only
|
|
* thing genuinely being withheld from an anonymous visitor is occupancy, and it
|
|
* is withheld because live `Presence` comes from the API and **the API is what
|
|
* refuses an anonymous caller** — not because this file declined to draw it. If
|
|
* a future deployment ever ships real occupant data, that server-side refusal is
|
|
* the fix; a `depth` argument in the browser is not, and never will be. See the
|
|
* note on `Audience` in `types.ts`.
|
|
*/
|
|
|
|
import * as THREE from "three";
|
|
import { createSceneKit, type Pose } from "../engine/scenekit.ts";
|
|
import type { StageScene } from "../engine/stage.ts";
|
|
import type { LightingState, Pin, View } from "../engine/types.ts";
|
|
import type { AssetRegistry } from "../assets/kit.ts";
|
|
import { MaterialRegistry, type MaterialQuality } from "../assets/materials.ts";
|
|
import type { InteriorPalette } from "../assets/palette.ts";
|
|
// Importing the catalogue registers the built-in `tera:` assets into the shared
|
|
// `kit`. A caller who passes their own registry is left alone with it — theirs
|
|
// is theirs, and re-registering ours over the top would clobber a deliberate
|
|
// replacement of a built-in id.
|
|
import "../assets/office/index.ts";
|
|
import { createFurnishings, type Furnishings } from "./furnish.ts";
|
|
import { Plan, type Depth, type PlanOptions } from "./plan.ts";
|
|
import { createPresenceLayer, type PresenceLayer, type PresencePalette } from "./presence.ts";
|
|
import { createShell, type Shell, type WallInfo } from "./shell.ts";
|
|
import { createLuminaires, type Luminaires, type Walker } from "./luminaires.ts";
|
|
import {
|
|
createOfficeWalker,
|
|
type OfficeWalker,
|
|
type OfficeWalkerOptions,
|
|
} from "./officeWalker.ts";
|
|
import {
|
|
createOfficeMediaPresentation,
|
|
type MediaSurfaceDescriptor,
|
|
type MediaSurfaceGrant,
|
|
type OfficeMediaPresentation,
|
|
} from "../media/presentation.ts";
|
|
import {
|
|
createRobotLayer,
|
|
type RobotLayer,
|
|
type RobotSpec,
|
|
type RobotView,
|
|
} from "./robots.ts";
|
|
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;
|
|
|
|
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<string, THREE.Vector3>;
|
|
setCeilingsVisible(visible: boolean): void;
|
|
/**
|
|
* Draw the robots, or do not.
|
|
*
|
|
* Visibility only, deliberately. A hidden robot still walks and still moves
|
|
* the vectors the luminaires hold, so the fittings above it still come up —
|
|
* which is the useful half of the switch rather than a caveat: it is how you
|
|
* watch the ceiling respond without a figure in the way. Gating `tick` would
|
|
* freeze the building instead.
|
|
*/
|
|
setRobotsVisible(visible: boolean): void;
|
|
setLighting(state: LightingState): void;
|
|
/**
|
|
* The sun's height, in degrees, from whatever clock the app is running.
|
|
*
|
|
* This is what turns the lights on. It is a separate call from `setLighting`
|
|
* and not a field on `LightingState` for the reason `scene.ts` gives for the
|
|
* city's identical pair: a `LightingState` is a rig, and how far below the
|
|
* horizon the sun is is a fact about the sky that the rig has already spent.
|
|
*/
|
|
setSolarElevation(degrees: number): void;
|
|
/**
|
|
* How much of the building's own light is on, 0..1, after the last
|
|
* `setSolarElevation`. The caller adds it to the rig — see `withHouseLights`
|
|
* in `daylight.ts`, and CONTRACT.md §4 on why this file does not.
|
|
*/
|
|
houseLevel(): number;
|
|
/**
|
|
* Who is moving about the floor, so the fittings above them can come up.
|
|
* Cheap; call it every frame. An empty list is the normal state.
|
|
*/
|
|
setWalkers(walkers: readonly Walker[]): void;
|
|
/**
|
|
* The robots walking about the floor, live. Empty when the pack asked for
|
|
* none.
|
|
*
|
|
* The **same array** every call, holding vectors the layer mutates in place —
|
|
* take the reference once and read it, rather than polling for a snapshot.
|
|
* The plan panel and the ceiling lights both consume it that way.
|
|
*/
|
|
robots(): readonly RobotView[];
|
|
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<string, Pin> | null = depth === "public" ? new Map() : null;
|
|
if (places) {
|
|
let n = 0;
|
|
for (const level of plan.levels) {
|
|
for (const prop of level.props) {
|
|
if (prop.source?.part !== "desk") continue;
|
|
n += 1;
|
|
places.set(prop.id, { id: `desk-${n}`, label: `Desk ${n}`, colorKey: "desk" });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (presence) {
|
|
// `pickables` is rebuilt in place whenever occupancy changes, so the getter
|
|
// rather than the array: the office outlives any one set of people in it.
|
|
kit.setPicking<Presence>({
|
|
targets: () => presence.pickables,
|
|
resolve: (hit) => (hit.object.userData.presence as Presence | undefined) ?? null,
|
|
onChange: (person) => options.onPresencePick?.(person),
|
|
});
|
|
} else if (places) {
|
|
// The furnishings are instanced, so the hit resolves in two steps: the
|
|
// instanced mesh plus the instance index gives a prop id, and only the prop
|
|
// ids that are in the map — the desks — resolve to anything at all. A chair,
|
|
// a plant or a light is not a place and comes back `null`.
|
|
kit.setPicking<Pin>({
|
|
targets: () => furnishings.pickables,
|
|
resolve: (hit) => {
|
|
const id = furnishings.propAt(hit.object, hit.instanceId);
|
|
return id === null ? null : (places.get(id) ?? null);
|
|
},
|
|
onChange: (place) => options.onPlacePick?.(place),
|
|
});
|
|
}
|
|
|
|
// ---- Occlusion fade -----------------------------------------------------
|
|
|
|
const fade = options.occlusionFade ?? true;
|
|
const lastEye = new THREE.Vector3(NaN, NaN, NaN);
|
|
const lastTarget = new THREE.Vector3(NaN, NaN, NaN);
|
|
const eye: Point2 = { x: 0, z: 0 };
|
|
const look: Point2 = { x: 0, z: 0 };
|
|
|
|
/**
|
|
* Walls between the camera and what it is looking at go translucent.
|
|
*
|
|
* A 2-D crossing test against each wall's centreline, which is why `shell.ts`
|
|
* stamps the segment on the mesh — the alternative is a raycast per wall per
|
|
* frame against geometry that has already been merged past recognition. Walls
|
|
* whose top is below the target are left alone: you can see over a 1.4 m
|
|
* partition, so it is not in the way, and fading it only makes the floor look
|
|
* unfinished.
|
|
*
|
|
* Recomputed only when the camera has actually moved. Orbit damping means it
|
|
* settles within a few frames of the pointer stopping, and then this costs
|
|
* nothing at all.
|
|
*/
|
|
function updateOcclusion() {
|
|
if (!fade) return;
|
|
const camera = kit.camera;
|
|
const target = kit.controls.target;
|
|
if (camera.position.distanceToSquared(lastEye) < 4e-4 && target.distanceToSquared(lastTarget) < 4e-4) {
|
|
return;
|
|
}
|
|
lastEye.copy(camera.position);
|
|
lastTarget.copy(target);
|
|
eye.x = camera.position.x;
|
|
eye.z = camera.position.z;
|
|
look.x = target.x;
|
|
look.z = target.z;
|
|
|
|
for (const mesh of shell.wallMeshes) {
|
|
const info = mesh.userData.wall as WallInfo | undefined;
|
|
if (!info) continue;
|
|
const blocking = info.top > target.y + 0.25 && segmentsCross(eye, look, info.from, info.to);
|
|
shell.setGhosted(mesh, blocking);
|
|
}
|
|
}
|
|
updateOcclusion();
|
|
|
|
// ---- The scene, as the stage sees it ------------------------------------
|
|
|
|
/**
|
|
* Disposal is one-way and it is checked, because the reason this handle gets
|
|
* thrown away is usually that another one is being built to replace it.
|
|
*
|
|
* Signing in while standing in the public office disposes this scene and
|
|
* constructs a `"full"` one; the stage is mid-frame when that happens, and a
|
|
* `tick` arriving after `dispose` would drive an `OrbitControls` that has
|
|
* already released its listeners. Guarding here rather than asking every
|
|
* caller to sequence it correctly is the difference between a dispose you can
|
|
* rely on and one that mostly works.
|
|
*/
|
|
let disposed = false;
|
|
let warnedNoPresence = false;
|
|
|
|
return {
|
|
scene,
|
|
camera: kit.camera,
|
|
controls: kit.controls,
|
|
plan,
|
|
depth,
|
|
walker: officeWalker,
|
|
listMediaSurfaces: () => mediaSurfaces.list(),
|
|
bindMediaSurface: (screenId, grant, texture) => mediaSurfaces.bind(screenId, grant, texture),
|
|
clearMediaSurface: (screenId) => mediaSurfaces.clear(screenId),
|
|
views,
|
|
// A public office anchors nothing, because it has nobody to anchor. The
|
|
// empty map is this scene's own rather than a shared module-level one: an
|
|
// HTML overlay that writes into what it was handed should not be able to
|
|
// reach across into another office.
|
|
anchors: presence?.anchors ?? new Map<string, THREE.Vector3>(),
|
|
flyTo(viewId) {
|
|
officeWalker?.setActive(false);
|
|
kit.controls.enabled = true;
|
|
flyTo(viewId);
|
|
},
|
|
current: () => currentView,
|
|
onViewChange(fn) {
|
|
viewListeners.push(fn);
|
|
},
|
|
setPresence(people) {
|
|
if (!presence) {
|
|
// Once, not once per poll: an occupancy feed pointed at the public
|
|
// office will call this every few seconds, and the console is where the
|
|
// author of the caller finds out that nothing is happening.
|
|
if (!warnedNoPresence) {
|
|
warnedNoPresence = true;
|
|
console.warn(
|
|
`[tera/interiors] office "${office.id}" was built at depth "public"; ` +
|
|
`${people.length} presence record(s) ignored. Rebuild at "full" to show people.`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
presence.setPresence(people);
|
|
},
|
|
setCeilingsVisible(visible) {
|
|
shell.ceilings.visible = visible;
|
|
},
|
|
setRobotsVisible(visible) {
|
|
if (robots) robots.group.visible = visible;
|
|
},
|
|
setLighting(state) {
|
|
kit.applyLighting(state);
|
|
paintHorizon(state);
|
|
},
|
|
setSolarElevation(degrees) {
|
|
luminaires.setSolarElevation(degrees);
|
|
},
|
|
houseLevel: () => luminaires.houseLevel(),
|
|
robots: () => robots?.robots() ?? NO_ROBOTS,
|
|
setWalkers(walkers) {
|
|
luminaires.setWalkers(walkers);
|
|
},
|
|
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<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);
|
|
}
|