/**
 * Bridges and roads — 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.
 *
 * ### 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. That is why the ribbons below carry
 * UVs they have no texture for.
 */

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 type { Bridge, LatLng } from "./types.ts";
import type { World } from "./world.ts";

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

/**
 * The three ways a surface out here is shaded.
 *
 * `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.
 */
type SurfaceKind = "deck" | "solid" | "marking";

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. */
  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 tube swept along a path — a bridge deck, a cable, a barrier. */
function tubeGeometry(points: THREE.Vector3[], width: number, radial = 4): THREE.BufferGeometry {
  const curve = new THREE.CatmullRomCurve3(points);
  return new THREE.TubeGeometry(curve, points.length * 2, width / 2, radial, false);
}

/**
 * A draped, flat road deck. A tube turns a freeway into a raised pipeline.
 *
 * The UVs run 0..1 across the carriageway 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 roadRibbonGeometry(
  points: readonly THREE.Vector3[],
  width: number,
  lift = 0,
): THREE.BufferGeometry {
  const positions: number[] = [];
  const normals: number[] = [];
  const uvs: number[] = [];
  const indices: number[] = [];
  const half = width / 2;
  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 nx = -dz / length;
    const nz = dx / length;
    if (index > 0) along += point.distanceTo(previous);
    positions.push(
      point.x + nx * half, point.y + lift, point.z + nz * half,
      point.x - nx * half, point.y + lift, point.z - nz * half,
    );
    normals.push(0, 1, 0, 0, 1, 0);
    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;
}

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

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;
  return new THREE.MeshBasicMaterial({ map: texture, toneMapped: false, side: THREE.DoubleSide });
}

/**
 * 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.
 */
export function createFreewayWorld(world: World, pack: TransportPack): THREE.Group {
  const group = new THREE.Group();
  group.name = "freeway-world-v2";
  const plan = buildFreewayWorldPlan(pack);
  group.userData.planSeed = plan.seed;

  const batch = new Batch();
  const asphalt = [0x353a3d, 0x303538];
  const shoulder = [0x555759, 0x4e5153];
  const berm = [0x64705c, 0x74674c];
  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 = 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 = new THREE.CylinderGeometry(0.045, 0.06, 0.45, 5);
  const crownGeometry = new THREE.IcosahedronGeometry(0.28, 0);
  const poleGeometry = new THREE.CylinderGeometry(0.022, 0.03, 0.72, 5);
  const siloGeometry = 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 path = drapePath(world, road.path, 52, 0.115);
    const route = plan.routes[roadIndex];
    const identityIndex = route?.identity === "interstate" ? 1 : 0;
    const routePath = route ? buildRoutePath(pack, route.routeId) : null;
    // Broad earthwork under separate decks makes grade and curve changes read.
    batch.add(
      "freeway:berm",
      roadRibbonGeometry(path, 2.75, -0.09),
      batch.material("deck", berm[identityIndex] ?? berm[0]!),
    );
    for (const side of [-1, 1] as const) {
      batch.add(
        "freeway:shoulder",
        roadRibbonGeometry(offsetPath(path, side * 0.64), 1.18, 0.004),
        batch.material("deck", shoulder[identityIndex] ?? shoulder[0]!),
      );
      batch.add(
        "freeway:carriageway",
        roadRibbonGeometry(offsetPath(path, side * 0.64), 1.03, 0.012),
        batch.material("deck", asphalt[identityIndex] ?? asphalt[0]!),
      );
      // Inner yellow edge, two lane dividers, outer white shoulder edge.
      batch.add(
        "freeway:edge-line",
        roadRibbonGeometry(offsetPath(path, side * 0.12), 0.026, 0.038),
        batch.material("deck", 0xf0c84f),
      );
      batch.add(
        "freeway:edge-line",
        roadRibbonGeometry(offsetPath(path, side * 1.16), 0.026, 0.038),
        batch.material("deck", 0xe8ece8),
      );
      const dashes = batch.material("marking", 0xf4f4ec);
      batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.47, 0.022), dashes);
      batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.81, 0.022), dashes);
      const guardPath = offsetPath(path, side * 1.27);
      batch.add(
        "freeway:outer-guardrail",
        new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length * 2), 0.025, 5, false),
        guardMaterial,
        { cast: true },
      );
    }
    // Low concrete median walls keep both carriageways visually independent.
    for (const side of [-1, 1] as const) {
      const medianPath = offsetPath(path, side * 0.075).map((point) => point.clone().setY(point.y + 0.065));
      batch.add(
        "freeway:median-barrier",
        new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length * 2), 0.055, 4, 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.
    const reflectorPoints = path.filter((_, index) => index % 2 === 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]) {
        const shifted = offsetPath(path, offset)[pointIndex * 2] ?? point;
        dummy.position.set(shifted.x, shifted.y + 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 = 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(0.035, 0.62, 0.035);
        post.translate(px, ground + 0.08 + 0.31, pz);
        batch.add("freeway:sign-post", post, guardMaterial, { cast: true });

        const board = new THREE.PlaneGeometry(0.42, 0.31);
        board.rotateY(-heading + (feature.side === 1 ? Math.PI : 0));
        board.translate(px, ground + 0.08 + 0.69, pz);
        batch.add(`freeway:sign:${route.shield}`, board, shieldMaterial);
        continue;
      }
      const visualScale = feature.scale * 0.58;
      const halfHeight = feature.kind === "power-pole" ? 0.36 : feature.kind === "silo" ? 0.275 : 0.225;
      dummy.position.set(px, ground + halfHeight * 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 += 0.25 * feature.scale;
        dummy.scale.set(feature.scale * 0.7, feature.scale * 0.5, 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;
}

export function createRoads(world: World): THREE.Group {
  const group = new THREE.Group();
  group.name = "roads";
  const batch = new Batch();
  for (const road of world.city.roads) {
    const color = road.kind === "freeway" ? 0x7d7166 : 0x8b8578;
    const path = drapePath(world, road.path);
    batch.add("road:deck", roadRibbonGeometry(path, road.width), batch.material("deck", color));
    if (road.kind === "freeway") {
      // One warm median stroke is enough at corridor scale to read as divided
      // highway without spending a textured asset or a draw call per lane.
      batch.add(
        "road:median-stroke",
        roadRibbonGeometry(path, Math.max(0.025, road.width * 0.035), 0.012),
        batch.material("deck", 0xd7c27c),
      );
    }
  }
  batch.flush(group);
  return group;
}

/**
 * A suspension bridge: deck, towers, and a main cable sagging between them.
 *
 * The cable is the detail worth the code. Two orange towers with a straight
 * line between them read as a trestle; the catenary is what makes the shape at
 * the mouth of the bay unmistakably the Golden Gate.
 */
export function createBridge(world: World, bridge: Bridge): THREE.Group {
  const group = new THREE.Group();
  group.name = bridge.name;

  const deckY = world.metres(bridge.deckHeight);
  const towerY = world.metres(bridge.towerHeight);

  /**
   * One material for the whole bridge, and one mesh out of it.
   *
   * This used to read `const material = () => new THREE.MeshLambertMaterial(…)`
   * and be called once per part, so the Golden Gate arrived as about
   * thirty-four meshes with thirty-four identical materials — thirty-four draw
   * calls the sorter had to keep apart, for one orange object. Everything a
   * bridge is made of is painted the same colour, so everything a bridge is made
   * of belongs in one bucket.
   */
  const batch = new Batch();
  const paint = batch.material("solid", bridge.color);
  const part = (geometry: THREE.BufferGeometry) =>
    batch.add(bridge.name, geometry, paint, { cast: true });

  const deckPoints = bridge.path.map(([lat, lng]) => {
    const [x, z] = world.project(lat, lng);
    return new THREE.Vector3(x, deckY, z);
  });

  part(tubeGeometry(deckPoints, 0.5));

  const towerTops: THREE.Vector3[] = [];
  for (const [lat, lng] of bridge.towers) {
    const [x, z] = world.project(lat, lng);
    part(new THREE.BoxGeometry(0.34, towerY, 0.34).translate(x, towerY / 2, z));

    // Cross-braces, which is most of what you see of a tower at distance.
    for (const frac of [0.55, 0.82]) {
      part(new THREE.BoxGeometry(0.5, 0.16, 0.4).translate(x, towerY * frac, z));
    }
    towerTops.push(new THREE.Vector3(x, towerY, z));
  }

  const anchors = [deckPoints[0], ...towerTops, deckPoints[deckPoints.length - 1]];
  for (let i = 0; i < anchors.length - 1; i++) {
    const a = anchors[i];
    const b = anchors[i + 1];
    if (!a || !b) continue;
    const isMainSpan = i > 0 && i < anchors.length - 2;
    const sag = bridge.sag * towerY * (isMainSpan ? 1 : 0.42);

    const pts: THREE.Vector3[] = [];
    for (let s = 0; s <= 18; s++) {
      const t = s / 18;
      const p = a.clone().lerp(b, t);
      p.y -= Math.sin(t * Math.PI) * sag;
      pts.push(p);
    }
    part(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 24, 0.055, 5, false));

    // Vertical hangers down to the deck.
    for (let s = 2; s < 18; s += 2) {
      const t = s / 18;
      const p = a.clone().lerp(b, t);
      const top = p.y - Math.sin(t * Math.PI) * sag;
      if (top <= deckY + 0.2) continue;
      const h = top - deckY;
      part(new THREE.BoxGeometry(0.035, h, 0.035).translate(p.x, deckY + h / 2, p.z));
    }
  }

  batch.flush(group);
  return group;
}

export function createBridges(world: World): THREE.Group {
  const group = new THREE.Group();
  group.name = "bridges";
  for (const b of world.city.bridges) group.add(createBridge(world, b));
  return group;
}
