/**
 * Ports — the busiest thing on any coast, and the one piece of it you can read
 * from a hundred kilometres up.
 *
 * `socal.ts`'s Harbour chapter has said "the busiest port complex in the
 * hemisphere" since the pack was written, and until this module existed the
 * picture underneath that sentence was a tan hexagon carrying the same generic
 * white blocks as the housing beside it. Nothing in frame said *port*. This is
 * the kit that says it.
 *
 * ### What makes a port read as a port
 *
 * Not the buildings — there are hardly any. Four things, in this order:
 *
 * 1. **The breakwater.** Thirteen kilometres of rubble across the mouth of a
 *    bight is the object that turns a bay into a harbour, and it is the only
 *    thing here that is legible at the whole-board pose: 13.06 km is 33.4 scene
 *    units on a 393-unit board, about nine per cent of the frame's width.
 * 2. **The cranes**, and they are the hero. See the arithmetic below.
 * 3. **The comb of slips** — the shape of the reclaimed land itself, basins cut
 *    into it and quays along their walls. That is authored in the city pack;
 *    this module only needs the land to already have the right outline.
 * 4. **The yards** — enormous flat rectangles of stacked steel, in a colour
 *    nothing else on a map is.
 *
 * ### THE CRANE IS THE HERO, AND VERTICAL EXAGGERATION IS WHY
 *
 * `world.metres()` carries the board's vertical exaggeration; horizontal
 * distances do not. On Southern California that is 3.4x at 390.6 m to the unit,
 * so a 130 m gantry crane stands **1.13 scene units tall** while an entire
 * 400 m container ship is **1.02 units long**. The crane is taller than the ship
 * is long. There is no other object in this feature with anything like that
 * legibility per triangle — sixty triangles buys a silhouette you cannot mistake
 * for anything else — which is why the crane row is authored data with a count
 * and a bearing, and why it went in before a single hull did.
 *
 * Boom angle is free storytelling on top of that. A boom lowered over a berth
 * reads as working and a boom raised to near-vertical reads as idle, and the
 * difference is a per-instance rotation costing nothing. `Crane.idleFraction`
 * is what a pack sets to say how much of a rail is stood down.
 *
 * ### A CONTAINER YARD IS PAINT. IT IS NOT INSTANCES. HERE IS THE ARITHMETIC
 *
 * This is the counterintuitive call in the kit and it will be "improved" into
 * instances by a later reader unless the numbers sit here in the module comment.
 *
 * A forty-foot container is **12.2 m** long. Southern California is **390.6 m to
 * the scene unit**, so one box is **0.0312 units** — and the closest pose this
 * board allows is the Harbour chapter at 40 units of camera distance, where a
 * unit is roughly fifty pixels. A container is **one and a half pixels**, from
 * the closest the camera ever gets, and the whole of San Pedro holds tens of
 * thousands of them. Instanced, that is tens of thousands of matrices to draw a
 * texture; painted, it is one quad per terminal indexing into one canvas, and it
 * reads *better* because the canvas can band the stacks at exactly the
 * proportion the data says rather than at whatever a random roll produced.
 *
 * So `yardAtlas` bands a canvas per yard exactly the way `airports.ts`'s
 * `markingsAtlas` bands runway paint, and `Yard.emptyShare` is drawn into those
 * pixels. On the Bay board (94.3 m/unit) a container is 0.129 units and the same
 * call still holds; anyone reaching for instances should re-run this paragraph's
 * arithmetic for their own board first.
 *
 * ### FIVE BUCKETS, AND THE NUMBER IS THE DESIGN
 *
 * The binding constraint on this feature is **draw calls on the mobile cell**,
 * not triangles. `scripts/performance-budget.mjs` measures socal mobile at 140
 * draws against a cap of 170: thirty spare for this feature and every future
 * one. (Desktop has 112 spare, which is why costing this against desktop makes
 * it look three times cheaper than it is.) So the kit has exactly four meshes,
 * with a fifth bucket left for the hulls that will lie alongside these quays:
 *
 *   1. `ports:stone`   — breakwater berms, quay decks, quay skirts, revetment
 *                        toes and terminal sheds. One Lambert material.
 *   2. `ports:yard`    — one ground quad per yard, all indexing one canvas atlas.
 *   3. `ports:channel` — the dredged water, a darker and smoother strip.
 *   4. `ports:cranes`  — every gantry on the board in ONE `InstancedMesh`.
 *   5. reserved for `vessels.ts`.
 *
 * and every one of them **merges across ports**, the way `airports.ts` merges
 * across airfields. A second port complex on the same board costs no extra draw
 * call at all; it costs a few hundred triangles. That property is asserted in
 * `src/test/render/ports.test.ts` rather than assumed, because this repo has
 * been bitten twice by the opposite — a suspension bridge at ~34 draw calls, and
 * twelve identical asphalt freeways that could never merge because a fresh
 * material was allocated per ribbon.
 *
 * The corollary is `airports.ts:43`'s scar and it applies here unchanged: every
 * geometry below carries position, normal **and uv**, indexed, whether or not it
 * has a texture, because `mergeGeometries` silently drops a bucket whose
 * attribute sets disagree.
 *
 * ### TWO COLUMNS OF `ports.sqlite` THAT MUST NEVER BE USED, AND WHY
 *
 * The upstream store has a `ports` table with seven rows. Two of its columns
 * look usable and are not:
 *
 * - **`lat`/`lon` cannot place anything.** All seven rows sit on an exact
 *   arc-minute grid — `lat*60` and `lon*60` are whole integers for every one of
 *   them. One arc-minute at 33.75 N is 1,852 m of latitude and 1,540 m of
 *   longitude, so a quay placed from those columns lands as much as 1.3 km —
 *   **3.3 SoCal scene units** — from the water it is supposed to edge. They are
 *   card anchors, not survey points. Every coordinate this kit draws is
 *   hand-traced in the city pack, per ARCHITECTURE §3.2.
 * - **`channel_depth_ft` is a binned WPI code.** It says Los Angeles is 14 ft
 *   deep, Long Beach 19 ft and Oakland 8 ft, against real dredged channels near
 *   50 ft. Any "may this ship enter" rule built on it excludes every container
 *   ship from the busiest port in the hemisphere, and it would look like a
 *   working feature the whole time.
 *
 * `harbor_type` **is** usable and is load-bearing: `CB` is a coastal breakwater
 * harbour and `CN` a coastal natural one. San Pedro and Long Beach are `CB` and
 * share the federal breakwater; Oakland is `CN` and must never be given one.
 * This module draws a breakwater only where a pack authored one, and a pack
 * should only author one where `harborType` says `CB`.
 */

import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import { LOD_HEIGHT_TOLERANCE } from "./terrain.ts";
import type { Crane, LatLng, LightingState, Port, Quay, Yard } from "./types.ts";
import type { World } from "./world.ts";

// ---- The authored contract ------------------------------------------------
//
// Re-exported so the kit reads as one import, and *declared* in `types.ts` for
// the reason `airports.ts:74-82` gives: `City` has to name `Port`,
// `src/index.ts` reaches `City`, and `src/test/integration/barrel.test.ts` reads
// the import graph as source and cannot tell an erased type-only edge from a
// real one. Plain data lives where plain data lives.

export type { Berth, Crane, Port, PortFreightRate, PortThroughput, Quay, Yard } from "./types.ts";

/**
 * What a renderer needs from this module, declared here rather than imported
 * from `scene.ts`.
 *
 * `fires.ts` makes exactly this move and for exactly this reason: the engine
 * must not take a dependency on the scene assembler, and neither side has to
 * exist for the other to compile. The two copies are checked against each other
 * at `SceneOptions.ports` and in `ports.test.ts`.
 *
 * **There is no `tick`.** Stone does not move. A layer that offered one would
 * invite a caller to spend a frame budget on a breakwater.
 */
export interface PortLayer {
  group: THREE.Object3D;
  setLighting(state: LightingState): void;
  dispose(): void;
}

// ---- Palette --------------------------------------------------------------

/**
 * Exported for the same reason `AIRPORT_PALETTE` is: a pack in a different
 * landscape may need to move these, and the contrasts between them are design
 * decisions worth seeing in one place.
 *
 * **The constraint that shaped this table is the Vincent Thomas Bridge.** It is
 * `0x3f7d55`, and in the Harbour frame it is already by far the most saturated
 * object on the water. Real container colours — postbox red, safety orange,
 * Maersk blue — put a second and third loud hue two hundred metres from it, and
 * the whole harbour reads as a toy. So the boxes here are a *muted* rust and a
 * *muted* steel blue at roughly half the chroma of the real thing, and the empty
 * stacks, which are the majority, are almost neutral. `AIRPORT_PALETTE.field`
 * records the same lesson from the other side: an olive green made six real
 * airfields read as golf courses across two boards.
 */
export const PORT_PALETTE = {
  /**
   * Rubble mound, quay concrete and revetment, all one swatch because they are
   * one bucket. Deliberately greyer and darker than `socal.ts`'s `shore`
   * (`0xc2b393`): a quay has to separate from the beach it is next to, and
   * value is what does that at distance.
   */
  stone: 0x8d8880,
  /** Yard asphalt, under the stacks. The darkest large surface on the coast. */
  yardBase: 0x54565b,
  /**
   * An empty box. Weathered, nearly neutral, and the commonest colour in the
   * yard — which is the whole point: three of every four boxes leaving Los
   * Angeles have nothing in them.
   */
  boxEmpty: 0xb2ada2,
  /** A loaded box. Muted rust; see the note above about the bridge. */
  boxLoadedA: 0x8d6053,
  /** A loaded box. Muted steel blue. */
  boxLoadedB: 0x5e6c7b,
  /**
   * What a stack is drawn as when nobody knows what is in it. One colour, never
   * a guessed mix — `Yard.emptyShare` absent means unknown, and unknown drawn as
   * a plausible-looking blend is the failure the fire layer nearly shipped.
   */
  boxUnknown: 0x7f7d77,
  /** Yard roadways and the block alleys, painted into the atlas. */
  yardLane: 0x6a6c70,
  /**
   * Gantry cream. Ship-to-shore cranes are painted white or off-white almost
   * everywhere, and pale is also what stands out against both the blue water
   * they lean over and the dark yard behind them.
   */
  crane: 0xdcd7cb,
  /** Dredged water: darker than `socal.ts`'s sea (`0x3f7391`) and smoother. */
  channel: 0x2e5a74,
} as const;

// ---- Authoring helpers ----------------------------------------------------

const DEG = Math.PI / 180;
const METRES_PER_DEGREE_LAT = 111_320;

function metresPerDegreeLng(lat: number): number {
  return METRES_PER_DEGREE_LAT * Math.cos(lat * DEG);
}

/** `[east, north]` unit vector for a true bearing. */
function bearingVector(heading: number): [number, number] {
  return [Math.sin(heading * DEG), Math.cos(heading * DEG)];
}

/** Move from a coordinate by metres east and metres north. */
export function offsetLatLng(
  origin: { lat: number; lng: number },
  east: number,
  north: number,
): LatLng {
  return [
    origin.lat + north / METRES_PER_DEGREE_LAT,
    origin.lng + east / metresPerDegreeLng(origin.lat),
  ];
}

/** Great-circle-ish metres between two coordinates. Flat earth is fine at this size. */
export function metresBetween(a: LatLng, b: LatLng): number {
  const north = (b[0] - a[0]) * METRES_PER_DEGREE_LAT;
  const east = (b[1] - a[1]) * metresPerDegreeLng((a[0] + b[0]) / 2);
  return Math.hypot(north, east);
}

/** Total length of a path in metres. What a breakwater is measured in. */
export function pathLengthMetres(path: readonly LatLng[]): number {
  let total = 0;
  for (let i = 1; i < path.length; i += 1) {
    const from = path[i - 1];
    const to = path[i];
    if (from && to) total += metresBetween(from, to);
  }
  return total;
}

/**
 * The four corners of a yard, from its centre, size and bearing.
 *
 * Exported because a pack that wants to check its own rectangle is on land — as
 * `socalPorts.test.ts` does — must derive the corners exactly the way the
 * renderer does, or the test is checking a different rectangle.
 */
export function yardCorners(yard: Yard): LatLng[] {
  const [alongE, alongN] = bearingVector(yard.bearing);
  // Right of the bearing is the bearing turned a quarter clockwise.
  const rightE = alongN;
  const rightN = -alongE;
  const halfLength = yard.length / 2;
  const halfWidth = yard.width / 2;
  return ([
    [-1, -1],
    [1, -1],
    [1, 1],
    [-1, 1],
  ] as const).map(([s, t]) =>
    offsetLatLng(
      yard,
      alongE * s * halfLength + rightE * t * halfWidth,
      alongN * s * halfLength + rightN * t * halfWidth,
    ),
  );
}

/**
 * Where each gantry on a rail stands, and whether its boom is up.
 *
 * `count` gantries spread evenly from `from` to `to`, half a pitch in from each
 * end — the same placement rule `airports.ts` uses for aircraft on stand, so a
 * row of eighteen never has one crane hanging off the end of its rail.
 *
 * The idle ones are taken from the **far end of the rail**, deterministically,
 * rather than rolled per crane. A partly-worked berth looks like a block of
 * gantries down over a ship and the rest stood off to one side; a random
 * scatter of raised booms looks like a fault. It is also the only way the count
 * can be asserted exactly.
 */
export function craneStations(crane: Crane): { at: LatLng; idle: boolean }[] {
  const count = Math.max(0, Math.floor(crane.count));
  if (count === 0) return [];
  const idleCount = Math.min(count, Math.round(count * (crane.idleFraction ?? 0)));
  const stations: { at: LatLng; idle: boolean }[] = [];
  for (let i = 0; i < count; i += 1) {
    const t = (i + 0.5) / count;
    stations.push({
      at: [
        crane.from[0] + (crane.to[0] - crane.from[0]) * t,
        crane.from[1] + (crane.to[1] - crane.from[1]) * t,
      ],
      idle: i >= count - idleCount,
    });
  }
  return stations;
}

// ---- Batching -------------------------------------------------------------
//
// A private copy of `airports.ts`'s `Batch`, and copied rather than imported for
// the reason that file gives at lines 31-41: the cache must not be module-level,
// because `createScene().dispose()` walks the scene disposing every material it
// finds, and a cache that outlived one build would hand the next board a
// disposed material and render the whole port black.

interface Bucket {
  readonly name: string;
  readonly material: THREE.Material;
  readonly castShadow: boolean;
  readonly receiveShadow: boolean;
  readonly parts: THREE.BufferGeometry[];
}

/** One build's worth of materials and geometry, merged on the way out. */
class Batch {
  private readonly buckets = new Map<string, Bucket>();

  add(
    name: string,
    geometry: THREE.BufferGeometry | null,
    material: THREE.Material,
    shadows: { cast?: boolean; receive?: boolean } = {},
  ): void {
    if (!geometry) return;
    const key = `${material.uuid}|${name}`;
    const bucket = this.buckets.get(key);
    if (bucket) {
      bucket.parts.push(geometry);
      return;
    }
    this.buckets.set(key, {
      name,
      material,
      castShadow: shadows.cast ?? false,
      receiveShadow: shadows.receive ?? true,
      parts: [geometry],
    });
  }

  flush(into: THREE.Object3D): void {
    for (const bucket of this.buckets.values()) {
      const merged =
        bucket.parts.length === 1 ? bucket.parts[0] : mergeGeometries(bucket.parts, false);
      if (!merged) {
        // Losing a bucket in silence is the failure the module comment warns
        // about, so say which one rather than rendering a port with no quay.
        console.warn(`ports: "${bucket.name}" has mismatched attributes and was not merged`);
        continue;
      }
      if (bucket.parts.length > 1) for (const part of bucket.parts) part.dispose();
      const mesh = new THREE.Mesh(merged, bucket.material);
      mesh.name = bucket.name;
      mesh.castShadow = bucket.castShadow;
      mesh.receiveShadow = bucket.receiveShadow;
      into.add(mesh);
    }
    this.buckets.clear();
  }
}

// ---- Geometry -------------------------------------------------------------
//
// `groundQuad`, `stripGeometry`, `slabGeometry` and `massGeometry` are copies of
// `airports.ts`'s, kept here for the same dispose reason the `Batch` is. Their
// scars are copied with them: the ground quad's winding, the strip's left rail
// first, the slab's ring orientation and the mass's un-negated `atan2`.

/**
 * A flat, axis-free quad in the ground plane.
 *
 * `(alongX, alongZ)` is a unit vector in scene space; the across direction is
 * its right-hand perpendicular. UVs run `u` along and `v` across, which is what
 * lets one yard index into a horizontal band of the atlas.
 */
function groundQuad(
  cx: number,
  y: number,
  cz: number,
  alongX: number,
  alongZ: number,
  halfLength: number,
  halfWidth: number,
  uv: { u0: number; u1: number; v0: number; v1: number } = { u0: 0, u1: 1, v0: 0, v1: 1 },
): THREE.BufferGeometry {
  const rightX = -alongZ;
  const rightZ = alongX;
  const positions: number[] = [];
  const uvs: number[] = [];
  for (const [s, t, u, v] of [
    [-halfLength, -halfWidth, uv.u0, uv.v0],
    [halfLength, -halfWidth, uv.u1, uv.v0],
    [-halfLength, halfWidth, uv.u0, uv.v1],
    [halfLength, halfWidth, uv.u1, uv.v1],
  ] as const) {
    positions.push(cx + alongX * s + rightX * t, y, cz + alongZ * s + rightZ * t);
    uvs.push(u, v);
  }
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
  geometry.setAttribute(
    "normal",
    new THREE.Float32BufferAttribute([0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0], 3),
  );
  geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
  // Wound so the face normal comes out +Y; the other order renders the yard as a
  // hole in the ground under a one-sided material.
  geometry.setIndex([0, 2, 1, 1, 2, 3]);
  return geometry;
}

/**
 * A flat strip along a polyline — the dredged channel.
 *
 * The **left rail is emitted first**, which is not a style choice: the other way
 * round reverses the winding, `computeVertexNormals` hands every triangle a
 * normal pointing at the seabed, and a one-sided material draws nothing at all.
 * `airports.ts` and `structures.ts` both carry this scar.
 */
function stripGeometry(points: readonly THREE.Vector3[], width: number): THREE.BufferGeometry {
  const positions: number[] = [];
  const uvs: number[] = [];
  const indices: number[] = [];
  const half = width / 2;
  for (let index = 0; index < points.length; index += 1) {
    const point = points[index];
    const previous = points[Math.max(0, index - 1)];
    const next = points[Math.min(points.length - 1, index + 1)];
    if (!point || !previous || !next) continue;
    const dx = next.x - previous.x;
    const dz = next.z - previous.z;
    const length = Math.hypot(dx, dz) || 1;
    const leftX = -(dz / length);
    const leftZ = dx / length;
    positions.push(
      point.x + leftX * half,
      point.y,
      point.z + leftZ * half,
      point.x - leftX * half,
      point.y,
      point.z - leftZ * half,
    );
    const v = index / Math.max(1, points.length - 1);
    uvs.push(0, v, 1, v);
    if (index < points.length - 1) {
      const a = index * 2;
      indices.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
    }
  }
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
  geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
  geometry.setIndex(indices);
  geometry.computeVertexNormals();
  return geometry;
}

/** A flat polygon in the ground plane, triangulated. A quay deck. */
function slabGeometry(
  points: readonly [number, number][],
  y: number,
): THREE.BufferGeometry | null {
  if (points.length < 3) return null;
  // `ShapeGeometry` builds in XY and faces +Z. Feeding it `(x, -z)` and turning
  // it a quarter about X puts north back at -z and the normal at +Y.
  let ring = points.map(([x, z]) => new THREE.Vector2(x, -z));
  // A clockwise ring comes out facing away from the camera and is invisible
  // under a one-sided material, so orient it here rather than asking every pack
  // to trace its quays in one direction.
  if (THREE.ShapeUtils.area(ring) < 0) ring = ring.reverse();
  const geometry = new THREE.ShapeGeometry(new THREE.Shape(ring));
  geometry.rotateX(-Math.PI / 2);
  geometry.translate(0, y, 0);
  return geometry;
}

/**
 * A box standing on the ground, turned to a bearing. Terminal sheds.
 *
 * The box's own length axis is +Z, and `rotateY(phi)` sends +Z to
 * `(sin phi, cos phi)` in (x, z) — so phi is `atan2(alongX, alongZ)` and **not**
 * `atan2(alongX, -alongZ)`. `airports.ts` records at length how the negation
 * fails: it does not rotate a building by a wrong angle, it *mirrors* it about
 * the east-west line.
 */
function massGeometry(
  cx: number,
  groundY: number,
  cz: number,
  alongX: number,
  alongZ: number,
  length: number,
  width: number,
  height: number,
): THREE.BufferGeometry {
  const geometry = new THREE.BoxGeometry(width, height, length);
  geometry.rotateY(Math.atan2(alongX, alongZ));
  geometry.translate(cx, groundY + height / 2, cz);
  return geometry;
}

/**
 * A trapezoid mound along a path — the breakwater.
 *
 * Three quad strips: a crest and two slopes down to the seabed. Emitted as one
 * indexed buffer with real normals, because a berm lit only on its crest is a
 * flat grey line and a berm lit on its three faces is unmistakably a heap of
 * rock. Left rail first, for `stripGeometry`'s reason.
 */
function bermGeometry(
  points: readonly THREE.Vector3[],
  crestHalf: number,
  baseHalf: number,
  crestY: number,
  baseY: number,
): THREE.BufferGeometry | null {
  if (points.length < 2) return null;
  const positions: number[] = [];
  const uvs: number[] = [];
  const indices: number[] = [];
  // Four rails across the section: left toe, left crest, right crest, right toe.
  const offsets: [number, number][] = [
    [baseHalf, baseY],
    [crestHalf, crestY],
    [-crestHalf, crestY],
    [-baseHalf, baseY],
  ];
  for (let index = 0; index < points.length; index += 1) {
    const point = points[index];
    const previous = points[Math.max(0, index - 1)];
    const next = points[Math.min(points.length - 1, index + 1)];
    if (!point || !previous || !next) continue;
    const dx = next.x - previous.x;
    const dz = next.z - previous.z;
    const length = Math.hypot(dx, dz) || 1;
    const leftX = -(dz / length);
    const leftZ = dx / length;
    const v = index / Math.max(1, points.length - 1);
    offsets.forEach(([across, y], rail) => {
      positions.push(point.x + leftX * across, y, point.z + leftZ * across);
      uvs.push(rail / (offsets.length - 1), v);
    });
    if (index < points.length - 1) {
      const a = index * offsets.length;
      const b = a + offsets.length;
      for (let rail = 0; rail < offsets.length - 1; rail += 1) {
        indices.push(a + rail, b + rail, a + rail + 1, a + rail + 1, b + rail, b + rail + 1);
      }
    }
  }
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
  geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
  geometry.setIndex(indices);
  geometry.computeVertexNormals();
  return geometry;
}

/**
 * A vertical ribbon hanging under a polyline — the quay face, and the revetment
 * toe under it.
 *
 * Its winding is the mirror of `stripGeometry`'s, because the two rails here are
 * a top and a bottom rather than a left and a right, and a wall wound the other
 * way faces into the land it is holding back.
 */
function wallGeometry(
  points: readonly [number, number][],
  topY: number,
  bottomY: number,
  outward: [number, number],
): THREE.BufferGeometry | null {
  if (points.length < 2) return null;
  const positions: number[] = [];
  const uvs: number[] = [];
  const indices: number[] = [];
  for (let index = 0; index < points.length; index += 1) {
    const point = points[index];
    if (!point) continue;
    positions.push(point[0], topY, point[1], point[0], bottomY, point[1]);
    const u = index / Math.max(1, points.length - 1);
    uvs.push(u, 1, u, 0);
    if (index < points.length - 1) {
      const a = index * 2;
      indices.push(a, a + 1, a + 2, a + 2, a + 1, a + 3);
    }
  }
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
  geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
  geometry.setIndex(indices);
  geometry.computeVertexNormals();
  // A vertical ribbon's computed normals can come out either way depending on
  // which direction the ring was traced. Force them to face the water, so the
  // sun on a quay wall is the sun on the side you can see.
  const normal = geometry.getAttribute("normal");
  const first = normal.getX(0) * outward[0] + normal.getZ(0) * outward[1];
  if (first < 0) {
    for (let i = 0; i < normal.count; i += 1) {
      normal.setXYZ(i, -normal.getX(i), -normal.getY(i), -normal.getZ(i));
    }
    const index = geometry.getIndex();
    if (index) {
      for (let i = 0; i < index.count; i += 3) {
        const b = index.getX(i + 1);
        index.setX(i + 1, index.getX(i + 2));
        index.setX(i + 2, b);
      }
      index.needsUpdate = true;
    }
    normal.needsUpdate = true;
  }
  return geometry;
}

// ---- The yard atlas -------------------------------------------------------

const YARD_ATLAS_WIDTH = 1024;
const YARD_BAND = 256;

/** Next power of two at or above `n`; keeps the atlas mipmappable everywhere. */
function powerOfTwo(n: number): number {
  let size = 1;
  while (size < n) size *= 2;
  return size;
}

/** A small deterministic PRNG, so a yard looks the same on every load. */
function seeded(seed: number): () => number {
  let state = (seed | 0) || 1;
  return () => {
    state = (state * 1_664_525 + 1_013_904_223) | 0;
    return ((state >>> 8) & 0xff_ffff) / 0x100_0000;
  };
}

/**
 * Every container yard on one board, painted into one texture.
 *
 * Each yard gets a horizontal band `YARD_BAND` pixels tall: x runs along the
 * yard's `length` from its low end, y across its `width`. Drawn in **metres and
 * mapped through** — `alongPx` and `acrossPx` are the only two places the
 * resolution appears — so a block stays the right size when a yard of a
 * different size shares the atlas.
 *
 * The unit drawn is a **stacking block**, not a container: roughly 250 m by
 * 32 m, which is what a straddle-carrier yard is actually laid out in and which
 * at this resolution is 135 px by 8 px. A container would be 13 px by 0.6, and
 * 0.6 of a pixel is not a thing a canvas can draw.
 *
 * ### `emptyShare` is drawn exactly, not rolled
 *
 * The empty blocks are chosen by an exact count — `round(blocks * emptyShare)` —
 * and then interleaved through the yard rather than sampled per block. That is
 * the difference between a picture that *is* 75.7 per cent empty and one that is
 * 75.7 per cent empty on average, and it is the whole reason the yard is paint:
 * a canvas can hold a proportion, a random scatter of instances cannot.
 *
 * With `emptyShare` absent every block is `boxUnknown`. One colour, never a
 * guessed mix.
 */
export function yardAtlas(yards: readonly Yard[]): THREE.Texture | null {
  if (typeof document === "undefined") return null;
  const canvas = document.createElement("canvas");
  canvas.width = YARD_ATLAS_WIDTH;
  canvas.height = powerOfTwo(YARD_BAND * Math.max(1, yards.length));
  const context = canvas.getContext("2d");
  if (!context) return null;

  const hex = (color: number) => `#${color.toString(16).padStart(6, "0")}`;

  // The whole sheet, including any unused tail band, is asphalt. A transparent
  // tail would show the sea through the last yard's mipmaps.
  context.fillStyle = hex(PORT_PALETTE.yardBase);
  context.fillRect(0, 0, canvas.width, canvas.height);

  /** A stacking block, metres. */
  const BLOCK_LENGTH = 250;
  const BLOCK_WIDTH = 32;
  const BLOCK_GAP_ALONG = 30;
  const BLOCK_GAP_ACROSS = 16;
  /** The perimeter road every terminal has, metres. */
  const PERIMETER = 40;

  yards.forEach((yard, index) => {
    const top = index * YARD_BAND;
    const alongPx = (metres: number) => (metres / yard.length) * YARD_ATLAS_WIDTH;
    const acrossPx = (metres: number) => (metres / yard.width) * YARD_BAND;
    const random = seeded(1 + index * 7919);

    context.save();
    context.beginPath();
    context.rect(0, top, YARD_ATLAS_WIDTH, YARD_BAND);
    context.clip();

    // The perimeter road, and the lanes between the block groups.
    context.fillStyle = hex(PORT_PALETTE.yardLane);
    context.fillRect(0, top, YARD_ATLAS_WIDTH, acrossPx(PERIMETER));
    context.fillRect(0, top + YARD_BAND - acrossPx(PERIMETER), YARD_ATLAS_WIDTH, acrossPx(PERIMETER));

    const columns = Math.max(1, Math.floor(yard.length / (BLOCK_LENGTH + BLOCK_GAP_ALONG)));
    const rows = Math.max(
      1,
      Math.floor((yard.width - 2 * PERIMETER) / (BLOCK_WIDTH + BLOCK_GAP_ACROSS)),
    );
    const total = columns * rows;
    const empties = yard.emptyShare === undefined ? 0 : Math.round(total * yard.emptyShare);

    /**
     * Which blocks are empties, spread through the yard rather than clumped.
     *
     * A Bresenham walk: block `k` is an empty when the running count crosses
     * another `empties/total`. Exactly `empties` blocks come out empty and they
     * are evenly distributed, which is what a real yard looks like — the empty
     * stacks are not in one corner, they are between the loaded ones.
     */
    const isEmpty = (k: number) =>
      yard.emptyShare !== undefined &&
      Math.floor(((k + 1) * empties) / total) > Math.floor((k * empties) / total);

    // Centred, so the slack left over by the integer block counts is split
    // between the two ends rather than dumped at one of them — an unpaved strip
    // down one side of a terminal reads as an unfinished texture.
    const alongMargin = (yard.length - columns * (BLOCK_LENGTH + BLOCK_GAP_ALONG)) / 2;
    const acrossMargin =
      (yard.width - 2 * PERIMETER - rows * (BLOCK_WIDTH + BLOCK_GAP_ACROSS)) / 2;

    for (let column = 0; column < columns; column += 1) {
      for (let row = 0; row < rows; row += 1) {
        const k = column * rows + row;
        const x = alongPx(alongMargin + column * (BLOCK_LENGTH + BLOCK_GAP_ALONG));
        const y = top + acrossPx(PERIMETER + acrossMargin + row * (BLOCK_WIDTH + BLOCK_GAP_ACROSS));
        const width = alongPx(BLOCK_LENGTH);
        const height = Math.max(1, acrossPx(BLOCK_WIDTH));
        const base =
          yard.emptyShare === undefined
            ? PORT_PALETTE.boxUnknown
            : isEmpty(k)
              ? PORT_PALETTE.boxEmpty
              : random() < 0.55
                ? PORT_PALETTE.boxLoadedA
                : PORT_PALETTE.boxLoadedB;
        // A stack is many boxes and no two weather alike; a flat fill reads as
        // a painted rectangle, which is what it is. +-6% of value is enough.
        const shade = 0.94 + random() * 0.12;
        const colour = new THREE.Color(base).multiplyScalar(shade);
        context.fillStyle = `#${colour.getHexString()}`;
        context.fillRect(x, y, width, height);
        // A block is never full to its end. Eat a random tail out of it, which
        // is what stops the yard reading as graph paper.
        if (random() < 0.35) {
          context.fillStyle = hex(PORT_PALETTE.yardBase);
          const eaten = width * (0.1 + random() * 0.3);
          context.fillRect(x + width - eaten, y, eaten, height);
        }
      }
    }
    context.restore();
  });

  const texture = new THREE.CanvasTexture(canvas);
  // Straight through: the atlas is authored top-down and the v coordinates below
  // are canvas rows over canvas height, so flipping it would put one yard's
  // stacks on another yard.
  texture.flipY = false;
  texture.colorSpace = THREE.SRGBColorSpace;
  texture.needsUpdate = true;
  return texture;
}

// ---- The build ------------------------------------------------------------

/**
 * The stack, in scene units. Same reasoning as `airports.ts`'s: these have to be
 * far enough apart to survive the depth buffer, and a hundredth of a unit is the
 * step that works — at 391 m per unit and 3.4x exaggeration the whole stack is
 * about a metre of real height, over ground that is genuinely built in layers.
 *
 * `PLATE_LIFT` has the same hard floor under it and it is not the ground:
 * `terrain.ts` builds its relief at `world.metres(e) + 0.012`, and the terrain
 * LOD may collapse a near-flat patch to a quad sitting up to
 * `LOD_HEIGHT_TOLERANCE` above the lattice points it replaced. A plate cleared
 * of neither is pierced by the ground it stands on — which is how Van Nuys grew
 * a tan wedge through the middle of its airfield. Two constants that must not
 * drift apart are one constant, so it is derived rather than typed.
 */
const PLATE_LIFT = LOD_HEIGHT_TOLERANCE + 0.04;
const YARD_LIFT = 0.01;
const QUAY_LIFT = 0.02;

/**
 * Where the sea is.
 *
 * `terrain.ts` puts the sea plane at `y = -0.06` and the shore plates at `y = 0`
 * — six hundredths of a unit, which at 391 m per unit is about seven metres of
 * step. A quay skirt has to reach the first number and a channel has to float a
 * hair above it, so both are derived from this rather than typed twice.
 */
const SEA_Y = -0.06;
const CHANNEL_LIFT = 0.01;

/**
 * How wide a breakwater is drawn, in metres, before the legibility floor.
 *
 * The federal breakwater at San Pedro is about ninety metres across its base,
 * which is 0.23 scene units on Southern California — under half a pixel at the
 * whole-board pose, where this object has to do its most important work. So it
 * carries a floor in **metres** the way `bridges.ts`'s members do, and for the
 * same stated reason: an invisible breakwater is a worse answer than a chunky
 * one. The floor used to be scene units (0.11 / 0.3), tuned at 391 m to the
 * unit. On the merged California board those always won, and the crest half
 * became 211 m. Floors are the metres those constants measured on SoCal, so
 * that board does not move. `socal.ts`'s LA River sets the same precedent from
 * the other direction, drawn at four times its true width because anything
 * narrower than a terrain cell renders as a dotted line rather than a thin one.
 */
const BREAKWATER_CREST_M = 44;
const BREAKWATER_BASE_M = 140;
const BREAKWATER_CREST_FLOOR_M = 43;
const BREAKWATER_BASE_FLOOR_M = 117;
/** Crest height above the waterline, metres. Exaggerated by the board like any height. */
const BREAKWATER_CREST_HEIGHT_M = 7;

/**
 * Dredged channel width, metres, and its floor.
 *
 * The Main Channel at San Pedro really is about 340 m between the shoal edges,
 * which is 0.87 units on SoCal — legible at the Harbour pose and marginal at
 * the whole board, hence the floor. The floor used to be 0.55 scene units:
 * 215 m at Los Angeles, 1.05 km on merged California. 340 m already clears
 * 215 m, so both boards now draw the real channel. It is one number for every
 * channel on the board rather than a field on `Port`, because a channel drawn
 * wider than the water it lies in laps onto the quay beside it, and 340 m is
 * what the narrowest reach on this board can take.
 */
const CHANNEL_WIDTH_M = 340;
const CHANNEL_FLOOR_M = 215;

/** Quay revetment toe, metres, and the floor that keeps it a strip of rock. */
const QUAY_TOE_M = 26;
const QUAY_TOE_FLOOR_M = 27;

/** A gantry's dimensions, metres, and the floors that keep the members visible. */
const CRANE_RAIL_GAUGE_M = 30;
const CRANE_MEMBER_M = 5;
const CRANE_GAUGE_FLOOR_M = 45;
const CRANE_MEMBER_FLOOR_M = 12.5;
const CRANE_OUTREACH_FLOOR_M = 102;

/**
 * Cross-section in metres, never thinner than `floorM` metres.
 *
 * The second argument used to be scene units, tuned at 391 m/unit. On a board
 * 1,919 m to the unit those floors always won: a 340 m channel became 1.05 km.
 * Floors are the metres those old constants measured on SoCal, so that board
 * does not move and every other board draws the same stone.
 */
function size(world: World, metres: number, floorM: number): number {
  return Math.max(metres, floorM) / world.metresPerUnit;
}

/** Degrees the boom stands at when it is raised. A stowed gantry is near vertical. */
const CRANE_BOOM_IDLE_DEG = 74;

/**
 * The A-frame mast, as a fraction of the portal height, and how far back over
 * the portal its apex sits.
 *
 * Both are read off real gantries and off the two crane candidates the owner
 * approved out of the asset factory, which independently agreed to within a few
 * per cent: a hinge at 58 m under an apex at 99–104 m is a mast 0.71–0.78 of the
 * portal again, with the apex 7–14 m — roughly a third of a rail gauge — behind
 * the waterside rail. 0.62 is the conservative end of that band, chosen because
 * this board's packs already author a gantry taller than a real one (82 m at
 * Pier 400 against a real 55–60), and taking the full 0.75 on top of an
 * already-tall portal would put an apex at 143 m where nothing has ever stood.
 *
 * The setback is not decoration: the forestay runs from the apex to the boom,
 * and an apex level with or ahead of the hinge would draw a stay that pulls the
 * boom *down*.
 */
const CRANE_MAST_RISE = 0.62;
const CRANE_APEX_SETBACK = 0.4;

/** Where the truck lane's head beam crosses the portal, as a fraction of it. */
const CRANE_PORTAL_BEAM = 0.44;

/** Backreach length, as a fraction of outreach, measured from the landside rail. */
const CRANE_BACKREACH = 0.55;

/** Every coordinate a port's graded plate has to be at least as high as. */
function plateSamples(port: Port): LatLng[] {
  const samples: LatLng[] = [];
  for (const quay of port.quays ?? []) for (const point of quay.polygon) samples.push(point);
  for (const yard of port.yards ?? []) samples.push(...yardCorners(yard));
  for (const berth of port.berths ?? []) samples.push([berth.lat, berth.lng]);
  if (samples.length === 0) samples.push([port.lat, port.lng]);
  return samples;
}

/**
 * One graded height for the whole port, for the reason `airports.ts` gives about
 * an airfield: a container terminal is reclaimed land, and what reclamation *is*
 * is somebody spending a great deal of money making the ground one plane.
 */
function plateHeight(world: World, port: Port): number {
  let highest = -Infinity;
  for (const [lat, lng] of plateSamples(port)) {
    const ground = world.groundAt(lat, lng);
    if (ground > highest) highest = ground;
  }
  return (Number.isFinite(highest) ? highest : 0) + PLATE_LIFT;
}

interface Surfaces {
  stone: THREE.Material;
  yard: THREE.Material;
  channel: THREE.Material;
  crane: THREE.Material;
}

function surfaces(yards: readonly Yard[]): { surfaces: Surfaces; atlas: THREE.Texture | null } {
  const atlas = yardAtlas(yards);
  return {
    atlas,
    surfaces: {
      stone: new THREE.MeshLambertMaterial({ color: PORT_PALETTE.stone }),
      yard: new THREE.MeshLambertMaterial({
        ...(atlas ? { map: atlas } : { color: PORT_PALETTE.yardBase }),
      }),
      /**
       * Standard rather than Lambert, and the only one in the kit that is: the
       * whole job of this surface is to be *smoother* than the sea beside it,
       * and roughness is not a thing a Lambert has. Dredged water is calmer and
       * darker than the shoal beside it, which is exactly the cue that makes a
       * channel legible from the air without drawing a line on the map.
       */
      channel: new THREE.MeshStandardMaterial({
        color: PORT_PALETTE.channel,
        roughness: 0.16,
        metalness: 0,
      }),
      crane: new THREE.MeshLambertMaterial({ color: PORT_PALETTE.crane }),
    },
  };
}

/** The outward (waterward) horizontal direction of a quay, in scene space. */
function quayOutward(world: World, quay: Quay): [number, number] {
  // The first two vertices are the water edge by authoring convention, so the
  // outward normal is the side of that edge the rest of the polygon is NOT on.
  const projected = quay.polygon.map(([lat, lng]) => world.project(lat, lng));
  const a = projected[0];
  const b = projected[1];
  if (!a || !b) return [0, -1];
  const dx = b[0] - a[0];
  const dz = b[1] - a[1];
  const length = Math.hypot(dx, dz) || 1;
  const leftX = -(dz / length);
  const leftZ = dx / length;
  // Centroid of the whole ring tells us where the land is.
  let cx = 0;
  let cz = 0;
  for (const [x, z] of projected) {
    cx += x;
    cz += z;
  }
  cx /= projected.length;
  cz /= projected.length;
  const towardLand = (cx - a[0]) * leftX + (cz - a[1]) * leftZ;
  return towardLand > 0 ? [-leftX, -leftZ] : [leftX, leftZ];
}

/** One port's stone, yards and channel, dropped into a batch that may hold others. */
function buildPort(
  world: World,
  port: Port,
  batch: Batch,
  paints: Surfaces,
  cranes: THREE.Matrix4[],
  bandOffset: number,
  bandTotal: number,
): void {
  const unit = 1 / world.metresPerUnit;
  const plate = plateHeight(world, port);

  // ---- The breakwater -----------------------------------------------------
  //
  // Drawn only where a pack authored one. `harborType` is the reason a pack
  // authors one: `CB` harbours have them and `CN` harbours must not be given
  // one, because giving Oakland a breakwater would be inventing the single
  // largest object on its waterfront.

  for (const arm of port.breakwater ?? []) {
    const points = arm.map(([lat, lng]) => {
      const [x, z] = world.project(lat, lng);
      return new THREE.Vector3(x, 0, z);
    });
    batch.add(
      "ports:stone",
      bermGeometry(
        points,
        size(world, BREAKWATER_CREST_M / 2, BREAKWATER_CREST_FLOOR_M),
        size(world, BREAKWATER_BASE_M / 2, BREAKWATER_BASE_FLOOR_M),
        world.metres(BREAKWATER_CREST_HEIGHT_M),
        SEA_Y - 0.02,
      ),
      paints.stone,
      { cast: true },
    );
  }

  // ---- The dredged channel ------------------------------------------------

  if (port.channel && port.channel.length >= 2) {
    const points = port.channel.map(([lat, lng]) => {
      const [x, z] = world.project(lat, lng);
      return new THREE.Vector3(x, SEA_Y + CHANNEL_LIFT, z);
    });
    batch.add(
      "ports:channel",
      stripGeometry(points, size(world, CHANNEL_WIDTH_M, CHANNEL_FLOOR_M)),
      paints.channel,
      { receive: false },
    );
  }

  // ---- Quays: a deck, a face down to the water, and a toe under it ---------

  for (const quay of port.quays ?? []) {
    const projected = quay.polygon.map(([lat, lng]) => world.project(lat, lng));
    const deckY = plate + QUAY_LIFT + world.metres(quay.deckHeight);
    batch.add("ports:stone", slabGeometry(projected, deckY), paints.stone);

    const a = projected[0];
    const b = projected[1];
    if (!a || !b) continue;
    const outward = quayOutward(world, quay);
    // The face: a vertical ribbon from the deck down past the waterline, so
    // there is no gap for the sea's swell to show through at a low sun.
    batch.add("ports:stone", wallGeometry([a, b], deckY, SEA_Y - 0.03, outward), paints.stone, {
      cast: true,
    });
    /**
     * The revetment toe: a short sloped apron of rock at the foot of the wall.
     *
     * Every quay in a working harbour has one, and at this scale it is what
     * stops the wall reading as a razor-thin line where the concrete meets the
     * water. Two quads, and it is the third thing the STONE bucket is for.
     */
    const toe = size(world, QUAY_TOE_M, QUAY_TOE_FLOOR_M);
    const toeA: [number, number] = [a[0] + outward[0] * toe, a[1] + outward[1] * toe];
    const toeB: [number, number] = [b[0] + outward[0] * toe, b[1] + outward[1] * toe];
    batch.add(
      "ports:stone",
      bermGeometry(
        [new THREE.Vector3(toeA[0], 0, toeA[1]), new THREE.Vector3(toeB[0], 0, toeB[1])],
        toe / 2,
        toe,
        SEA_Y + 0.02,
        SEA_Y - 0.03,
      ),
      paints.stone,
    );
  }

  // ---- Yards: one quad each, all indexing one atlas ------------------------

  (port.yards ?? []).forEach((yard, index) => {
    const [x, z] = world.project(yard.lat, yard.lng);
    const [east, north] = bearingVector(yard.bearing);
    // Scene space runs x east and z south, so a bearing's north component is a
    // negative z.
    const alongX = east;
    const alongZ = -north;
    const band = bandOffset + index;
    batch.add(
      "ports:yard",
      groundQuad(
        x,
        plate + YARD_LIFT,
        z,
        alongX,
        alongZ,
        (yard.length / 2) * unit,
        (yard.width / 2) * unit,
        {
          u0: 0,
          u1: 1,
          v0: (band * YARD_BAND) / bandTotal,
          v1: ((band + 1) * YARD_BAND) / bandTotal,
        },
      ),
      paints.yard,
    );

    /**
     * One shed at the landward end of the yard.
     *
     * A terminal is almost all flat ground, and a yard with nothing standing on
     * it anywhere reads as a car park from a low camera. One transit shed per
     * yard — twelve triangles — is the whole of the relief this kit spends on
     * buildings, and it is where the reefer racks and the maintenance shop
     * genuinely are.
     */
    const shedAlong = Math.min(yard.length * 0.16, 260);
    const shedAcross = Math.min(yard.width * 0.3, 120);
    const [shedLat, shedLng] = offsetLatLng(
      yard,
      -east * (yard.length / 2 - shedAlong * 0.75),
      -north * (yard.length / 2 - shedAlong * 0.75),
    );
    const [sx, sz] = world.project(shedLat, shedLng);
    batch.add(
      "ports:stone",
      massGeometry(
        sx,
        plate + YARD_LIFT,
        sz,
        alongX,
        alongZ,
        shedAlong * unit,
        shedAcross * unit,
        world.metres(16),
      ),
      paints.stone,
      { cast: true },
    );
  });

  // ---- Cranes: matrices only; the mesh is built once for the whole board ---

  for (const crane of port.cranes ?? []) {
    pushCraneMatrices(world, crane, plate, cranes);
  }
}

/**
 * Every box of every gantry on one rail, as instance matrices.
 *
 * **The instanced geometry is a unit box and each crane contributes eleven of
 * them.** That is what makes a per-instance boom angle possible inside a single
 * `InstancedMesh`: an instance matrix can rotate a boom about its pivot, but it
 * cannot rotate one limb of a rigid crane geometry relative to the rest. Boxes,
 * one draw call for every gantry on the board, and the boom angle is free. A
 * `Group` per crane — the obvious shape — would be eleven draws times
 * fifty-six gantries against a mobile cap with thirty spare, which is the
 * ~34-draw suspension bridge and the twelve unmergeable freeways all over again.
 * `ports.test.ts` asserts no crane is a `Group`.
 *
 * ### Why eleven and not five
 *
 * It was five — two legs, a portal beam, a boom and a backreach — and from
 * altitude fifty-six of those read as **fifty-six crosses**. Two coincident
 * verticals, one horizontal bar through them, nothing above the bar. The
 * silhouette a container terminal is recognised by has one more move in it than
 * that, and it is the one thing the five-box crane had none of: **the A-frame
 * mast standing above the girder, with the boom slung from it on stays.** A
 * ship-to-shore gantry's hinge is around 58 m and its apex around 100 — the
 * mast is two thirds of the portal again on top of it — so the tallest part of
 * a *working* crane is not the boom at all, it is the mast, and the raised boom
 * of a parked one clears its own apex by only fifteen to twenty-five per cent.
 * Without the mast a working crane has no height above its own deck line and a
 * whole berth flattens into a picket fence.
 *
 * The other six boxes are what stop the mast reading as a flagpole: a sill
 * beam so the legs stand on something, a truck-lane beam so the two legs read
 * as one portal frame, an apex cap, the machinery house over the tail, and the
 * two stays that make the boom look *held*. Where the parts came from:
 * geometry approved out of the asset factory (`sts-crane-…-eaff8b` and
 * `…-df0f72`, both upvoted) was measured for its proportions and rebuilt here,
 * rather than pasted — the factory's cranes are three hundred lattice members
 * and five materials apiece, which is the right answer for a hero shot of one
 * crane and the wrong one for fifty-six of them 1.1 units tall.
 *
 * Cost, which is the whole reason this is a matrix list and not a model: 132
 * triangles a gantry against 60, so 7,392 for San Pedro Bay against 3,360 —
 * **+4,032 on a board that draws 1.42 million**, and still exactly one draw
 * call. The census in `ports.test.ts` is the ledger.
 *
 * ### The board stretches height and not width, so the boom is composed
 *
 * `world.metres()` applies vertical exaggeration and horizontal distance does
 * not, so a 70 m boom is 0.18 units lying flat and 0.61 units standing up. Its
 * apparent length and apparent angle are therefore computed from the two
 * components separately — never from one length and a rotation — or a raised
 * boom would be three times too short. Everything inclined below goes through
 * `strut`, which takes two points in the (distance along the boom axis, height)
 * plane and is therefore composed by construction: there is no way to hand it a
 * length and an angle and get the exaggeration wrong.
 */
function pushCraneMatrices(
  world: World,
  crane: Crane,
  plate: number,
  out: THREE.Matrix4[],
): void {
  const gauge = size(world, CRANE_RAIL_GAUGE_M, CRANE_GAUGE_FLOOR_M);
  const member = size(world, CRANE_MEMBER_M, CRANE_MEMBER_FLOOR_M);
  const portal = world.metres(crane.height);
  const outreach = size(world, crane.outreach, CRANE_OUTREACH_FLOOR_M);

  // The boom's horizontal direction in scene space: x east, z south.
  const [east, north] = bearingVector(crane.bearing);
  const boomX = east;
  const boomZ = -north;
  // `rotateY(phi)` sends local +X to `(cos phi, -sin phi)` in (x, z), so aiming
  // +X at `(boomX, boomZ)` wants `phi = atan2(-boomZ, boomX)`.
  const yaw = Math.atan2(-boomZ, boomX);

  const dummy = new THREE.Object3D();
  const push = (
    x: number,
    y: number,
    z: number,
    sx: number,
    sy: number,
    sz: number,
    tilt = 0,
  ) => {
    dummy.position.set(x, y, z);
    dummy.rotation.set(0, 0, 0);
    dummy.quaternion.setFromEuler(new THREE.Euler(0, yaw, tilt, "YZX"));
    dummy.scale.set(sx, sy, sz);
    dummy.updateMatrix();
    out.push(dummy.matrix.clone());
  };

  for (const station of craneStations(crane)) {
    const [rx, rz] = world.project(station.at[0], station.at[1]);
    const base = plate;
    const top = base + portal;
    // The girder line: boom, backreach and mast foot all meet here, one member
    // above the leg tops. Everything above the portal is measured from it.
    const girderY = top + member * 1.4;

    /**
     * A part placed by where its two ends are, in the vertical plane the boom
     * lies in.
     *
     * `s` is signed distance along the boom's bearing from the waterside rail —
     * positive over the water, negative over the yard — and `y` is a scene
     * height that has already been through `world.metres`. Both ends given, the
     * length and the tilt fall out, which is the point: the exaggeration bug
     * this module's header warns about is only reachable by *starting* from a
     * length and an angle, and there is no way to do that here.
     */
    const strut = (
      s0: number,
      y0: number,
      s1: number,
      y1: number,
      thick: number,
      wide = thick,
    ) => {
      const ds = s1 - s0;
      const dy = y1 - y0;
      const length = Math.hypot(ds, dy);
      if (length <= 0) return;
      const s = s0 + ds / 2;
      push(
        rx + boomX * s,
        y0 + dy / 2,
        rz + boomZ * s,
        length,
        thick,
        wide,
        Math.atan2(dy, ds),
      );
    };
    /** A level member, from `s0` to `s1` at one height. */
    const beam = (s0: number, s1: number, y: number, thick: number, wide: number) =>
      strut(s0, y, s1, y, thick, wide);

    // ---- The portal: two legs, a sill under them, a frame between them ----
    //
    // Still two legs and not four. A real gantry has a leg at each corner of a
    // 27 × 30 m footprint, and at SoCal's 391 m to the unit that 14 m along the
    // quay is 0.036 units while the member floor that keeps a leg visible at
    // all is 12.5 m — 0.032 units on this board. The pair would overlap by
    // ninety per cent and cost 56 extra boxes to draw one slightly fatter
    // leg. The board's own scale is the argument, not the triangle count.
    push(rx, base + portal / 2, rz, member, portal, member);
    push(rx - boomX * gauge, base + portal / 2, rz - boomZ * gauge, member, portal, member);

    // The sill. Without it the legs end at the quay with nothing under them and
    // a crane appears to be standing on two pins.
    beam(member * 0.4, -gauge - member * 0.4, base + member * 0.45, member * 0.5, member * 1.15);

    // The truck lane's head beam. A gantry straddles a road, and this is the
    // thing trucks pass under; it is also the only rung the two legs get, which
    // is what makes them read as one frame rather than as two posts that happen
    // to stand near each other.
    //
    // An X of diagonals above it was built, photographed and cut. The bay it
    // would brace is 0.115 units wide and 0.38 tall on this board, so the two
    // diagonals come out within a few degrees of vertical, land inside the legs'
    // own silhouette, and add a lump at mid-leg rather than an X. Two boxes a
    // gantry for a smudge is the wrong trade, and *at this scale* is the whole
    // reason — the same X at the factory's framing is one of the best things
    // about the crane it came from.
    beam(0, -gauge, base + portal * CRANE_PORTAL_BEAM, member * 0.62, member * 0.9);

    // ---- The girder: boom forward, backreach aft, in one line ----
    //
    // The backreach starts at the hinge rather than a full gauge behind it, so
    // boom and backreach are one unbroken girder over the portal. They used to
    // leave a gap the width of the gauge with the portal beam sitting below it,
    // which from the air read as a bar floating clear of a second bar.
    const angle = station.idle ? CRANE_BOOM_IDLE_DEG * DEG : 0;
    const reachX = outreach * Math.cos(angle);
    // Vertical is the exaggerated axis; horizontal is not. Composed, not rotated.
    const reachY = world.metres(crane.outreach * Math.sin(angle));
    strut(0, girderY, reachX, girderY + reachY, member * 0.85);

    const backLength = outreach * CRANE_BACKREACH;
    const tailS = -(gauge + backLength);
    beam(0, tailS, girderY, member * 0.75, member * 0.75);

    // The machinery house, over the tail. It is the one heavy mass on a gantry
    // and it is what tells you which end is the land.
    beam(
      -gauge - backLength * 0.2,
      tailS + backLength * 0.1,
      girderY + member * 1.05,
      member * 1.6,
      member * 1.45,
    );

    // ---- The mast, and the two stays that make the boom look held ----
    //
    // See the header. The apex is `CRANE_MAST_RISE` of the portal height above
    // the girder and set back over the portal, which is where a real one sits:
    // it has to be behind the hinge for the forestay to pull the boom *up*.
    const apexS = -gauge * CRANE_APEX_SETBACK;
    // Through `craneApexY`, not `girderY + portal * CRANE_MAST_RISE` written out
    // again here: `craneLights` hangs the obstruction light off the same number
    // and a second copy of it is a row of red dots hovering over the cranes.
    const apexY = craneApexY(world, crane, base);
    strut(-gauge * 0.12, girderY, apexS, apexY, member * 0.72);
    beam(apexS - member * 0.7, apexS + member * 0.7, apexY + member * 0.25, member * 0.45, member * 0.8);
    // Forestay to the boom and backstay to the tail. Drawn at a third of a
    // member, which is several times a real pendant's diameter and the smallest
    // thing that survives a 391 m scene unit.
    strut(apexS, apexY, reachX * 0.6, girderY + reachY * 0.6, member * 0.32);
    strut(apexS, apexY, tailS * 0.94, girderY - member * 0.15, member * 0.32);
  }
}

/**
 * Where a terminal's obstruction lights go, as bare positions.
 *
 * The twin of `bridges.ts`'s `bridgeLights`, and for the same reason. San Pedro
 * Bay at 21:35 is a graded plate the colour of the water it sticks out of, with
 * fifty-six pale gantries on it that the moon barely finds; the yards come up on
 * their own emissive and the cranes do not come up at all. What actually marks a
 * container terminal from the air after dark is the row of **red obstruction
 * lights on the crane apexes** — one per gantry, at the top of the A-frame,
 * which is what CFR 14 §77 asks for on anything this tall near a channel.
 *
 * **These are positions, not lights.** CONTRACT.md §4 gives `Atmosphere` the
 * only rig there is, and fifty-six point lights is not a slow version of this,
 * it is an impossible one. `nightlights.ts` turns what comes back into one
 * additive point cloud in one draw call, exactly as it already does for the
 * bridge tower heads — and it reuses that cloud's own red, because a light on a
 * 143 m crane and a light on a 227 m tower are the same fixture doing the same
 * job and there is no reason for the board to have two reds in it.
 *
 * A pure function of the pack, so it is identical across frames and reloads, and
 * it recomputes the apex from the same constants `pushCraneMatrices` uses rather
 * than reading them back off the matrices — a lamp derived from a second,
 * separately-maintained idea of where the top of a crane is is exactly how a row
 * of lights ends up floating beside the cranes instead of on them. Both call the
 * same `craneApexY`.
 */
export function craneLights(world: World, port: Port): { heads: number[] } {
  const heads: number[] = [];
  const plate = plateHeight(world, port);
  const gauge = size(world, CRANE_RAIL_GAUGE_M, CRANE_GAUGE_FLOOR_M);
  const member = size(world, CRANE_MEMBER_M, CRANE_MEMBER_FLOOR_M);
  const setback = gauge * CRANE_APEX_SETBACK;
  for (const crane of port.cranes ?? []) {
    // Clear of the apex cap, for the reason `bridges.ts` lifts its head lights
    // clear of the saddle: a sprite drawn at exactly the top of the thing it
    // marks is half-eaten by that thing's own depth, and the half it loses is
    // the bottom half — so it reads as dimmer *and* as sitting high.
    const y = craneApexY(world, crane, plate) + member;
    // Same decomposition `pushCraneMatrices` uses: the apex is a setback back
    // along the boom's bearing from the waterside rail, and `boomZ` is `-north`.
    const [east, north] = bearingVector(crane.bearing);
    for (const station of craneStations(crane)) {
      const [rx, rz] = world.project(station.at[0], station.at[1]);
      heads.push(rx - east * setback, y, rz + north * setback);
    }
  }
  return { heads };
}

/**
 * The height of a gantry's mast apex, in scene units.
 *
 * The one place that arithmetic lives. `pushCraneMatrices` draws the mast to it
 * and `craneLights` hangs the obstruction light off it; a second copy of
 * `girder + portal * rise` in either would be a light hovering above or inside
 * the crane it is supposed to be on, and nothing but a screenshot would catch it.
 */
function craneApexY(world: World, crane: Crane, plate: number): number {
  const member = size(world, CRANE_MEMBER_M, CRANE_MEMBER_FLOOR_M);
  const portal = world.metres(crane.height);
  return plate + portal + member * 1.4 + portal * CRANE_MAST_RISE;
}

/**
 * Every port on a board, as one small set of merged meshes.
 *
 * **Merged across ports, not per port**, which is `airports.ts`'s decision and
 * the same argument: sharing the buckets makes the draw count a function of how
 * many *kinds* of surface a port has rather than of how many ports a board
 * declares. San Pedro and Long Beach share one stone mesh, one yard atlas, one
 * channel mesh and one crane `InstancedMesh` between them, and a third complex
 * would add no draw call at all. `ports.test.ts` asserts that property directly
 * — four ports must return the same mesh count as one.
 *
 * Returns an empty group when a pack declares none, so a scene can add it
 * unconditionally and a city without a port costs one `Group` and no draw call.
 */
export function createPorts(world: World, ports: readonly Port[]): THREE.Group {
  const group = new THREE.Group();
  group.name = "ports";
  group.userData.portIds = ports.map((port) => port.id);
  if (ports.length === 0) return group;

  const yards = ports.flatMap((port) => port.yards ?? []);
  const bandTotal = powerOfTwo(YARD_BAND * Math.max(1, yards.length));
  const { surfaces: paints, atlas } = surfaces(yards);
  const batch = new Batch();
  const cranes: THREE.Matrix4[] = [];

  let bandOffset = 0;
  for (const port of ports) {
    buildPort(world, port, batch, paints, cranes, bandOffset, bandTotal);
    bandOffset += (port.yards ?? []).length;
  }
  batch.flush(group);

  if (cranes.length > 0) {
    const box = new THREE.BoxGeometry(1, 1, 1);
    // A merged bucket carries uv whether or not it is textured, and so does
    // this: `BoxGeometry` already does, but the assertion in `ports.test.ts`
    // walks every geometry in the group and does not care where it came from.
    const gantries = new THREE.InstancedMesh(box, paints.crane, cranes.length);
    gantries.name = "ports:cranes";
    gantries.castShadow = true;
    gantries.receiveShadow = false;
    cranes.forEach((matrix, index) => gantries.setMatrixAt(index, matrix));
    gantries.instanceMatrix.needsUpdate = true;
    gantries.computeBoundingSphere();
    group.add(gantries);
  }

  group.userData.atlas = atlas;
  return group;
}

/**
 * The port kit as a layer, which is what `scene.ts` constructs through.
 *
 * `span` is accepted and deliberately unused. Every other injected layer is
 * sized from the board — a fire mark, a rain sheet and a mote cloud are all map
 * symbols whose size is a fraction of the frame — but a breakwater is thirteen
 * kilometres of rock and it is thirteen kilometres of rock at every camera
 * distance. Taking the argument and ignoring it keeps all four factories one
 * shape; inventing a use for it would make the port grow when the camera pulled
 * back.
 */
export function createPortLayer(
  world: World,
  ports: readonly Port[],
  _options: { span: number } = { span: 0 },
): PortLayer {
  const group = createPorts(world, ports);

  return {
    group,

    /**
     * Nothing here is a light, and nothing here casts one — CONTRACT §4 gives
     * `Atmosphere` sole ownership of the rig, and a lit container terminal is
     * one of the more tempting exceptions in the product.
     *
     * What this does instead is the same move `nightlights.ts` makes: a yard
     * under high-mast floods is genuinely one of the brightest surfaces on any
     * coast at three in the morning, so the yard material carries an emissive
     * that comes up as the sun goes down, modulated by the atlas so the stacks
     * catch the floodlights and the asphalt between them does not. Emission is a
     * property of a material, not a light in the scene, and it costs no draw
     * call and no shadow map.
     *
     * ### How dark it is comes from the SKY, and `sun.direction` is a trap
     *
     * The obvious signal is the y component of `sun.direction` — the sine of the
     * key light's elevation — and it is wrong twice over. `LightingState.sun` is
     * the *key light*, and `atmosphere.ts`'s `combineKey` hands that over to the
     * moon after dark: on a moonlit night the "sun" is thirty degrees **up**.
     * And the direction is deliberately floored so the shadow camera stays
     * usable, so it never reads as far below the horizon even when it is. A port
     * lit off that number stays dark on exactly the nights it should be brightest.
     *
     * `sky.top` has neither problem. It is a pure function of the true solar
     * elevation in `atmosphere.ts`'s keyframe table — `0x05070f` at -18 degrees,
     * `0x2a4275` at the horizon, daylight above — it is displayed unmodified
     * because three marks the background unlit, and it is the same number the
     * viewer is looking at. `null` means a rig with no sky at all, which is an
     * office, and an office has no container yard in it.
     */
    setLighting(state) {
      const top = state.sky?.top;
      // Relative luminance of the display value; the keyframes are authored in
      // sRGB and shown unmodified, so the bytes are the right thing to weigh.
      const luminance =
        top === undefined
          ? 1
          : (0.2126 * ((top >> 16) & 0xff) +
              0.7152 * ((top >> 8) & 0xff) +
              0.0722 * (top & 0xff)) /
            255;
      // Full at astronomical night, about half at nautical twilight, off by the
      // time the sky has any blue in it.
      const night = Math.min(1, Math.max(0, (0.18 - luminance) / 0.15));
      for (const child of group.children) {
        if (!(child instanceof THREE.Mesh)) continue;
        const material = child.material;
        if (Array.isArray(material)) continue;
        if (child.name === "ports:yard" && material instanceof THREE.MeshLambertMaterial) {
          material.emissive.setHex(0xffe9c4);
          material.emissiveIntensity = 0.62 * night;
          if (material.map) material.emissiveMap = material.map;
          material.needsUpdate = true;
        }
        if (child.name === "ports:cranes" && material instanceof THREE.MeshLambertMaterial) {
          material.emissive.setHex(0xfff2d8);
          material.emissiveIntensity = 0.4 * night;
        }
      }
    },

    dispose() {
      group.traverse((object) => {
        if (object instanceof THREE.Mesh) {
          object.geometry.dispose();
          const material = object.material;
          if (Array.isArray(material)) for (const one of material) one.dispose();
          else material.dispose();
        }
      });
      const atlas = group.userData.atlas as THREE.Texture | null | undefined;
      atlas?.dispose();
      group.clear();
    },
  };
}
