/**
 * Ships, and the wakes that are what you actually see.
 *
 * ### The wake is the ship at board scale
 *
 * That sentence is the whole design and it is arithmetic rather than taste. On
 * the Southern California board one scene unit is 390.6 m, so a 400 m ultra-
 * large container vessel — the biggest thing that comes to Los Angeles — is
 * **1.024 units long**. Photographed at the whole-board pose that is about four
 * pixels: a hull is not a picture of a ship, it is a fleck. Its wake, at
 * 1.5 km, is **3.84 units** and about fourteen pixels, and it reads at once.
 *
 * So the wake is not decoration on top of the ships. It is the primary object,
 * the hull is the thing at the sharp end of it, and the two are budgeted the
 * other way round from how they look in a list of features: one `InstancedMesh`
 * for every hull on the board, one `LineSegments` for every wake on the board,
 * two draw calls total, and the wake gets the vertices.
 *
 * A moored ship has **no wake at all**, and that absence is information: a wake
 * is a function of speed through water, so a quay lined with wakeless hulls and
 * one long V curving in past the breakwater is a picture of a working harbour
 * on a normal day, drawn from numbers rather than staged.
 *
 * ### Ships are drawn at TRUE size, which inverts the aircraft rule
 *
 * `aircraftGeometry.ts` deliberately oversizes: an aeroplane in flight is held
 * at 0.42 units on every board, which over SoCal is about four times life size,
 * and nobody has ever noticed because an aeroplane is alone in the sky with
 * nothing to be wrong against. **A ship is never alone.** It is alongside a quay,
 * under a gantry crane and beside a breakwater, every one of them drawn at true
 * scale by `ports.ts`, so a hull scaled to be legible would be a hull visibly
 * longer than the berth it is lying in. The legibility that oversizing would buy
 * is bought by the wake instead, which is free to be as long as the water it
 * disturbed.
 *
 * `vesselScale.test.ts` asserts the truthful numbers — 400 m is 1.024 units on
 * SoCal and 4.24 on the Bay — precisely because "make the ships a bit bigger so
 * you can see them" is a reasonable-sounding change that would quietly break the
 * relationship this layer exists inside.
 *
 * ### Two solids, and the seam that lets there be a third
 *
 * Every merchant ship on a board is the same 40-triangle solid — slab, raked
 * bow, aft house, funnel — with **length, beam and depth as per-instance
 * scale**, so a 90 m coaster and a 400 m box ship are the same forty triangles
 * at four times the size. Deck detail is a band of a canvas atlas rather than
 * geometry, so a hatch run and a pipe rack are the same six triangles with
 * different pixels.
 *
 * The **tug** is the exception and now has its own 64-triangle solid, because
 * the merchant hull is not a small ship, it is a *differently shaped* ship: a
 * cargo hull carries its block aft over the screw and a tug carries it forward
 * and nearly full beam, with the low towing deck astern. Drawn at a tug's size
 * the merchant solid produced a thirty-metre container ship, and that is the one
 * hull on the board whose proportions were wrong rather than merely simple. See
 * `tugHull`, whose numbers come off the component the owner voted up rather than
 * out of a catalogue.
 *
 * `hullShape(kind)` is the seam and the arms are how better geometry lands one
 * kind at a time: it maps a `VesselKind` to a shape id, and a real container-ship
 * mesh arrives as a new id in `HULL_SHAPES` plus a case in `hullGeometry`. The
 * layer builds one `InstancedMesh` per shape and adds it to the group only when
 * it has hulls in it, so the draw count is a function of **how many kinds of hull
 * geometry are afloat**, never of how many ships are on the board — the same
 * property `airports.ts` gets by merging buckets across airports rather than per
 * airport. A board with no tug on it still costs one draw.
 *
 * A shape is a *geometry* and never a material. Two geometries are two draws
 * only on a board carrying both; two materials would be two draws on every board
 * for ever, so hue stays an `instanceColor` tint over the one shared atlas.
 *
 * ### What this layer will not do
 *
 * It will not put a hull anywhere between two observed fixes. Upstream listens
 * for thirty seconds every fifteen minutes and a ship makes about five
 * kilometres in between, so the chord between two samples is not a path anything
 * took. Motion comes from `reckonVessel` in `server/vessels.ts`, which advances
 * one fix along its own reported course at its own reported speed and has no
 * second fix to reach toward. That is a property of the function's signature
 * rather than of this file's discipline.
 *
 * It also owns no light. `CONTRACT.md` §4 gives the rig to `atmosphere.ts`, and
 * a harbour is exactly the tempting exception — floodlights over the yard, deck
 * lights, red and green at the breakwater entrance. Those belong on
 * `nightlights.ts`'s existing `THREE.Points` cloud, which puts twelve thousand
 * street lamps in one draw call; there is no `THREE.Light` anywhere below.
 */

import * as THREE from "three";
import type { LightingState, Vessel, VesselKind } from "./types.ts";
import type { World } from "./world.ts";

// ---- Capacities -----------------------------------------------------------

/**
 * How many hulls the instance buffer holds.
 *
 * Matches `VESSEL_DRAW_LIMIT` in `src/server/vessels.ts`, which is the cap the
 * gate already applies — this is the buffer that assumes it. Not a data claim:
 * the busiest box in the store, LA/Long Beach, holds 81 vessels on a typical
 * latest fix and San Francisco Bay 82.
 */
export const VESSEL_HULL_CAPACITY = 192;

/**
 * How many wakes are drawn at once.
 *
 * Lower than the hull capacity on purpose, and the ratio is measured rather than
 * guessed: of the 81 hulls in the LA/LB box, 31 are moving and 22 are making way
 * at five knots or more; SF Bay is 31 and 27. Sixty-four is comfortably above
 * both and is what the buffer below is sized from.
 */
export const WAKE_CAPACITY = 64;

/**
 * Points down each rail of one wake.
 *
 * The rails are straight, so this buys smoothness of the *fade* rather than of
 * the line: sixteen steps is enough that the taper from foam to nothing has no
 * visible banding at the closest pose. Sixty-four wakes of five rails of fifteen
 * segments is 9,600 vertices, which is 268 KB of position and colour — the whole
 * buffer, allocated once, for every wake on the board.
 */
export const WAKE_POINTS = 16;

/**
 * How many rails one wake is drawn from.
 *
 * WebGL ignores `gl.lineWidth` — every line is one pixel, on every driver — so a
 * wake's *width* has to be geometry. Five rails at fractions of the Kelvin
 * spread give the wedge a fill rather than an outline, and three of the five are
 * real features rather than padding: the two cusp lines at the full angle, which
 * are the brightest part of a real wake, and the turbulent centreline the hull
 * drags directly astern. The two between them are what stop the V reading as a
 * pair of scratches at close range.
 */
export const WAKE_RAILS: readonly number[] = [-1, -0.5, 0, 0.5, 1];

/**
 * The Kelvin half-angle, degrees.
 *
 * 19.47° — arcsin(1/3) — and it is a real physical constant rather than a tuned
 * one: the wedge of a displacement hull's wake is that angle **regardless of
 * speed**, for any hull, in deep water. Speed changes how long the wake is and
 * how bright it is; it does not open or close the V. Using the true number costs
 * nothing and means the picture is right for a reason.
 */
export const KELVIN_HALF_ANGLE_DEG = 19.47;

/**
 * How many hull lengths of wake a vessel at full speed leaves behind it.
 *
 * Calibrated against the one number the design work photographed: a 1.5 km wake
 * behind a 400 m ship, which is 3.84 units on SoCal and the length at which a
 * moving vessel becomes legible from the whole-board pose. Expressed in hull
 * lengths so that a 30 m tug gets a 112 m wake instead of a kilometre and a half
 * of foam behind something the size of a bus.
 */
export const WAKE_HULL_LENGTHS = 3.75;

/** The speed at which a hull leaves its full-length wake, m/s. About 12 knots. */
export const WAKE_FULL_SPEED_MPS = 6.17;

/**
 * The speed below which a hull leaves no wake at all, m/s. Half a knot.
 *
 * The same threshold `server/vessels.ts` gates motion on, restated here in the
 * units the renderer holds, because a hull that is stopped for the purposes of
 * dead reckoning and a hull that is stopped for the purposes of foam must be the
 * same hull — otherwise a berthed ship trails a stub of wake for ever.
 */
export const WAKE_MIN_SPEED_MPS = 0.257;

/** Head-of-wake opacity. Foam is bright, and it is still not the brightest thing. */
const WAKE_ALPHA = 0.62;

/**
 * The sea's surface, in scene units.
 *
 * `terrain.ts` sets its water plane to `y = -0.06` and does not export the
 * number. It is restated here rather than imported because `terrain.ts` is not
 * this workstream's file to change, and a hull floating a hair above its own
 * reflection is a defect a picture finds instantly: on SoCal 0.06 units is 23 m,
 * which at the harbour pose is a ship hovering a building's height over the
 * water. If the sea ever moves, this moves with it — and the two are worth
 * unifying behind an export the next time anyone owns both files.
 */
const SEA_SURFACE_Y = -0.06;

/**
 * How far above the sea the wake is drawn, in scene units.
 *
 * Small enough to read as being *on* the water and large enough to stay out of
 * the depth buffer's argument with the sea plane, which is the failure mode a
 * line lying exactly on a plane always has. 0.04 units is 15.6 m on SoCal and
 * 3.8 m on the Bay board — sub-pixel from every pose either board is looked at
 * from — and lines take no polygon offset, so height is the only dial there is.
 */
const WAKE_LIFT = 0.04;

const DEG = Math.PI / 180;

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

/**
 * Hull colours, per kind, applied as a per-instance tint over one shared atlas.
 *
 * A tint rather than a material, because a material per kind is a draw call per
 * kind and the whole argument of this file is that hulls cost one. `instanceColor`
 * multiplies the atlas, so value contrast — a pale deck against a dark side —
 * lives in the texture and hue lives here.
 *
 * These are display buckets and not liveries. There is no attempt to render a
 * particular operator's colours, for the same reason there is no name on the
 * card: this deployment has no licensed feed behind a claim about a specific
 * ship.
 */
export const VESSEL_PALETTE: Readonly<Record<VesselKind, number>> = {
  /**
   * Box ships are the blue-hulled majority of anything at San Pedro.
   *
   * Lighter than a real hull, and photographed into being so — twice. A true
   * dark navy, multiplied through the atlas and then shaded by a Lambert term
   * whose key is a high sun on a vertical topside, came out near-black; at board
   * scale a black fleck on blue water reads as a rendering fault rather than as
   * a ship. These sit in the same value register as everything else on these
   * boards — `AIRPORT_PALETTE.aircraft` is 0xe2e6e9, the block scatter is pale
   * grey — because a board is a legibility problem before it is a colour one.
   * Value buys legibility; hue keeps the kinds apart.
   */
  container: 0x93aac4,
  tanker: 0x949da4,
  bulk: 0xa39d92,
  /** A car carrier is a floating white shoebox and is unmistakable for it. */
  "vehicle-carrier": 0xdadee2,
  /** Harbour tugs are the one genuinely bright thing on the water. */
  tug: 0xc2705c,
  ferry: 0xe0e5e9,
  fishing: 0x9dabb4,
  other: 0x8d949b,
};

/** Foam, in daylight. Not pure white: pure white has nowhere left to go. */
const WAKE_COLOR_DAY = 0xe8f0f4;

// ---- Hull geometry --------------------------------------------------------

/**
 * Which solid a kind is drawn from.
 *
 * The seam for the hull assets being modelled separately. Every kind currently
 * answers `"generic"`, so a board builds exactly one `InstancedMesh` however
 * many kinds are on it; a real container-ship mesh arrives as a new shape id
 * here plus a case in `hullGeometry`, and the layer grows a second instanced
 * mesh without anything else in this file changing. That is the difference
 * between an enum the factory switches on and a hardcoded builder, and it is why
 * this function exists despite currently having one answer.
 */
export type HullShape = "generic" | "tug" | "container";

export function hullShape(kind: VesselKind): HullShape {
  // The arms are where better geometry lands one kind at a time, which is why
  // this is a switch and not an `if`: an `if` would have to be replaced, and
  // these are added to. `tug` is the first arm to leave home.
  switch (kind) {
    case "tug":
      return "tug";
    case "container":
      return "container";
    case "tanker":
    case "bulk":
    case "vehicle-carrier":
    case "ferry":
    case "fishing":
    case "other":
      return "generic";
  }
}

/**
 * Every shape a board may need a mesh for. Three.
 *
 * Three shapes is **three draw calls at most, and fewer on most boards**: the
 * layer builds an `InstancedMesh` per shape up front and adds it to the group
 * only when it has hulls in it, so the cost is a function of how many kinds of
 * hull *geometry* exist and never of how many ships are floating. The Bay board
 * with no tug and no box ship on it pays for one.
 *
 * `container` is the third and it arrived exactly the way this note said it
 * would — one entry here, one case in `hullGeometry`, one arm in `hullShape` —
 * which is the only evidence that the seam was real rather than merely claimed.
 * A fourth (a tanker's flush deck and midships manifold is the obvious next one)
 * is the same three edits.
 */
export const HULL_SHAPES: readonly HullShape[] = ["generic", "tug", "container"];

/**
 * Draught as a fraction of length, per kind.
 *
 * **Authored, never observed, and never a cargo claim.** `vessels` in the store
 * has no draught column at all, the AIS static message that would carry one is
 * absent for most hulls most of the time, and "empty or full" is answered at the
 * port — see `PortThroughput` and the 75.7% of boxes that leave Los Angeles
 * empty. This is a hull dimension used to decide how much of a ship is under the
 * water, and nothing downstream may present it as a measurement.
 *
 * A 400 m box ship at 0.036 is 14.4 m deep, which is what they draw fully laden;
 * a tug at 0.13 is 3.9 m, which is what a harbour tug draws. Both are hull
 * facts.
 */
const DRAUGHT_RATIO: Readonly<Record<VesselKind, number>> = {
  container: 0.036,
  tanker: 0.045,
  bulk: 0.05,
  "vehicle-carrier": 0.045,
  tug: 0.13,
  ferry: 0.045,
  fishing: 0.1,
  other: 0.05,
};

/**
 * Freeboard — waterline to main deck — as a fraction of length.
 *
 * Bigger than draught for a box ship, which surprises people and is correct: a
 * loaded ULCV stands about thirty metres out of the water before you count the
 * container stack, and the stack is what makes the silhouette. A car carrier is
 * the extreme case and is nearly all freeboard.
 */
const FREEBOARD_RATIO: Readonly<Record<VesselKind, number>> = {
  container: 0.075,
  tanker: 0.05,
  bulk: 0.055,
  "vehicle-carrier": 0.14,
  /**
   * A tug is the one hull whose freeboard is *small*, and 0.17 was wrong.
   *
   * At 0.17 a 32 m tug stood 5.4 m out of the water, which is a coaster's
   * topside on a boat you step down onto from the quay; the real number is under
   * three. It mattered once `tugHull` gave the kind its own solid, because the
   * whole superstructure is scaled by keel-to-deck depth: an over-deep hull made
   * the wheelhouse tower, and the silhouette the owner voted for is a low boat
   * under a big house rather than a small ship.
   */
  tug: 0.09,
  ferry: 0.11,
  fishing: 0.16,
  other: 0.07,
};

/** Metres of hull under the waterline. Authored from the length, never observed. */
export function hullDraughtMetres(vessel: Pick<Vessel, "kind" | "length" | "draught">): number {
  const authored = vessel.draught;
  if (typeof authored === "number" && Number.isFinite(authored) && authored > 0) return authored;
  return vessel.length * (DRAUGHT_RATIO[vessel.kind] ?? DRAUGHT_RATIO.other);
}

/** Metres of hull above the waterline, to the main deck. */
export function hullFreeboardMetres(vessel: Pick<Vessel, "kind" | "length">): number {
  return vessel.length * (FREEBOARD_RATIO[vessel.kind] ?? FREEBOARD_RATIO.other);
}

/**
 * The unit hull: 1 long, 1 wide, 1 deep from keel to main deck, bow to the
 * north at a bearing of zero.
 *
 * Built as one non-indexed buffer rather than merged from boxes, which is
 * deliberate. `mergeGeometries` silently drops a bucket whose attribute sets
 * disagree — `airports.ts:43` records that exact scar — and the only way to be
 * certain a hull carries position, normal *and* uv is to write all three.
 * Non-indexed gives flat facets for free, which is what a slab-sided ship is.
 *
 * The superstructure deliberately stands **above** y = 1. The unit is the hull
 * depth, not the overall height, so scaling by keel-to-deck metres gives a tug a
 * tug-sized wheelhouse and a box ship a box ship's, with no second scale to keep
 * in step.
 *
 * Forty triangles: ten for the body, six for the bow wedge, twelve for the aft
 * house and twelve for the funnel.
 */
export function hullGeometry(shape: HullShape = "generic"): THREE.BufferGeometry {
  switch (shape) {
    case "generic":
      return genericHull();
    case "tug":
      return tugHull();
    case "container":
      return containerHull();
  }
}

/**
 * Atlas bands, shared by every hull solid.
 *
 * `v` runs 0 at the top of the canvas to 1 at the bottom, which is only true
 * because `deckAtlas` turns `flipY` off. Module-level rather than per-solid so
 * that a second hull shape cannot quietly wear a third set of bands and drift
 * out of step with the one canvas they all sample.
 */
/**
 * The atlas is four bands now, and they are **computed from one layout** rather
 * than typed as six literals.
 *
 * The fourth band is the container stack, and adding it had to move the other
 * three: `v` runs 0..1 over the whole canvas, so there was no free space to
 * append to — the old three occupied 0.02..0.98. That is exactly the edit that
 * produced this file's worst shipped bug the first time round, when every hull
 * wore the superstructure's window rows down its side because `flipY` inverted
 * the bands and it read merely as "the ships are a bit dark". So the edges are
 * no longer writable by hand: `band(i)` is the only thing that knows where a
 * band is, `ATLAS_ROWS` is the only thing that knows how tall the canvas is, and
 * `deckAtlas` paints through the same function the geometry samples through. A
 * fifth band is one number here.
 *
 * `INSET` keeps a band's drawn pixels off its own edges, because a `LinearFilter`
 * sample at the seam between two bands mixes them: without it the boot-top
 * bled into the hatch coamings at grazing angles.
 */
const ATLAS_BANDS = 4;
const ATLAS_ROWS = 128;
const INSET = 2 / ATLAS_ROWS;

/** Band `i` of `ATLAS_BANDS`, in `v`, inset from its own edges. */
function band(index: number): Band {
  const height = 1 / ATLAS_BANDS;
  return [index * height + INSET, (index + 1) * height - INSET];
}

/** The rows band `i` occupies on the canvas — the same edges, in pixels. */
function bandRows(index: number): readonly [number, number] {
  const [v0, v1] = band(index);
  return [Math.round(v0 * ATLAS_ROWS), Math.round(v1 * ATLAS_ROWS)];
}

const SIDE: Band = band(0);
const DECK: Band = band(1);
const HOUSE: Band = band(2);
/** Stacked boxes on a hatch cover: the band `containerHull` is drawn through. */
const BOX: Band = band(3);

type P = readonly [number, number, number];
type Band = readonly [number, number];
type UV = readonly [number, number];

/**
 * A face writer, shared by every hull solid in this file.
 *
 * Non-indexed on purpose, and the reason is `airports.ts:43`'s scar:
 * `mergeGeometries` silently drops a bucket whose attribute sets disagree, and
 * the only way to be certain a hull carries position, normal *and* uv is to
 * write all three. Non-indexed also gives flat facets for free, which is what a
 * slab-sided ship is.
 *
 * Every face handed to `quad` and `tri` must be wound counter-clockwise **seen
 * from outside**. `computeVertexNormals` reads the winding, so a face wound the
 * other way is both back-face culled and lit from inside. The first photograph
 * of the generic hull had a deck wound downward — you saw straight through it to
 * the inside of the bottom plating, and it read merely as "the ships are a bit
 * dark" rather than as a hole. It took a picture to find and one sign to fix,
 * and `vessels.test.ts` now measures the outward flux so the next one is caught
 * by the suite instead.
 */
function hullFaces(): {
  quad(a: P, b: P, c: P, d: P, band: Band): void;
  tri(a: P, b: P, c: P, uvA: UV, uvB: UV, uvC: UV): void;
  box(x0: number, x1: number, y0: number, y1: number, z0: number, z1: number, band: Band): void;
  finish(name: string): THREE.BufferGeometry;
} {
  const positions: number[] = [];
  const uvs: number[] = [];

  const tri = (a: P, b: P, c: P, uvA: UV, uvB: UV, uvC: UV): void => {
    positions.push(a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]);
    uvs.push(uvA[0], uvA[1], uvB[0], uvB[1], uvC[0], uvC[1]);
  };
  const quad = (a: P, b: P, c: P, d: P, band: Band): void => {
    const [v0, v1] = band;
    tri(a, b, c, [0, v0], [1, v0], [1, v1]);
    tri(a, c, d, [0, v0], [1, v1], [0, v1]);
  };
  const box = (
    x0: number,
    x1: number,
    y0: number,
    y1: number,
    z0: number,
    z1: number,
    band: Band,
  ): void => {
    // Reversed against the obvious ordering, for the winding reason above:
    // written the natural way, every one of these six faces points into the box.
    quad([x0, y1, z0], [x1, y1, z0], [x1, y0, z0], [x0, y0, z0], band);
    quad([x1, y1, z1], [x0, y1, z1], [x0, y0, z1], [x1, y0, z1], band);
    quad([x0, y1, z1], [x0, y1, z0], [x0, y0, z0], [x0, y0, z1], band);
    quad([x1, y1, z0], [x1, y1, z1], [x1, y0, z1], [x1, y0, z0], band);
    quad([x0, y1, z1], [x1, y1, z1], [x1, y1, z0], [x0, y1, z0], band);
    quad([x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1], band);
  };

  return {
    quad,
    tri,
    box,
    finish(name: string): THREE.BufferGeometry {
      const geometry = new THREE.BufferGeometry();
      geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
      geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
      geometry.computeVertexNormals();
      geometry.name = name;
      return geometry;
    },
  };
}

/** The merchant hull: forty triangles, house and funnel aft. */
function genericHull(): THREE.BufferGeometry {
  const f = hullFaces();

  // Bow at -z, stern at +z, so a bearing of zero points the ship north — the
  // board's north is -z, and `bearingRotation` below is the only place that
  // convention is turned into a rotation.
  const bx = 0.5; // half beam
  const zBow = -0.5;
  const zShoulder = -0.28;
  const zStern = 0.5;
  const stem = 0.34; // how high up the stem the forefoot starts

  // ---- Body: five quads, ten triangles.
  f.quad([-bx, 0, zShoulder], [-bx, 0, zStern], [-bx, 1, zStern], [-bx, 1, zShoulder], SIDE);
  f.quad([bx, 0, zStern], [bx, 0, zShoulder], [bx, 1, zShoulder], [bx, 1, zStern], SIDE);
  f.quad([-bx, 0, zStern], [bx, 0, zStern], [bx, 1, zStern], [-bx, 1, zStern], SIDE);
  f.quad([-bx, 0, zShoulder], [bx, 0, zShoulder], [bx, 0, zStern], [-bx, 0, zStern], SIDE);
  f.quad([-bx, 1, zStern], [bx, 1, zStern], [bx, 1, zShoulder], [-bx, 1, zShoulder], DECK);

  // ---- Bow: two side quads and two triangles, six triangles.
  f.quad([-bx, 0, zShoulder], [-bx, 1, zShoulder], [0, 1, zBow], [0, stem, zBow], SIDE);
  f.quad([bx, 1, zShoulder], [bx, 0, zShoulder], [0, stem, zBow], [0, 1, zBow], SIDE);
  f.tri([-bx, 1, zShoulder], [bx, 1, zShoulder], [0, 1, zBow], [0, DECK[0]], [1, DECK[0]], [0.5, DECK[1]]);
  f.tri([-bx, 0, zShoulder], [0, stem, zBow], [bx, 0, zShoulder], [0, SIDE[0]], [0.5, SIDE[1]], [1, SIDE[0]]);

  // ---- Aft house: a box on the quarterdeck, twelve triangles.
  f.box(-0.34, 0.34, 1, 1.55, 0.26, 0.46, HOUSE);

  // ---- Funnel: twelve triangles, and the reason a ship reads as a ship.
  f.box(-0.12, 0.12, 1.55, 1.86, 0.32, 0.42, HOUSE);

  return f.finish("vessel-hull");
}

/**
 * The box ship: seventy-six triangles, and the only hull whose cargo is drawn.
 *
 * ### Why this shape had to exist
 *
 * Because vertical exaggeration is the one thing that flattens a ship, and
 * nothing else on this coast. `Crane`'s own doc note says it plainly — at
 * SoCal's 3.4x a 130 m gantry stands 1.13 scene units while a 400 m ship is
 * 1.02 units *long*, so the crane is taller than the ship is long. Exaggeration
 * multiplies heights; a length is a plan measurement and takes 1x. So every
 * object around a hull is stretched upward by 3.4x and the hull is not, and the
 * result is the defect the owner reported: at the Harbour pose the cranes, the
 * yards and the bridge read instantly and the ships do not read at all. Eleven
 * of fifteen hulls in a busy harbour are alongside a berth, and a moored hull
 * has no wake by design, so for eleven of them there was nothing to see.
 *
 * ### The fix is truthful rather than generous
 *
 * `FREEBOARD_RATIO` already says where it is: "a loaded ULCV stands about thirty
 * metres out of the water **before you count the container stack, and the stack
 * is what makes the silhouette**". The stack was never drawn. Drawing it adds no
 * length and no beam — `vesselScale.test.ts` stays green to the digit, and a
 * hull still cannot overhang the berth it lies in — and it roughly triples the
 * above-water mass, because it is above-water height and above-water height is
 * the axis this board multiplies by 3.4.
 *
 * Eight tiers of 2.9 m high-cube is 23.2 m of boxes. The unit here is keel-to-
 * deck depth, which for a container ship is `(0.036 + 0.075) x length`, so the
 * stack is `23.2 / (0.111 x length)` units tall: **0.52 for a 400 m ULCV**, and
 * that is the number below. It is length-independent only if a feeder stacks as
 * high as a ULCV, which it does not — a 200 m feeder at five tiers wants about
 * 0.65 — so this errs *low* on small ships and never high, which is the safe
 * direction for a solid that must not out-grow its berth.
 *
 * ### Three bays, and one of them a tier down
 *
 * Not one slab. Three bays with a gap between them give two shadow lines across
 * the deck, and from directly above — which is how this board is looked at —
 * those lines are most of what separates "a ship with cargo on it" from "a
 * rectangle". The forward bay is a tier lower, the way a real box ship steps its
 * stack down for visibility over the bow, and it costs nothing but reads as
 * deliberate from any angle.
 *
 * Everything is inside the parallel midbody (`|z| <= 0.27`) on purpose: forward
 * of the shoulder the hull is narrowing to the stem, and a full-beam bay put
 * there hangs its outboard corners over open water.
 *
 * ### Seventy-six triangles, and it is one draw call
 *
 * Body ten, bow six, three bays thirty-six, house twelve, funnel twelve. Against
 * the merchant hull's forty that is thirty-six more per *geometry*, not per
 * ship: `HULL_SHAPES` builds one `InstancedMesh` per shape and adds it to the
 * group only when its count is above zero, so a harbour of thirty box ships
 * costs seventy-six triangles and one draw call, and a board with no box ship on
 * it costs neither.
 */
function containerHull(): THREE.BufferGeometry {
  const f = hullFaces();

  const bx = 0.5;
  const zBow = -0.5;
  // Fuller than the merchant hull's -0.28: a modern box ship carries its deck
  // line well forward, which is what lets the forward bay sit at -0.27.
  const zShoulder = -0.32;
  const zStern = 0.5;
  const stem = 0.3;

  // ---- Body: five quads, ten triangles.
  f.quad([-bx, 0, zShoulder], [-bx, 0, zStern], [-bx, 1, zStern], [-bx, 1, zShoulder], SIDE);
  f.quad([bx, 0, zStern], [bx, 0, zShoulder], [bx, 1, zShoulder], [bx, 1, zStern], SIDE);
  f.quad([-bx, 0, zStern], [bx, 0, zStern], [bx, 1, zStern], [-bx, 1, zStern], SIDE);
  f.quad([-bx, 0, zShoulder], [bx, 0, zShoulder], [bx, 0, zStern], [-bx, 0, zStern], SIDE);
  f.quad([-bx, 1, zStern], [bx, 1, zStern], [bx, 1, zShoulder], [-bx, 1, zShoulder], DECK);

  // ---- Bow: six triangles.
  f.quad([-bx, 0, zShoulder], [-bx, 1, zShoulder], [0, 1, zBow], [0, stem, zBow], SIDE);
  f.quad([bx, 1, zShoulder], [bx, 0, zShoulder], [0, stem, zBow], [0, 1, zBow], SIDE);
  f.tri([-bx, 1, zShoulder], [bx, 1, zShoulder], [0, 1, zBow], [0, DECK[0]], [1, DECK[0]], [0.5, DECK[1]]);
  f.tri([-bx, 0, zShoulder], [0, stem, zBow], [bx, 0, zShoulder], [0, SIDE[0]], [0.5, SIDE[1]], [1, SIDE[0]]);

  // ---- The stack: three bays, thirty-six triangles. `STACK` is eight tiers of
  // high-cube; `STEP` is the forward bay, two tiers down.
  const STACK = 1.52;
  const STEP = 1.39;
  // Boxes overhang the hatch coamings on a ULCV, so the stack is wider than the
  // house and only just inside the beam.
  const sx = 0.45;
  f.box(-0.4, 0.4, 1, STEP, -0.27, -0.1, BOX);
  f.box(-sx, sx, 1, STACK, -0.07, 0.1, BOX);
  f.box(-sx, sx, 1, STACK, 0.13, 0.27, BOX);

  // ---- Aft house: right aft, and taller than the merchant hull's, because on a
  // box ship it has to see over eight tiers of cargo.
  f.box(-0.34, 0.34, 1, 1.62, 0.31, 0.47, HOUSE);

  // ---- Funnel: twelve triangles.
  f.box(-0.12, 0.12, 1.62, 1.9, 0.35, 0.45, HOUSE);

  return f.finish("vessel-hull-container");
}

/**
 * The harbour tug: sixty-four triangles, and the mass in the wrong place on
 * purpose.
 *
 * ### Why a tug needs its own solid at all
 *
 * Because the generic hull is a *merchant* hull, and the two silhouettes are
 * opposites. A cargo ship carries its block aft — house and funnel over the
 * screw, a long clear foredeck ahead of them — and drawn small that reads as a
 * bar with a lump at one end. A tug carries its block **forward and nearly the
 * full beam**, with the clear low towing deck aft, and drawn small it reads as a
 * lump with a bar behind it. Using the merchant solid for a tug produced a
 * thirty-metre container ship, which is a thing that does not exist, and it was
 * the only hull on the board whose proportions were wrong rather than merely
 * simple.
 *
 * ### It came off a photograph, not out of a catalogue
 *
 * The proportions below are read from the asset the owner voted up
 * (`tug-20260823-043140-7dbe8e`) rather than invented here, and specifically
 * from the three things that survive being shrunk to a few pixels: the deckhouse
 * runs from about a quarter to about three quarters of the length measured from
 * the stern; it is roughly 0.84 of the beam, which is nearly full width where a
 * merchant house is barely half; and the wheelhouse on top of it carries an
 * overhanging roof that is the widest thing above the deck. Everything else in
 * that render — the tyre fenders, the tow rope, the lit windows, the mast — is
 * hero detail for a camera two boat-lengths away and is sub-pixel here, so none
 * of it is modelled. **Nothing of that component is pasted:** it brought fifteen
 * `MeshStandardMaterial`s and four emissive navigation lights, and both would
 * violate the one-material rule this layer is built on and CONTRACT §4.
 *
 * ### Sixty-four triangles, and why that is free
 *
 * Twenty-four more than the merchant hull, on a board that holds at most a
 * handful of tugs at once — under two hundred triangles for the whole harbour's
 * tug fleet. What it costs is **one draw call**, and only on a board that has a
 * tug on it, because the layer adds an `InstancedMesh` to the group only when
 * its count is above zero.
 */
function tugHull(): THREE.BufferGeometry {
  const f = hullFaces();

  const bx = 0.5;
  const zBow = -0.5;
  // Bluffer than the merchant hull's 0.22: a tug's entry is short and full,
  // because it is built to push rather than to make twenty knots economically.
  const zShoulder = -0.36;
  const zStern = 0.5;
  const stem = 0.42;

  // ---- Body: five quads, ten triangles. A flat transom, which is what a tug
  // has and what the towing deck is built out to.
  f.quad([-bx, 0, zShoulder], [-bx, 0, zStern], [-bx, 1, zStern], [-bx, 1, zShoulder], SIDE);
  f.quad([bx, 0, zStern], [bx, 0, zShoulder], [bx, 1, zShoulder], [bx, 1, zStern], SIDE);
  f.quad([-bx, 0, zStern], [bx, 0, zStern], [bx, 1, zStern], [-bx, 1, zStern], SIDE);
  f.quad([-bx, 0, zShoulder], [bx, 0, zShoulder], [bx, 0, zStern], [-bx, 0, zStern], SIDE);
  f.quad([-bx, 1, zStern], [bx, 1, zStern], [bx, 1, zShoulder], [-bx, 1, zShoulder], DECK);

  // ---- Bow: six triangles.
  f.quad([-bx, 0, zShoulder], [-bx, 1, zShoulder], [0, 1, zBow], [0, stem, zBow], SIDE);
  f.quad([bx, 1, zShoulder], [bx, 0, zShoulder], [0, stem, zBow], [0, 1, zBow], SIDE);
  f.tri([-bx, 1, zShoulder], [bx, 1, zShoulder], [0, 1, zBow], [0, DECK[0]], [1, DECK[0]], [0.5, DECK[1]]);
  f.tri([-bx, 0, zShoulder], [0, stem, zBow], [bx, 0, zShoulder], [0, SIDE[0]], [0.5, SIDE[1]], [1, SIDE[0]]);

  // ---- Deckhouse: forward, and 0.84 of the beam. The one measurement that
  // decides whether this reads as a tug rather than as a small freighter.
  f.box(-0.42, 0.42, 1, 1.42, -0.3, 0.1, HOUSE);

  // ---- Wheelhouse, set a little aft of the house front so the bridge wings
  // look down the tow.
  f.box(-0.36, 0.36, 1.42, 1.78, -0.24, 0.04, HOUSE);

  // ---- The wheelhouse roof, overhanging on every side. Twelve triangles for a
  // slab, and worth them: from the vertical pose this board is mostly looked at
  // from, the roof plan *is* the tug.
  f.box(-0.4, 0.4, 1.78, 1.84, -0.28, 0.08, HOUSE);

  // ---- Funnel: low, fat and aft of the house, standing no higher than the
  // wheelhouse. A tug whose stack out-tops its bridge is a tug from 1950.
  f.box(-0.14, 0.14, 1.42, 1.66, 0.16, 0.3, HOUSE);

  // Aft of z = 0.3 the deck is deliberately clear. That empty quarter is the
  // towing deck and it is half of the silhouette.
  return f.finish("vessel-hull-tug");
}

/**
 * The deck atlas: three horizontal bands, drawn on a canvas at runtime.
 *
 * No binary asset, by house rule and by `scripts/check-no-binaries.mjs`. Three
 * bands rather than one per kind, because a per-kind band needs a per-instance
 * uv offset, which needs an instanced attribute and a shader patch — and the
 * thing a per-kind band would buy (a container stack that is visibly containers)
 * is 0.0312 units per box on SoCal, which is sub-pixel from every pose this
 * board is ever looked at from. Hue comes from `VESSEL_PALETTE` as a per-instance
 * tint instead, and the atlas carries the *relative* value that makes a hull
 * read as a hull from above: a pale deck against a slightly darker side.
 *
 * Painted near white on purpose. `instanceColor` **multiplies** this texture, and
 * two mid-greys multiplied are a dark grey: the first pass of this file drew the
 * bands at a plausible ship's own values, and the fleet photographed as a row of
 * black slabs on blue water. A modulation map has to sit near 1.0 and let the
 * tint be the colour.
 *
 * Returns `null` under Node, where there is no `document`. Every caller treats a
 * missing atlas as a flat material rather than as an error, exactly as
 * `airports.ts` does with its runway markings.
 */
export function deckAtlas(): THREE.Texture | null {
  if (typeof document === "undefined") return null;
  const canvas = document.createElement("canvas");
  canvas.width = 64;
  canvas.height = ATLAS_ROWS;
  const ctx = canvas.getContext("2d");
  if (!ctx) return null;

  /*
   * Every band is painted through `bandRows`, which is the same function
   * `band()` gives the geometry its `v` from. Nothing below knows a row number.
   */
  const fill = (index: number, colour: string): readonly [number, number] => {
    const [top, bottom] = bandRows(index);
    ctx.fillStyle = colour;
    ctx.fillRect(0, top, 64, bottom - top);
    return [top, bottom];
  };

  // ---- 0. Side: flat, with a darker boot-top along the waterline.
  //
  // The boot-top goes at the TOP of the band in canvas rows, which is the keel
  // end: `quad` maps the y=0 vertex to `band[0]`, and with `flipY` off a smaller
  // `v` is a smaller row. Getting this backwards paints the boot-top along the
  // deck edge, which looks like a shadow rather than like a mistake.
  {
    const [top] = fill(0, "#f4f6f7");
    ctx.fillStyle = "#9aa0a6";
    ctx.fillRect(0, top, 64, 6);
  }

  // ---- 1. Deck: bright, with hatch coamings across it. Cross-ship stripes,
  // because that is the way hatches and container rows run and it is the one
  // detail that says "this is the top of a ship" from directly above.
  {
    const [top, bottom] = fill(1, "#ffffff");
    ctx.fillStyle = "#c8ced4";
    for (let x = 3; x < 62; x += 6) ctx.fillRect(x, top + 1, 3, bottom - top - 2);
    ctx.fillStyle = "#e4e9ed";
    ctx.fillRect(0, top + Math.round((bottom - top) * 0.4), 64, 2);
  }

  // ---- 2. Superstructure: pale, with two rows of windows.
  {
    const [top, bottom] = fill(2, "#ffffff");
    const h = bottom - top;
    ctx.fillStyle = "#7d858c";
    ctx.fillRect(4, top + Math.round(h * 0.25), 56, 3);
    ctx.fillRect(4, top + Math.round(h * 0.55), 56, 2);
  }

  /*
   * ---- 3. Boxes.
   *
   * **This band is a modulation map, not container colours, and the first pass
   * of it was container colours.** The note at the head of `deckAtlas` already
   * records why that cannot work — `instanceColor` *multiplies* this texture, so
   * two mid values multiplied are a dark one, and the first version of this
   * whole file photographed as a row of black slabs for exactly this reason.
   * Painted at `PORT_PALETTE`'s real box values (a muted rust of 0x8d6053, a
   * steel blue of 0x5e6c7b) under a container ship's own 0x93aac4 tint, the
   * stack came out near black, which is *darker* than the hull it sits on and
   * the opposite of what a photograph of a laden box ship shows. So the hue of
   * a stack is the hull's tint, like every other part of every other hull, and
   * what this band carries is what the rest of the atlas carries: **relative
   * value**.
   *
   * That is not a compromise at the size this draws at. A stack is 25 px on the
   * long axis at the Harbour pose, and at 25 px the individual box hues average
   * out to one tone — what survives is the *structure*: the shadow gaps between
   * bays and rows, and the tier lines up the side. So those are what is drawn,
   * and they are drawn dark against a base that sits a little under the deck's
   * white so a laden stack reads a shade below the white house beside it, which
   * is the relationship a photograph shows.
   *
   * `u` runs across a face, so the vertical divisions are the rows of a stack
   * across the beam and the horizontal ones are tiers. Both are visible from
   * directly above and from the side, which is what a stack has to survive
   * because a harbour is looked at from both.
   *
   * The variation is a fixed sequence rather than a PRNG, for `yardAtlas`'s
   * reason: a stack has to look the same on every load, and a hull that
   * reshuffles its cargo between two screenshots makes the two impossible to
   * compare.
   */
  {
    const [top, bottom] = fill(3, "#e6e2dc");
    const h = bottom - top;
    // Eight rows across the beam. The values are all inside 0.82..0.98 of white
    // so the tint stays the colour; they exist to stop the stack reading as one
    // flat slab, not to be a cargo manifest.
    const rows = ["#e9e5df", "#d8d4ce", "#efebe5", "#dedad4", "#e4e0da", "#d2cec8", "#ebe7e1", "#dcd8d2"] as const;
    const width = 64 / rows.length;
    for (let i = 0; i < rows.length; i += 1) {
      ctx.fillStyle = rows[i] ?? "#e6e2dc";
      ctx.fillRect(Math.round(i * width), top, Math.ceil(width), h);
    }
    // The shadow between two boxes: what makes a block read as a stack of things
    // rather than as a slab. Dark, but a modulation dark rather than a black.
    ctx.fillStyle = "#6e6a64";
    for (let i = 1; i < rows.length; i += 1) ctx.fillRect(Math.round(i * width) - 1, top, 1, h);
    ctx.fillStyle = "#a8a49e";
    for (let tier = 1; tier < 4; tier += 1) {
      ctx.fillRect(0, top + Math.round((h * tier) / 4) - 1, 64, 1);
    }
  }

  const texture = new THREE.CanvasTexture(canvas);
  texture.name = "vessel-deck-atlas";
  texture.colorSpace = THREE.SRGBColorSpace;
  /**
   * `flipY = false`, and it is not a detail.
   *
   * three.js flips an image on upload so that `v = 0` is the *bottom* of the
   * picture, which silently inverts a banded atlas: the first photograph of this
   * layer had every hull wearing the superstructure's window rows down its side
   * and the boot-top across its wheelhouse, and it looked merely dark rather
   * than wrong. Drawing the bands top-down and turning the flip off keeps the
   * canvas's y and the geometry's v the same axis.
   */
  texture.flipY = false;
  texture.wrapS = THREE.ClampToEdgeWrapping;
  texture.wrapT = THREE.ClampToEdgeWrapping;
  return texture;
}

// ---- Scale ----------------------------------------------------------------

/**
 * Metres to scene units **horizontally**, with no vertical exaggeration.
 *
 * The distinction matters and is the one thing easy to get wrong here.
 * `world.metres()` applies the board's `verticalExaggeration` — 3.4 on SoCal —
 * because it is for heights, and it is what makes a 130 m gantry crane stand
 * 1.13 units tall. A ship's *length* is a plan measurement lying flat next to a
 * quay that was projected, not exaggerated, so it must not go through the same
 * function: a 400 m hull run through `world.metres` would be 3.5 units long and
 * would overhang its berth by two ship-lengths.
 *
 * Exported because `vesselScale.test.ts` asserts the two boards' answers
 * directly, and because it is the number anybody reasoning about this layer
 * needs first.
 */
export function metresAcross(world: World, metres: number): number {
  return metres / world.metresPerUnit;
}

/** Length overall, in scene units, on this board. 1.024 for 400 m on SoCal. */
export function hullLengthUnits(world: World, vessel: Pick<Vessel, "length">): number {
  return metresAcross(world, vessel.length);
}

/**
 * How long a wake is, in metres.
 *
 * Speed through water and hull length, which is what a wake is a function of.
 * Below `WAKE_MIN_SPEED_MPS` it is zero and the ship has none — a moored hull
 * with a stub of foam behind it would be the layer quietly claiming motion it
 * has no evidence for.
 */
export function wakeLengthMetres(speedMps: number, lengthMetres: number): number {
  if (!Number.isFinite(speedMps) || speedMps < WAKE_MIN_SPEED_MPS) return 0;
  if (!Number.isFinite(lengthMetres) || lengthMetres <= 0) return 0;
  const fraction = Math.min(1, speedMps / WAKE_FULL_SPEED_MPS);
  return lengthMetres * WAKE_HULL_LENGTHS * fraction;
}

/**
 * A true bearing as a rotation about the board's vertical.
 *
 * North is `-z` and east is `+x`, and the unit hull is built with its bow at
 * `-z`, so a bearing of B is a yaw of `-B`. One line, in one place, because
 * getting it wrong by a sign sails the entire fleet backwards and looks
 * completely plausible in a still frame.
 */
export function bearingRotation(bearingDegrees: number): number {
  return -bearingDegrees * DEG;
}

// ---- The layer ------------------------------------------------------------

/**
 * What a vessel layer can be asked, beyond the seam `scene.ts` declares.
 *
 * The three readbacks are for tests and for a panel: everything else about this
 * layer is observed through the scene graph, which is the rule `flights.test.ts`
 * set and the reason there is no private state exposed here.
 */
export interface VesselLayerHandle {
  group: THREE.Object3D;
  setVessels(vessels: readonly Vessel[] | null): void;
  setLighting(state: LightingState): void;
  tick(dt: number): void;
  dispose(): void;
  /** How many hulls are drawn right now. */
  hullCount(): number;
  /** How many wakes are drawn right now. Never more than `hullCount`. */
  wakeCount(): number;
  /** Where a hull is being drawn, in scene units, or `null`. */
  positionOf(id: string): THREE.Vector3 | null;
}

/**
 * Build the layer.
 *
 * The signature is `SceneOptions.vessels`'s exactly — `(world, { span })` — and
 * `span` is deliberately unused: every size in here is a true metre converted
 * through the board's own scale, which is the whole argument above. It is taken
 * anyway so the four new layer factories are one shape, and so that a future
 * fade-with-distance has the number it needs without a seam change.
 *
 * **Nothing is added to the group until a vessel arrives.** An empty board costs
 * one `THREE.Group` and no draw call, which is the same posture `scene.ts` takes
 * toward a layer that is not configured at all: a layer that is not visited is
 * cheaper and more honest than one drawing nothing.
 */
export function createVesselLayer(
  world: World,
  options: { span: number } = { span: 0 },
): VesselLayerHandle {
  void options;
  const group = new THREE.Group();
  group.name = "vessels";

  const atlas = deckAtlas();
  const hullMaterial = new THREE.MeshLambertMaterial({
    color: 0xffffff,
    ...(atlas ? { map: atlas } : {}),
  });
  hullMaterial.name = "vessel-hull";

  /**
   * One `InstancedMesh` per hull shape, built up front and added to the group
   * only when it has hulls in it.
   *
   * Built up front rather than per update because an `InstancedMesh` owns a
   * `Float32Array` of sixteen floats per instance and rebuilding it whenever the
   * feed answers would allocate and discard 12 KB every fifteen minutes for the
   * life of the page. `count` is the dial instead, which is what it is for.
   *
   * **A geometry per shape, and one material for all of them.** The two are not
   * symmetric and the asymmetry is the whole budget: a second *geometry* is a
   * second draw call only on a board that has that shape floating on it, while a
   * second *material* would be a second draw call on every board for ever. So
   * the tug gets its own solid and wears the same atlas, and its colour arrives
   * as an `instanceColor` tint like every other hull's.
   */
  const geometries = new Map<HullShape, THREE.BufferGeometry>();
  const hullMeshes = new Map<HullShape, THREE.InstancedMesh>();
  for (const shape of HULL_SHAPES) {
    const geometry = hullGeometry(shape);
    geometries.set(shape, geometry);
    const mesh = new THREE.InstancedMesh(geometry, hullMaterial, VESSEL_HULL_CAPACITY);
    mesh.name = `vessels:${shape}`;
    mesh.count = 0;
    // No shadow, and it is a budget decision with a number behind it. The SoCal
    // mobile cell has thirty draw calls spare for the whole of ports and ships,
    // a shadow pass costs another draw per mesh, and a 400 m hull's shadow on
    // water at 390 m to the unit is under three pixels from any pose this board
    // is looked at from. The cranes are what cast the shadows that matter.
    mesh.castShadow = false;
    mesh.receiveShadow = false;
    // The instance matrices are rewritten from scene-space every time the feed
    // answers and every frame a hull is under way, so the bounding sphere three
    // computes at construction is permanently wrong and culling on it would cull
    // the fleet.
    mesh.frustumCulled = false;
    mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
    hullMeshes.set(shape, mesh);
  }

  /**
   * One `LineSegments` for every wake on the board.
   *
   * The same object `flights.ts` builds for aircraft trails and for the same
   * reasons: the vertex count is trivial either way, and a single preallocated
   * buffer means traffic changing does not allocate and dispose geometry. Per-
   * vertex alpha does the fade, which needs a four-component colour attribute —
   * three.js reads the item size and switches the shader on it.
   *
   * Allocated once, here, and never resized. `wakes.test.ts` asserts the array's
   * length across a hundred ticks precisely because "just push a few more
   * points" is the change that would undo it.
   */
  const maxWakeVertices = WAKE_CAPACITY * WAKE_RAILS.length * (WAKE_POINTS - 1) * 2;
  const wakePositions = new Float32Array(maxWakeVertices * 3);
  const wakeColors = new Float32Array(maxWakeVertices * 4);
  const wakeGeometry = new THREE.BufferGeometry();
  wakeGeometry.setAttribute("position", new THREE.BufferAttribute(wakePositions, 3));
  wakeGeometry.setAttribute("color", new THREE.BufferAttribute(wakeColors, 4));
  wakeGeometry.setDrawRange(0, 0);
  const wakeMaterial = new THREE.LineBasicMaterial({
    vertexColors: true,
    transparent: true,
    // Wakes cross each other in a busy channel and are the faintest thing on the
    // water; letting them write depth makes whichever drew first punch a hole in
    // every one behind it. `flights.ts` learned this on contrails.
    depthWrite: false,
  });
  const wakeLine = new THREE.LineSegments(wakeGeometry, wakeMaterial);
  wakeLine.name = "vessel-wakes";
  wakeLine.frustumCulled = false;

  const foam = new THREE.Color(WAKE_COLOR_DAY);
  let foamAlpha = WAKE_ALPHA;

  /** What the feed last said, and how long ago in layer time. */
  let fixes: Vessel[] = [];
  /** Seconds since `fixes` were handed over. Reset by `setVessels`, grown by `tick`. */
  let sinceFix = 0;
  /** Where each hull is being drawn, so a caller can ask without a raycast. */
  const drawn = new Map<string, THREE.Vector3>();
  let wakes = 0;

  const matrix = new THREE.Matrix4();
  const quaternion = new THREE.Quaternion();
  const position = new THREE.Vector3();
  const scale = new THREE.Vector3();
  const axis = new THREE.Vector3(0, 1, 0);
  const instanceColor = new THREE.Color();

  function rebuild(): void {
    let vertex = 0;
    wakes = 0;
    drawn.clear();

    for (const mesh of hullMeshes.values()) mesh.count = 0;

    const counts = new Map<HullShape, number>();
    for (const vessel of fixes) {
      const shape = hullShape(vessel.kind);
      const mesh = hullMeshes.get(shape);
      if (!mesh) continue;
      const index = counts.get(shape) ?? 0;
      if (index >= VESSEL_HULL_CAPACITY) continue;

      // Motion, and the only motion there is: one fix advanced along its own
      // reported course. `reckonVessel` takes a single fix and therefore has no
      // second point to interpolate toward — see `server/vessels.ts`.
      const here = reckon(vessel, sinceFix + (vessel.ageSeconds ?? 0));
      const [x, z] = world.project(here.lat, here.lng);

      const draught = hullDraughtMetres(vessel);
      const depth = draught + hullFreeboardMetres(vessel);
      // Keel below the waterline, waterline on the sea. `world.metres` for the
      // vertical because that is the axis the board exaggerates; `metresAcross`
      // for the other two because they lie in the plan the quay was projected
      // into.
      position.set(x, SEA_SURFACE_Y - world.metres(draught), z);
      quaternion.setFromAxisAngle(axis, bearingRotation(vessel.bearing));
      scale.set(metresAcross(world, vessel.beam), world.metres(depth), metresAcross(world, vessel.length));
      matrix.compose(position, quaternion, scale);
      mesh.setMatrixAt(index, matrix);
      instanceColor.setHex(VESSEL_PALETTE[vessel.kind] ?? VESSEL_PALETTE.other);
      mesh.setColorAt(index, instanceColor);
      counts.set(shape, index + 1);
      drawn.set(vessel.id, new THREE.Vector3(x, position.y, z));

      if (wakes < WAKE_CAPACITY) {
        const written = writeWake(vessel, x, z, vertex);
        if (written > vertex) {
          vertex = written;
          wakes += 1;
        }
      }
    }

    for (const [shape, mesh] of hullMeshes) {
      mesh.count = counts.get(shape) ?? 0;
      mesh.instanceMatrix.needsUpdate = true;
      if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
      const wanted = mesh.count > 0;
      const present = mesh.parent === group;
      if (wanted && !present) group.add(mesh);
      else if (!wanted && present) group.remove(mesh);
    }

    wakeGeometry.setDrawRange(0, vertex);
    /**
     * Upload only what was written.
     *
     * `needsUpdate` alone re-sends the whole `Float32Array` — three.js reads an
     * empty update range as "all of it" — which is 1.4 MB a frame for a buffer a
     * quiet harbour writes two percent of. `clearUpdateRanges` first or the
     * ranges accumulate and the saving is gone inside a second. Straight out of
     * `flights.ts`, which measured it.
     */
    const positionAttr = wakeGeometry.attributes.position as THREE.BufferAttribute;
    const colorAttr = wakeGeometry.attributes.color as THREE.BufferAttribute;
    positionAttr.clearUpdateRanges();
    colorAttr.clearUpdateRanges();
    if (vertex > 0) {
      positionAttr.addUpdateRange(0, vertex * 3);
      colorAttr.addUpdateRange(0, vertex * 4);
      positionAttr.needsUpdate = true;
      colorAttr.needsUpdate = true;
    }

    const wantWake = vertex > 0;
    const haveWake = wakeLine.parent === group;
    if (wantWake && !haveWake) group.add(wakeLine);
    else if (!wantWake && haveWake) group.remove(wakeLine);
  }

  /**
   * One vessel's wake, as two straight rails diverging astern.
   *
   * The rails leave the ship at its quarters — half a beam either side of the
   * centreline at the stern — and open at the Kelvin half-angle, which is a
   * constant. Nothing here is a history buffer: the wake is drawn along the
   * course the hull was reckoned along, which is the same line, so a stored
   * track would be a second copy of the same claim with an opportunity to
   * disagree with it.
   *
   * The honest limitation, stated because it is visible: when a new fix arrives
   * with a different course the whole V swings to the new one rather than
   * bending. A bend would have to be drawn through positions between two fixes,
   * and there are none — that is the rule this layer is built around.
   */
  function writeWake(vessel: Vessel, x: number, z: number, start: number): number {
    const lengthMetres = wakeLengthMetres(vessel.speed, vessel.length);
    if (lengthMetres <= 0) return start;
    const course = vessel.course ?? vessel.bearing;
    const length = metresAcross(world, lengthMetres);
    const half = metresAcross(world, vessel.length) / 2;
    const beam = metresAcross(world, vessel.beam) / 2;
    const spread = Math.tan(KELVIN_HALF_ANGLE_DEG * DEG);

    const radians = course * DEG;
    // Course over ground as a direction on the board: north is -z, east is +x.
    const forwardX = Math.sin(radians);
    const forwardZ = -Math.cos(radians);
    // Starboard is the course turned a quarter clockwise.
    const rightX = Math.cos(radians);
    const rightZ = Math.sin(radians);

    // The stern, which is where a wake starts and the hull's centre is not.
    const sternX = x - forwardX * half;
    const sternZ = z - forwardZ * half;

    let vertex = start;
    for (const side of WAKE_RAILS) {
      let previousX = sternX + rightX * beam * side;
      let previousZ = sternZ + rightZ * beam * side;
      let previousFade = 1;
      for (let i = 1; i < WAKE_POINTS; i++) {
        if (vertex + 2 > maxWakeVertices) return vertex;
        const t = i / (WAKE_POINTS - 1);
        const back = t * length;
        const across = beam + back * spread;
        const px = sternX - forwardX * back + rightX * across * side;
        const pz = sternZ - forwardZ * back + rightZ * across * side;
        // Foam dies away from the ship. The exponent puts most of the fade in
        // the older half so the segment right behind the stern stays bright,
        // which is both what foam does and what makes the ship findable.
        const fade = (1 - t) ** 1.6;
        writeWakeVertex(vertex++, previousX, previousZ, previousFade);
        writeWakeVertex(vertex++, px, pz, fade);
        previousX = px;
        previousZ = pz;
        previousFade = fade;
      }
    }
    return vertex;
  }

  function writeWakeVertex(index: number, x: number, z: number, fade: number): void {
    const p = index * 3;
    wakePositions[p] = x;
    wakePositions[p + 1] = SEA_SURFACE_Y + WAKE_LIFT;
    wakePositions[p + 2] = z;
    const c = index * 4;
    wakeColors[c] = foam.r;
    wakeColors[c + 1] = foam.g;
    wakeColors[c + 2] = foam.b;
    wakeColors[c + 3] = fade * foamAlpha;
  }

  function reckon(vessel: Vessel, seconds: number): { lat: number; lng: number } {
    // Inlined rather than imported so that `engine/` keeps taking no dependency
    // on `server/`, which is the direction of the arrow everywhere else in this
    // repo. The arithmetic is `reckonVessel`'s and the two are asserted against
    // each other in `vesselGate.test.ts`.
    if (!Number.isFinite(seconds) || seconds <= 0) return vessel;
    if (vessel.course === null || !Number.isFinite(vessel.course)) return vessel;
    if (!Number.isFinite(vessel.speed) || vessel.speed < WAKE_MIN_SPEED_MPS) return vessel;
    const dt = Math.min(seconds, RECKON_CEILING_SECONDS);
    const distance = vessel.speed * dt;
    const radians = vessel.course * DEG;
    const lat = vessel.lat + (Math.cos(radians) * distance) / METRES_PER_DEGREE_LAT;
    const metresPerDegreeLng = METRES_PER_DEGREE_LAT * Math.cos(vessel.lat * DEG);
    const lng =
      metresPerDegreeLng > 1
        ? vessel.lng + (Math.sin(radians) * distance) / metresPerDegreeLng
        : vessel.lng;
    return { lat, lng };
  }

  return {
    group,

    setVessels(vessels) {
      // `null` and `[]` are the same picture and a different sentence — nothing
      // has answered, against the feed answered and this board is empty. The
      // distinction is the panel's to draw, from `VesselPromotion`; here they
      // are both an empty fleet and both cost zero draw calls.
      fixes = vessels ? vessels.filter((v) => v && Number.isFinite(v.lat) && Number.isFinite(v.lng)) : [];
      sinceFix = 0;
      rebuild();
    },

    setLighting(state) {
      /**
       * The hulls need nothing here: they are `MeshLambertMaterial` and are lit
       * by the one rig, which is the entire point of CONTRACT §4. Only the foam
       * is unlit, and only because a line has no normal to light.
       *
       * So the foam takes the key light's colour and a share of its intensity: a
       * wake under a low orange sun goes warm, and at night it dims to something
       * the harbour's own lights can sit above rather than glowing white on
       * black water. `sun.intensity` is the proxy rather than a solar elevation
       * because `VesselLayer` carries no elevation setter — the migration layer
       * is the one that needed one, and adding it here to tint foam would be a
       * seam widened for a tint.
       */
      const key = Number.isFinite(state.sun.intensity) ? Math.max(0, state.sun.intensity) : 1;
      const daylight = Math.min(1, key / DAYLIGHT_KEY_INTENSITY);
      foam.setHex(WAKE_COLOR_DAY).lerp(new THREE.Color(state.sun.color), 0.25 * (1 - daylight));
      foam.multiplyScalar(0.45 + 0.55 * daylight);
      foamAlpha = WAKE_ALPHA * (0.55 + 0.45 * daylight);
      rebuild();
    },

    tick(dt) {
      if (!Number.isFinite(dt) || dt <= 0) return;
      // Only a board with something making way pays for a rebuild. A harbour
      // full of moored hulls is static geometry and is left alone, which is the
      // common case: 50 of the 81 vessels in the LA/LB box are stopped.
      let moving = false;
      for (const vessel of fixes) {
        if (vessel.course !== null && vessel.speed >= WAKE_MIN_SPEED_MPS) {
          moving = true;
          break;
        }
      }
      sinceFix += dt;
      if (!moving) return;
      rebuild();
    },

    dispose() {
      for (const geometry of geometries.values()) geometry.dispose();
      geometries.clear();
      hullMaterial.dispose();
      atlas?.dispose();
      wakeGeometry.dispose();
      wakeMaterial.dispose();
      for (const mesh of hullMeshes.values()) mesh.dispose();
      hullMeshes.clear();
      drawn.clear();
      group.clear();
    },

    hullCount() {
      let total = 0;
      for (const mesh of hullMeshes.values()) total += mesh.count;
      return total;
    },

    wakeCount() {
      return wakes;
    },

    positionOf(id) {
      const at = drawn.get(id);
      return at ? at.clone() : null;
    },
  };
}

/**
 * A noon sun's key intensity, from `atmosphere.ts`'s own table.
 *
 * Used only to turn the key into a 0..1 daylight fraction for the foam. Restated
 * rather than imported because importing it would give this layer an opinion
 * about the rig, and the number it wants is a ratio rather than a value.
 */
const DAYLIGHT_KEY_INTENSITY = 2.1;

const METRES_PER_DEGREE_LAT = 111_320;

/** The dead-reckoning ceiling, in seconds. Fifteen minutes: the sample interval. */
const RECKON_CEILING_SECONDS = 900;
