/**
 * 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);
}
