/**
 * Airports — the one piece of infrastructure you can name from twenty
 * kilometres up.
 *
 * The board has had real aircraft in the sky since `flights.ts` landed, and
 * every one of them was climbing away from, or descending onto, nothing at all.
 * This is the ground half of that layer.
 *
 * ### The pattern is the recognition, not the buildings
 *
 * A terminal is a shed. From the altitude a city board is looked at, an airport
 * is **two or three long pale bars at fixed angles to each other on a flat
 * apron**, and the angles are the whole of the identity: SFO's crossing pairs,
 * LAX's four east–west parallels, Heathrow's two. Get the headings and the
 * relative lengths right and the shape is unmistakable before a single building
 * is drawn; get them wrong and the most detailed terminal model in the world
 * reads as a generic airfield.
 *
 * So this kit is arranged around that priority. A runway is **one quad**. Its
 * markings — threshold bars, centreline, edge lines, aiming points, the painted
 * designator — are a canvas texture drawn once per airport and shared by every
 * runway on it through an atlas, so four runways with four different sets of
 * numbers on them are still one mesh, one material and one upload. Everything
 * above that (taxiways, aprons, terminals, a tower, aircraft on stand) is a
 * handful of boxes on top.
 *
 * Measured on the Bay Area board, the whole of SFO — four runways, six
 * taxiways, three aprons, nine terminal masses, a tower and twenty-two parked
 * airliners — is 2,910 triangles in 7 draw calls. That is the argument for
 * doing it this way rather than modelling jet bridges.
 *
 * ### Everything here is batched, for the reason `structures.ts` gives
 *
 * Same two rules, and this module keeps its own copy of them rather than
 * importing `structures.ts`'s `Batch`, which is private to that file: materials
 * are cached by role for the life of one build, geometry is merged per
 * material, and the cache is deliberately **not** module-level, because
 * `createScene().dispose()` walks the scene disposing every material it finds
 * and a cache that outlived a build would hand the next board a disposed
 * material and render it black.
 *
 * The corollary is the same too: 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.
 *
 * ### An airport is a graded platform, and that is why it is flat
 *
 * The field is laid at one height for the whole airport: the highest ground it
 * covers, plus a hair. That is what grading *is* — the reason a runway is a
 * usable runway is that somebody spent a great deal of money making the ground
 * under it one plane.
 *
 * The height deliberately does **not** come from `Airport.elevation`. Field
 * elevation is a fact about the place (it is what the altimeter reads on the
 * ground, and later layers will want it), but a board exaggerates its relief —
 * 3.6× on the Bay Area — so SFO's true 4 m would stand the whole field 14 m
 * proud of the shoreline it is built on and the plate would read as a floating
 * slab from any low camera. Sampling the ground instead keeps the platform
 * sitting on the terrain it is part of.
 */

import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import { airlinerGeometry, AIRLINER_LENGTH } from "./aircraftGeometry.ts";
import { LOD_HEIGHT_TOLERANCE } from "./terrain.ts";
import type { Airport, LatLng, Runway, Taxiway } from "./types.ts";
import type { World } from "./world.ts";

// ---- The authored contract ------------------------------------------------
//
// The airport data types live in `types.ts`, beside `City`, `Bridge` and
// `Road`, and are re-exported here so the kit reads as one import.
//
// They moved there rather than staying here for a reason worth recording,
// because it is a trap the whole repo is arranged around: `City` has to name
// `Airport`, `src/index.ts` reaches `City`, and the package surface promises
// that nothing reachable from it imports three.js. A type-only import is erased
// at build time and would have been harmless — but `src/test/integration/
// barrel.test.ts` reads the import graph as *source*, because that is the only
// way to assert the promise, and it cannot tell an erased edge from a real one.
// Putting the plain data where the plain data lives makes the question moot
// instead of teaching the guard to look away.

export type { Airport, Apron, Runway, Taxiway, Terminal, Tower } from "./types.ts";

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

/**
 * Exported because a city pack in a different landscape may need to move these,
 * and because the contrast between them is a design decision worth being able
 * to see in one place.
 *
 * The one that matters is `field` against `runway`: concrete runways are
 * *lighter* than the ground around them, which is the opposite of the road
 * convention two files over, and it is what SFO actually looks like. If a pack
 * ever darkens the field, the bars have to stay the lighter of the two or the
 * pattern stops reading at distance.
 */
export const AIRPORT_PALETTE = {
  /**
   * Graded fill: bay mud, decomposed granite, mown grass. All of it drab.
   *
   * **Drab, and specifically not green**, which it was: `0x7b8070` is an olive
   * with more green in it than red, and a second board proved that unusable.
   * On the Bay Area's cream shore and on Southern California's tan basin the
   * same swatch was the most saturated green thing in frame, so six real
   * airfields read as golf courses until you were close enough to count the
   * runways. Hue is what says *vegetation* at board scale; value is what says
   * *graded platform*. This is the same drabness with the green taken out and
   * the contrast against the ground carried by being darker instead.
   */
  field: 0x8e8a7e,
  /**
   * Grooved concrete, and the one relationship in this table that is load-
   * bearing: a runway must stay LIGHTER than the field it is laid on, because
   * from twenty kilometres up the pattern of pale bars *is* the airport. It is
   * brighter than it was for the same reason the field is drabber — the two
   * were four steps of value apart and the bars did not carry.
   */
  runway: 0xbcbeb8,
  /** Asphalt, and darker than the runways on purpose. */
  taxiway: 0x70747a,
  apron: 0x94968f,
  terminal: 0xb4b8bb,
  roof: 0x8d9296,
  tower: 0xc9ccce,
  aircraft: 0xe2e6e9,
  /** Paint. Unlit, so it survives dusk; see `markingMaterial`. */
  paint: 0xf2f3ee,
} as const;

// ---- Authoring helpers ----------------------------------------------------
//
// These run at pack-authoring time and return plain data. They are exported
// because a pack that computes a parallel taxiway from the runway it parallels
// cannot drift out of alignment with it, and a hand-typed one can.

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

/** Metres per degree of longitude at a latitude. */
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),
  ];
}

/** The two threshold coordinates, low designator first. */
export function runwayThresholds(runway: Runway): { low: LatLng; high: LatLng } {
  const [east, north] = bearingVector(runway.heading);
  const half = runway.length / 2;
  return {
    low: offsetLatLng(runway, -east * half, -north * half),
    high: offsetLatLng(runway, east * half, north * half),
  };
}

/**
 * The centreline as a two-point path.
 *
 * For anything that wants a runway as a line rather than a surface — a minimap,
 * an approach path, or a board that has not wired the kit in yet and is drawing
 * runways as pale roads.
 */
export function runwayCentreline(runway: Runway): LatLng[] {
  const { low, high } = runwayThresholds(runway);
  return [low, high];
}

/**
 * A taxiway running alongside a runway, `offset` metres to one side.
 *
 * `side` is +1 for the right of the low-to-high direction and −1 for the left.
 * `trim` shortens it at both ends, which is what keeps a parallel taxiway from
 * running out past the threshold it serves.
 */
export function parallelTaxiway(
  runway: Runway,
  side: 1 | -1,
  offset: number,
  trim = 0,
  id?: string,
): Taxiway {
  const [east, north] = bearingVector(runway.heading);
  // Right of the heading is the heading turned a quarter clockwise.
  const rightEast = north;
  const rightNorth = -east;
  const half = Math.max(0, runway.length / 2 - trim);
  const acrossE = rightEast * offset * side;
  const acrossN = rightNorth * offset * side;
  return {
    ...(id === undefined ? {} : { id }),
    path: [
      offsetLatLng(runway, acrossE - east * half, acrossN - north * half),
      offsetLatLng(runway, acrossE + east * half, acrossN + north * half),
    ],
  };
}

// ---- Batching -------------------------------------------------------------

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,
    material: THREE.Material,
    shadows: { cast?: boolean; receive?: boolean } = {},
  ): void {
    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 and why rather than rendering an airport with
        // no paint on it.
        console.warn(`airports: "${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 -------------------------------------------------------------

/**
 * 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, so a quad built from a bearing has the same
 * handedness as everything else on the board. UVs run `u` along and `v` across,
 * which is what lets a long thin runway index into a horizontal band of an
 * 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 whole
  // airport 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 — a taxiway.
 *
 * The **left rail is emitted first**, which is not a style choice: with the two
 * rails the other way round the winding reverses, `computeVertexNormals` hands
 * every triangle a normal pointing at the ground, and a one-sided material
 * draws nothing at all. This was written right-rail-first and the taxiways were
 * simply absent from the board — no warning, no black stripe, nothing to see.
 * `structures.ts`'s `bandGeometry` carries the same rule and the same 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. */
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 of `ShapeGeometry` 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 outlines 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. */
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);
  /**
   * The box's own length axis is +Z, and `rotateY(φ)` sends +Z to
   * `(sin φ, cos φ)` in (x, z) — so φ is `atan2(alongX, alongZ)` and **not**
   * `atan2(alongX, −alongZ)`, which is the bearing you would write if you were
   * converting a compass reading rather than aiming an axis that is already a
   * scene-space vector.
   *
   * That negation was here, and it is worth recording because of how it failed.
   * It does not rotate a building by a wrong angle, it *mirrors* it about the
   * east–west line: SFO's terminal horseshoe came out on 153.5° instead of
   * 26.5°, which is a plausible-looking building on a plausible-looking apron,
   * lying across the airport at right angles to everything else. The tell was
   * that the aircraft on stand — which take their positions from the along
   * vector directly and never went through this function — were parked in a
   * neat row beside nothing at all.
   */
  geometry.rotateY(Math.atan2(alongX, alongZ));
  geometry.translate(cx, groundY + height / 2, cz);
  return geometry;
}

// ---- Runway markings ------------------------------------------------------

const ATLAS_WIDTH = 2048;
const ATLAS_BAND = 128;

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

/**
 * Every runway on one airport, painted into one texture.
 *
 * Each runway gets a horizontal band `ATLAS_BAND` pixels tall: x runs along the
 * runway from the low-designator threshold, y runs across it. The band is
 * transparent apart from the paint, because this is an overlay laid a hair
 * above the concrete rather than the concrete itself — which is what lets the
 * surface be lit by the sun while the paint is not (see `markingMaterial`).
 *
 * Drawn in **metres and mapped through**, never in pixels: `alongPx` and
 * `acrossPx` are the only two places the resolution appears, so the marks stay
 * the right size when a runway of a different length shares the atlas.
 */
function markingsAtlas(runways: readonly Runway[]): THREE.Texture | null {
  if (typeof document === "undefined") return null;
  const canvas = document.createElement("canvas");
  canvas.width = ATLAS_WIDTH;
  canvas.height = powerOfTwo(ATLAS_BAND * Math.max(1, runways.length));
  const context = canvas.getContext("2d");
  if (!context) return null;

  const paint = `#${AIRPORT_PALETTE.paint.toString(16).padStart(6, "0")}`;
  context.fillStyle = paint;
  context.strokeStyle = paint;

  runways.forEach((runway, index) => {
    const top = index * ATLAS_BAND;
    const alongPx = (metres: number) => (metres / runway.length) * ATLAS_WIDTH;
    const acrossPx = (metres: number) => (metres / runway.width) * ATLAS_BAND;
    const centre = top + ATLAS_BAND / 2;

    // Edge lines, 0.9 m wide, inset a metre from the pavement edge.
    const edge = acrossPx(runway.width / 2 - 1);
    for (const side of [-1, 1]) {
      context.fillRect(0, centre + side * edge, ATLAS_WIDTH, Math.max(1, acrossPx(0.9)));
    }

    // Centreline: 30 m stripes with 20 m gaps, which is the real cadence and
    // is also what makes a runway read as a runway rather than a road.
    const stripe = Math.max(1, acrossPx(0.9));
    for (let m = 60; m < runway.length - 60; m += 50) {
      context.fillRect(alongPx(m), centre - stripe / 2, alongPx(30), stripe);
    }

    for (const end of [0, 1] as const) {
      // `at` measures inward from this end whichever end it is, so both ends
      // are painted by one pass and neither is a transposed copy of the other.
      const at = (metres: number, span: number): number =>
        end === 0 ? alongPx(metres) : ATLAS_WIDTH - alongPx(metres) - alongPx(span);

      // Threshold bars — the piano keys. Eight of them for a 45 m runway, and
      // they are the single most recognisable mark on a paved surface.
      const bars = 8;
      const barWidth = acrossPx(1.8);
      const gap = acrossPx((runway.width - 4 - bars * 1.8) / (bars - 1));
      const barsTop = centre - (bars * barWidth + (bars - 1) * gap) / 2;
      for (let bar = 0; bar < bars; bar += 1) {
        context.fillRect(at(6, 30), barsTop + bar * (barWidth + gap), alongPx(30), barWidth);
      }

      // Aiming point: two 45 m blocks 300 m in. What a pilot flies at, and at
      // board scale the thing that stops the runway being a bare stripe.
      for (const side of [-1, 1]) {
        context.fillRect(
          at(300, 45),
          centre + side * acrossPx(11) - acrossPx(3),
          alongPx(45),
          acrossPx(6),
        );
      }

      const designator = runway.designators?.[end];
      if (designator) {
        // Painted numerals are 20 m tall and read across the runway, so they
        // are drawn into a turned frame. At a board camera they are a smudge in
        // the right place, which is exactly what they are from the air.
        context.save();
        context.translate(at(70, 0), centre);
        context.rotate(end === 0 ? -Math.PI / 2 : Math.PI / 2);
        context.font = `700 ${Math.round(acrossPx(13))}px ui-monospace, monospace`;
        context.textAlign = "center";
        context.textBaseline = end === 0 ? "top" : "bottom";
        context.fillText(designator, 0, 0);
        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 every
  // runway's paint on a different runway.
  texture.flipY = false;
  texture.colorSpace = THREE.SRGBColorSpace;
  texture.needsUpdate = true;
  return texture;
}

/**
 * Paint, and unlit on purpose — the same call `structures.ts` makes for lane
 * markings, for the same reason. Runway paint is retroreflective and its whole
 * job is to be a fixed known white; putting it through the tone-mapping
 * shoulder with everything else turns a threshold bar into a grey smear at
 * midday and loses it entirely at dusk, which is the hour this board is most
 * often looked at.
 */
function markingMaterial(map: THREE.Texture | null): THREE.Material {
  return new THREE.MeshBasicMaterial({
    ...(map ? { map } : { color: AIRPORT_PALETTE.paint }),
    transparent: true,
    // Off, so the paint never occludes the aircraft parked beyond it. It is
    // drawn a hair above a surface it exactly covers, so it has nothing to sort
    // against but itself.
    depthWrite: false,
    toneMapped: false,
  });
}

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

/**
 * The stack, in scene units: fill, then apron, then taxiway, then runway, then
 * paint.
 *
 * These have to be **far enough apart to survive the depth buffer**, and the
 * first pass at them was not. Tenths of a millimetre of scene apart is under
 * the depth resolution at any distance a board camera actually sits at — at 40
 * units out with a far plane three board spans away, a 24-bit buffer resolves
 * about a thousandth of a unit — so four coplanar surfaces spaced 0.001 apart
 * flicker against each other as the camera moves. 0.01 is the step
 * `structures.ts` already uses for road markings and it is the right order of
 * magnitude: at 94 m per unit and 3.6× exaggeration the whole stack is 1.3 m of
 * real-world height, which nothing can see, over an airport that is genuinely
 * built in layers anyway.
 *
 * The order is the order a paving crew would lay them, which is also the order
 * that makes a taxiway crossing a runway disappear under it rather than cut a
 * notch out of it.
 *
 * `FIELD_LIFT` is the one with a hard floor under it, and it is not the ground.
 * Two separate things push the terrain up past `groundAt`:
 *
 * 1. `terrain.ts` builds its relief mesh at `world.metres(e) + 0.012` — a bias
 *    of its own, to hold the surface off the flat shore plate at y=0 — so an
 *    airport laid at `groundAt` plus a hundredth is laid *under the terrain*,
 *    and the whole field vanishes except for the sliver that overhangs the
 *    water. That was found by painting the plate magenta and looking, because
 *    it fails silently: no warning, no z-fighting, just no airport.
 * 2. The terrain LOD collapses a near-flat patch to a single quad, and that
 *    quad may sit up to `LOD_HEIGHT_TOLERANCE` **above** the lattice points it
 *    replaced. A plate cleared only of (1) is therefore still pierced by the
 *    ground it is standing on wherever a collapsed patch bulges — which is how
 *    Van Nuys grew a tan wedge through the middle of the field.
 *
 * So the lift is derived from the tolerance rather than typed next to it: two
 * constants that must not drift apart are one constant. The margin on top is
 * the same order as the paint stack below, and at 391 m per unit the whole
 * thing is under a metre of real height.
 */
const FIELD_LIFT = LOD_HEIGHT_TOLERANCE + 0.04;
const APRON_LIFT = 0.01;
const TAXIWAY_LIFT = 0.02;
const RUNWAY_LIFT = 0.03;
const PAINT_LIFT = 0.04;

/** Length a parked aircraft is drawn at, metres. A narrowbody on stand. */
const PARKED_AIRCRAFT_LENGTH = 40;

/** Every coordinate the field has to be at least as high as. */
function fieldSamples(airport: Airport): LatLng[] {
  const samples: LatLng[] = [[airport.lat, airport.lng]];
  for (const runway of airport.runways) {
    const { low, high } = runwayThresholds(runway);
    samples.push(low, high, [runway.lat, runway.lng]);
  }
  for (const point of airport.field ?? []) samples.push(point);
  for (const apron of airport.aprons ?? []) for (const point of apron.polygon) samples.push(point);
  return samples;
}

function fieldHeight(world: World, airport: Airport): number {
  let highest = -Infinity;
  for (const [lat, lng] of fieldSamples(airport)) {
    const ground = world.groundAt(lat, lng);
    if (ground > highest) highest = ground;
  }
  return (Number.isFinite(highest) ? highest : 0) + FIELD_LIFT;
}
/**
 * The materials one build uses, made once and shared by every airport in it.
 *
 * Not module-level, and that is the same trap `structures.ts` records:
 * `createScene().dispose()` walks the scene disposing every material it finds,
 * so a cache that outlived a build would hand the next board a disposed
 * material and render the airport black.
 */
interface Surfaces {
  field: THREE.Material;
  runway: THREE.Material;
  taxiway: THREE.Material;
  apron: THREE.Material;
  terminal: THREE.Material;
  roof: THREE.Material;
  tower: THREE.Material;
  aircraft: THREE.Material;
  paint: THREE.Material;
}

function surfaces(runways: readonly Runway[]): Surfaces {
  const lambert = (color: number) => new THREE.MeshLambertMaterial({ color });
  return {
    field: lambert(AIRPORT_PALETTE.field),
    runway: lambert(AIRPORT_PALETTE.runway),
    taxiway: lambert(AIRPORT_PALETTE.taxiway),
    apron: lambert(AIRPORT_PALETTE.apron),
    terminal: lambert(AIRPORT_PALETTE.terminal),
    roof: lambert(AIRPORT_PALETTE.roof),
    tower: lambert(AIRPORT_PALETTE.tower),
    aircraft: lambert(AIRPORT_PALETTE.aircraft),
    paint: markingMaterial(markingsAtlas(runways)),
  };
}

/**
 * One airport's geometry, dropped into a batch that may already hold others.
 *
 * `bandOffset` is where this airport's runways start in the board-wide markings
 * atlas, and `bandTotal` is its height — the two together are why every runway
 * on a board can share one texture and therefore one mesh.
 */
function buildAirport(
  world: World,
  airport: Airport,
  batch: Batch,
  paints: Surfaces,
  stands: THREE.Matrix4[],
  bandOffset: number,
  bandTotal: number,
): void {
  const unit = 1 / world.metresPerUnit;
  const y = fieldHeight(world, airport);

  // ---- The graded plate ---------------------------------------------------

  if (airport.field && airport.field.length >= 3) {
    const slab = slabGeometry(
      airport.field.map(([lat, lng]) => world.project(lat, lng)),
      y,
    );
    if (slab) batch.add("airports:field", slab, paints.field);
  }

  // ---- Aprons -------------------------------------------------------------

  for (const apron of airport.aprons ?? []) {
    const slab = slabGeometry(
      apron.polygon.map(([lat, lng]) => world.project(lat, lng)),
      y + APRON_LIFT,
    );
    if (slab) batch.add("airports:apron", slab, paints.apron);
  }

  // ---- Taxiways -----------------------------------------------------------

  for (const taxiway of airport.taxiways ?? []) {
    const points = taxiway.path.map(([lat, lng]) => {
      const [x, z] = world.project(lat, lng);
      return new THREE.Vector3(x, y + TAXIWAY_LIFT, z);
    });
    if (points.length < 2) continue;
    batch.add(
      "airports:taxiway",
      stripGeometry(points, (taxiway.width ?? 25) * unit),
      paints.taxiway,
    );
  }

  // ---- Runways, and the paint on them -------------------------------------

  airport.runways.forEach((runway, index) => {
    const [x, z] = world.project(runway.lat, runway.lng);
    const [east, north] = bearingVector(runway.heading);
    // 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 halfLength = (runway.length / 2) * unit;
    const halfWidth = (runway.width / 2) * unit;
    batch.add(
      "airports:runway",
      groundQuad(x, y + RUNWAY_LIFT, z, alongX, alongZ, halfLength, halfWidth),
      paints.runway,
    );
    const band = bandOffset + index;
    batch.add(
      "airports:markings",
      groundQuad(x, y + PAINT_LIFT, z, alongX, alongZ, halfLength, halfWidth, {
        u0: 0,
        u1: 1,
        v0: (band * ATLAS_BAND) / bandTotal,
        v1: ((band + 1) * ATLAS_BAND) / bandTotal,
      }),
      paints.paint,
      { receive: false },
    );
  });

  // ---- Terminals, and the aircraft against them ---------------------------

  const dummy = new THREE.Object3D();
  for (const terminal of airport.terminals ?? []) {
    const [x, z] = world.project(terminal.lat, terminal.lng);
    const [east, north] = bearingVector(terminal.heading);
    const alongX = east;
    const alongZ = -north;
    const height = world.metres(terminal.height);
    batch.add(
      "airports:terminal",
      massGeometry(x, y, z, alongX, alongZ, terminal.length * unit, terminal.width * unit, height),
      paints.terminal,
      { cast: true },
    );
    // A slightly wider, flatter cap. One extra box per terminal buys the roof
    // line that stops a mass reading as an extruded footprint, and a roof is
    // most of what is visible of a building from directly above.
    batch.add(
      "airports:terminal-roof",
      massGeometry(
        x, y + height, z, alongX, alongZ,
        terminal.length * unit * 1.02,
        terminal.width * unit * 1.06,
        world.metres(2.5),
      ),
      paints.roof,
      { cast: true },
    );

    const gates = terminal.gates;
    if (!gates || gates.count <= 0) continue;
    const rightX = -alongZ;
    const rightZ = alongX;
    // Nose-in: the aircraft faces the terminal, so its nose points back along
    // the stand's outward direction.
    const noseX = -gates.side * rightX;
    const noseZ = -gates.side * rightZ;
    const rotation = Math.atan2(noseX, noseZ);
    // Clear of the wall by the building's own half width plus most of an
    // aircraft. Anything less parks the tails inside the terminal.
    const standOut = (terminal.width / 2 + PARKED_AIRCRAFT_LENGTH * 0.6) * unit * gates.side;
    for (let gate = 0; gate < gates.count; gate += 1) {
      // Evenly along the face, half a pitch in from each corner.
      const along = ((gate + 0.5) / gates.count - 0.5) * terminal.length * unit;
      dummy.position.set(
        x + alongX * along + rightX * standOut,
        // On the apron, because that is what it is standing on.
        y + APRON_LIFT,
        z + alongZ * along + rightZ * standOut,
      );
      dummy.rotation.set(0, rotation, 0);
      dummy.scale.setScalar((PARKED_AIRCRAFT_LENGTH * unit) / AIRLINER_LENGTH);
      dummy.updateMatrix();
      stands.push(dummy.matrix.clone());
    }
  }

  // ---- The tower ----------------------------------------------------------

  if (airport.tower) {
    const [x, z] = world.project(airport.tower.lat, airport.tower.lng);
    const shaftHeight = world.metres(airport.tower.height * 0.86);
    const shaft = new THREE.CylinderGeometry(7 * unit, 10 * unit, shaftHeight, 8, 1, false);
    shaft.translate(x, y + shaftHeight / 2, z);
    batch.add("airports:tower", shaft, paints.tower, { cast: true });
    const cab = new THREE.BoxGeometry(
      19 * unit,
      world.metres(airport.tower.height * 0.14),
      19 * unit,
    );
    cab.translate(x, y + shaftHeight + world.metres(airport.tower.height * 0.07), z);
    batch.add("airports:tower", cab, paints.tower, { cast: true });
  }
}

/**
 * Every airport on a board, as one small set of merged meshes.
 *
 * **Merged across airports, not per airport**, and that is a decision rather
 * than a convenience. A per-airport group is nicer to read in the scene graph
 * and costs one bucket per surface class per field: the Bay Area's two airports
 * came to 17 draw calls that way, and Southern California's six — LAX, Burbank,
 * Long Beach, John Wayne, Ontario, Van Nuys — would have come to about fifty,
 * against a board budget of 650 with eighty-eight spare. Sharing the buckets
 * makes the count a function of how many *kinds* of surface an airport has
 * rather than of how many airports a board declares, which is the property a
 * published kit needs. The runway paint shares one atlas for the same reason.
 *
 * Returns an empty group when a pack declares none, so a scene can add it
 * unconditionally and a city without an airport costs one `Group` and no draw
 * call.
 */
export function createAirports(world: World, airports: readonly Airport[]): THREE.Group {
  const group = new THREE.Group();
  group.name = "airports";
  group.userData.airportIds = airports.map((airport) => airport.id);
  if (airports.length === 0) return group;

  const runways = airports.flatMap((airport) => airport.runways);
  const bandTotal = powerOfTwo(ATLAS_BAND * Math.max(1, runways.length));
  const paints = surfaces(runways);
  const batch = new Batch();
  const stands: THREE.Matrix4[] = [];

  let bandOffset = 0;
  for (const airport of airports) {
    buildAirport(world, airport, batch, paints, stands, bandOffset, bandTotal);
    bandOffset += airport.runways.length;
  }
  batch.flush(group);

  /**
   * Aircraft on stand, instanced.
   *
   * They reuse `airlinerGeometry` — the same ~100 triangles the sky is drawn
   * from — but **not** the same sizing rule, and the contrast is the point. An
   * aeroplane in flight is a map symbol: it is drawn at a fixed 0.42 units on
   * every board so it stays findable, which over SoCal makes it four times life
   * size and nobody has ever noticed. An aeroplane on stand is standing next to
   * a truthfully-sized terminal, so it has to be truthfully sized too — 40 m,
   * converted through this board's own scale.
   */
  if (stands.length > 0) {
    const parked = new THREE.InstancedMesh(airlinerGeometry(), paints.aircraft, stands.length);
    parked.name = "airports:stands";
    parked.castShadow = true;
    stands.forEach((matrix, index) => parked.setMatrixAt(index, matrix));
    parked.instanceMatrix.needsUpdate = true;
    group.add(parked);
  }

  return group;
}

/** One airport, for a caller that has exactly one. */
export function createAirport(world: World, airport: Airport): THREE.Group {
  return createAirports(world, [airport]);
}
