/**
 * An `Office` resolved into something the renderer and the walk controller can
 * ask questions of: wall runs, collision segments, seats, props, zones and
 * bounds.
 *
 * This is `World`'s opposite number, and it keeps the same discipline: build
 * everything once in the constructor, then answer questions. An office pack is
 * static data — nothing in it changes between frames — so anything that can be
 * computed at load time should be, and every consumer should be reading the same
 * answer rather than deriving its own.
 *
 * Like `World`, and unlike everything downstream of it, **`Plan` imports no
 * three.js**. Its output is plain numbers and plain records. That keeps the
 * splitting pass testable without a WebGL context, and it keeps the office
 * contract (`types.ts`) and its resolution (this file) on the same side of the
 * line from the mesh library that consumes them.
 *
 * ### The wall pass is the point of this file
 *
 * CONTRACT.md §2 chose an explicit wall list with 1-D openings over walls
 * inferred from room edges, and the reason is here: splitting a wall around its
 * openings has to happen anyway to draw the solid parts, and the *same*
 * decomposition says where a walker can and cannot go. Two products, one pass,
 * no second list to keep in sync. A lintel over a door is drawn and is not
 * collided with; the apron under a window is drawn and *is* collided with; and
 * neither of those facts is stated twice.
 *
 * ### Everything here is in office-world metres
 *
 * The authored contract measures heights "above this level's floor", because
 * that is how a person describes a wall. A `LevelPlan` has already added
 * `Level.elevation` to every `y`, `bottom`, `top`, `sill` and `head` it exposes.
 * Mixing the two conventions in one build product is how a mezzanine ends up in
 * the basement, so the conversion happens exactly once, here, and what comes out
 * can be dropped into a single scene at the origin.
 *
 * ### It drops rather than throws
 *
 * A self-hoster authoring their first office will write a polygon that crosses
 * itself, a door that runs off the end of its wall, and two things with the same
 * id. Every one of those is reported through `problems` — and, in dev, through
 * `console.warn` — and then the offending item is dropped or repaired. An
 * exception with no context in the middle of a 180-prop pack tells the author
 * nothing and loses the other 179.
 *
 * ### Three things resolve late, and all of them are addresses
 *
 * Almost everything here is resolved level by level, in one pass, because a wall
 * and the slab under it are facts about one storey. Three authored things are
 * not: a **device** names a prop, and the prop may be anywhere in the building;
 * the **exterior arrival stall** names a level and stands outside every one of
 * them; and a **transition** names two levels at once and is a fact about
 * neither on its own. All three are therefore resolved after the level loop has
 * run, next to the prop-to-seat binding pass that runs late for exactly the same
 * reason — a cross-level address checked against a half-built plan reports a
 * problem that is not there.
 *
 * The transition is the one that drops *whole* rather than in pieces, and the
 * reason is worth stating where a reader will find it: everywhere else a bad
 * item costs a viewer that item, but half a staircase is a gap in a balustrade
 * with nothing behind it. See `resolveTransition`.
 *
 * A device is the first authored record in this format that takes its
 * *coordinate* from another record rather than restating one. `DeviceAnchor` in
 * `src/devices/types.ts` argues that case; the consequence here is that the
 * derivation happens once, in `resolveDevice`, and a device that cannot find its
 * hardware is dropped rather than given a position of its own.
 *
 * ### Depth: a public build does not build the private half
 *
 * `PlanOptions.depth` is the other reason something can be absent from the build
 * product, and it is the one that is not an error. At `"public"` every item the
 * pack marked `audience: "private"` is skipped here, in the resolution pass,
 * before it is a placement and long before it is a mesh. That ordering is the
 * whole point: a private prop that is built and then hidden is still in
 * `scene.traverse`, in the devtools graph and in a `JSON.stringify` of this
 * object, which is a data leak with a checkbox in front of it. Read the note on
 * `Audience` in `types.ts` — including the paragraph saying this is a UI tier
 * and not a security boundary, because a pack is bundled into the static build
 * and is public whatever it is marked.
 */

import type {
  DeviceCapability,
  DeviceDeclaration,
  DeviceKind,
  DeviceProvenance,
} from "../devices/types.ts";
import { deviceKindOfAssetId, validateDeviceDeclaration } from "../devices/types.ts";
import type {
  AssetId,
  Audience,
  DeskBank,
  ExteriorArrival,
  Level,
  Office,
  Opening,
  OpeningKind,
  Outline,
  Point2,
  Prop,
  Room,
  Seat,
  SeatPose,
  SurfaceId,
  Transition,
  TransitionEnd,
  TransitionKind,
  Viewpoint,
  Wall,
  Yaw,
} from "./types.ts";

/** Documented in `types.ts` as the fallback when a level names no thickness. */
const DEFAULT_WALL_THICKNESS = 0.12;

/**
 * Desk centre to seat centre when a `DeskBank` does not say. A 0.75 m desk plus
 * a chair pulled most of the way under it; far enough that the chair reads as a
 * separate object, close enough that nobody looks like they are reaching.
 */
const DEFAULT_SEAT_OFFSET = 0.6;

/**
 * How much of a hole a walker needs before it counts as a way through, in metres
 * above the floor. A door clears it, a window does not, and a serving hatch does
 * not — which is the answer you want in all three cases without the format
 * having to name any of them.
 */
const DEFAULT_WALK_HEIGHT = 1.1;

/**
 * A flight's width when the pack does not say, in metres.
 *
 * 1.2 m is two people passing, and it is the width of the balustrade gap
 * `mateo-court` already authored for its stair — which is the number a pack
 * author has usually already chosen by the time they write the transition.
 */
const DEFAULT_TRANSITION_WIDTH = 1.2;

/**
 * The footprint a landing is checked against, in metres.
 *
 * `DEFAULT_WALKER_RADIUS` from `walker.ts`, restated rather than imported: this
 * file is `walker.ts`'s dependency and not the other way round, and one number
 * flowing uphill would make the dependency circular for no gain. The two are
 * asserted equal in `src/test/plan.test.ts`.
 */
const TRANSITION_LANDING_RADIUS = 0.3;

/** Below this, two floats are the same number and a length is zero. */
const EPS = 1e-6;

// ---- Build products -------------------------------------------------------

/** An axis-aligned extent on the floor plane, in metres. */
export interface Bounds {
  minX: number;
  maxX: number;
  minZ: number;
  maxZ: number;
  width: number;
  depth: number;
  center: Point2;
}

/**
 * One solid piece of wall: a box `length` long, `thickness` deep and
 * `top - bottom` tall, standing on the floor plane at `center` and turned by
 * `yaw`.
 *
 * These map one-to-one onto `parts.wallRun(length, top - bottom, thickness)`
 * placed at `{ x: center.x, y: bottom, z: center.z, yaw }` — that part runs
 * along local +X with its base at y = 0, which is why the run reports a centre
 * and a base rather than two endpoints.
 */
export interface WallRun {
  wallId: string;
  center: Point2;
  length: number;
  thickness: number;
  /** Office-world metres, level elevation included. */
  bottom: number;
  top: number;
  yaw: Yaw;
  surface: SurfaceId | undefined;
  /** Distance along the wall from its `from` end, for anyone who wants it back in 1-D. */
  start: number;
  end: number;
  /**
   * What this run is. A `solid` run spans the wall's full height; a `lintel`
   * sits over an opening and an `apron` under one, and both name the opening so
   * a reveal or a frame can be drawn against the same numbers.
   */
  role: "solid" | "lintel" | "apron";
  openingId?: string;
}

/**
 * A hole in a wall, resolved out of its 1-D interval into a rectangle standing
 * in the world.
 *
 * The opening keeps its own record because the leaf, frame or glazing is drawn
 * by the opening rather than by the runs around it — see the note on `Opening`
 * in `types.ts` about why there is no `tera:shell.door`.
 */
export interface ResolvedOpening {
  /** `"${wallId}#0"`. Openings are not authored with ids; a mesh needs one. */
  id: string;
  wallId: string;
  kind: OpeningKind;
  /** Centre of the hole on the floor plane. */
  center: Point2;
  width: number;
  /** Office-world metres, level elevation included. */
  sill: number;
  head: number;
  thickness: number;
  yaw: Yaw;
  start: number;
  end: number;
  /** True when a walker can pass through this hole. See `DEFAULT_WALK_HEIGHT`. */
  passable: boolean;
}

/**
 * A stretch of wall a walker cannot cross, as a centreline segment.
 *
 * Adjacent blocking stretches are merged, so a wall of five windows is one
 * segment rather than eleven. The consumer inflates by `thickness / 2` plus
 * whatever radius it gives the walker; `Plan.blocked()` does exactly that and is
 * the reason this is a segment and not a box.
 */
export interface Segment {
  wallId: string;
  from: Point2;
  to: Point2;
  thickness: number;
}

/**
 * One prop, placed. The build input the mesh layer walks.
 *
 * This is CONTRACT.md §2's `Placement[]`, renamed. `src/assets/parts.ts` already
 * exports a `Placement` meaning a part inside one asset's mesh, and exporting
 * that name twice meaning two different things is the exact failure the contract
 * exists to stop. This one is an asset instance in an office; that one is a box
 * inside an asset.
 */
export interface PropPlacement {
  id: string;
  kind: AssetId;
  levelId: string;
  /** Office-world metres. `y` is the base of the prop, level elevation included. */
  position: { x: number; y: number; z: number };
  rotation: Yaw;
  /** Always three numbers here, whatever the pack wrote. */
  scale: [number, number, number];
  colorKey: string | undefined;
  /** The seat this prop belongs to, if it was bound to one. Purely an address. */
  seat: string | undefined;
  /** Set when this prop came out of a `DeskBank` rather than being authored. */
  source: { bankId: string; station: number; part: "desk" | "chair" } | undefined;
}

/** A seat, resolved onto a level. Still an address, now with a floor under it. */
export interface ResolvedSeat {
  id: string;
  levelId: string;
  position: Point2;
  /** Office-world metres: the floor the seat stands on. */
  y: number;
  facing: Yaw;
  pose: SeatPose;
  source: { bankId: string; station: number } | undefined;
}

/**
 * A device, bound to the hardware it stands on and given a coordinate.
 *
 * The declaration in the pack has no position — see `DeviceAnchor` in
 * `src/devices/types.ts`, which argues the case at length: a device is hardware,
 * hardware sits on something, and that something is already placed. So this is
 * where the coordinate comes from, exactly once, by reading the anchor prop's
 * resolved transform and adding the authored offset **in the prop's own frame**.
 * Nudge the desk and the mic moves with it, because there was never a second
 * number to forget to update.
 *
 * `roomId` is filled in even when the pack left it out, because the answer is a
 * lookup the pack should not have to restate and a consumer should not have to
 * repeat. `seatId` is not — a device that serves no seat serves no seat, and
 * inventing the nearest one would be the engine deciding what a microphone is
 * pointed at.
 */
export interface ResolvedDevice {
  id: string;
  kind: DeviceKind;
  label: string;
  assetId: AssetId;
  levelId: string;
  /** The prop this device's hardware is, or stands on. Always resolves. */
  propId: string;
  /** Office-world metres: anchor prop transform, plus the prop-frame offset. */
  position: { x: number; y: number; z: number };
  /** The anchor prop's yaw. A device faces the way its hardware faces. */
  rotation: Yaw;
  /** The room the hardware stands in, resolved when the pack did not say. */
  roomId: string | undefined;
  /** The seat this device serves, if the pack bound it to one. An address. */
  seatId: string | undefined;
  capabilities: readonly DeviceCapability[];
  provenance: DeviceProvenance;
  /** The sentence a viewer is shown next to the readings. Never empty. */
  disclosure: string;
}

/** A room's floor slab, cleaned, re-wound and measured. */
export interface ResolvedRoom {
  id: string;
  name: string;
  levelId: string;
  /** Counter-clockwise in plan view, no repeated closing point. */
  outline: Outline;
  floor: SurfaceId;
  /** `null` is an atrium: the pack said so explicitly. */
  ceiling: { height: number; surface: SurfaceId | undefined } | null;
  /** Office-world metres: the top of the floor slab. */
  y: number;
  bounds: Bounds;
  /** Square metres. Always positive. */
  area: number;
  centroid: Point2;
}

/** A zone, cleaned and measured. Still means nothing to the engine. */
export interface ResolvedZone {
  id: string;
  name: string;
  levelId: string;
  outline: Outline;
  colorKey: string | undefined;
  y: number;
  bounds: Bounds;
  area: number;
  centroid: Point2;
}

/**
 * A `Transition`, resolved: two ends with floors under them, and a path with a
 * height at every point on it.
 *
 * Both halves matter and they are used by different consumers. `lower`/`upper`
 * are what the walk controller asks about — "am I standing on a way up, and
 * where does it put me down" — and `path` is what the renderer builds treads
 * along and what the crossing animation interpolates position and height over.
 * They come from the same record, which is the point: a stair that is drawn
 * somewhere a walker cannot climb is not expressible.
 */
export interface ResolvedTransition {
  id: string;
  kind: TransitionKind;
  label: string | undefined;
  width: number;
  surface: SurfaceId | undefined;
  lower: ResolvedTransitionEnd;
  upper: ResolvedTransitionEnd;
  /**
   * Foot to head, in office-world metres, level elevation included.
   *
   * At least two points. The first is `lower.landing` at the lower floor and the
   * last is `upper.landing` at the upper one, so a consumer can walk it without
   * knowing anything about levels.
   */
  path: readonly { x: number; y: number; z: number }[];
  /** Office-world metres climbed. `upper.floorY - lower.floorY`, and positive. */
  rise: number;
}

/** One end of a resolved transition. Its `floorY` is office-world metres. */
export interface ResolvedTransitionEnd {
  levelId: string;
  floorY: number;
  /** Plan coordinates, cleaned and re-wound the way a room outline is. */
  footprint: Outline;
  bounds: Bounds;
  landing: Point2;
  facing: Yaw;
}

/**
 * One end of a transition, from the point of view of somebody standing on it.
 *
 * What `transitionAt` answers with, and deliberately not just the transition:
 * the question a walker asks is "where does this take me", and answering with a
 * record that has a `lower` and an `upper` makes every caller work out which one
 * it is standing on. This has already done that.
 */
export interface TransitionSide {
  transition: ResolvedTransition;
  /** The end the query point is standing on. */
  from: ResolvedTransitionEnd;
  /** The other end. Where the crossing lands. */
  to: ResolvedTransitionEnd;
  /** True when `to` is the upper end: this crossing goes up. */
  ascending: boolean;
}

/** One storey, resolved. Everything in office-world metres. */
export interface LevelPlan {
  id: string;
  name: string;
  index: number;
  /** Office-world metres: this storey's floor. */
  floorY: number;
  wallHeight: number;
  wallThickness: number;
  wallSurface: SurfaceId | undefined;
  rooms: readonly ResolvedRoom[];
  runs: readonly WallRun[];
  openings: readonly ResolvedOpening[];
  props: readonly PropPlacement[];
  seats: readonly ResolvedSeat[];
  zones: readonly ResolvedZone[];
  /** Resolved after every level exists — see the pass in the constructor. */
  devices: readonly ResolvedDevice[];
  collision: readonly Segment[];
  bounds: Bounds;
}

// ---- Problems -------------------------------------------------------------

/**
 * One thing the pack got wrong, and what was done about it.
 *
 * Kept as data rather than only as a log line so that a pack can be checked in a
 * test, or by a self-hoster's own tooling, without capturing `console`.
 */
export interface PlanProblem {
  /** Where it was, as a path into the pack: `"levels[0].walls[3].openings[1]"`. */
  where: string;
  message: string;
  /** `dropped` means it is not in the build product; `repaired` means it was fixed in place. */
  action: "dropped" | "repaired";
}

/**
 * One authored device waiting for the rest of the building to exist.
 *
 * `sink` is the array the level already handed out as `LevelPlan.devices`, so
 * the late pass fills in the answer the level is already advertising rather than
 * replacing it.
 */
interface PendingDevice {
  where: string;
  /** The level whose floorplan declared it, which its anchor must agree with. */
  levelId: string;
  declaration: DeviceDeclaration;
  sink: ResolvedDevice[];
  /**
   * Prop ids this level had and this *depth* does not — the private half, in a
   * public build. Shared by every device on the level.
   *
   * Without it a public build would report a problem for every device standing
   * on a private prop, which is not a problem: it is the depth doing exactly
   * what it is for. See the note beside the audience skips in `buildLevel`.
   */
  hidden: Set<string>;
}

/** How every pass reports. Threaded through rather than closed over, so the
 * polygon and opening helpers can stay free functions. */
type Report = (where: string, message: string, action: PlanProblem["action"]) => void;

/**
 * How much of a pack to resolve.
 *
 * The counterpart to `Audience` and deliberately not the same union: an item
 * says who it is *for* (`"public"` or `"private"`), a build says how far in it
 * *goes* (`"public"` or `"full"`). Spelling both with a shared two-member union
 * would make `depth === audience` compile and mean nothing.
 *
 * It lives here rather than in `types.ts` because it is not something a pack can
 * say. `types.ts` is the contract for authored data; this is an argument to the
 * thing that reads it.
 */
export type Depth = "public" | "full";

/** Whether a build at `depth` includes an item the pack marked `audience`. */
function included(depth: Depth, audience: Audience | undefined): boolean {
  return depth === "full" || audience !== "private";
}

export interface PlanOptions {
  /**
   * `"full"` — the default — resolves the whole pack. `"public"` skips every
   * item marked `audience: "private"`, which is how the office gets a
   * not-signed-in version without a second pack to keep in step. See the header.
   *
   * One consequence worth knowing about: id collisions are detected against what
   * was actually resolved, so a pack whose private half collides with its public
   * half reports that problem at `"full"` and not at `"public"`. Validate a pack
   * at full depth — that is the build the author is responsible for, and the
   * public one is a subset of it.
   */
  depth?: Depth;
  /**
   * How high a hole must clear for a walker to pass through it, in metres.
   * Defaults to 1.1.
   */
  walkHeight?: number;
  /**
   * Whether to `console.warn` each problem. Defaults to true under a dev build
   * and false otherwise — an author wants to hear about the door that runs off
   * the end of its wall, a visitor does not. `plan.problems` is populated either
   * way.
   */
  warn?: boolean;
}

/**
 * Vite replaces `import.meta.env.DEV` at build time; Node leaves `env`
 * undefined, which is the answer we want there anyway. Read defensively so this
 * module stays importable from a plain test runner.
 */
function devBuild(): boolean {
  try {
    return Boolean((import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV);
  } catch {
    return false;
  }
}

// ---- Plan -----------------------------------------------------------------

export class Plan {
  readonly office: Office;
  /**
   * How much of the pack this is. Read it rather than inferring it from what is
   * missing — an office with nothing marked private resolves identically at both
   * depths, and that is the normal case rather than a suspicious one.
   */
  readonly depth: Depth;
  readonly levels: readonly LevelPlan[];
  /** Only those whose `levelId` resolves. `viewpoints[0]` is still the arrival pose. */
  readonly viewpoints: readonly Viewpoint[];
  /**
   * Where a vehicle stands outside, or `null` when the pack authored none or
   * authored one that does not resolve.
   *
   * Deliberately **not** called `arrival`, because `arrival()` next to it means
   * something else entirely and has since before this field existed: that one is
   * the camera pose you open the building at, this one is a rectangle of tarmac
   * outside it. Two things called arrival in one class is how a caller ends up
   * parking a car in the lobby.
   *
   * The authored object is handed back by reference rather than copied. It is
   * plain data on a frozen-by-convention pack, and a consumer that wants to keep
   * it can `structuredClone` it — the same treatment `office` gets.
   */
  readonly exteriorArrival: ExteriorArrival | null;
  /**
   * Every way up and down that resolved, in declaration order.
   *
   * Empty for every pack that authors none, which is all of them but one — see
   * the note on `Transition` in `types.ts`.
   */
  readonly transitions: readonly ResolvedTransition[];
  /** Everything the validation pass dropped or repaired, in build order. */
  readonly problems: readonly PlanProblem[];
  /** The whole office, every level unioned. */
  readonly bounds: Bounds;

  private readonly walkHeight: number;
  private readonly levelsById = new Map<string, LevelPlan>();
  private readonly seatsById = new Map<string, ResolvedSeat>();
  private readonly propsById = new Map<string, PropPlacement>();
  private readonly viewpointsById = new Map<string, Viewpoint>();
  private readonly devicesById = new Map<string, ResolvedDevice>();
  private readonly transitionsById = new Map<string, ResolvedTransition>();

  constructor(office: Office, options: PlanOptions = {}) {
    this.office = office;
    this.depth = options.depth ?? "full";
    this.walkHeight = options.walkHeight ?? DEFAULT_WALK_HEIGHT;

    const problems: PlanProblem[] = [];
    const warn = options.warn ?? devBuild();
    const report: Report = (where, message, action) => {
      problems.push({ where, message, action });
      if (warn) console.warn(`[office ${office.id}] ${where}: ${message} (${action})`);
    };

    // Ids are checked per kind and across the whole office, not per level. A
    // seat id is what a `Presence` binds to and a prop id is what an occupancy
    // layer dims, so both have to mean one thing in the building, not one thing
    // per storey.
    const seen = {
      level: new Set<string>(),
      room: new Set<string>(),
      wall: new Set<string>(),
      prop: new Set<string>(),
      seat: new Set<string>(),
      zone: new Set<string>(),
      viewpoint: new Set<string>(),
      device: new Set<string>(),
      transition: new Set<string>(),
    };

    // `levels` and `viewpoints` are required by the type, but a pack arriving as
    // JSON has been through no type checker at all, and a missing array should
    // produce an empty office rather than a TypeError with a stack trace in it.
    // Devices are collected here and resolved after the level loop, for the same
    // reason the prop-to-seat pass below runs late: an anchor is an address into
    // the whole building, and checking one against a half-built plan reports a
    // problem that is not there.
    const pending: PendingDevice[] = [];

    const levels: LevelPlan[] = [];
    (office.levels ?? []).forEach((level, li) => {
      const where = `levels[${li}]`;
      if (seen.level.has(level.id)) {
        report(where, `duplicate level id "${level.id}"`, "dropped");
        return;
      }
      seen.level.add(level.id);
      const built = this.buildLevel(level, levels.length, where, seen, report, pending);
      levels.push(built);
      this.levelsById.set(built.id, built);
      for (const seat of built.seats) this.seatsById.set(seat.id, seat);
      for (const prop of built.props) this.propsById.set(prop.id, prop);
    });

    // Prop-to-seat bindings are resolved last, because a prop on level 1 may
    // legitimately name a seat declared on level 2 and the check would be a
    // false positive if it ran during the level pass.
    for (const level of levels) {
      for (const prop of level.props) {
        if (prop.seat !== undefined && !this.seatsById.has(prop.seat)) {
          report(
            `levels[${level.index}].props "${prop.id}"`,
            `bound to unknown seat "${prop.seat}"`,
            "repaired",
          );
          prop.seat = undefined;
        }
      }
    }

    for (const item of pending) this.resolveDevice(item, seen.device, report);

    // Transitions resolve here, next to devices, and for the same reason: a
    // transition is a *cross-level* address and there is no point in the level
    // loop at which both of its ends exist. Unlike a device it is not a fact
    // about one storey at all, which is why it is authored on the office rather
    // than on either floorplan.
    const transitions: ResolvedTransition[] = [];
    (office.transitions ?? []).forEach((transition, ti) => {
      const resolved = this.resolveTransition(transition, `transitions[${ti}]`, seen.transition, report);
      if (!resolved) return;
      transitions.push(resolved);
      this.transitionsById.set(resolved.id, resolved);
    });
    this.transitions = transitions;

    const viewpoints: Viewpoint[] = [];
    (office.viewpoints ?? []).forEach((viewpoint, vi) => {
      const where = `viewpoints[${vi}]`;
      if (seen.viewpoint.has(viewpoint.id)) {
        report(where, `duplicate viewpoint id "${viewpoint.id}"`, "dropped");
        return;
      }
      if (!this.levelsById.has(viewpoint.levelId)) {
        report(where, `on unknown level "${viewpoint.levelId}"`, "dropped");
        return;
      }
      // Not a problem, so not reported: a viewpoint the pack reserved for the
      // signed-in building is absent from `viewpoints` at public depth, which
      // means it is absent from the legend too rather than leaving a button that
      // flies nowhere.
      if (!included(this.depth, viewpoint.audience)) return;
      seen.viewpoint.add(viewpoint.id);
      viewpoints.push(viewpoint);
      this.viewpointsById.set(viewpoint.id, viewpoint);
    });

    const extent = new Extent();
    for (const level of levels) extent.addBounds(level.bounds);

    this.levels = levels;
    this.viewpoints = viewpoints;
    this.exteriorArrival = this.acceptArrival(office.site?.arrival, report);
    this.problems = problems;
    this.bounds = extent.finish();
  }

  // ---- Queries ------------------------------------------------------------

  level(id: string): LevelPlan | null {
    return this.levelsById.get(id) ?? null;
  }

  seat(id: string): ResolvedSeat | null {
    return this.seatsById.get(id) ?? null;
  }

  prop(id: string): PropPlacement | null {
    return this.propsById.get(id) ?? null;
  }

  viewpoint(id: string): Viewpoint | null {
    return this.viewpointsById.get(id) ?? null;
  }

  device(id: string): ResolvedDevice | null {
    return this.devicesById.get(id) ?? null;
  }

  transition(id: string): ResolvedTransition | null {
    return this.transitionsById.get(id) ?? null;
  }

  /**
   * The way up under a point on a storey, if there is one.
   *
   * The whole of the walk controller's interface to this record: standing on the
   * footprint is the input, so this is the question `officeWalker` asks every
   * frame. It is a linear scan because a building has one or two of these and a
   * spatial index for two entries is a slower way to be cleverer.
   */
  transitionAt(levelId: string, point: Point2): TransitionSide | null {
    for (const transition of this.transitions) {
      for (const ascending of [true, false]) {
        const from = ascending ? transition.lower : transition.upper;
        if (from.levelId !== levelId) continue;
        if (point.x < from.bounds.minX || point.x > from.bounds.maxX) continue;
        if (point.z < from.bounds.minZ || point.z > from.bounds.maxZ) continue;
        if (!pointInOutline(point, from.footprint)) continue;
        return {
          transition,
          from,
          to: ascending ? transition.upper : transition.lower,
          ascending,
        };
      }
    }
    return null;
  }

  /** Every device in the building, in declaration order, levels in order. */
  allDevices(): ResolvedDevice[] {
    return [...this.devicesById.values()];
  }

  /** Where you arrive. `viewpoints[0]`, or nothing if the pack declared none. */
  arrival(): Viewpoint | null {
    return this.viewpoints[0] ?? null;
  }

  /** Every seat in the building, in declaration order, banks expanded. */
  allSeats(): ResolvedSeat[] {
    return [...this.seatsById.values()];
  }

  /**
   * Which room a point is in.
   *
   * Later rooms win, because a pack lays the open floor down first and then puts
   * the meeting rooms on top of it — the same order it would be drawn in, and
   * the same order a reader of the source expects.
   */
  roomAt(levelId: string, point: Point2): ResolvedRoom | null {
    const level = this.levelsById.get(levelId);
    if (!level) return null;
    for (let i = level.rooms.length - 1; i >= 0; i--) {
      const room = level.rooms[i];
      if (!room) continue;
      if (point.x < room.bounds.minX || point.x > room.bounds.maxX) continue;
      if (point.z < room.bounds.minZ || point.z > room.bounds.maxZ) continue;
      if (pointInOutline(point, room.outline)) return room;
    }
    return null;
  }

  collisionAt(levelId: string): readonly Segment[] {
    return this.levelsById.get(levelId)?.collision ?? [];
  }

  /**
   * Whether a walker of `radius` moving from one point to another crosses a
   * wall on this level.
   *
   * A capsule-versus-segment test rather than a point-in-box one, so a fast
   * walker cannot tunnel through a 0.12 m partition between two frames. It lives
   * here rather than in the controller because inflating the centreline by half
   * the wall thickness is the sort of detail that gets forgotten in one of the
   * three places that needs it.
   */
  blocked(levelId: string, from: Point2, to: Point2, radius = 0.3): boolean {
    for (const seg of this.collisionAt(levelId)) {
      const clearance = radius + seg.thickness / 2;
      if (segmentDistance(from, to, seg.from, seg.to) < clearance) return true;
    }
    return false;
  }

  // ---- Build --------------------------------------------------------------

  private buildLevel(
    level: Level,
    index: number,
    where: string,
    seen: Record<"room" | "wall" | "prop" | "seat" | "zone", Set<string>>,
    report: Report,
    pending: PendingDevice[],
  ): LevelPlan {
    const floorY = level.elevation;
    const wallThickness = level.wallThickness ?? DEFAULT_WALL_THICKNESS;
    const floorplan = level.floorplan;
    const extent = new Extent();

    // Every one of the five passes below opens the same way: a private item at
    // public depth is skipped before anything is resolved about it, so it never
    // becomes a `ResolvedRoom`, a `PropPlacement` or a `ResolvedSeat` and there
    // is nothing downstream for a mesh layer to build or a traversal to find.
    // It is not reported — the pack is not wrong, it is being read at a depth
    // that does not include it.
    const rooms: ResolvedRoom[] = [];
    (floorplan.rooms ?? []).forEach((room, ri) => {
      const at = `${where}.rooms[${ri}]`;
      if (!included(this.depth, room.audience)) return;
      if (seen.room.has(room.id)) {
        report(at, `duplicate room id "${room.id}"`, "dropped");
        return;
      }
      const outline = cleanOutline(room.outline, at, report);
      if (!outline) return;
      seen.room.add(room.id);
      const bounds = outlineBounds(outline);
      extent.addBounds(bounds);
      rooms.push({
        id: room.id,
        name: room.name,
        levelId: level.id,
        outline,
        floor: room.floor,
        ceiling: resolveCeiling(room, level, floorY),
        y: floorY,
        bounds,
        area: Math.abs(shoelace(outline)) / 2,
        centroid: centroidOf(outline),
      });
    });

    const runs: WallRun[] = [];
    const openings: ResolvedOpening[] = [];
    const collision: Segment[] = [];
    (floorplan.walls ?? []).forEach((wall, wi) => {
      const at = `${where}.walls[${wi}]`;
      if (seen.wall.has(wall.id)) {
        report(at, `duplicate wall id "${wall.id}"`, "dropped");
        return;
      }
      seen.wall.add(wall.id);
      const split = this.splitWall(wall, level, wallThickness, floorY, at, report);
      if (!split) return;
      runs.push(...split.runs);
      openings.push(...split.openings);
      collision.push(...split.collision);
      extent.add(wall.from);
      extent.add(wall.to);
    });

    // Desk banks expand before authored props and seats so that an authored seat
    // colliding with a generated one is reported against the hand-written line,
    // which is the one whose author can do something about it.
    const props: PropPlacement[] = [];
    const seats: ResolvedSeat[] = [];
    // Ids the depth took away rather than ids the pack got wrong. Only devices
    // read it, and only to tell "your hardware is not in this build" apart from
    // "your hardware does not exist".
    const hidden = new Set<string>();

    (floorplan.deskBanks ?? []).forEach((bank, bi) => {
      const at = `${where}.deskBanks[${bi}]`;
      if (!included(this.depth, bank.audience)) {
        // A private bank generates no props at all, so its stations' ids have to
        // be derived rather than observed. They are contractual — `types.ts`
        // promises a pack author exactly these strings — which is why they are
        // computed by the same helper that emits them.
        const stations = Math.floor(bank.columns) * Math.floor(bank.rows);
        for (let station = 1; station <= stations; station += 1) {
          hidden.add(bankPropId(bank.id, "desk", station));
          if (bank.chair !== undefined) hidden.add(bankPropId(bank.id, "chair", station));
        }
        return;
      }
      this.expandBank(bank, level, floorY, at, seen, report, props, seats);
    });

    (floorplan.props ?? []).forEach((prop, pi) => {
      const at = `${where}.props[${pi}]`;
      if (!included(this.depth, prop.audience)) {
        hidden.add(prop.id);
        return;
      }
      if (seen.prop.has(prop.id)) {
        report(at, `duplicate prop id "${prop.id}"`, "dropped");
        return;
      }
      seen.prop.add(prop.id);
      props.push(placeProp(prop, level.id, floorY));
    });

    (floorplan.seats ?? []).forEach((seat, si) => {
      const at = `${where}.seats[${si}]`;
      if (!included(this.depth, seat.audience)) return;
      if (seen.seat.has(seat.id)) {
        report(at, `duplicate seat id "${seat.id}"`, "dropped");
        return;
      }
      seen.seat.add(seat.id);
      seats.push(placeSeat(seat, level.id, floorY));
    });

    const zones: ResolvedZone[] = [];
    (floorplan.zones ?? []).forEach((zone, zi) => {
      const at = `${where}.zones[${zi}]`;
      if (!included(this.depth, zone.audience)) return;
      if (seen.zone.has(zone.id)) {
        report(at, `duplicate zone id "${zone.id}"`, "dropped");
        return;
      }
      const outline = cleanOutline(zone.outline, at, report);
      if (!outline) return;
      seen.zone.add(zone.id);
      const bounds = outlineBounds(outline);
      extent.addBounds(bounds);
      zones.push({
        id: zone.id,
        name: zone.name,
        levelId: level.id,
        outline,
        colorKey: zone.colorKey,
        y: floorY,
        bounds,
        area: Math.abs(shoelace(outline)) / 2,
        centroid: centroidOf(outline),
      });
    });

    // Devices are not resolved here, only queued: the array this level exposes is
    // filled in by the pass in the constructor, once every prop in the building
    // has an id and a transform. Handing the same array out now and filling it
    // later is what lets `LevelPlan` stay one flat record rather than growing a
    // second, half-built shape nobody can tell from the finished one.
    const devices: ResolvedDevice[] = [];
    (floorplan.devices ?? []).forEach((declaration, di) => {
      pending.push({
        where: `${where}.devices[${di}]`,
        levelId: level.id,
        declaration,
        sink: devices,
        hidden,
      });
    });

    // Props and seats join the extent last so that bank expansions are included
    // too — the bounds of a floor whose only content is one desk bank should not
    // come out as a point at the origin.
    for (const prop of props) extent.add(prop.position);
    for (const seat of seats) extent.add(seat.position);

    return {
      id: level.id,
      name: level.name,
      index,
      floorY,
      wallHeight: level.wallHeight,
      wallThickness,
      wallSurface: level.wallSurface,
      rooms,
      runs,
      openings,
      props,
      seats,
      zones,
      devices,
      collision,
      bounds: extent.finish(),
    };
  }

  /**
   * The pass CONTRACT.md §2 is built around: one wall, its openings sorted and
   * validated, decomposed into intervals along its length.
   *
   * Each interval is either solid — one full-height run, and it blocks — or a
   * hole, which contributes an apron below and a lintel above, and blocks only
   * if the hole does not clear `walkHeight` from the floor. Blocking intervals
   * are merged as they are walked, so a wall of five windows produces one
   * collision segment rather than eleven.
   */
  private splitWall(
    wall: Wall,
    level: Level,
    levelThickness: number,
    floorY: number,
    where: string,
    report: Report,
  ): { runs: WallRun[]; openings: ResolvedOpening[]; collision: Segment[] } | null {
    const dx = wall.to.x - wall.from.x;
    const dz = wall.to.z - wall.from.z;
    const length = Math.hypot(dx, dz);
    if (length < EPS) {
      report(where, `wall "${wall.id}" has zero length`, "dropped");
      return null;
    }

    const ux = dx / length;
    const uz = dz / length;
    // A run's mesh lies along its local +X, which for yaw φ points at
    // (cos φ, -sin φ) — three.js's rotation about +Y, as `Yaw` promises. The
    // `+ 0` turns IEEE's negative zero back into zero: an east-west wall
    // otherwise reports a yaw of `-0`, which renders identically and looks like
    // a bug in every diff and every snapshot.
    const yaw = Math.atan2(-uz, ux) + 0;
    const height = wall.height ?? level.wallHeight;
    const thickness = wall.thickness ?? levelThickness;
    const surface = wall.surface ?? level.wallSurface;
    const at = (u: number): Point2 => ({ x: wall.from.x + ux * u, z: wall.from.z + uz * u });

    const holes = acceptOpenings(wall, length, height, where, report);

    const runs: WallRun[] = [];
    const openings: ResolvedOpening[] = [];
    const collision: Segment[] = [];
    let blockStart: number | null = null;

    const pushRun = (
      u0: number,
      u1: number,
      bottom: number,
      top: number,
      role: WallRun["role"],
      openingId?: string,
    ): void => {
      if (u1 - u0 < EPS || top - bottom < EPS) return;
      runs.push({
        wallId: wall.id,
        center: at((u0 + u1) / 2),
        length: u1 - u0,
        thickness,
        bottom: floorY + bottom,
        top: floorY + top,
        yaw,
        surface,
        start: u0,
        end: u1,
        role,
        openingId,
      });
    };

    /**
     * Extend or close the run of blocked wall. `u0` is where the interval just
     * decided about begins, which is also where an open interval ends — so a
     * stretch of solid wall, a window and more solid wall arrives as one segment
     * rather than three.
     */
    const block = (u0: number, blocks: boolean): void => {
      if (blocks) {
        if (blockStart === null) blockStart = u0;
        return;
      }
      const from = blockStart;
      if (from !== null) {
        collision.push({ wallId: wall.id, from: at(from), to: at(u0), thickness });
        blockStart = null;
      }
    };

    let cursor = 0;
    for (const hole of holes) {
      pushRun(cursor, hole.start, 0, height, "solid");
      block(cursor, hole.start - cursor > EPS);

      // Numbered by the opening's position in the *authored* list, so that
      // dropping a bad opening does not renumber its siblings and move whatever
      // a mesh cached against the id.
      const id = `${wall.id}#${hole.index}`;
      pushRun(hole.start, hole.end, 0, hole.sill, "apron", id);
      pushRun(hole.start, hole.end, hole.head, height, "lintel", id);

      const passable = hole.sill <= EPS && hole.head >= this.walkHeight - EPS;
      block(hole.start, !passable);

      openings.push({
        id,
        wallId: wall.id,
        kind: hole.kind,
        center: at((hole.start + hole.end) / 2),
        width: hole.end - hole.start,
        sill: floorY + hole.sill,
        head: floorY + hole.head,
        thickness,
        yaw,
        start: hole.start,
        end: hole.end,
        passable,
      });
      cursor = hole.end;
    }

    pushRun(cursor, length, 0, height, "solid");
    block(cursor, length - cursor > EPS);
    // Close whatever was still blocking when the wall ran out.
    const tail = blockStart;
    if (tail !== null) {
      collision.push({ wallId: wall.id, from: at(tail), to: at(length), thickness });
    }

    return { runs, openings, collision };
  }

  /**
   * A `DeskBank` into props and seats.
   *
   * The generated ids are contractual — `types.ts` promises a pack author that
   * the fourth station of bank `eng` is seat `eng-04`, because a `Presence`
   * binds to that string and is written by hand somewhere else entirely. Nothing
   * here may renumber.
   */
  private expandBank(
    bank: DeskBank,
    level: Level,
    floorY: number,
    where: string,
    seen: Record<"prop" | "seat", Set<string>>,
    report: Report,
    props: PropPlacement[],
    seats: ResolvedSeat[],
  ): void {
    const columns = Math.floor(bank.columns);
    const rows = Math.floor(bank.rows);
    if (!(columns >= 1) || !(rows >= 1)) {
      report(where, `bank "${bank.id}" has ${bank.columns} x ${bank.rows} stations`, "dropped");
      return;
    }
    if (!(bank.pitch > 0)) {
      report(where, `bank "${bank.id}" has a pitch of ${bank.pitch} m`, "dropped");
      return;
    }

    const rowPitch = bank.rowPitch ?? bank.pitch;
    const seatOffset = bank.seatOffset ?? DEFAULT_SEAT_OFFSET;
    const pose = bank.pose ?? "sit";
    const prefix = bank.seatPrefix ?? bank.id;
    const cos = Math.cos(bank.rotation);
    const sin = Math.sin(bank.rotation);

    // Named arguments, because desk and chair differ in three of six fields and
    // a positional call would eventually put a chair where a desk goes.
    const pushProp = (p: {
      id: string;
      kind: AssetId;
      at: Point2;
      rotation: Yaw;
      seat: string;
      part: "desk" | "chair";
      station: number;
    }): void => {
      if (seen.prop.has(p.id)) {
        report(where, `station ${p.station} generates the taken prop id "${p.id}"`, "dropped");
        return;
      }
      seen.prop.add(p.id);
      props.push({
        id: p.id,
        kind: p.kind,
        levelId: level.id,
        position: { x: p.at.x, y: floorY, z: p.at.z },
        rotation: p.rotation,
        scale: [1, 1, 1],
        colorKey: undefined,
        seat: p.seat,
        source: { bankId: bank.id, station: p.station, part: p.part },
      });
    };

    for (let r = 0; r < rows; r++) {
      // With `facingRows`, the first row of each pair turns to look back down
      // the bank, which puts its desk between the two occupants and its chair on
      // the outside — a bench, rather than two rows of people staring at the
      // back of each other's heads.
      const flipped = bank.facingRows === true && r % 2 === 0;
      const facing = bank.rotation + (flipped ? Math.PI : 0);
      // Where a seat sits relative to its desk: behind it, so that the occupant
      // looks out over the desktop. That is the desk's local +Z, which for yaw φ
      // points at (sin φ, cos φ).
      const seatX = Math.sin(facing) * seatOffset;
      const seatZ = Math.cos(facing) * seatOffset;

      for (let c = 0; c < columns; c++) {
        const station = r * columns + c + 1;
        const n = String(station).padStart(2, "0");
        const u = c * bank.pitch;
        const v = r * rowPitch;
        const x = bank.origin.x + u * cos + v * sin;
        const z = bank.origin.z - u * sin + v * cos;

        const seatId = `${prefix}-${n}`;
        if (seen.seat.has(seatId)) {
          report(where, `station ${station} generates the taken seat id "${seatId}"`, "dropped");
          continue;
        }
        seen.seat.add(seatId);
        seats.push({
          id: seatId,
          levelId: level.id,
          position: { x: x + seatX, z: z + seatZ },
          y: floorY,
          facing,
          pose,
          source: { bankId: bank.id, station },
        });

        pushProp({
          id: bankPropId(bank.id, "desk", station),
          kind: bank.desk,
          at: { x, z },
          rotation: facing,
          seat: seatId,
          part: "desk",
          station,
        });

        if (bank.chair !== undefined) {
          pushProp({
            id: bankPropId(bank.id, "chair", station),
            kind: bank.chair,
            at: { x: x + seatX, z: z + seatZ },
            rotation: facing,
            seat: seatId,
            part: "chair",
            station,
          });
        }
      }
    }
  }

  /**
   * One authored device, bound to the hardware it stands on.
   *
   * Nothing here throws, and that is a deliberate difference from
   * `resolveRobotOperations`, which does. A robot's station list is authored
   * *behaviour* and a station with no floor under it is a bug in the pack. A
   * device is authored *furniture*: one mic with a typo in its anchor should
   * cost a viewer that mic and not the building. So every failure below drops
   * one device and records why, exactly as a bad wall opening does.
   *
   * The order is: what the declaration says about itself, then what it says
   * about the plan. `validateDeviceDeclaration` owns the first half — id, label,
   * kind against asset id, capabilities, and the disclosure check that stops a
   * simulated reading being shown without the word "simulated" anywhere near it.
   * This method owns only the half that needs a resolved building.
   */
  private resolveDevice(item: PendingDevice, seen: Set<string>, report: Report): void {
    const { where, levelId, declaration } = item;
    const id = declaration.id;

    const faults = validateDeviceDeclaration(declaration);
    if (faults.length > 0) {
      for (const fault of faults) report(where, fault, "dropped");
      return;
    }
    if (seen.has(id)) {
      report(where, `duplicate device id "${id}"`, "dropped");
      return;
    }

    const anchor = declaration.anchor;
    // The anchor restates the level it was declared on, because the declaration
    // type has to stand on its own over the wire. Restated facts disagree
    // eventually, so the disagreement is caught here rather than resolved by
    // picking a winner.
    if (anchor.levelId !== levelId) {
      report(
        where,
        `device "${id}" is declared on level "${levelId}" and anchored to "${anchor.levelId}"`,
        "dropped",
      );
      return;
    }

    // At public depth a private prop was never resolved, so a device standing on
    // one lands here and is dropped — which is the right answer: hardware whose
    // furniture is not in the build has nowhere to be.
    const prop = this.propsById.get(anchor.propId);
    if (!prop) {
      // Not reported, and not a problem: the pack is fine, it is being read at a
      // depth that does not include the furniture this device stands on. The
      // device goes with it, which is the answer you want — a public build with
      // a floating microphone over a desk it cannot see is worse than a public
      // build with no microphone.
      if (item.hidden.has(anchor.propId)) return;
      report(where, `device "${id}" is anchored to unknown prop "${anchor.propId}"`, "dropped");
      return;
    }
    if (prop.levelId !== levelId) {
      report(
        where,
        `device "${id}" is anchored to prop "${prop.id}", which is on level "${prop.levelId}"`,
        "dropped",
      );
      return;
    }

    /**
     * A mic bolted to a speaker is not a rendering mistake, it is a command sent
     * to the wrong instrument, so it is dropped rather than drawn.
     *
     * Only when the anchor prop is *itself* device hardware, though. A mic
     * standing on a desk is the ordinary case — `DeviceAnchor.offset` exists for
     * exactly those few centimetres — and a desk claims to be no device at all,
     * so `deviceKindOfAssetId` returns null for it and there is nothing to
     * disagree with.
     */
    const propKind = deviceKindOfAssetId(prop.kind);
    if (propKind !== null && propKind !== declaration.kind) {
      report(
        where,
        `device "${id}" is a ${declaration.kind} anchored to ${prop.kind}, which is a ${propKind}`,
        "dropped",
      );
      return;
    }

    // The one calculation in this method. Local +X of a prop at yaw φ points at
    // (cos φ, -sin φ) and local +Z at (sin φ, cos φ) — the same frame
    // `expandBank` lays its stations out in, and the reason the offset is
    // authored in the prop's frame rather than the room's: turn the desk and the
    // mic stays on the corner of it.
    const offset = anchor.offset;
    const cos = Math.cos(prop.rotation);
    const sin = Math.sin(prop.rotation);
    const position = offset
      ? {
          x: prop.position.x + offset.x * cos + offset.z * sin,
          y: prop.position.y + offset.y,
          z: prop.position.z - offset.x * sin + offset.z * cos,
        }
      : { x: prop.position.x, y: prop.position.y, z: prop.position.z };

    // `roomId` and `seatId` are addresses rather than positions, and they are
    // treated the way every other address in this file is: an unknown one is
    // cleared and reported, never invented. The room is derived when the pack
    // left it out, because that answer is a lookup and a pack should not have to
    // restate what the geometry already knows.
    let roomId = anchor.roomId;
    if (roomId !== undefined) {
      const known = this.levelsById.get(levelId)?.rooms.some((room) => room.id === roomId) ?? false;
      if (!known) {
        report(where, `device "${id}" names unknown room "${roomId}"`, "repaired");
        roomId = undefined;
      }
    }
    if (roomId === undefined) {
      roomId = this.roomAt(levelId, { x: position.x, z: position.z })?.id;
    }

    let seatId = anchor.seatId;
    if (seatId !== undefined && !this.seatsById.has(seatId)) {
      report(where, `device "${id}" serves unknown seat "${seatId}"`, "repaired");
      seatId = undefined;
    }

    seen.add(id);
    const resolved: ResolvedDevice = {
      id,
      kind: declaration.kind,
      label: declaration.label,
      assetId: declaration.assetId,
      levelId,
      propId: prop.id,
      position,
      rotation: prop.rotation,
      roomId,
      seatId,
      // Copied rather than aliased: a build product that shares an array with
      // the pack is one `sort()` away from editing the authored data.
      capabilities: [...declaration.capabilities],
      provenance: declaration.provenance,
      disclosure: declaration.disclosure,
    };
    item.sink.push(resolved);
    this.devicesById.set(id, resolved);
  }

  /**
   * One authored way up, resolved — or dropped whole.
   *
   * **Whole** is the operative word and it is the one rule this method exists to
   * keep. Everywhere else in this file a bad item costs a viewer that item; here
   * a half-resolved transition would cost them a floor they can walk off. A
   * staircase whose upper end did not resolve is a hole in a collider with an
   * invitation standing in front of it, so every check below drops the record
   * entirely and says which end was wrong.
   *
   * What is checked, in the order a pack gets it wrong:
   *
   * - the id is unique across the office, like every other id here;
   * - both levels resolve, and `upper` is genuinely above `lower` — a pack that
   *   has them the wrong way round would otherwise build a stair that descends
   *   into the slab;
   * - each footprint is a polygon with area, inside its own level's bounds, and
   *   with no collision segment running through it. That last one is the "leaves
   *   the footprint clear" half of the contract: the wall resolver already
   *   leaves a gap in `collision` for a door, and a transition whose footprint
   *   is on the wrong side of a wall has no such gap and is not a way anywhere;
   * - each landing is inside its own footprint and is a place a walker of the
   *   default radius could actually stand;
   * - the legs form a finite path whose rise shares are not all zero.
   */
  private resolveTransition(
    transition: Transition,
    where: string,
    seen: Set<string>,
    report: Report,
  ): ResolvedTransition | null {
    const id = transition.id;
    if (typeof id !== "string" || id === "") {
      report(where, "transition has no id", "dropped");
      return null;
    }
    if (seen.has(id)) {
      report(where, `duplicate transition id "${id}"`, "dropped");
      return null;
    }
    if (transition.kind !== "stair" && transition.kind !== "lift") {
      report(where, `transition "${id}" has unknown kind "${String(transition.kind)}"`, "dropped");
      return null;
    }
    // Not reported: a private transition at public depth is the depth doing its
    // job, exactly as it is for a private room. A pack that wants a staff stair
    // absent from the anonymous build says so and gets a building with no stair
    // in it rather than a stair that goes nowhere.
    if (!included(this.depth, transition.audience)) return null;

    const lower = this.resolveTransitionEnd(transition.lower, `${where}.lower`, id, report);
    const upper = this.resolveTransitionEnd(transition.upper, `${where}.upper`, id, report);
    if (!lower || !upper) return null;

    if (lower.levelId === upper.levelId) {
      report(where, `transition "${id}" joins level "${lower.levelId}" to itself`, "dropped");
      return null;
    }
    const rise = upper.floorY - lower.floorY;
    if (!(rise > EPS)) {
      report(
        where,
        `transition "${id}" does not rise: "${lower.levelId}" is at ${lower.floorY} and ` +
          `"${upper.levelId}" is at ${upper.floorY}`,
        "dropped",
      );
      return null;
    }

    const path = this.transitionPath(transition, lower, upper, rise, where, id, report);
    if (!path) return null;

    seen.add(id);
    return {
      id,
      kind: transition.kind,
      label: transition.label,
      width: positiveOr(transition.width, DEFAULT_TRANSITION_WIDTH),
      surface: transition.surface ?? this.levelsById.get(lower.levelId)?.wallSurface,
      lower,
      upper,
      path,
      rise,
    };
  }

  /** One end: a level, a cleaned footprint on it, and a place to stand. */
  private resolveTransitionEnd(
    end: TransitionEnd | undefined,
    where: string,
    id: string,
    report: Report,
  ): ResolvedTransitionEnd | null {
    if (!end || typeof end.levelId !== "string") {
      report(where, `transition "${id}" is missing an end`, "dropped");
      return null;
    }
    const level = this.levelsById.get(end.levelId);
    if (!level) {
      report(where, `transition "${id}" names unknown level "${end.levelId}"`, "dropped");
      return null;
    }
    const footprint = cleanOutline(end.footprint, where, report);
    if (!footprint) return null;
    const bounds = outlineBounds(footprint);
    if (
      bounds.minX < level.bounds.minX - EPS || bounds.maxX > level.bounds.maxX + EPS ||
      bounds.minZ < level.bounds.minZ - EPS || bounds.maxZ > level.bounds.maxZ + EPS
    ) {
      report(
        where,
        `transition "${id}" has a footprint outside level "${end.levelId}"`,
        "dropped",
      );
      return null;
    }
    for (const segment of level.collision) {
      if (!segmentCrossesOutline(segment.from, segment.to, footprint, bounds)) continue;
      report(
        where,
        `transition "${id}" has wall "${segment.wallId}" running through its footprint on ` +
          `level "${end.levelId}"`,
        "dropped",
      );
      return null;
    }

    const landing = end.landing;
    if (!landing || !Number.isFinite(landing.x) || !Number.isFinite(landing.z)) {
      report(where, `transition "${id}" has a landing that is not a point`, "dropped");
      return null;
    }
    if (!pointInOutline(landing, footprint)) {
      report(where, `transition "${id}" has a landing outside its own footprint`, "dropped");
      return null;
    }
    if (this.blocked(end.levelId, landing, landing, TRANSITION_LANDING_RADIUS)) {
      report(
        where,
        `transition "${id}" has a landing a walker cannot stand on — it is inside a wall`,
        "dropped",
      );
      return null;
    }

    return {
      levelId: end.levelId,
      floorY: level.floorY,
      footprint,
      bounds,
      landing: { x: landing.x, z: landing.z },
      facing: Number.isFinite(end.facing) ? (end.facing as Yaw) : 0,
    };
  }

  /**
   * The legs, turned into an absolute path with a height at every point.
   *
   * The shares are normalised here rather than trusted, because "the flights add
   * up to the storey" is the sort of arithmetic a pack author does in their head
   * and gets wrong by a twentieth. A path whose shares are all zero is refused
   * rather than normalised: that is a lift authored as a stair, and silently
   * turning it into one flat run would draw a floor where a flight should be.
   */
  private transitionPath(
    transition: Transition,
    lower: ResolvedTransitionEnd,
    upper: ResolvedTransitionEnd,
    rise: number,
    where: string,
    id: string,
    report: Report,
  ): { x: number; y: number; z: number }[] | null {
    const authored = transition.legs ?? [];
    const legs = authored.length > 0
      ? authored
      // One straight flight, which is what a single run of stairs and a lift
      // shaft both are. The share is the whole climb because there is nothing
      // else to share it with.
      : [{ to: upper.landing, rise: 1 }];

    let total = 0;
    for (const leg of legs) {
      if (!leg || !leg.to || !Number.isFinite(leg.to.x) || !Number.isFinite(leg.to.z)) {
        report(where, `transition "${id}" has a leg that is not a point`, "dropped");
        return null;
      }
      if (!Number.isFinite(leg.rise) || leg.rise < 0) {
        report(where, `transition "${id}" has a leg with a rise share of "${String(leg.rise)}"`, "dropped");
        return null;
      }
      total += leg.rise;
    }
    if (!(total > EPS)) {
      report(where, `transition "${id}" climbs nothing: every leg's rise share is zero`, "dropped");
      return null;
    }

    const path = [{ x: lower.landing.x, y: lower.floorY, z: lower.landing.z }];
    let climbed = 0;
    for (const leg of legs) {
      climbed += leg.rise / total;
      path.push({ x: leg.to.x, y: lower.floorY + rise * climbed, z: leg.to.z });
    }
    // The last leg is *made* to arrive at the upper landing rather than checked
    // against it. A pack that ends its final flight a few centimetres short has
    // authored a stair that does not reach its own landing, and the alternative
    // to snapping it is a walker who finishes a climb standing next to the floor.
    const head = path[path.length - 1]!;
    head.x = upper.landing.x;
    head.z = upper.landing.z;
    head.y = upper.floorY;
    return path;
  }

  /**
   * The exterior stall, checked for the three things that would make it
   * unusable and deliberately not for the fourth.
   *
   * Level, kind and finite numbers are checked. **Whether the stall is actually
   * outside the building is not**, and that is on purpose: a courtyard block
   * with a stall in its own yard, a covered undercroft, a loading bay half under
   * an overhang are all things a real pack might mean, and `Plan` has no
   * business ruling on architecture. A pack that wants that guarantee asserts it
   * in its own test — the shipped three do, in `src/test/packs/`.
   */
  private acceptArrival(
    arrival: ExteriorArrival | undefined,
    report: Report,
  ): ExteriorArrival | null {
    if (arrival === undefined || arrival === null) return null;
    const where = "site.arrival";
    if (arrival.kind !== "vehicle-stall") {
      report(where, `arrival anchor has unknown kind "${String(arrival.kind)}"`, "dropped");
      return null;
    }
    if (!this.levelsById.has(arrival.levelId)) {
      report(where, `arrival anchor is on unknown level "${arrival.levelId}"`, "dropped");
      return null;
    }
    const position = arrival.position;
    if (
      !position ||
      !Number.isFinite(position.x) ||
      !Number.isFinite(position.z) ||
      !Number.isFinite(arrival.rotation)
    ) {
      report(where, "arrival anchor has a position or rotation that is not a number", "dropped");
      return null;
    }
    return arrival;
  }
}

// ---- Placement helpers ----------------------------------------------------

/**
 * The prop id a bank station generates.
 *
 * Contractual: `types.ts` promises a pack author that bank `eng`'s fourth desk
 * is `eng-desk-04` and nothing may renumber it. It is a function because two
 * places need the answer — the expansion that emits them, and the depth pass
 * that has to name the ones a private bank did *not* emit — and two copies of a
 * promise is one copy too many.
 */
function bankPropId(bankId: string, part: "desk" | "chair", station: number): string {
  return `${bankId}-${part}-${String(station).padStart(2, "0")}`;
}

function placeProp(prop: Prop, levelId: string, floorY: number): PropPlacement {
  const s = prop.scale ?? 1;
  const scale: [number, number, number] =
    typeof s === "number" ? [s, s, s] : [s[0], s[1], s[2]];
  return {
    id: prop.id,
    kind: prop.kind,
    levelId,
    position: { x: prop.position.x, y: floorY + (prop.elevation ?? 0), z: prop.position.z },
    rotation: prop.rotation,
    scale,
    colorKey: prop.colorKey,
    seat: prop.seat,
    source: undefined,
  };
}

function placeSeat(seat: Seat, levelId: string, floorY: number): ResolvedSeat {
  return {
    id: seat.id,
    levelId,
    position: { x: seat.position.x, z: seat.position.z },
    y: floorY,
    facing: seat.facing,
    pose: seat.pose,
    source: undefined,
  };
}

/**
 * A room's ceiling. `undefined` means the level's default, explicit `null` means
 * there is not one.
 *
 * The surface is allowed to stay undefined: a level has a default *wall* finish
 * but no default ceiling finish, and inventing one here would be worse than
 * letting the material registry fall back to its own `ceilingTile` role — which
 * is where that decision belongs.
 */
function resolveCeiling(room: Room, level: Level, floorY: number): ResolvedRoom["ceiling"] {
  if (room.ceiling === null) return null;
  return {
    height: floorY + (room.ceiling?.height ?? level.wallHeight),
    surface: room.ceiling?.surface,
  };
}

/** An opening that survived validation. `index` is its position in the authored list. */
interface AcceptedOpening {
  index: number;
  kind: OpeningKind;
  start: number;
  end: number;
  sill: number;
  head: number;
}

/**
 * The openings that survive, sorted along the wall and guaranteed not to
 * overlap — which is what lets the splitting pass be a single left-to-right
 * walk rather than an interval-tree problem.
 */
function acceptOpenings(
  wall: Wall,
  length: number,
  height: number,
  where: string,
  report: Report,
): AcceptedOpening[] {
  const indexed = (wall.openings ?? []).map((opening, index) => ({ opening, index }));
  indexed.sort((a, b) => a.opening.start - b.opening.start);

  const kept: AcceptedOpening[] = [];
  for (const { opening, index } of indexed) {
    const at = `${where}.openings[${index}]`;
    const accepted = normaliseOpening(opening, index, length, height, at, report);
    if (!accepted) continue;
    // Sorted by `start`, so only the previous survivor can be in the way.
    const previous = kept[kept.length - 1];
    if (previous && accepted.start < previous.end - EPS) {
      report(at, `overlaps the opening starting at ${previous.start.toFixed(2)} m`, "dropped");
      continue;
    }
    kept.push(accepted);
  }
  return kept;
}

/**
 * One opening, checked against its wall.
 *
 * The horizontal errors are fatal to the opening — a hole that runs off the end
 * of a wall has no sensible repair, and clamping it would silently move a door.
 * The vertical ones are clamped, because a head above the wall is unambiguously
 * "all the way up" and a negative sill is unambiguously "on the floor".
 */
function normaliseOpening(
  opening: Opening,
  index: number,
  length: number,
  height: number,
  where: string,
  report: Report,
): AcceptedOpening | null {
  if (!(opening.width > EPS)) {
    report(where, `width is ${opening.width} m`, "dropped");
    return null;
  }
  if (opening.start < -EPS) {
    report(where, `starts ${(-opening.start).toFixed(2)} m before its wall`, "dropped");
    return null;
  }
  const end = opening.start + opening.width;
  if (end > length + EPS) {
    report(
      where,
      `runs ${(end - length).toFixed(2)} m past the end of a ${length.toFixed(2)} m wall`,
      "dropped",
    );
    return null;
  }

  let sill = opening.sill;
  let head = opening.head;
  if (sill < 0) {
    report(where, `sill is ${sill} m; clamped to the floor`, "repaired");
    sill = 0;
  }
  if (head > height + EPS) {
    report(where, `head is above a ${height.toFixed(2)} m wall; clamped`, "repaired");
    head = height;
  }
  if (head - sill < EPS) {
    report(where, `head ${head} m is not above sill ${sill} m`, "dropped");
    return null;
  }

  return { index, kind: opening.kind, start: opening.start, end, sill, head };
}

// ---- Polygons -------------------------------------------------------------

/**
 * A usable outline, or nothing.
 *
 * Three repairs and two rejections. Repeated points collapse — including a
 * repeated *first* point, which is the single most common thing a new author
 * writes, because every other polygon format they have met wanted the ring
 * closed by hand. Reversed winding is re-wound silently, as `types.ts` promises.
 * Fewer than three distinct points, or an outline that crosses itself, is
 * dropped: a self-intersecting floor slab triangulates into a black bowtie, and
 * every downstream area, centroid and point-in-polygon answer about it is
 * meaningless.
 */
function cleanOutline(
  outline: Outline,
  where: string,
  report: Report,
): Outline | null {
  const points: Point2[] = [];
  for (const p of outline) {
    if (!Number.isFinite(p.x) || !Number.isFinite(p.z)) {
      report(where, `outline has a non-finite point`, "dropped");
      return null;
    }
    const last = points[points.length - 1];
    if (last && Math.abs(last.x - p.x) < EPS && Math.abs(last.z - p.z) < EPS) continue;
    points.push({ x: p.x, z: p.z });
  }
  const first = points[0];
  const last = points[points.length - 1];
  if (points.length > 1 && first && last && Math.abs(first.x - last.x) < EPS && Math.abs(first.z - last.z) < EPS) {
    report(where, "outline repeats its first point; outlines are implicitly closed", "repaired");
    points.pop();
  }

  if (points.length < 3) {
    report(where, `outline has ${points.length} distinct points`, "dropped");
    return null;
  }

  const area = shoelace(points);
  if (Math.abs(area) / 2 < EPS) {
    report(where, "outline encloses no area", "dropped");
    return null;
  }
  if (selfIntersects(points)) {
    report(where, "outline crosses itself", "dropped");
    return null;
  }

  // Positive shoelace over (x, z) is *clockwise* in plan view, because +Z runs
  // down the page. Counter-clockwise in plan view is also what a floor slab
  // needs to triangulate into +Y-facing triangles, which is why the convention
  // is worth normalising rather than merely documenting.
  if (area > 0) points.reverse();
  return points;
}

/**
 * Does a wall's centreline run through a footprint?
 *
 * A crossing test rather than a distance one, deliberately. A footprint is a
 * patch of floor and a wall along its edge is the ordinary case — the stair in
 * `mateo-court` has brick on two sides of it — so inflating by anything at all
 * would refuse every transition authored against a wall, which is where stairs
 * go. What is not allowed is a wall *through* the middle of it, because then the
 * two halves are not one place and the collider will not let a walker cross
 * between them.
 *
 * The landing is checked separately, with the walker's radius, which is where
 * "can somebody actually stand here" is answered.
 */
function segmentCrossesOutline(
  from: Point2,
  to: Point2,
  outline: Outline,
  bounds: Bounds,
): boolean {
  const minX = Math.min(from.x, to.x);
  const maxX = Math.max(from.x, to.x);
  const minZ = Math.min(from.z, to.z);
  const maxZ = Math.max(from.z, to.z);
  if (maxX < bounds.minX || minX > bounds.maxX || maxZ < bounds.minZ || minZ > bounds.maxZ) {
    return false;
  }
  for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) {
    const a = outline[j];
    const b = outline[i];
    if (!a || !b) continue;
    if (properCrossing(from, to, a, b)) return true;
  }
  // A segment wholly inside the footprint crosses no edge, and is still a wall
  // standing in the middle of it.
  return pointInOutline(from, outline) || pointInOutline(to, outline);
}

/** Two segments crossing at an interior point of both. Collinear is not a crossing. */
function properCrossing(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;
}

/** A positive finite override, or the default. Used for optional dimensions. */
function positiveOr(value: number | undefined, fallback: number): number {
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}

function shoelace(outline: Outline): number {
  let sum = 0;
  for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) {
    const a = outline[j];
    const b = outline[i];
    if (!a || !b) continue;
    sum += a.x * b.z - b.x * a.z;
  }
  return sum;
}

function centroidOf(outline: Outline): Point2 {
  let cx = 0;
  let cz = 0;
  let twiceArea = 0;
  for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) {
    const a = outline[j];
    const b = outline[i];
    if (!a || !b) continue;
    const cross = a.x * b.z - b.x * a.z;
    twiceArea += cross;
    cx += (a.x + b.x) * cross;
    cz += (a.z + b.z) * cross;
  }
  if (Math.abs(twiceArea) < EPS) {
    // Degenerate rings never reach here, but a caller with its own outline
    // might; the vertex mean is at least inside the hull.
    let mx = 0;
    let mz = 0;
    for (const p of outline) {
      mx += p.x;
      mz += p.z;
    }
    const n = Math.max(1, outline.length);
    return { x: mx / n, z: mz / n };
  }
  return { x: cx / (3 * twiceArea), z: cz / (3 * twiceArea) };
}

/**
 * Whether any two non-adjacent edges cross.
 *
 * O(n²), and deliberately so: an office room is a dozen points, a sweep-line is
 * fifty lines of code that would be wrong in the collinear cases, and this runs
 * once at load.
 */
function selfIntersects(outline: Outline): boolean {
  const n = outline.length;
  for (let i = 0; i < n; i++) {
    const a1 = outline[i];
    const a2 = outline[(i + 1) % n];
    if (!a1 || !a2) continue;
    for (let j = i + 1; j < n; j++) {
      // Skip the shared-vertex pairs: consecutive edges always touch, and the
      // last edge always touches the first.
      if (j === i || (j + 1) % n === i || (i + 1) % n === j) continue;
      const b1 = outline[j];
      const b2 = outline[(j + 1) % n];
      if (!b1 || !b2) continue;
      if (segmentsCross(a1, a2, b1, b2)) return true;
    }
  }
  return false;
}

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

/** Proper crossing only. Touching endpoints and collinear overlap do not count. */
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;
}

/** Ray casting, in the XZ plane. The same algorithm `World` uses on lat/lng. */
function pointInOutline(point: Point2, outline: Outline): boolean {
  let inside = false;
  for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) {
    const a = outline[i];
    const b = outline[j];
    if (!a || !b) continue;
    if (a.z > point.z !== b.z > point.z) {
      const x = ((b.x - a.x) * (point.z - a.z)) / (b.z - a.z) + a.x;
      if (point.x < x) inside = !inside;
    }
  }
  return inside;
}

// ---- Distance -------------------------------------------------------------

function pointToSegment(p: Point2, a: Point2, b: Point2): number {
  const dx = b.x - a.x;
  const dz = b.z - a.z;
  const lenSq = dx * dx + dz * dz;
  let t = lenSq < EPS ? 0 : ((p.x - a.x) * dx + (p.z - a.z) * dz) / lenSq;
  t = Math.max(0, Math.min(1, t));
  return Math.hypot(p.x - (a.x + t * dx), p.z - (a.z + t * dz));
}

/**
 * Closest approach between two segments in 2D.
 *
 * Crossing segments are zero apart; otherwise the minimum is attained at one of
 * the four endpoints, which is a well-known property of convex sets and much
 * less error-prone than solving the parametric system.
 */
function segmentDistance(a1: Point2, a2: Point2, b1: Point2, b2: Point2): number {
  if (segmentsCross(a1, a2, b1, b2)) return 0;
  return Math.min(
    pointToSegment(a1, b1, b2),
    pointToSegment(a2, b1, b2),
    pointToSegment(b1, a1, a2),
    pointToSegment(b2, a1, a2),
  );
}

// ---- Extents --------------------------------------------------------------

class Extent {
  private minX = Infinity;
  private maxX = -Infinity;
  private minZ = Infinity;
  private maxZ = -Infinity;

  add(p: Point2): void {
    if (p.x < this.minX) this.minX = p.x;
    if (p.x > this.maxX) this.maxX = p.x;
    if (p.z < this.minZ) this.minZ = p.z;
    if (p.z > this.maxZ) this.maxZ = p.z;
  }

  addBounds(b: Bounds): void {
    this.add({ x: b.minX, z: b.minZ });
    this.add({ x: b.maxX, z: b.maxZ });
  }

  finish(): Bounds {
    if (!Number.isFinite(this.minX)) {
      return { minX: 0, maxX: 0, minZ: 0, maxZ: 0, width: 0, depth: 0, center: { x: 0, z: 0 } };
    }
    return {
      minX: this.minX,
      maxX: this.maxX,
      minZ: this.minZ,
      maxZ: this.maxZ,
      width: this.maxX - this.minX,
      depth: this.maxZ - this.minZ,
      center: { x: (this.minX + this.maxX) / 2, z: (this.minZ + this.maxZ) / 2 },
    };
  }
}

function outlineBounds(outline: Outline): Bounds {
  const extent = new Extent();
  for (const p of outline) extent.add(p);
  return extent.finish();
}
