/**
 * Roads and bridges — the lines that tie the landmasses together and give the
 * grid something to hang off.
 *
 * Roads follow the terrain: each path is resampled far more finely than it is
 * written in the city pack, and every sample takes its height from the ground,
 * so a street climbs out of the flats instead of burrowing through the hill.
 *
 * A road also has a **surface** rather than a colour. The cross-section — verge,
 * shoulder, edge line, lanes, median — is painted once into a canvas texture and
 * mapped across every ribbon, which is what took a city-pack freeway from a flat
 * grey stroke lying on the terrain to something that reads as a road, at no
 * triangle cost at all. See `paintRoadSurface`. The one corridor you can drive
 * down is the exception and is built out of real ribbons by
 * `createFreewayWorld`, because at chase-camera height an embankment has to have
 * a normal.
 *
 * **The bridge kit lives in `bridges.ts`.** A suspension bridge is enough of a
 * problem on its own — towers, a cable in a parabola, hangers, and a classifier
 * that works out which parts of a crossing hang from anything — and both of the
 * Bay Area's famous ones are configurations of it. What stays here is the
 * batching it hands its geometry to, and the asphalt its decks wear.
 *
 * ### Everything here is batched, and it has to be
 *
 * The city ran at 616 draw calls against a budget of 650 while the office spent
 * 8% of its triangle budget: quality is nearly free indoors and is not free at
 * all out here, so anything this module can hand back is headroom the exterior
 * vehicles and the aircraft get to spend. Batching the corridor and the bridges
 * took the California board to 557 measured — 59 calls, from 59 freeway meshes
 * down to 20 plus the four extra shadow-pass draws the guardrails and sign
 * posts used to cost.
 *
 * Two rules keep it honest, and both were broken before:
 *
 *   1. **Materials are cached by colour**, in a `Batch` that lives as long as
 *      the build call. Twelve identical asphalt decks used to be twelve
 *      `MeshLambertMaterial`s, which is twelve things that can never merge, and
 *      a single suspension bridge minted a fresh material for its deck, each
 *      tower, each brace, each cable and each hanger — about thirty-four.
 *   2. **Geometry is merged per material.** Every helper below returns a
 *      `BufferGeometry` rather than a `Mesh`, and the caller drops it into a
 *      named bucket; one mesh comes out per bucket at the end.
 *
 * The cache is deliberately *not* module-level. `createScene().dispose()` walks
 * the scene and disposes every material it finds, so a cache that outlived one
 * build would hand the next board a disposed material and render it black.
 *
 * The corollary for anyone adding a helper here: give every geometry the **same
 * attribute set** — position, normal, uv, indexed — or `mergeGeometries`
 * refuses the bucket and silently drops it. The UVs are load-bearing twice over
 * now: the road surface is a texture that reads them, and a part without them
 * takes its whole bucket with it.
 */

import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import { buildFreewayWorldPlan } from "../transport/freewayWorld.ts";
import type { TransportPack } from "../transport/types.ts";
import { buildRoutePath, sampleRoute } from "../transport/vehicleSim.ts";
import { buildBridge } from "./bridges.ts";
import type { SurfaceKind } from "./bridges.ts";
import type { Bridge, LatLng } from "./types.ts";
import type { World } from "./world.ts";

// ---- 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.
 *
 * Buckets are keyed on **name and material together** rather than on the
 * material alone. Sharing the material is what saves the draw call; keeping the
 * name is what lets somebody looking at the scene graph still find the
 * guardrails, and the one extra call it costs where two classes happen to share
 * a material is worth being able to debug the thing.
 */
class Batch {
  private readonly materials = new Map<string, THREE.Material>();
  private readonly buckets = new Map<string, Bucket>();

  /**
   * The one material for a kind and colour in this build.
   *
   * `SurfaceKind` is declared in `bridges.ts`, because that module is the other
   * side of this one's `GeometrySink` boundary, but the three materials it names
   * are made here and here only.
   *
   * `deck` and `solid` differ only in sidedness: a road deck is a one-sided
   * strip that has to survive being looked at from underneath on a bridge
   * approach, and a tower is a closed solid where a back face is a waste.
   *
   * `marking` is unlit and `toneMapped: false` on purpose. Paint on a road is
   * the one thing in the frame whose job is to be a fixed, known white — it is
   * retroreflective, it is what a driver navigates by, and putting it through
   * the ACES shoulder with everything else turns a lane line into a grey smear
   * at midday and loses it entirely at dusk.
   */
  material(kind: SurfaceKind, color: number): THREE.Material {
    const key = `${kind}:${color.toString(16)}`;
    const hit = this.materials.get(key);
    if (hit) return hit;
    const made =
      kind === "marking"
        ? new THREE.MeshBasicMaterial({ color, toneMapped: false, side: THREE.DoubleSide })
        : new THREE.MeshLambertMaterial({
            color,
            side: kind === "deck" ? THREE.DoubleSide : THREE.FrontSide,
          });
    made.name = key;
    this.materials.set(key, made);
    return made;
  }

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

  /** Merge every bucket and hang the results off `into`. */
  flush(into: THREE.Group): void {
    for (const bucket of this.buckets.values()) {
      const merged =
        bucket.parts.length === 1 ? bucket.parts[0] : mergeGeometries(bucket.parts, false);
      // `mergeGeometries` returns null when the attribute sets disagree. Losing
      // the bucket silently is exactly the failure the module comment warns
      // about, so say so rather than rendering a road with no markings on it.
      if (!merged) {
        console.warn(`structures: "${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();
  }
}

/** Resample a lat/lng path into scene-space points that ride the ground. */
function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.14): THREE.Vector3[] {
  const out: THREE.Vector3[] = [];
  for (let i = 0; i < path.length - 1; i++) {
    const from = path[i];
    const to = path[i + 1];
    if (!from || !to) continue;
    const [lat0, lng0] = from;
    const [lat1, lng1] = to;
    const steps = i === path.length - 2 ? samplesPerLeg : samplesPerLeg - 1;
    for (let s = 0; s <= steps; s++) {
      const t = s / samplesPerLeg;
      const lat = lat0 + (lat1 - lat0) * t;
      const lng = lng0 + (lng1 - lng0) * t;
      const [x, z] = world.project(lat, lng);
      out.push(new THREE.Vector3(x, world.groundAt(lat, lng) + lift, z));
    }
  }
  return out;
}

/**
 * A draped strip running between two parallel offsets from a path, each at its
 * own lateral distance and its own height.
 *
 * The flat symmetric case is a road deck; the asymmetric case is an embankment
 * batter, and it is the reason this generalised. A ribbon whose two rails sit
 * at different heights has a **tilted normal**, which is the entire mechanism
 * by which a freeway stops reading as a line drawn on the ground: the crown
 * catches the sun and the two flanks do not, so the corridor has a lit edge and
 * a shaded one at every hour instead of being one flat value.
 *
 * The UVs run 0..1 across the strip and in **metres** along it, which is the
 * sane convention if anyone ever puts a surface texture on a road. Right now
 * nothing does, and they are here for a duller reason: `mergeGeometries` only
 * merges geometries whose attribute sets match exactly, so a strip without UVs
 * cannot share a bucket with the tube barriers beside it.
 */
function bandGeometry(
  points: readonly THREE.Vector3[],
  offsetA: number,
  liftA: number,
  offsetB: number,
  liftB: number,
): THREE.BufferGeometry {
  /**
   * The rail at the larger offset is always emitted first, whichever order the
   * caller wrote them in.
   *
   * This is not tidiness. These strips are `deck` material, which is
   * `DoubleSide`, and three.js negates the shading normal on a back face — so a
   * strip whose two rails arrive in the opposite order to its neighbours has
   * reversed winding, gets its up-pointing normal turned to face the ground,
   * and renders as an unlit black band. That is exactly what the right-hand
   * embankment did the first time it was built from `side * 1.75` and
   * `side * 2.3`: on the `-1` side those two offsets are in decreasing order,
   * and a black stripe ran the length of US-101.
   */
  const ordered = offsetA >= offsetB;
  const leftOffset = ordered ? offsetA : offsetB;
  const leftLift = ordered ? liftA : liftB;
  const rightOffset = ordered ? offsetB : offsetA;
  const rightLift = ordered ? liftB : liftA;
  const positions: number[] = [];
  const normals: number[] = [];
  const uvs: number[] = [];
  const indices: number[] = [];
  let along = 0;

  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 tx = dx / length;
    const tz = dz / length;
    // Left of travel, in the ground plane.
    const nx = -tz;
    const nz = tx;
    if (index > 0) along += point.distanceTo(previous);

    // The across-vector from the right rail to the left one, in three
    // dimensions. Crossed with the tangent it gives the strip's true normal;
    // the sign flip keeps that normal pointing at the sky whichever way round
    // the two offsets were handed in.
    const ax = nx * (leftOffset - rightOffset);
    const ay = leftLift - rightLift;
    const az = nz * (leftOffset - rightOffset);
    let mx = ay * tz - az * 0;
    let my = az * tx - ax * tz;
    let mz = ax * 0 - ay * tx;
    const mLength = Math.hypot(mx, my, mz) || 1;
    mx /= mLength;
    my /= mLength;
    mz /= mLength;
    if (my < 0) {
      mx = -mx;
      my = -my;
      mz = -mz;
    }

    positions.push(
      point.x + nx * leftOffset, point.y + leftLift, point.z + nz * leftOffset,
      point.x + nx * rightOffset, point.y + rightLift, point.z + nz * rightOffset,
    );
    normals.push(mx, my, mz, mx, my, mz);
    uvs.push(0, along, 1, along);
    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("normal", new THREE.Float32BufferAttribute(normals, 3));
  geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
  geometry.setIndex(indices);
  geometry.computeBoundingSphere();
  return geometry;
}

/** A draped, flat road deck. A tube turns a freeway into a raised pipeline. */
function roadRibbonGeometry(
  points: readonly THREE.Vector3[],
  width: number,
  lift = 0,
): THREE.BufferGeometry {
  return bandGeometry(points, width / 2, lift, -width / 2, lift);
}

function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vector3[] {
  return points.map((point, index) => {
    const previous = points[Math.max(0, index - 1)] ?? point;
    const next = points[Math.min(points.length - 1, index + 1)] ?? point;
    const dx = next.x - previous.x;
    const dz = next.z - previous.z;
    const length = Math.hypot(dx, dz) || 1;
    return new THREE.Vector3(point.x + (-dz / length) * offset, point.y, point.z + (dx / length) * offset);
  });
}

/** Merge alternating path spans into one dashed marking geometry. */
function dashedRibbonGeometry(
  points: readonly THREE.Vector3[],
  offset: number,
  width: number,
): THREE.BufferGeometry {
  const shifted = offsetPath(points, offset);
  const positions: number[] = [];
  const uvs: number[] = [];
  const indices: number[] = [];
  for (let index = 0; index < shifted.length - 1; index += 2) {
    const a = shifted[index];
    const spanEnd = shifted[Math.min(index + 1, shifted.length - 1)];
    if (!a || !spanEnd) continue;
    const b = a.clone().lerp(spanEnd, 0.44);
    const dx = b.x - a.x;
    const dz = b.z - a.z;
    const length = Math.hypot(dx, dz) || 1;
    const nx = (-dz / length) * width / 2;
    const nz = (dx / length) * width / 2;
    const base = positions.length / 3;
    positions.push(
      a.x + nx, a.y + 0.035, a.z + nz,
      a.x - nx, a.y + 0.035, a.z - nz,
      b.x + nx, b.y + 0.035, b.z + nz,
      b.x - nx, b.y + 0.035, b.z - nz,
    );
    uvs.push(0, 0, 1, 0, 0, 1, 1, 1);
    indices.push(base, base + 2, base + 1, base + 1, base + 2, base + 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;
}

// ---- The road surface ------------------------------------------------------

/**
 * What a road is made of, drawn once on a canvas and mapped across every ribbon
 * on the board.
 *
 * The defect this exists for: a city-pack road was one flat mid-grey ribbon the
 * width of the carriageway, and at 94 m to the scene unit that is three or four
 * pixels of unbroken value lying exactly on the terrain. It read as a line
 * somebody drew on the map rather than as a road, which is precisely the
 * complaint — a wireframe overlay, not a surface.
 *
 * The fix is a cross-section rather than more geometry. The ribbon is widened by
 * half, the extra going to a graded verge, and this texture paints the whole
 * width: verge, shoulder, edge line, lanes, and the median. The eye then reads
 * *pale / dark / pale* with a warm line down the middle, which is what a road
 * looks like from a mile up, and it costs no triangles at all — the alternative,
 * a ribbon per stripe, is what `createFreewayWorld` does for the one corridor
 * you can drive down, and it costs eight ribbons a carriageway.
 *
 * Two conventions the callers depend on:
 *
 *   - **u runs 0..1 across the ribbon** and the widening is symmetric, so the
 *     fractions below are the same for a 19 m street and a 40 m freeway.
 *   - **v is distance along in scene units**, which is what `bandGeometry` and
 *     the bridge deck both write, so the repeat is set from `metresPerUnit` and
 *     the dash cycle comes out the same length in metres on every board.
 */
const ROAD_WIDEN = { freeway: 1.9, street: 1.5, bridge: 1 } as const;
/** Metres of road per vertical repeat of the texture — one dash cycle. */
const ROAD_CYCLE_M = 24;

function paintRoadSurface(
  context: CanvasRenderingContext2D,
  width: number,
  height: number,
  kind: "freeway" | "street" | "bridge",
): void {
  const across = (fraction: number) => fraction * width;
  /**
   * How much of the ribbon is verge rather than pavement — and deliberately not
   * `(1 - 1/ROAD_WIDEN)/2`, which is where the widening actually went.
   *
   * A texture's far mip is its average colour, and that average is what a road
   * three pixels wide *is*. Giving the verge the whole of the widening made it
   * 47% of the width, so the average came out olive and the freeways on the
   * SoCal board drew as gold threads across the harbour. Keeping the verge to a
   * narrow graded strip and putting the rest of the widening into pavement
   * leaves the average a grey, which is what a road is.
   */
  const verge = kind === "bridge" ? 0 : 0.13;
  // Not the near-black asphalt looks like from a car. A road seen from a mile
  // up is a mid grey — the first pass used a true kerbside value and drew the
  // peninsula as a line of ink across pale sand.
  const asphalt = kind === "freeway" ? "#454a4d" : "#4c4f51";

  if (kind === "bridge") {
    // A bridge deck has no verge to grade: the roadway runs to the edge of the
    // structure and stops. Filling the margins with asphalt rather than earth is
    // what keeps the deck from appearing to have soft shoulders over water.
    context.fillStyle = asphalt;
    context.fillRect(0, 0, width, height);
  } else {
    // A shade greener and darker than the flats it is cut into. A verge the
    // colour of the ground is not a verge — the first pass painted one and the
    // corridor still read as a single dark stroke on pale sand.
    context.fillStyle = "#7b7358";
    context.fillRect(0, 0, width, height);
    context.fillStyle = asphalt;
    context.fillRect(across(verge), 0, across(1 - 2 * verge), height);
  }

  const inner = kind === "bridge" ? 0.06 : verge;
  // Shoulders: a lighter, dustier strip inside each edge. This is the band that
  // does the most work at distance — it is what separates the dark carriageway
  // from the ground on both sides at every sun angle.
  // Pale, not another shade of asphalt. This is the band that carries the road
  // at distance: dark carriageway between two light edges is what the eye reads
  // as a road from a mile up, and a shoulder within a few values of the asphalt
  // leaves one thick dark line instead.
  context.fillStyle = kind === "freeway" ? "#8b8d87" : "#7f817c";
  context.fillRect(across(inner), 0, across(0.055), height);
  context.fillRect(across(1 - inner - 0.055), 0, across(0.055), height);

  // Speckle. Asphalt that is one exact value reads as plastic the moment the
  // camera comes down to deck height on a bridge or a drive chapter.
  let seed = 0x9e3779b9;
  const random = () => {
    seed = (seed * 1664525 + 1013904223) >>> 0;
    return seed / 0xffffffff;
  };
  context.globalAlpha = 0.14;
  for (let i = 0; i < width * height * 0.05; i += 1) {
    context.fillStyle = random() > 0.5 ? "#5a6063" : "#22272a";
    context.fillRect(Math.floor(random() * width), Math.floor(random() * height), 1, 1);
  }
  context.globalAlpha = 1;

  const line = Math.max(1, Math.round(width * 0.012));
  const edge = inner + 0.062;
  context.fillStyle = "#e6e9e4";
  context.fillRect(across(edge), 0, line, height);
  context.fillRect(across(1 - edge) - line, 0, line, height);

  const dash = (x: number, colour: string) => {
    context.fillStyle = colour;
    // 3 m of paint in a 12 m cycle, which is the US standard and reads as
    // dashes rather than as a second solid line right down to a few pixels.
    for (const start of [0, 0.5]) {
      context.fillRect(x, (start + 0.02) * height, line, height * 0.21);
    }
  };

  if (kind === "street") {
    dash(across(0.5) - line / 2, "#e6e9e4");
    return;
  }
  // Divided highway: two yellow lines down the middle, and a lane divider in
  // each carriageway.
  context.fillStyle = "#e0bb52";
  context.fillRect(across(0.5) - line * 2, 0, line, height);
  context.fillRect(across(0.5) + line, 0, line, height);
  const laneInner = kind === "bridge" ? 0.2 : verge + 0.1;
  dash(across(laneInner + (0.5 - laneInner) * 0.5), "#e6e9e4");
  dash(across(1 - laneInner - (0.5 - laneInner) * 0.5), "#e6e9e4");
}

/**
 * The material a road ribbon or a bridge deck wears.
 *
 * Falls back to a flat colour where there is no DOM, which is every test in this
 * repo: `check-no-binaries.mjs` means every texture on the board is drawn at
 * runtime, and a module that can only build its materials in a browser cannot be
 * unit-tested at all.
 */
function roadSurfaceMaterial(
  metresPerUnit: number,
  kind: "freeway" | "street" | "bridge",
): THREE.Material {
  const flat = kind === "street" ? 0x6f6a5c : 0x4a4e50;
  if (typeof document === "undefined") {
    return new THREE.MeshLambertMaterial({ color: flat, side: THREE.DoubleSide });
  }
  const canvas = document.createElement("canvas");
  canvas.width = 96;
  canvas.height = 192;
  const context = canvas.getContext("2d");
  if (!context) return new THREE.MeshLambertMaterial({ color: flat, side: THREE.DoubleSide });
  paintRoadSurface(context, canvas.width, canvas.height, kind);
  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  texture.wrapS = THREE.ClampToEdgeWrapping;
  texture.wrapT = THREE.RepeatWrapping;
  // v arrives in scene units; the cycle is authored in metres so that a dash is
  // the same length on a 94 m board and a 391 m one.
  texture.repeat.set(1, metresPerUnit / ROAD_CYCLE_M);
  texture.anisotropy = 8;
  texture.needsUpdate = true;
  const material = new THREE.MeshLambertMaterial({ map: texture, side: THREE.DoubleSide });
  material.name = `road:${kind}`;
  // `Material.dispose()` does not free the material's textures, and the scene's
  // teardown sweep only reaches materials — so a board switch would orphan one
  // canvas per road class on the GL context, which is the exact arithmetic
  // `scene.ts` records for the renderer itself. Three's materials are event
  // dispatchers and emit `dispose`, so the texture can simply follow its owner.
  material.addEventListener("dispose", () => texture.dispose());
  return material;
}

function makeShieldMaterial(identity: "us-highway" | "interstate", shield: string): THREE.Material {
  if (typeof document === "undefined") {
    return new THREE.MeshBasicMaterial({ color: identity === "interstate" ? 0x2d5b8c : 0xe8edf0 });
  }
  const canvas = document.createElement("canvas");
  canvas.width = 256;
  canvas.height = 192;
  const context = canvas.getContext("2d");
  if (!context) return new THREE.MeshBasicMaterial({ color: 0xe8edf0 });
  context.fillStyle = identity === "interstate" ? "#174b80" : "#f4f5ef";
  context.fillRect(6, 6, 244, 180);
  context.lineWidth = 12;
  context.strokeStyle = identity === "interstate" ? "#f3f5f7" : "#151b20";
  context.strokeRect(6, 6, 244, 180);
  context.fillStyle = identity === "interstate" ? "#f3f5f7" : "#151b20";
  context.font = "700 52px ui-monospace, monospace";
  context.textAlign = "center";
  context.fillText(identity === "interstate" ? "INTERSTATE" : "US", 128, 63);
  context.font = "800 86px ui-monospace, monospace";
  context.fillText(shield, 128, 151);
  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  texture.needsUpdate = true;
  const material = new THREE.MeshBasicMaterial({ map: texture, toneMapped: false, side: THREE.DoubleSide });
  // As above: the shield canvas follows the material it belongs to, or every
  // board switch leaves one per route on the context.
  material.addEventListener("dispose", () => texture.dispose());
  return material;
}

/**
 * The atlas berm width, in scene units. Every other atlas offset is a fraction
 * of this, so a metre corridor is the same drawing at `widthM / ATLAS_BERM_WIDTH`.
 */
const ATLAS_BERM_WIDTH = 3.5;

/** Atlas symbol vs the road as it is on the ground. See `createFreewayWorld`. */
export type FreewayScale = "atlas" | "metre";

/**
 * Browser-feasible authored freeway world. Geometry is deliberately batched by
 * road/marking class: the corridor gains lane-scale readability without one
 * draw call per reflector, tree, or roadside prop.
 *
 * Two scales, because one board now has two jobs. **Atlas** is the 4.7 km
 * glyph that reads as a corridor from the state pose — the drawing
 * `createFreewayWorld` was authored as. **Metre** is the same cross-section
 * restated so the berm is `road.widthM` (44 m on US-101), which is what a
 * chase camera and a descended city can actually look at. Scene units of the
 * atlas are a constant that assumed one board; putting cities on that board
 * made the glyph wider than the Golden Gate. Withdrawing it close in was the
 * half-measure; this is the other half. The two are separate groups so the
 * LOD can show one and not the other without rebuilding, and so the atlas
 * tests keep counting the atlas.
 */
export function createFreewayWorld(
  world: World,
  pack: TransportPack,
  scale: FreewayScale = "atlas",
): THREE.Group {
  const metre = scale === "metre";
  const mpu = world.metresPerUnit;
  if (metre && !(mpu > 0)) {
    throw new Error("createFreewayWorld(\"metre\") needs world.metresPerUnit");
  }
  const u = (m: number) => m / mpu;
  const group = new THREE.Group();
  group.name = metre ? "freeway-world-metre" : "freeway-world-v2";
  const plan = buildFreewayWorldPlan(pack);
  group.userData.planSeed = plan.seed;
  group.userData.scale = scale;

  const batch = new Batch();
  const asphalt = [0x353a3d, 0x303538];
  const shoulder = [0x555759, 0x4e5153];
  const berm = [0x8d8a66, 0x9a8c62];
  // One shadow colour for both corridors' batters. Two would be one more
  // material and one more draw call for a difference nobody can see on a
  // surface that is, by construction, the part of the corridor facing away
  // from the sun.
  const batter = 0x5f5740;
  const barrierMaterial = new THREE.MeshLambertMaterial({ color: 0xb6b4aa });
  const guardMaterial = new THREE.MeshStandardMaterial({ color: 0x9fa8aa, metalness: 0.64, roughness: 0.42 });
  const reflectorMaterial = new THREE.MeshBasicMaterial({ color: 0xf7e3a0, toneMapped: false });
  const reflectorGeometry = metre
    ? new THREE.BoxGeometry(u(0.18), u(0.1), u(0.28))
    : new THREE.BoxGeometry(0.018, 0.01, 0.028);
  const treeTrunkMaterial = new THREE.MeshLambertMaterial({ color: 0x66513b });
  const treeCrownMaterials = [
    new THREE.MeshLambertMaterial({ color: 0x3f5942 }),
    new THREE.MeshLambertMaterial({ color: 0x687347 }),
  ];
  const trunkGeometry = metre
    ? new THREE.CylinderGeometry(u(0.55), u(0.75), u(5.5), 5)
    : new THREE.CylinderGeometry(0.045, 0.06, 0.45, 5);
  const crownGeometry = metre
    ? new THREE.IcosahedronGeometry(u(7), 0)
    : new THREE.IcosahedronGeometry(0.28, 0);
  const poleGeometry = metre
    ? new THREE.CylinderGeometry(u(0.22), u(0.3), u(9), 5)
    : new THREE.CylinderGeometry(0.022, 0.03, 0.72, 5);
  const siloGeometry = metre
    ? new THREE.CylinderGeometry(u(2.2), u(2.5), u(8), 8)
    : new THREE.CylinderGeometry(0.14, 0.16, 0.55, 8);
  const roadsideFeatures = plan.routes.reduce((sum, route) => sum + route.roadside.length, 0);
  const trunks = new THREE.InstancedMesh(trunkGeometry, treeTrunkMaterial, roadsideFeatures);
  const coastalCrowns = new THREE.InstancedMesh(crownGeometry, treeCrownMaterials[0]!, roadsideFeatures);
  const orchardCrowns = new THREE.InstancedMesh(crownGeometry, treeCrownMaterials[1]!, roadsideFeatures);
  const poles = new THREE.InstancedMesh(poleGeometry, guardMaterial, roadsideFeatures);
  const silos = new THREE.InstancedMesh(siloGeometry, barrierMaterial, roadsideFeatures);
  trunks.name = "freeway:roadside-trunks";
  coastalCrowns.name = "freeway:coastal-oaks";
  orchardCrowns.name = "freeway:orchards";
  poles.name = "freeway:power-poles";
  silos.name = "freeway:valley-silos";
  let trunkCount = 0;
  let coastalCrownCount = 0;
  let orchardCrownCount = 0;
  let poleCount = 0;
  let siloCount = 0;
  const dummy = new THREE.Object3D();
  const reflectorMatrices: THREE.Matrix4[] = [];

  world.city.roads.forEach((road, roadIndex) => {
    if (road.kind !== "freeway") return;
    const pavedM = road.widthM ?? road.width * (mpu || 1);
    const k = metre ? pavedM / mpu / ATLAS_BERM_WIDTH : 1;
    const s = (n: number) => n * k;
    const path = drapePath(world, road.path, 52, s(0.115));
    const route = plan.routes[roadIndex];
    const identityIndex = route?.identity === "interstate" ? 1 : 0;
    const routePath = route ? buildRoutePath(pack, route.routeId) : null;
    /**
     * The earthwork, as a crown and two batters rather than one flat ribbon.
     *
     * This is the fix for the defect that mattered most on the California
     * board: at 1,919 m to the scene unit the whole corridor is about eleven
     * pixels wide from the default camera, and eleven pixels of flat mid-grey
     * lying exactly on the ground reads as a line somebody drew on the map, not
     * as a road. Three things change that, and none of them is width for its
     * own sake:
     *
     *   - **A graded right-of-way that is not the colour of the asphalt.** The
     *     crown runs out to ±1.75 in dry cut earth, so the corridor arrives as
     *     pale / dark / pale instead of as one dark stroke, and the eye reads
     *     three bands where it used to read one line.
     *   - **Batters with a real normal.** The flanks fall 0.09 units over 0.55,
     *     which is about nine degrees — enough that Lambert separates them from
     *     the crown at every sun angle, and enough that at dusk the corridor has
     *     a lit side and a shaded side.
     *   - **Sitting slightly proud of the ground.** The crown is at -0.02
     *     rather than -0.09, so the earthwork is a causeway across the flats
     *     rather than a trench cut into them.
     *
     * All three survive the drive chapters, where the same geometry is two
     * hundred pixels of verge and a shallow embankment falling away to the
     * fields — which is what US-101 through the Salinas Valley actually looks
     * like out of a car window.
     */
    const bermMaterial = batch.material("deck", berm[identityIndex] ?? berm[0]!);
    batch.add("freeway:berm", roadRibbonGeometry(path, s(ATLAS_BERM_WIDTH), s(-0.02)), bermMaterial);
    const batterMaterial = batch.material("deck", batter);
    for (const side of [-1, 1] as const) {
      batch.add(
        "freeway:embankment",
        bandGeometry(path, side * s(1.75), s(-0.02), side * s(2.3), s(-0.11)),
        batterMaterial,
      );
    }
    for (const side of [-1, 1] as const) {
      batch.add(
        "freeway:shoulder",
        roadRibbonGeometry(offsetPath(path, side * s(0.64)), s(1.18), s(0.004)),
        batch.material("deck", shoulder[identityIndex] ?? shoulder[0]!),
      );
      batch.add(
        "freeway:carriageway",
        roadRibbonGeometry(offsetPath(path, side * s(0.64)), s(1.03), s(0.012)),
        batch.material("deck", asphalt[identityIndex] ?? asphalt[0]!),
      );
      /**
       * Inner yellow edge, two lane dividers, outer white shoulder edge — and
       * every one of them `marking`, which is to say unlit.
       *
       * The two edge lines used to be `deck`, which is Lambert, and that is the
       * single thing that made this corridor unreadable after dark: at 21:15 the
       * `drive-101` frame was a black rectangle with the lane dashes floating in
       * it, because the dashes were already `marking` and the lines that
       * actually define where the road *is* were being shaded by a sun eight
       * degrees under the horizon.
       *
       * Unlit is not a cheat here, it is the more honest of the two. Road paint
       * is retroreflective — that is its entire specification — so at night it
       * returns the driver's own light back along the axis it arrived on and is
       * very nearly the *only* thing out on an unlit corridor that does. A lit
       * material models it as matte chalk and dims it into the asphalt exactly
       * when a real edge line is at its most visible. `sfo`'s night frame
       * already makes the same argument for runway markings, in the shot list's
       * own words: after sunset the airport is a diagram of itself.
       */
      batch.add(
        "freeway:edge-line",
        roadRibbonGeometry(offsetPath(path, side * s(0.12)), s(0.026), s(0.038)),
        batch.material("marking", 0xf0c84f),
      );
      batch.add(
        "freeway:edge-line",
        roadRibbonGeometry(offsetPath(path, side * s(1.16)), s(0.026), s(0.038)),
        batch.material("marking", 0xe8ece8),
      );
      const dashes = batch.material("marking", 0xf4f4ec);
      batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * s(0.47), s(0.022)), dashes);
      batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * s(0.81), s(0.022)), dashes);
      const guardPath = offsetPath(path, side * s(1.27));
      /**
       * One tubular segment per draped sample, and three sides, not five.
       *
       * `drapePath` already samples every leg 52 times — roughly a point per
       * kilometre along a 700 km corridor — so a tube at `length * 2` was
       * subdividing an interval nothing curves inside. Between the four
       * guardrails and the four median walls that was 82,000 triangles, an
       * eighth of the whole board's budget, spent on two objects that are a
       * hairline from the state camera and a thin grey rail from the chase
       * camera. Halving the segments and dropping two radial sides gives back
       * 55,000 of them, which is what pays for the state's relief and its
       * cities; a five-sided 25 mm-radius tube and a three-sided one are the
       * same handful of pixels at both distances this corridor is ever seen
       * from.
       *
       * It also stopped casting. A shadow caster is drawn twice, and what this
       * one casts is the shadow of a fifty-metre pipe standing in for a
       * half-metre rail — a fiction lying a few centimetres from the object
       * that threw it, at both distances this corridor is seen from. The sign
       * posts still cast, because a sign standing clear of the road is the one
       * roadside object whose shadow tells you where the ground is.
       */
      batch.add(
        "freeway:outer-guardrail",
        new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length), s(0.025), 3, false),
        guardMaterial,
      );
    }
    // Low concrete median walls keep both carriageways visually independent.
    for (const side of [-1, 1] as const) {
      const medianPath = offsetPath(path, side * s(0.075)).map((point) => point.clone().setY(point.y + s(0.065)));
      batch.add(
        "freeway:median-barrier",
        new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length), s(0.055), 3, false),
        barrierMaterial,
      );
    }
    // Retroreflectors are instanced and restrained, never roadside light blobs.
    // The matrices are collected across every corridor and committed to one
    // `InstancedMesh` after the loop, because two corridors' worth of the same
    // 0.018 m box is two draw calls for something nobody can resolve.
    //
    // Every sixth sample rather than every second: 2,296 boxes were 27,500
    // triangles for studs the chase camera sees a dozen of at a time and the
    // state camera cannot resolve at all. At this stride they are still about
    // one every seven kilometres of a road whose lanes are two kilometres wide,
    // and 20,000 triangles come back to the relief and the cities.
    const reflectorStride = 6;
    const reflectorPoints = path.filter((_, index) => index % reflectorStride === 0);
    for (const pointIndex of reflectorPoints.keys()) {
      const point = reflectorPoints[pointIndex];
      if (!point) continue;
      for (const offset of [-0.81, -0.47, 0.47, 0.81].map(s)) {
        const shifted = offsetPath(path, offset)[pointIndex * reflectorStride] ?? point;
        dummy.position.set(shifted.x, shifted.y + s(0.055), shifted.z);
        dummy.rotation.set(0, 0, 0);
        dummy.scale.setScalar(1);
        dummy.updateMatrix();
        reflectorMatrices.push(dummy.matrix.clone());
      }
    }

    if (!route || !routePath) return;
    const shieldMaterial = makeShieldMaterial(route.identity, route.shield);
    for (const feature of route.roadside) {
      const sample = sampleRoute(routePath, feature.distanceM);
      const [x, z] = world.project(sample.lat, sample.lng);
      const heading = (sample.headingDeg * Math.PI) / 180;
      const sceneSetback = metre
        ? u(pavedM / 2 + feature.setbackM)
        : feature.kind === "route-sign" ? 1.42 : 1.7 + feature.setbackM * 0.014;
      const px = x + Math.cos(heading) * sceneSetback * feature.side;
      const pz = z + Math.sin(heading) * sceneSetback * feature.side;
      const ground = world.groundAt(sample.lat, sample.lng);
      if (feature.kind === "route-sign") {
        // Baked into world space rather than parented under a per-sign `Group`.
        // Nine signs used to be nine groups of two meshes; they are now two
        // meshes for the whole route, and the shield's own name survives on the
        // board so the scene graph still says which route it belongs to.
        const post = new THREE.BoxGeometry(s(0.035), s(0.62), s(0.035));
        post.translate(px, ground + s(0.08) + s(0.31), pz);
        batch.add("freeway:sign-post", post, guardMaterial, { cast: true });

        const board = new THREE.PlaneGeometry(s(0.42), s(0.31));
        board.rotateY(-heading + (feature.side === 1 ? Math.PI : 0));
        board.translate(px, ground + s(0.08) + s(0.69), pz);
        batch.add(`freeway:sign:${route.shield}`, board, shieldMaterial);
        continue;
      }
      const visualScale = metre ? feature.scale : feature.scale * 0.58;
      const halfHeight = metre
        ? feature.kind === "power-pole" ? u(4.5) : feature.kind === "silo" ? u(4) : u(2.75)
        : feature.kind === "power-pole" ? 0.36 : feature.kind === "silo" ? 0.275 : 0.225;
      dummy.position.set(px, ground + halfHeight * (metre ? 1 : visualScale), pz);
      dummy.rotation.set(0, heading + feature.scale, 0);
      dummy.scale.setScalar(visualScale);
      dummy.updateMatrix();
      if (feature.kind === "power-pole") poles.setMatrixAt(poleCount++, dummy.matrix);
      else if (feature.kind === "silo") silos.setMatrixAt(siloCount++, dummy.matrix);
      else {
        trunks.setMatrixAt(trunkCount++, dummy.matrix);
        dummy.position.y += metre ? u(4) * feature.scale : 0.25 * feature.scale;
        dummy.scale.set(
          metre ? visualScale : feature.scale * 0.7,
          metre ? visualScale * 0.7 : feature.scale * 0.5,
          metre ? visualScale * 0.85 : feature.scale * 0.62,
        );
        dummy.updateMatrix();
        if (feature.kind === "oak") coastalCrowns.setMatrixAt(coastalCrownCount++, dummy.matrix);
        else orchardCrowns.setMatrixAt(orchardCrownCount++, dummy.matrix);
      }
    }
  });
  batch.flush(group);

  const reflectors = new THREE.InstancedMesh(
    reflectorGeometry,
    reflectorMaterial,
    Math.max(1, reflectorMatrices.length),
  );
  reflectors.name = "freeway:reflectors";
  reflectorMatrices.forEach((matrix, index) => reflectors.setMatrixAt(index, matrix));
  reflectors.count = reflectorMatrices.length;
  group.add(reflectors);

  trunks.count = trunkCount;
  poles.count = poleCount;
  silos.count = siloCount;
  coastalCrowns.count = coastalCrownCount;
  orchardCrowns.count = orchardCrownCount;
  group.add(trunks, coastalCrowns, orchardCrowns, poles, silos);
  return group;
}

/**
 * Every road on a city board, as two draw calls.
 *
 * This used to be a flat ribbon the width of the carriageway plus, on a freeway,
 * a second ribbon of warm paint down the middle — three meshes, and a corridor
 * that read as a line on a map. It is now one ribbon half again wider carrying
 * the cross-section as a texture: the verge, the shoulders, the edge lines and
 * the median are all paint, so the road gained a surface and *lost* a third of
 * its triangles along with a draw call. See `paintRoadSurface`.
 *
 * The one corridor you can drive down is not built here — `createFreewayWorld`
 * builds that one out of real ribbons, because at chase-camera height a texture
 * is a texture and an embankment has to have a normal.
 */
export function createRoads(world: World): THREE.Group {
  const group = new THREE.Group();
  group.name = "roads";
  const batch = new Batch();
  const surfaces = {
    freeway: roadSurfaceMaterial(world.metresPerUnit, "freeway"),
    street: roadSurfaceMaterial(world.metresPerUnit, "street"),
  };
  for (const road of world.city.roads) {
    const path = drapePath(world, road.path);
    batch.add(
      `road:${road.kind}`,
      roadRibbonGeometry(path, road.width * ROAD_WIDEN[road.kind]),
      surfaces[road.kind],
    );
  }
  batch.flush(group);
  return group;
}

/**
 * One bridge, as the kit in `bridges.ts` builds it: a deck box, tapered towers
 * with portal bracing, main cables in parabola over the tower tops, hangers, and
 * piers under whatever is not suspended from anything.
 *
 * Two meshes come out, not one. The structure is painted the bridge's own colour
 * — International Orange, or the Bay Bridge's grey — and the roadway on top of
 * it is the same asphalt surface every road on the board wears, because a bridge
 * deck is a road and painting it orange was the single thing most responsible
 * for the Golden Gate reading as a red line rather than as a crossing.
 *
 * The material cache is deliberately per call and not module-level:
 * `createScene().dispose()` walks the scene disposing every material it finds,
 * so a cache that outlived one build would hand the next board a disposed
 * material and render it black.
 */
export function createBridge(world: World, bridge: Bridge, roadway?: THREE.Material): THREE.Group {
  const group = new THREE.Group();
  group.name = bridge.name;
  const batch = new Batch();
  buildBridge(world, bridge, batch, roadway ?? roadSurfaceMaterial(world.metresPerUnit, "bridge"));
  batch.flush(group);
  return group;
}

/**
 * Every crossing on a board, in one batch.
 *
 * One `Batch` across all of them rather than one each, which is what makes the
 * Bay Area's five crossings six draw calls instead of ten. Buckets are keyed on
 * material *and* name, so this loses nothing: each bridge's structure has both
 * its own colour and its own name and stays its own mesh — the scene graph still
 * says which one is the Bay Bridge — while five decks of identical asphalt,
 * sharing a material and a bucket name, merge into a single roadway.
 */
export function createBridges(world: World): THREE.Group {
  const group = new THREE.Group();
  group.name = "bridges";
  const batch = new Batch();
  const roadway = roadSurfaceMaterial(world.metresPerUnit, "bridge");
  for (const bridge of world.city.bridges) buildBridge(world, bridge, batch, roadway);
  batch.flush(group);
  return group;
}
