/**
 * Lumbridge EV-01: an original, unbadged procedural electric crossover, built
 * as a hero asset rather than as background traffic.
 *
 * The asset is authored in metres with its origin on the road at the centre of
 * the wheelbase. It faces −Z at yaw zero, matching the rest of Tera; +X is the
 * vehicle's right. Legacy `ModelX*` API names are retained as serialised and
 * realtime compatibility contracts, not as design intent — nothing here is
 * anyone's trademark, and `ARCHITECTURE.md` §3.1 is why every badge, grille
 * shape and plate in the file is abstract.
 *
 * ### Why this was rebuilt
 *
 * The first version was 160 triangles and read like it. Five things were wrong
 * and all five are worth naming, because they are the five things that separate
 * a car-shaped object from a car:
 *
 * 1. **A single `computeVertexNormals()` over the whole shell.** The comment
 *    claimed "deliberately strong shoulder highlights" and the code averaged
 *    every normal across every crease, so the shoulder line — the one horizontal
 *    highlight that tells you a body panel is pressed metal — was smoothed away.
 *    Normals are now computed **per panel**, and a panel boundary *is* a crease.
 *    See `PANEL_HARD` and `emitPanels`.
 * 2. **Glass floated on the paint.** Five quads were laid over an unbroken
 *    shell. The greenhouse is now an *opening*: the loft's daylight band carries
 *    the glass material and the paint genuinely stops at its edge, with the
 *    blackout reveal above and below modelled as its own strips.
 * 3. **The wheels intersected a flat flank.** There were no arches at all. The
 *    section profile now deforms into a real wheel opening — see `archHeightAt`
 *    and the `wellWall`/`archLip` points — which is one continuous loft, not a
 *    topological cut, so the shell stays watertight and the arch stays smooth.
 * 4. **A tyre was a `TorusGeometry`.** A torus has no sidewall, no shoulder, no
 *    tread and no flat where it meets the road. The tyre is now a revolved
 *    profile with a loaded sidewall bulge, a shoulder crease and circumferential
 *    grooves, and the tread band is flat so the wheel *sits* on the road.
 * 5. **Fake ambient on the paint.** `emissive: 0x11191e` existed because the
 *    scene had no environment map, so `metalness` had nothing to reflect and the
 *    car went black. `engine/environmentRig.ts` now supplies one, and an
 *    emissive term on top of it double-counts. It is gone from `paint` and
 *    `wheel`; the head- and tail-lights keep theirs, because those are lamps.
 *
 * ### Two LODs, and what actually differs
 *
 * `corridor` is the parked and background car — forty of them go through
 * `engine/roadTraffic.ts` as instanced batches, so its triangle count is
 * multiplied by forty and is the number that matters to the city's budget.
 * `follow` is the one car the chase camera is looking at, and it is the only
 * one that gets the dense wheels, the interior, the mirrors, the shutlines and
 * the applied window trim. The split is real: `corridor` is roughly 3.7k
 * triangles and `follow` roughly 12k.
 *
 * Build one rig and clone it. Clones share geometry and materials while their
 * wheel and steering joints stay independent, so `disposeModelX` must only ever
 * be called on a prototype.
 */

import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";

export const MODEL_X_METRICS = {
  length: 5.04,
  width: 2.08,
  height: 1.68,
  wheelbase: 2.965,
  track: 1.78,
  wheelRadius: 0.405,
  tireWidth: 0.285,
  maxSteeringAngle: 0.62,
} as const;

/** Preferred product-facing name; MODEL_X_METRICS remains a compatibility alias. */
export const LUMBRIDGE_EV_METRICS = MODEL_X_METRICS;

export type ModelXDetail = "corridor" | "follow";

export interface ModelXMaterials {
  paint: THREE.Material;
  glass: THREE.Material;
  trim: THREE.Material;
  tire: THREE.Material;
  wheel: THREE.Material;
  brake: THREE.Material;
  headlight: THREE.Material;
  tailLight: THREE.Material;
  /**
   * The mirror glass itself. Separate from `glass` because a wing mirror is a
   * first-surface reflector and the side windows are tinted and translucent —
   * one material cannot be both, and with an environment map present the
   * difference between them is most of what makes a mirror read as a mirror.
   */
  mirror: THREE.Material;
  /**
   * The number plate. Blank and unlettered on purpose: a plate carrying
   * characters is either somebody's real registration or an invented one that
   * looks like it, and neither belongs in an Apache-2.0 repo (ARCHITECTURE.md
   * §3.1). What the plate contributes is the pale rectangle low on a dark
   * bumper, which is the part the eye actually uses.
   */
  plate: THREE.Material;
}

export interface ModelXBuildOptions {
  detail?: ModelXDetail;
  /** Used only when `materials` is omitted. */
  paint?: THREE.ColorRepresentation;
  /** A complete externally owned skin, useful for a world-level material pool. */
  materials?: ModelXMaterials;
}

export interface ModelXWheel {
  /** Yaw this group to steer. Rear steering groups stay at zero. */
  steering: THREE.Group;
  /** Rotate this group around local X to roll the wheel. */
  spin: THREE.Group;
  tire: THREE.Mesh;
  rim: THREE.Mesh;
}

export interface ModelXWheels {
  frontLeft: ModelXWheel;
  frontRight: ModelXWheel;
  rearLeft: ModelXWheel;
  rearRight: ModelXWheel;
}

export interface ModelXRig {
  root: THREE.Group;
  /** Static bodywork; useful when a traffic renderer instances body meshes. */
  body: THREE.Group;
  wheels: ModelXWheels;
  /** True only on a prototype that created its own default material set. */
  readonly ownsMaterials: boolean;
}

export interface ModelXInstancePart {
  name: string;
  geometry: THREE.BufferGeometry;
  material: THREE.Material | THREE.Material[];
  /** Transform from the vehicle root to this drawable in the neutral pose. */
  matrix: THREE.Matrix4;
  castShadow: boolean;
  receiveShadow: boolean;
}

const UNIT_BOX = new THREE.BoxGeometry(1, 1, 1);
const UNIT_PLANE = new THREE.PlaneGeometry(1, 1);

// ---- Materials ------------------------------------------------------------

/**
 * Default materials are intentionally untextured: no binary art and no UV
 * dependency, which is the same promise `src/assets/textures.ts` makes one
 * level up.
 *
 * The paint is `MeshPhysicalMaterial` with a full clearcoat because that is the
 * one thing a car body has that no other surface in the library does — a
 * lacquer layer with its own, much sharper, reflection sitting on top of a
 * metallic basecoat. It needs an environment to reflect; `createEnvironmentRig`
 * supplies one, and a scene without one renders the car duller rather than
 * broken.
 */
export function createModelXMaterials(
  paint: THREE.ColorRepresentation = 0x465157,
): ModelXMaterials {
  return {
    paint: createModelXPaint(paint),
    glass: new THREE.MeshPhysicalMaterial({
      name: "model-x.glass",
      color: 0x14262f,
      metalness: 0.1,
      roughness: 0.08,
      transparent: true,
      opacity: 0.72,
      // The greenhouse is a closed volume with an interior inside it, so the
      // far side of the glass is genuinely visible through the near side.
      side: THREE.DoubleSide,
      depthWrite: false,
    }),
    trim: new THREE.MeshStandardMaterial({
      name: "model-x.trim",
      color: 0x1d2225,
      metalness: 0.42,
      roughness: 0.46,
    }),
    tire: new THREE.MeshStandardMaterial({
      name: "model-x.tire",
      color: 0x111213,
      metalness: 0,
      roughness: 0.93,
    }),
    wheel: new THREE.MeshStandardMaterial({
      name: "model-x.wheel",
      color: 0x646c73,
      metalness: 0.9,
      roughness: 0.26,
    }),
    brake: new THREE.MeshStandardMaterial({
      name: "model-x.brake",
      color: 0x6d7275,
      metalness: 0.9,
      roughness: 0.34,
    }),
    mirror: new THREE.MeshStandardMaterial({
      name: "model-x.mirror",
      color: 0xc9d3d8,
      metalness: 1,
      roughness: 0.06,
    }),
    plate: new THREE.MeshStandardMaterial({
      name: "model-x.plate",
      color: 0xd6dad4,
      metalness: 0,
      roughness: 0.62,
    }),
    headlight: new THREE.MeshStandardMaterial({
      name: "model-x.headlight",
      color: 0xd9f4ff,
      emissive: 0xb8eaff,
      emissiveIntensity: 3.2,
      roughness: 0.16,
    }),
    tailLight: new THREE.MeshStandardMaterial({
      name: "model-x.tail-light",
      color: 0xff2433,
      emissive: 0xd70918,
      emissiveIntensity: 2.8,
      roughness: 0.2,
    }),
  };
}

function createModelXPaint(color: THREE.ColorRepresentation): THREE.MeshPhysicalMaterial {
  return new THREE.MeshPhysicalMaterial({
    name: "model-x.paint",
    color,
    metalness: 0.72,
    roughness: 0.26,
    clearcoat: 1,
    clearcoatRoughness: 0.1,
  });
}

/**
 * The stock colours a street of these is drawn from.
 *
 * Deliberately narrow and deliberately desaturated. A car park where every car
 * is a different hue reads as a toy shelf; real traffic is four greys, a white,
 * a dark blue and one red, and it is the *distribution* rather than the range
 * that makes it look observed rather than generated.
 */
export const MODEL_X_PAINTS: readonly number[] = [
  0x1c2024, 0x9aa1a6, 0xd7dade, 0x465157, 0x2c3f57, 0x6c2f31, 0x3a4a42,
];

/**
 * A pool of complete skins that differ only in their paint.
 *
 * This exists because of a constraint the renderer imposes and the pack layer
 * cannot work around: an `InstancedMesh` shares one material across every
 * instance, so per-car colour has to arrive as a *different material*, not as a
 * per-instance attribute. `setColorAt` would multiply the material's colour
 * rather than replace it, and it cannot reach the clearcoat at all.
 *
 * Seven skins is seven extra draw calls at worst and one geometry — the pool
 * shares every non-paint material, so the tyres, glass, trim and wheels of all
 * seven are the same objects.
 */
export function createModelXPaintPool(
  colors: readonly number[] = MODEL_X_PAINTS,
): readonly ModelXMaterials[] {
  const shared = createModelXMaterials(colors[0] ?? 0x465157);
  return colors.map((color, index) =>
    index === 0 ? shared : { ...shared, paint: createModelXPaint(color) },
  );
}

/** Dispose a pool built by `createModelXPaintPool`, shared materials included. */
export function disposeModelXPaintPool(pool: readonly ModelXMaterials[]): void {
  const seen = new Set<THREE.Material>();
  for (const skin of pool) {
    for (const material of Object.values(skin)) {
      if (material instanceof THREE.Material) seen.add(material);
    }
  }
  for (const material of seen) material.dispose();
}

// ---- Placement and batching ----------------------------------------------

interface Placement {
  position?: readonly [number, number, number];
  scale?: readonly [number, number, number];
  rotation?: readonly [number, number, number];
}

function matrix(place: Placement): THREE.Matrix4 {
  const position = new THREE.Vector3(...(place.position ?? [0, 0, 0]));
  const quaternion = new THREE.Quaternion().setFromEuler(
    new THREE.Euler(...(place.rotation ?? [0, 0, 0]), "YXZ"),
  );
  const scale = new THREE.Vector3(...(place.scale ?? [1, 1, 1]));
  return new THREE.Matrix4().compose(position, quaternion, scale);
}

/**
 * Collects transformed pieces and emits one mesh per material, the same trade
 * `assets/parts.ts` makes for the office.
 *
 * One rule governs everything that goes in here: **every geometry under one
 * material must carry the same attribute set**, because `mergeGeometries`
 * refuses a mixture and `build` treats the refusal as "skip this material" —
 * which is not an error, it is a car with no paint on it. Three.js primitives
 * all carry `position`/`normal`/`uv`, so every generated geometry in this file
 * carries all three too, `uv` included, even where nothing samples it.
 */
class StaticBatch {
  private readonly groups = new Map<THREE.Material, THREE.BufferGeometry[]>();

  add(geometry: THREE.BufferGeometry, material: THREE.Material, place: Placement = {}): void {
    const transformed = geometry.clone().applyMatrix4(matrix(place));
    const found = this.groups.get(material);
    if (found) found.push(transformed);
    else this.groups.set(material, [transformed]);
  }

  /** Add a one-off generated shape, then release its untransformed source. */
  addOwned(geometry: THREE.BufferGeometry, material: THREE.Material, place: Placement = {}): void {
    this.add(geometry, material, place);
    geometry.dispose();
  }

  box(material: THREE.Material, place: Placement): void {
    this.add(UNIT_BOX, material, place);
  }

  /** Mirror a placement across the centreline. `side` is +1 right, −1 left. */
  boxPair(material: THREE.Material, place: Placement, mirrorRotation = true): void {
    for (const side of [-1, 1] as const) {
      const [x, y, z] = place.position ?? [0, 0, 0];
      const [rx, ry, rz] = place.rotation ?? [0, 0, 0];
      this.box(material, {
        position: [side * x, y, z],
        scale: place.scale,
        rotation: mirrorRotation ? [rx, side * ry, side * rz] : [rx, ry, rz],
      });
    }
  }

  get materialCount(): number {
    return this.groups.size;
  }

  build(name: string): THREE.Group {
    const group = new THREE.Group();
    group.name = name;
    for (const [material, geometries] of this.groups) {
      const geometry =
        geometries.length === 1 ? geometries[0] : mergeGeometries(geometries, false);
      if (geometries.length > 1) for (const item of geometries) item.dispose();
      if (!geometry) continue;
      const mesh = new THREE.Mesh(geometry, material);
      mesh.name = `${name}:${material.name || "surface"}`;
      mesh.castShadow = true;
      mesh.receiveShadow = true;
      group.add(mesh);
    }
    this.groups.clear();
    return group;
  }
}

// ---- The body loft --------------------------------------------------------

/**
 * One authored cross-section of the bodyshell, in metres.
 *
 * Every field is a *line* that runs the length of the car, which is how a car
 * is actually drawn: you draw the shoulder line, the beltline and the roof line
 * in side view first, and the sections follow from them. Naming them here is
 * what lets the arch deformation, the applied trim and the shutlines all agree
 * about where a surface is without any of them measuring the mesh.
 */
interface BodySection {
  z: number;
  /** Half-width at the widest line — the shoulder. */
  w: number;
  /** Underbody pan. */
  pan: number;
  /** Lower edge of the bodyside skin: the rocker line. */
  sill: number;
  /** The shoulder crease. Widest point of the body and the hardest edge on it. */
  hip: number;
  /** Lower edge of the daylight opening — the beltline. */
  belt: number;
  /** Upper edge of the daylight opening, where the glass meets the roof rail. */
  rail: number;
  /** Roof centreline. */
  crown: number;
}

/**
 * Nineteen control sections. The loft resamples between them (see
 * `monotoneSlopes`) rather than treating these as the mesh, so adding a section
 * here refines the shape instead of adding facets.
 */
const BODY_SECTIONS: readonly BodySection[] = [
  { z: -2.52, w: 0.6, pan: 0.52, sill: 0.46, hip: 0.7, belt: 0.76, rail: 0.8, crown: 0.82 },
  { z: -2.38, w: 0.85, pan: 0.42, sill: 0.34, hip: 0.8, belt: 0.86, rail: 0.9, crown: 0.93 },
  { z: -2.16, w: 0.96, pan: 0.34, sill: 0.26, hip: 0.87, belt: 0.94, rail: 0.98, crown: 1.01 },
  { z: -1.88, w: 0.995, pan: 0.31, sill: 0.24, hip: 0.9, belt: 0.99, rail: 1.03, crown: 1.06 },
  { z: -1.58, w: 1.0, pan: 0.3, sill: 0.235, hip: 0.91, belt: 1.01, rail: 1.05, crown: 1.085 },
  { z: -1.3, w: 1.005, pan: 0.3, sill: 0.235, hip: 0.915, belt: 1.03, rail: 1.08, crown: 1.115 },
  { z: -1.12, w: 1.0, pan: 0.3, sill: 0.235, hip: 0.918, belt: 1.045, rail: 1.14, crown: 1.18 },
  { z: -0.95, w: 0.998, pan: 0.3, sill: 0.235, hip: 0.92, belt: 1.055, rail: 1.33, crown: 1.39 },
  { z: -0.62, w: 0.995, pan: 0.3, sill: 0.235, hip: 0.92, belt: 1.065, rail: 1.52, crown: 1.575 },
  { z: -0.28, w: 0.995, pan: 0.3, sill: 0.235, hip: 0.92, belt: 1.072, rail: 1.605, crown: 1.658 },
  { z: 0.06, w: 0.995, pan: 0.3, sill: 0.235, hip: 0.92, belt: 1.075, rail: 1.628, crown: 1.678 },
  { z: 0.42, w: 0.992, pan: 0.3, sill: 0.235, hip: 0.92, belt: 1.075, rail: 1.632, crown: 1.68 },
  { z: 0.8, w: 0.99, pan: 0.3, sill: 0.235, hip: 0.918, belt: 1.07, rail: 1.622, crown: 1.672 },
  { z: 1.16, w: 0.988, pan: 0.3, sill: 0.238, hip: 0.915, belt: 1.058, rail: 1.59, crown: 1.64 },
  { z: 1.48, w: 0.99, pan: 0.31, sill: 0.245, hip: 0.91, belt: 1.045, rail: 1.535, crown: 1.585 },
  { z: 1.8, w: 0.982, pan: 0.33, sill: 0.265, hip: 0.9, belt: 1.028, rail: 1.45, crown: 1.505 },
  { z: 2.1, w: 0.95, pan: 0.37, sill: 0.315, hip: 0.885, belt: 1.0, rail: 1.325, crown: 1.39 },
  { z: 2.34, w: 0.875, pan: 0.42, sill: 0.375, hip: 0.855, belt: 0.96, rail: 1.155, crown: 1.215 },
  { z: 2.52, w: 0.64, pan: 0.5, sill: 0.46, hip: 0.79, belt: 0.88, rail: 0.99, crown: 1.04 },
] as const;

const HALF_WHEELBASE = MODEL_X_METRICS.wheelbase / 2;

/** Half-length of the wheel opening in Z, and the height of its crown. */
const ARCH_SEMI_Z = 0.6;
const ARCH_TOP = 0.868;
/** How far the arch lip stands proud of the shoulder line at its crown. */
const ARCH_FLARE = 0.035;
/** Absolute half-width of the wheel-well inner wall. Must clear the tyre. */
const WELL_HALF_WIDTH = 0.72;
/** How far the daylight opening is recessed behind its blackout surround. */
const DLO_INSET = 0.012;

/**
 * The top of the wheel opening at `z`, or the rocker line where there is none.
 *
 * An ellipse rather than a circle, and one that lands exactly on the rocker line
 * at its ends: a wheel arch meets the sill vertically, which is why the tangent
 * at `|dz| = ARCH_SEMI_Z` is infinite here and why the opening closes cleanly
 * instead of leaving a step to explain away.
 */
function archHeightAt(z: number, sill: number): number {
  let top = sill;
  for (const axle of [-HALF_WHEELBASE, HALF_WHEELBASE]) {
    const dz = (z - axle) / ARCH_SEMI_Z;
    if (Math.abs(dz) >= 1) continue;
    const y = sill + (ARCH_TOP - sill) * Math.sqrt(1 - dz * dz);
    if (y > top) top = y;
  }
  return top;
}

/**
 * Slopes for a monotone cubic (Fritsch–Carlson) interpolant.
 *
 * Catmull-Rom was the obvious choice and is the wrong one: it overshoots at the
 * nose and the tail, where the sections close down fast, and an overshoot in
 * `crown` is a roof that is taller than the car's published height — which is
 * not a cosmetic problem, because `MODEL_X_METRICS.height` is what the transport
 * layer collides and frames with. The limiter below is the whole reason this is
 * hand-written rather than borrowed from three's curve classes.
 */
function monotoneSlopes(xs: readonly number[], ys: readonly number[]): number[] {
  const n = xs.length;
  const secants: number[] = [];
  for (let i = 0; i < n - 1; i++) {
    secants.push((ys[i + 1]! - ys[i]!) / (xs[i + 1]! - xs[i]!));
  }
  const m = new Array<number>(n).fill(0);
  m[0] = secants[0] ?? 0;
  m[n - 1] = secants[n - 2] ?? 0;
  for (let i = 1; i < n - 1; i++) {
    const a = secants[i - 1]!;
    const b = secants[i]!;
    m[i] = a * b <= 0 ? 0 : (a + b) / 2;
  }
  for (let i = 0; i < n - 1; i++) {
    const s = secants[i]!;
    if (s === 0) {
      m[i] = 0;
      m[i + 1] = 0;
      continue;
    }
    const a = m[i]! / s;
    const b = m[i + 1]! / s;
    const h = Math.hypot(a, b);
    if (h > 3) {
      const t = 3 / h;
      m[i] = t * a * s;
      m[i + 1] = t * b * s;
    }
  }
  return m;
}

type SectionField = Exclude<keyof BodySection, "z">;
const SECTION_FIELDS: readonly SectionField[] = ["w", "pan", "sill", "hip", "belt", "rail", "crown"];

/** Precomputed interpolant, built once at module load and shared by both LODs. */
const SECTION_Z = BODY_SECTIONS.map((s) => s.z);
const SECTION_SLOPES: Record<SectionField, number[]> = Object.fromEntries(
  SECTION_FIELDS.map((field) => [
    field,
    monotoneSlopes(
      SECTION_Z,
      BODY_SECTIONS.map((s) => s[field]),
    ),
  ]),
) as Record<SectionField, number[]>;

/** The bodyshell's authored lines at any `z`, clamped outside the car. */
function sectionAt(z: number): BodySection {
  const last = BODY_SECTIONS.length - 1;
  const clamped = Math.min(Math.max(z, SECTION_Z[0]!), SECTION_Z[last]!);
  let i = 0;
  while (i < last - 1 && SECTION_Z[i + 1]! < clamped) i++;
  const z0 = SECTION_Z[i]!;
  const z1 = SECTION_Z[i + 1]!;
  const h = z1 - z0;
  const t = (clamped - z0) / h;
  // Hermite basis. Written out rather than looped because the four coefficients
  // are the same for every field and computing them once is the point.
  const t2 = t * t;
  const t3 = t2 * t;
  const h00 = 2 * t3 - 3 * t2 + 1;
  const h10 = t3 - 2 * t2 + t;
  const h01 = -2 * t3 + 3 * t2;
  const h11 = t3 - t2;
  const out: Record<string, number> = { z: clamped };
  for (const field of SECTION_FIELDS) {
    const y0 = BODY_SECTIONS[i]![field];
    const y1 = BODY_SECTIONS[i + 1]![field];
    const slopes = SECTION_SLOPES[field];
    out[field] = h00 * y0 + h10 * h * slopes[i]! + h01 * y1 + h11 * h * slopes[i + 1]!;
  }
  return out as unknown as BodySection;
}

/**
 * The fifteen points of a half-section, outboard from the underbody centreline
 * up and over to the roof centreline.
 *
 * Index order is the ring order, and it is load-bearing twice over: the strips
 * between consecutive points are the panels, and the winding derived from
 * "tangent along the ring × tangent along +Z" points outward everywhere only
 * because the traversal never reverses.
 */
const P_PAN_CENTRE = 0;
const P_WELL_WALL = 3;
const P_ARCH_LIP = 4;
const P_HIP = 6;
const P_BELT = 8;
const P_DLO_BOTTOM = 9;
const P_DLO_TOP = 10;
const P_DLO_UPPER = 11;
const P_RAIL_TOP = 12;
const P_CROWN = 14;
const HALF_POINTS = 15;

/** Which of the fifteen carry a crease. A panel boundary is exactly this set. */
const PANEL_HARD: ReadonlySet<number> = new Set([
  P_WELL_WALL,
  P_ARCH_LIP,
  P_HIP,
  P_BELT,
  P_DLO_BOTTOM,
  P_DLO_TOP,
  P_DLO_UPPER,
  P_RAIL_TOP,
]);

function smoothstep(edge0: number, edge1: number, x: number): number {
  const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
  return t * t * (3 - 2 * t);
}

/** Z range over which the car has a daylight opening at all. */
const CABIN_Z: readonly [number, number] = [-1.16, 2.1];
/** Z range of the panoramic glass, from the cowl to the tailgate. */
const GLASS_ROOF_Z: readonly [number, number] = [-1.16, 2.16];
/** The three side windows. Everything between them inside `CABIN_Z` is a pillar. */
const WINDOW_Z: readonly (readonly [number, number])[] = [
  [-0.92, 0.16],
  [0.3, 1.3],
  [1.46, 1.92],
];

function within(range: readonly [number, number], z: number): boolean {
  return z >= range[0] && z <= range[1];
}

/**
 * The half-section at `z`, as fifteen `(x, y)` pairs.
 *
 * The wheel opening is a *deformation of this list*, not a hole cut into the
 * finished shell, and that is the single decision the arch quality rests on.
 * As `archT` runs 0 → 1:
 *
 *  - `wellWall` slides inboard from the rocker's outer edge to the well liner
 *    and rises to the arch crown, so the strip below it turns from the
 *    underbody chamfer into the vertical inner wall of the wheel well;
 *  - `archLip` rises with it, so the strip between them turns from the rocker's
 *    underside into the horizontal roof of the wheel well;
 *  - the flank above shortens to the band of paint between the arch lip and the
 *    shoulder line.
 *
 * One continuous loft does all three, the shell stays watertight, and there is
 * no z-order of overlapping cut geometry to get wrong.
 */
function halfSection(z: number): Float64Array {
  const s = sectionAt(z);
  const archY = archHeightAt(z, s.sill);
  const archT = (archY - s.sill) / (ARCH_TOP - s.sill);
  const flare = ARCH_FLARE * archT;
  const wOuter = s.w + flare;
  const wUpper = s.w + flare * 0.35;
  const inset = DLO_INSET * smoothstep(CABIN_Z[0] - 0.14, CABIN_Z[0], z) *
    (1 - smoothstep(CABIN_Z[1], CABIN_Z[1] + 0.14, z));

  const out = new Float64Array(HALF_POINTS * 2);
  const put = (i: number, x: number, y: number): void => {
    out[i * 2] = x;
    out[i * 2 + 1] = y;
  };

  put(0, 0, s.pan);
  put(1, 0.44 * s.w, s.pan);
  put(2, 0.72 * s.w, s.pan - 0.008);
  put(3, 0.86 * s.w + (WELL_HALF_WIDTH - 0.86 * s.w) * archT, archY);
  put(4, wOuter, archY);
  put(5, wOuter * 0.9985, archY + (s.hip - archY) * 0.55);
  put(6, wOuter, s.hip);
  put(7, wUpper * 0.986, s.hip + (s.belt - s.hip) * 0.55);
  put(8, wUpper * 0.965, s.belt);
  put(9, wUpper * 0.965 - inset, s.belt + 0.014);
  put(10, s.w * 0.878 - inset, s.rail - 0.014);
  put(11, s.w * 0.878, s.rail);
  put(12, s.w * 0.735, s.rail + (s.crown - s.rail) * 0.58);
  put(13, s.w * 0.4, s.crown - 0.011);
  put(14, 0, s.crown);
  return out;
}

/**
 * Where the bodyside surface is, at a given height, on the right-hand side.
 *
 * Applied trim — a shutline, a handle, the window garnish, the charge-port door
 * — has to sit *on* the paint, and the paint is a lofted curve rather than a
 * plane. Sampling the profile is how every one of those parts finds its own
 * position without any of them repeating the section table.
 */
function bodyXAt(z: number, y: number): number {
  const half = halfSection(z);
  let best = half[P_HIP * 2]!;
  let bestGap = Infinity;
  for (let i = P_ARCH_LIP; i < P_RAIL_TOP; i++) {
    const y0 = half[i * 2 + 1]!;
    const y1 = half[(i + 1) * 2 + 1]!;
    const lo = Math.min(y0, y1);
    const hi = Math.max(y0, y1);
    if (y >= lo && y <= hi && hi - lo > 1e-6) {
      const t = (y - y0) / (y1 - y0);
      return half[i * 2]! + (half[(i + 1) * 2]! - half[i * 2]!) * t;
    }
    const gap = Math.min(Math.abs(y - lo), Math.abs(y - hi));
    if (gap < bestGap) {
      bestGap = gap;
      best = y < lo ? half[i * 2]! : half[(i + 1) * 2]!;
    }
  }
  return best;
}

/** A ring corner: which half-profile point, and which side of the car. */
interface Corner {
  p: number;
  side: 1 | -1;
}

/**
 * The full closed ring, from the underbody centreline up the right side, over
 * the crown and back down the left.
 *
 * Twenty-eight corners for fifteen half-points: the two on the centreline are
 * shared, the rest appear twice.
 */
const RING: readonly Corner[] = (() => {
  const ring: Corner[] = [{ p: P_PAN_CENTRE, side: 1 }];
  for (let p = 1; p <= HALF_POINTS - 2; p++) ring.push({ p, side: 1 });
  ring.push({ p: P_CROWN, side: 1 });
  for (let p = HALF_POINTS - 2; p >= 1; p--) ring.push({ p, side: -1 });
  return ring;
})();

/**
 * The runs of ring corners that shade together.
 *
 * A panel starts and ends on a hard point, so `computeVertexNormals` run over
 * one panel's geometry averages only within that panel and the boundary between
 * two panels is a crease with two different normals on it. That is what "split
 * vertices at creases" means in practice, and it is why the shoulder line
 * survives this time.
 */
const PANELS: readonly Corner[][] = (() => {
  const hardAt: number[] = [];
  for (let i = 0; i < RING.length; i++) {
    if (PANEL_HARD.has(RING[i]!.p)) hardAt.push(i);
  }
  const panels: Corner[][] = [];
  for (let k = 0; k < hardAt.length; k++) {
    const start = hardAt[k]!;
    const end = hardAt[(k + 1) % hardAt.length]!;
    const corners: Corner[] = [];
    let i = start;
    for (;;) {
      corners.push(RING[i]!);
      if (i === end) break;
      i = (i + 1) % RING.length;
    }
    panels.push(corners);
  }
  return panels;
})();

type PanelSurface = "paint" | "glass" | "trim";

/**
 * Which surface a panel carries at a given `z`.
 *
 * Three panels are not always paint, and between them they are the whole
 * greenhouse: the daylight band is glass over a window and blackout trim over a
 * pillar, the two reveals around it are blackout trim wherever there is a
 * cabin, and the roof is glass from the cowl to the tailgate — one continuous
 * panoramic pane, which is this car's whole silhouette idea and costs nothing
 * extra because the roof strips already exist.
 */
function panelSurfaceAt(corners: readonly Corner[], z: number): PanelSurface {
  const points = new Set(corners.map((c) => c.p));
  if (points.has(P_CROWN)) return within(GLASS_ROOF_Z, z) ? "glass" : "paint";
  const inCabin = within(CABIN_Z, z);
  if (points.has(P_DLO_BOTTOM) && points.has(P_DLO_TOP)) {
    if (!inCabin) return "paint";
    return WINDOW_Z.some((range) => within(range, z)) ? "glass" : "trim";
  }
  const isReveal =
    (points.has(P_BELT) && points.has(P_DLO_BOTTOM)) ||
    (points.has(P_DLO_TOP) && points.has(P_DLO_UPPER));
  if (isReveal) return inCabin ? "trim" : "paint";
  return "paint";
}

/** The z values a given LOD lofts through. */
function sectionSamples(detail: ModelXDetail): number[] {
  const control =
    detail === "follow"
      ? SECTION_Z
      : SECTION_Z.filter((_, i) => i % 2 === 0 || i === 6 || i === 7 || i === 14);
  // The arch is a curve inside a small span of z, so it needs its own samples;
  // without them a wheel opening is an octagon however dense the rest is.
  const archOffsets = detail === "follow" ? [0, 0.1, 0.22, 0.34, 0.44, 0.52, 0.575] : [0, 0.4];
  const set = new Set<number>(control);
  for (const axle of [-HALF_WHEELBASE, HALF_WHEELBASE]) {
    for (const offset of archOffsets) {
      set.add(Number((axle - offset).toFixed(4)));
      set.add(Number((axle + offset).toFixed(4)));
    }
  }
  return [...set]
    .filter((z) => z > SECTION_Z[0]! && z < SECTION_Z[SECTION_Z.length - 1]!)
    .concat([SECTION_Z[0]!, SECTION_Z[SECTION_Z.length - 1]!])
    .sort((a, b) => a - b);
}

function finishGeometry(
  positions: number[],
  indices: number[],
  uvs: number[],
  name: string,
): THREE.BufferGeometry {
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
  geometry.setIndex(indices);
  geometry.computeVertexNormals();
  // `uv` last, so the attribute order matches three's own primitives and
  // `mergeGeometries` has nothing to disagree with.
  geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
  geometry.name = name;
  return geometry;
}

/**
 * Loft every panel into `batch`, splitting each one wherever its surface
 * changes along the car.
 *
 * A run is a maximal stretch of section pairs carrying the same surface. Runs
 * are separate geometries, which means a glass-to-paint boundary is also a
 * crease — correct, because it is a physical edge, and free, because the split
 * had to happen anyway to change material.
 */
function emitPanels(batch: StaticBatch, materials: ModelXMaterials, detail: ModelXDetail): void {
  const zs = sectionSamples(detail);
  const sections = zs.map((z) => halfSection(z));
  const zSpan = zs[zs.length - 1]! - zs[0]!;
  const surface: Record<PanelSurface, THREE.Material> = {
    paint: materials.paint,
    glass: materials.glass,
    trim: materials.trim,
  };

  for (let panelIndex = 0; panelIndex < PANELS.length; panelIndex++) {
    const corners = PANELS[panelIndex]!;
    const width = corners.length;
    let runStart = 0;
    let runSurface = panelSurfaceAt(corners, (zs[0]! + zs[1]!) / 2);

    const flush = (endPair: number): void => {
      const first = runStart;
      const last = endPair + 1;
      const positions: number[] = [];
      const uvs: number[] = [];
      const indices: number[] = [];
      for (let si = first; si <= last; si++) {
        const half = sections[si]!;
        const v = (zs[si]! - zs[0]!) / zSpan;
        for (let c = 0; c < width; c++) {
          const corner = corners[c]!;
          positions.push(corner.side * half[corner.p * 2]!, half[corner.p * 2 + 1]!, zs[si]!);
          uvs.push(width === 1 ? 0 : c / (width - 1), v);
        }
      }
      for (let si = 0; si < last - first; si++) {
        for (let c = 0; c < width - 1; c++) {
          const a = si * width + c;
          const b = a + 1;
          const d = a + width;
          const cc = d + 1;
          indices.push(a, b, d, b, cc, d);
        }
      }
      if (indices.length === 0) return;
      batch.addOwned(
        finishGeometry(positions, indices, uvs, `model-x.panel${panelIndex}`),
        surface[runSurface],
      );
    };

    for (let pair = 1; pair < zs.length - 1; pair++) {
      const next = panelSurfaceAt(corners, (zs[pair]! + zs[pair + 1]!) / 2);
      if (next === runSurface) continue;
      flush(pair - 1);
      runStart = pair;
      runSurface = next;
    }
    flush(zs.length - 2);
  }
}

/** Close the nose and the tail with a shallow fan. */
function emitCaps(batch: StaticBatch, materials: ModelXMaterials, detail: ModelXDetail): void {
  const zs = sectionSamples(detail);
  for (const front of [true, false]) {
    const z = front ? zs[0]! : zs[zs.length - 1]!;
    const half = halfSection(z);
    const apexZ = front ? z - 0.012 : z + 0.012;
    let apexY = 0;
    for (const corner of RING) apexY += half[corner.p * 2 + 1]!;
    apexY /= RING.length;

    const positions: number[] = [0, apexY, apexZ];
    const uvs: number[] = [0.5, 0.5];
    for (const corner of RING) {
      positions.push(corner.side * half[corner.p * 2]!, half[corner.p * 2 + 1]!, z);
      uvs.push(0, 0);
    }
    const indices: number[] = [];
    for (let i = 0; i < RING.length; i++) {
      const a = 1 + i;
      const b = 1 + ((i + 1) % RING.length);
      // Front faces −Z and rear faces +Z, so the fan winds the opposite way.
      if (front) indices.push(0, b, a);
      else indices.push(0, a, b);
    }
    batch.addOwned(
      finishGeometry(positions, indices, uvs, front ? "model-x.nose-cap" : "model-x.tail-cap"),
      materials.paint,
    );
  }
}

// ---- Applied detail -------------------------------------------------------

/** Where the doors and the tailgate break, per side. Used for shutlines and trim. */
const SHUTLINE_Z: readonly number[] = [-1.02, 0.2, 1.34];

function emitBodyDetail(
  batch: StaticBatch,
  materials: ModelXMaterials,
  detail: ModelXDetail,
): void {
  const { trim, headlight, tailLight, plate } = materials;

  // ---- Bumpers. Unpainted lower mouldings, which is what stops a dark car
  // dissolving into the road and what gives the nose and tail their mass.
  batch.box(trim, { position: [0, 0.42, -2.425], scale: [1.72, 0.3, 0.22] });
  batch.box(trim, { position: [0, 0.3, -2.345], scale: [1.5, 0.1, 0.38] });
  batch.box(trim, { position: [0, 0.44, 2.4], scale: [1.66, 0.3, 0.22] });
  batch.box(trim, { position: [0, 0.34, 2.32], scale: [1.44, 0.12, 0.34] });
  // Rear diffuser fins. Five thin blades read as a diffuser from any angle you
  // can see under the car from, and cost sixty triangles.
  for (let i = -2; i <= 2; i++) {
    batch.box(trim, { position: [i * 0.24, 0.33, 2.38], scale: [0.035, 0.11, 0.26] });
  }

  // ---- Number plates. Blank, recessed, and mounted on the bumper rather than
  // on the paint, which is where a plate actually goes.
  batch.box(trim, { position: [0, 0.53, -2.49], scale: [0.4, 0.15, 0.02] });
  batch.box(plate, { position: [0, 0.53, -2.505], scale: [0.36, 0.115, 0.012] });
  batch.box(trim, { position: [0, 0.62, 2.45], scale: [0.4, 0.15, 0.02] });
  batch.box(plate, { position: [0, 0.62, 2.465], scale: [0.36, 0.115, 0.012] });

  // ---- Lighting. A continuous blade across the nose and the tail, plus the
  // wrapped corner units. No grille shape and no badge anywhere near either.
  batch.box(headlight, { position: [0, 0.9, -2.45], scale: [1.34, 0.045, 0.03] });
  batch.box(tailLight, { position: [0, 1.0, 2.45], scale: [1.4, 0.04, 0.028] });
  batch.boxPair(headlight, {
    position: [0.64, 0.9, -2.4],
    scale: [0.56, 0.09, 0.04],
    rotation: [0, 0.13, 0.04],
  });
  batch.boxPair(tailLight, {
    position: [0.64, 1.0, 2.38],
    scale: [0.58, 0.08, 0.045],
    rotation: [0, -0.12, -0.03],
  });

  // ---- Rocker cladding: the dark band along the bottom of the doors, sitting
  // on the sampled surface so it follows the car rather than a straight line.
  for (let i = 0; i < 5; i++) {
    const z = -1.6 + i * 0.8;
    const y = sectionAt(z).sill + 0.07;
    batch.boxPair(
      trim,
      { position: [bodyXAt(z, y) - 0.01, y, z], scale: [0.03, 0.12, 0.82] },
      false,
    );
  }

  // ---- Roof rails. Satin rather than paint, so the glass roof reads as glass
  // between two rails instead of as a hole in a painted roof.
  for (let i = 0; i < 6; i++) {
    const z = -0.85 + i * 0.55;
    const s = sectionAt(z);
    batch.boxPair(
      trim,
      { position: [s.w * 0.735, s.rail + (s.crown - s.rail) * 0.6, z], scale: [0.05, 0.03, 0.56] },
      false,
    );
  }

  // ---- Charge port. A flush door on the left rear quarter with its own shut
  // line — small, and one of the very few details that says "electric" without
  // writing a word on the car.
  {
    const z = 1.98;
    const y = 0.84;
    const x = bodyXAt(z, y);
    batch.box(trim, { position: [-x - 0.002, y, z], scale: [0.014, 0.1, 0.15] });
    batch.box(materials.paint, { position: [-x - 0.008, y, z], scale: [0.01, 0.082, 0.13] });
  }

  if (detail !== "follow") return;

  // ---- Shutlines. Panel gaps are the cheapest possible "this is assembled out
  // of pressings" cue and they are invisible until they are missing.
  for (const z of SHUTLINE_Z) {
    const s = sectionAt(z);
    const mid = (s.sill + s.belt) / 2;
    batch.boxPair(
      trim,
      { position: [bodyXAt(z, mid) + 0.001, mid, z], scale: [0.012, s.belt - s.sill, 0.01] },
      false,
    );
  }
  // Bonnet and tailgate outlines.
  batch.boxPair(trim, { position: [0.72, 1.11, -1.62], scale: [0.012, 0.012, 0.9] }, false);
  batch.box(trim, { position: [0, 1.175, -1.15], scale: [1.44, 0.012, 0.012] });
  batch.box(trim, { position: [0, 1.0, 2.16], scale: [1.6, 0.012, 0.012] });

  // ---- Door handles. Flush, because a protruding handle at this scale is a
  // smudge; what reads is the shadow line of the recess.
  for (const z of [-0.62, 0.72]) {
    const y = sectionAt(z).belt - 0.075;
    const x = bodyXAt(z, y);
    batch.boxPair(trim, { position: [x - 0.004, y, z], scale: [0.016, 0.042, 0.24] }, false);
  }
}

/**
 * The window surround, as its own named group.
 *
 * The loft already recesses the daylight opening and blacks out the reveal
 * around it; this is the applied garnish that sits proud of both — the strip a
 * real car has along the beltline and up each pillar. It is separate geometry
 * rather than more loft strips because it stands off the surface, and it is a
 * separate *object* because the device and capture layers want to be able to
 * find it by name.
 */
function buildGlassFrames(materials: ModelXMaterials): THREE.Group {
  const batch = new StaticBatch();
  const edges = [
    CABIN_Z[0] + 0.02,
    ...WINDOW_Z.flatMap((range) => [range[0], range[1]]),
    CABIN_Z[1] - 0.16,
  ];
  for (const z of edges) {
    const s = sectionAt(z);
    const mid = (s.belt + s.rail) / 2;
    batch.boxPair(
      materials.trim,
      { position: [bodyXAt(z, mid) + 0.004, mid, z], scale: [0.014, s.rail - s.belt, 0.028] },
      false,
    );
  }
  // The beltline garnish, in four segments so it follows the section curve.
  for (let i = 0; i < 4; i++) {
    const z = -0.7 + i * 0.86;
    const s = sectionAt(z);
    batch.boxPair(
      materials.trim,
      { position: [bodyXAt(z, s.belt) + 0.004, s.belt + 0.012, z], scale: [0.014, 0.026, 0.86] },
      false,
    );
  }
  const group = batch.build("model-x.glass-frames");
  group.userData.kind = "vehicle-glass-frames";
  return group;
}

/**
 * Wing mirrors, as their own named group.
 *
 * They are held inside `MODEL_X_METRICS.width` rather than standing proud of it
 * the way a real mirror does. That is deliberate and it is a simulation
 * decision, not a modelling shortcut: `width` is the number the transport layer
 * collides, frames and lane-positions with, and a mesh wider than its own
 * published width is a car that clips kerbs it looks like it cleared.
 */
function buildMirrors(materials: ModelXMaterials): THREE.Group {
  const batch = new StaticBatch();
  const z = -0.8;
  const y = 1.11;
  const rootX = bodyXAt(z, y - 0.06);
  batch.boxPair(
    materials.trim,
    { position: [(rootX + 0.978) / 2, y - 0.03, z - 0.05], scale: [0.12, 0.035, 0.05] },
    false,
  );
  batch.boxPair(
    materials.trim,
    { position: [0.978, y, z], scale: [0.1, 0.075, 0.19], rotation: [0, 0.12, 0] },
    true,
  );
  for (const side of [-1, 1] as const) {
    batch.add(UNIT_PLANE, materials.mirror, {
      position: [side * 0.976, y, z + 0.093],
      scale: [0.085, 0.058, 1],
      rotation: [0, Math.PI + side * 0.12, 0],
    });
  }
  const group = batch.build("model-x.mirrors");
  group.userData.kind = "vehicle-mirrors";
  return group;
}

/**
 * The cabin, seen through a panoramic roof and three windows.
 *
 * Something has to be in here at **both** LODs. A glass shell with nothing
 * inside it shows you the far side of the body from the inside, which
 * backface-culls to the sky — a car you can see straight through. The corridor
 * version is one dark block that reads as an occupied cabin at fifteen metres;
 * the follow version is a dashboard, a console, four seats and a wheel, because
 * the chase camera is two metres from the glass.
 */
function emitInterior(batch: StaticBatch, materials: ModelXMaterials, detail: ModelXDetail): void {
  const t = materials.trim;
  if (detail !== "follow") {
    batch.box(t, { position: [0, 1.02, 0.35], scale: [1.78, 0.44, 2.6] });
    return;
  }

  // Floor, transmission tunnel and door cards: the box that stops daylight.
  batch.box(t, { position: [0, 0.82, 0.35], scale: [1.78, 0.06, 2.7] });
  batch.boxPair(t, { position: [0.88, 1.0, 0.35], scale: [0.06, 0.42, 2.6] }, false);
  batch.box(t, { position: [0, 0.94, 0.12], scale: [0.36, 0.2, 1.5] });

  // Dashboard, cowl and the centre display.
  batch.box(t, { position: [0, 1.02, -0.86], scale: [1.74, 0.22, 0.42], rotation: [-0.14, 0, 0] });
  batch.box(t, { position: [0, 1.12, -0.72], scale: [1.7, 0.05, 0.24], rotation: [-0.3, 0, 0] });
  batch.box(t, { position: [0, 1.1, -0.62], scale: [0.42, 0.26, 0.02], rotation: [-0.1, 0, 0] });

  // Steering wheel, on the left as most of the world drives.
  batch.add(
    new THREE.TorusGeometry(0.17, 0.018, 8, 20),
    t,
    { position: [-0.38, 1.13, -0.62], rotation: [1.24, 0, 0] },
  );
  batch.box(t, { position: [-0.38, 1.05, -0.55], scale: [0.05, 0.16, 0.05], rotation: [0.33, 0, 0] });

  // Four seats. A base, a raked back and a headrest each — the headrest is what
  // makes a seat read as a seat through glass, because it breaks the skyline.
  for (const [z, height] of [
    [-0.22, 0.58],
    [0.92, 0.52],
  ] as const) {
    batch.boxPair(t, { position: [0.36, 0.88, z], scale: [0.5, 0.12, 0.5] }, false);
    batch.boxPair(
      t,
      { position: [0.36, 0.94, z + 0.3], scale: [0.48, height, 0.14], rotation: [0.16, 0, 0] },
      false,
    );
    batch.boxPair(
      t,
      { position: [0.36, 0.94 + height, z + 0.36], scale: [0.26, 0.16, 0.11] },
      false,
    );
  }
}

function buildBody(materials: ModelXMaterials, detail: ModelXDetail): THREE.Group {
  const batch = new StaticBatch();
  emitPanels(batch, materials, detail);
  emitCaps(batch, materials, detail);
  emitBodyDetail(batch, materials, detail);
  emitInterior(batch, materials, detail);
  const body = batch.build("model-x.body");
  body.userData.kind = "vehicle-body";
  return body;
}

// ---- Wheels ---------------------------------------------------------------

/** One point of a profile revolved about the wheel's X axis. */
interface RevolvePoint {
  /** Lateral offset from the wheel's centre plane. */
  x: number;
  /** Radius. */
  r: number;
  /** Start a new shading panel here. Same crease machinery as the bodyshell. */
  hard?: boolean;
}

/**
 * Revolve a profile about +X, splitting at creases the same way the body does.
 *
 * `flip` mirrors the profile for the other side of the car and reverses the
 * winding with it. Building two variants rather than scaling one by −1 is the
 * only correct option: a negative scale inverts every normal, and a wheel whose
 * rim lights from the inside is a bug that survives three code reviews because
 * it only shows on one side.
 */
function revolveX(
  profile: readonly RevolvePoint[],
  segments: number,
  name: string,
  flip = false,
): THREE.BufferGeometry[] {
  const points = flip ? profile.map((p) => ({ ...p, x: -p.x })).reverse() : profile;
  const runs: RevolvePoint[][] = [];
  let current: RevolvePoint[] = [];
  for (let i = 0; i < points.length; i++) {
    const point = points[i]!;
    current.push(point);
    const isLast = i === points.length - 1;
    if (!isLast && point.hard === true && current.length > 1) {
      runs.push(current);
      current = [point];
    }
    if (isLast) runs.push(current);
  }

  return runs
    .filter((run) => run.length > 1)
    .map((run, runIndex) => {
      const positions: number[] = [];
      const uvs: number[] = [];
      const indices: number[] = [];
      for (let i = 0; i < run.length; i++) {
        const point = run[i]!;
        for (let j = 0; j <= segments; j++) {
          const angle = (j / segments) * Math.PI * 2;
          positions.push(point.x, point.r * Math.cos(angle), point.r * Math.sin(angle));
          uvs.push(j / segments, i / (run.length - 1));
        }
      }
      const stride = segments + 1;
      for (let i = 0; i < run.length - 1; i++) {
        for (let j = 0; j < segments; j++) {
          const a = i * stride + j;
          const b = (i + 1) * stride + j;
          const d = a + 1;
          const c = b + 1;
          // Derived from a plain cylinder at angle 0: (B−A)×(D−A) points inward
          // there, so the quad winds A,D,B / B,D,C to face out.
          indices.push(a, d, b, b, d, c);
        }
      }
      return finishGeometry(positions, indices, uvs, `${name}.${runIndex}`);
    });
}

const TIRE_OUTER = MODEL_X_METRICS.wheelRadius;
/** Visual half-width. Narrower than `tireWidth` so the tyre clears the arch lip. */
const TIRE_HALF = 0.12;

/**
 * The tyre profile, outside-in.
 *
 * Three things this has that a torus does not, in order of how much they
 * matter:
 *
 * 1. **A flat tread band.** The tread sits at exactly `wheelRadius` across its
 *    whole width, so the tyre meets the road along a line rather than at a
 *    point. That line *is* the contact patch as far as the eye is concerned.
 *    A modelled flat spot would be wrong for a different reason worth stating:
 *    the tyre spins, so a deformation baked into the mesh rotates with it, and
 *    a flat spot going round and round is a broken wheel rather than a loaded
 *    one.
 * 2. **A sidewall that bulges past the shoulder.** A loaded tyre's widest point
 *    is below the tread, not at it, and that single silhouette cue is most of
 *    what makes a car look like it has weight on its wheels.
 * 3. **Circumferential grooves**, at `follow` only. Four ribs, two grooves a
 *    side. Lateral tread blocks were tried and dropped: at the size a tyre
 *    occupies on screen they add several hundred triangles of noise that the
 *    mip chain eats anyway.
 */
function tireProfile(detail: ModelXDetail): RevolvePoint[] {
  const shoulder: RevolvePoint[] =
    detail === "follow"
      ? [
          { x: -TIRE_HALF, r: 0.25, hard: true },
          { x: -TIRE_HALF - 0.008, r: 0.286 },
          { x: -TIRE_HALF - 0.013, r: 0.33 },
          { x: -TIRE_HALF - 0.008, r: 0.372 },
          { x: -TIRE_HALF + 0.002, r: 0.398, hard: true },
        ]
      : [
          { x: -TIRE_HALF, r: 0.25, hard: true },
          { x: -TIRE_HALF - 0.013, r: 0.33 },
          { x: -TIRE_HALF + 0.002, r: 0.398, hard: true },
        ];
  const tread: RevolvePoint[] =
    detail === "follow"
      ? [
          { x: -0.104, r: TIRE_OUTER, hard: true },
          { x: -0.062, r: TIRE_OUTER, hard: true },
          { x: -0.052, r: 0.392, hard: true },
          { x: -0.03, r: 0.392, hard: true },
          { x: -0.02, r: TIRE_OUTER, hard: true },
          { x: 0.02, r: TIRE_OUTER, hard: true },
          { x: 0.03, r: 0.392, hard: true },
          { x: 0.052, r: 0.392, hard: true },
          { x: 0.062, r: TIRE_OUTER, hard: true },
          { x: 0.104, r: TIRE_OUTER, hard: true },
        ]
      : [
          { x: -0.104, r: TIRE_OUTER, hard: true },
          { x: 0.104, r: TIRE_OUTER, hard: true },
        ];
  const mirrored = shoulder
    .map((p) => ({ ...p, x: -p.x }))
    .reverse();
  return [...shoulder, ...tread, ...mirrored];
}

/**
 * The rim: a dished barrel with a turbine face.
 *
 * The barrel is revolved so the lip, the well and the mounting face are one
 * continuous surface with creases where a real wheel has them. The spokes are
 * boxes, tapered by placing them on a radius and letting the scale do the work,
 * which is enough at any distance a wheel is legible from.
 */
function rimGeometries(detail: ModelXDetail, flip: boolean): THREE.BufferGeometry[] {
  const segments = detail === "follow" ? 28 : 10;
  // The corridor barrel keeps the lip, the mounting face and the dish and drops
  // the intermediate radii: at fifteen metres a wheel is forty pixels across and
  // the profile between the lip and the hub is one of them.
  const profile: RevolvePoint[] =
    detail === "follow"
      ? [
          { x: -0.108, r: 0.252, hard: true },
          { x: -0.104, r: 0.29 },
          { x: -0.09, r: 0.31, hard: true },
          { x: -0.02, r: 0.318, hard: true },
          { x: 0.04, r: 0.322 },
          { x: 0.086, r: 0.34, hard: true },
          { x: 0.104, r: 0.345, hard: true },
          { x: 0.108, r: 0.33, hard: true },
          { x: 0.09, r: 0.3 },
          { x: 0.075, r: 0.2, hard: true },
          { x: 0.072, r: 0.09, hard: true },
          { x: 0.078, r: 0.075, hard: true },
          { x: 0.05, r: 0.07 },
          { x: 0.02, r: 0.068, hard: true },
        ]
      : [
          { x: -0.104, r: 0.29, hard: true },
          { x: -0.02, r: 0.318, hard: true },
          { x: 0.104, r: 0.345, hard: true },
          { x: 0.108, r: 0.33, hard: true },
          { x: 0.075, r: 0.2, hard: true },
          { x: 0.072, r: 0.075, hard: true },
          { x: 0.02, r: 0.068, hard: true },
        ];
  const parts = revolveX(profile, segments, "model-x.rim", flip);

  const spokes = detail === "follow" ? 10 : 5;
  const face = flip ? -0.082 : 0.082;
  for (let i = 0; i < spokes; i++) {
    const angle = (i / spokes) * Math.PI * 2;
    parts.push(
      UNIT_BOX.clone().applyMatrix4(
        matrix({
          position: [face, Math.cos(angle) * 0.2, Math.sin(angle) * 0.2],
          scale: [0.032, 0.28, 0.052],
          rotation: [angle, 0, 0],
        }),
      ),
    );
  }
  return parts;
}

interface WheelGeometry {
  tire: THREE.BufferGeometry;
  rim: THREE.BufferGeometry;
  brake: THREE.BufferGeometry;
  /** Non-rotating. Present at `follow` only. */
  caliper: THREE.BufferGeometry | null;
}

function mergeOwned(parts: THREE.BufferGeometry[], name: string): THREE.BufferGeometry {
  const merged = parts.length === 1 ? parts[0]! : mergeGeometries(parts, false);
  if (parts.length > 1) for (const part of parts) part.dispose();
  if (!merged) throw new Error(`model-x: could not merge "${name}"`);
  merged.name = name;
  return merged;
}

function createWheelGeometry(detail: ModelXDetail, flip: boolean): WheelGeometry {
  const segments = detail === "follow" ? 32 : 12;
  const tire = mergeOwned(
    revolveX(tireProfile(detail), segments, "model-x.tire", flip),
    "model-x.wheel.tire",
  );
  const rim = mergeOwned(rimGeometries(detail, flip), "model-x.wheel.rim");

  // The disc, with a hat behind it so it is not a coin seen edge-on.
  const discSegments = detail === "follow" ? 24 : 12;
  const disc = new THREE.CylinderGeometry(0.185, 0.185, 0.026, discSegments, 1);
  disc.rotateZ(Math.PI / 2);
  const hat = new THREE.CylinderGeometry(0.088, 0.088, 0.07, Math.max(8, discSegments >> 1), 1);
  hat.rotateZ(Math.PI / 2);
  hat.translate(flip ? 0.03 : -0.03, 0, 0);
  const brake = mergeOwned([disc, hat], "model-x.wheel.brake");

  // The caliper does not rotate with the wheel, so it is built separately and
  // hung off the steering group. A caliper that spins is the single most common
  // tell that a wheel was assembled by whoever needed it done quickly.
  let caliper: THREE.BufferGeometry | null = null;
  if (detail === "follow") {
    const side = flip ? -1 : 1;
    const body = UNIT_BOX.clone().applyMatrix4(
      matrix({ position: [side * 0.045, 0.2, 0.05], scale: [0.055, 0.14, 0.1] }),
    );
    const bridge = UNIT_BOX.clone().applyMatrix4(
      matrix({ position: [0, 0.208, 0.05], scale: [0.13, 0.05, 0.09] }),
    );
    const inner = UNIT_BOX.clone().applyMatrix4(
      matrix({ position: [side * -0.045, 0.2, 0.05], scale: [0.04, 0.12, 0.09] }),
    );
    caliper = mergeOwned([body, bridge, inner], "model-x.wheel.caliper");
  }
  return { tire, rim, brake, caliper };
}

function buildWheel(
  name: string,
  x: number,
  z: number,
  geometries: WheelGeometry,
  materials: ModelXMaterials,
): ModelXWheel {
  const steering = new THREE.Group();
  steering.name = `${name}.steering`;
  steering.position.set(x, MODEL_X_METRICS.wheelRadius, z);

  const spin = new THREE.Group();
  spin.name = `${name}.spin`;
  steering.add(spin);

  const brake = new THREE.Mesh(geometries.brake, materials.brake);
  brake.name = `${name}.brake`;
  brake.castShadow = true;
  spin.add(brake);

  const rim = new THREE.Mesh(geometries.rim, materials.wheel);
  rim.name = `${name}.rim`;
  rim.castShadow = true;
  spin.add(rim);

  const tire = new THREE.Mesh(geometries.tire, materials.tire);
  tire.name = `${name}.tire`;
  tire.castShadow = true;
  tire.receiveShadow = true;
  spin.add(tire);

  if (geometries.caliper) {
    const caliper = new THREE.Mesh(geometries.caliper, materials.brake);
    caliper.name = `${name}.caliper`;
    caliper.castShadow = true;
    steering.add(caliper);
  }

  return { steering, spin, tire, rim };
}

const WHEEL_KEYS = ["frontLeft", "frontRight", "rearLeft", "rearRight"] as const;

function resolveRig(root: THREE.Group, ownsMaterials: boolean): ModelXRig {
  const body = root.getObjectByName("model-x.body");
  if (!(body instanceof THREE.Group)) throw new Error("model-x: body group is missing");

  const wheels = {} as Record<(typeof WHEEL_KEYS)[number], ModelXWheel>;
  for (const key of WHEEL_KEYS) {
    const steering = root.getObjectByName(`${key}.steering`);
    const spin = root.getObjectByName(`${key}.spin`);
    const tire = root.getObjectByName(`${key}.tire`);
    const rim = root.getObjectByName(`${key}.rim`);
    if (!(steering instanceof THREE.Group) || !(spin instanceof THREE.Group)) {
      throw new Error(`model-x: wheel rig "${key}" is incomplete`);
    }
    if (!(tire instanceof THREE.Mesh) || !(rim instanceof THREE.Mesh)) {
      throw new Error(`model-x: wheel meshes for "${key}" are missing`);
    }
    wheels[key] = { steering, spin, tire, rim };
  }
  return { root, body, wheels, ownsMaterials };
}

export function buildModelX(options: ModelXBuildOptions = {}): ModelXRig {
  const detail = options.detail ?? "follow";
  const materials = options.materials ?? createModelXMaterials(options.paint);
  const root = new THREE.Group();
  root.name = "model-x";
  root.userData.kind = "vehicle";
  root.userData.vehicleModel = "model-x";
  root.userData.design = "lumbridge-ev-01";
  root.userData.lod = detail;
  root.userData.unbranded = true;
  root.userData.forwardAxis = "-Z";

  const body = buildBody(materials, detail);
  root.add(body);
  if (detail === "follow") {
    root.add(buildMirrors(materials));
    root.add(buildGlassFrames(materials));
  }

  // Two geometry sets, one per side, so the rim dish and the caliper face the
  // right way on both. Left and right share nothing but the material.
  const right = createWheelGeometry(detail, false);
  const left = createWheelGeometry(detail, true);
  const halfTrack = MODEL_X_METRICS.track / 2;
  const wheels: ModelXWheels = {
    frontLeft: buildWheel("frontLeft", -halfTrack, -HALF_WHEELBASE, left, materials),
    frontRight: buildWheel("frontRight", halfTrack, -HALF_WHEELBASE, right, materials),
    rearLeft: buildWheel("rearLeft", -halfTrack, HALF_WHEELBASE, left, materials),
    rearRight: buildWheel("rearRight", halfTrack, HALF_WHEELBASE, right, materials),
  };
  for (const key of WHEEL_KEYS) root.add(wheels[key].steering);
  return { root, body, wheels, ownsMaterials: !options.materials };
}

/** Preferred builder for the original Lumbridge EV visual. */
export function buildLumbridgeEV(options: ModelXBuildOptions = {}): ModelXRig {
  return buildModelX(options);
}

/** Clone the hierarchy while sharing all immutable geometry and material resources. */
export function cloneModelX(source: ModelXRig): ModelXRig {
  return resolveRig(source.root.clone(true), false);
}

export function setModelXWheelRotation(rig: ModelXRig, radians: number): void {
  for (const key of WHEEL_KEYS) rig.wheels[key].spin.rotation.x = radians;
}

export function advanceModelXWheels(rig: ModelXRig, distanceMetres: number): void {
  const delta = -distanceMetres / MODEL_X_METRICS.wheelRadius;
  for (const key of WHEEL_KEYS) rig.wheels[key].spin.rotation.x += delta;
}

/** Set both front wheels, clamped to the physical steering envelope. */
export function setModelXSteering(rig: ModelXRig, radians: number): void {
  const angle = THREE.MathUtils.clamp(
    radians,
    -MODEL_X_METRICS.maxSteeringAngle,
    MODEL_X_METRICS.maxSteeringAngle,
  );
  rig.wheels.frontLeft.steering.rotation.y = angle;
  rig.wheels.frontRight.steering.rotation.y = angle;
}

/**
 * Describe neutral-pose drawables for a traffic renderer that groups matching
 * pieces into InstancedMesh batches. Matrices are cloned and safe to retain.
 */
export function modelXInstanceParts(rig: ModelXRig): ModelXInstancePart[] {
  rig.root.updateMatrixWorld(true);
  const inverseRoot = rig.root.matrixWorld.clone().invert();
  const result: ModelXInstancePart[] = [];
  rig.root.traverse((object) => {
    if (!(object instanceof THREE.Mesh)) return;
    result.push({
      name: object.name,
      geometry: object.geometry,
      material: object.material,
      matrix: inverseRoot.clone().multiply(object.matrixWorld),
      castShadow: object.castShadow,
      receiveShadow: object.receiveShadow,
    });
  });
  return result;
}

/**
 * Dispose the prototype's unique geometry, and its default materials if owned.
 * Never dispose a clone: it references the same GPU resources as the prototype.
 */
export function disposeModelX(
  rig: ModelXRig,
  options: { disposeMaterials?: boolean } = {},
): void {
  const geometries = new Set<THREE.BufferGeometry>();
  const materials = new Set<THREE.Material>();
  rig.root.traverse((object) => {
    if (!(object instanceof THREE.Mesh)) return;
    geometries.add(object.geometry);
    if (Array.isArray(object.material)) {
      for (const material of object.material) materials.add(material);
    } else materials.add(object.material);
  });
  for (const geometry of geometries) geometry.dispose();
  if (options.disposeMaterials ?? rig.ownsMaterials) {
    for (const material of materials) material.dispose();
  }
}

// ---- The headlamp pool -----------------------------------------------------

/**
 * The light the car throws on the road ahead of it — as emission, not as a light.
 *
 * From the chase camera the subject is *the back of a car*, so on an unlit
 * corridor after dark the whole picture of it is two tail lamps and a roof
 * strip: the `drive-101` night frame measured 7.8 mean luminance against 23.2
 * for the same body over a city, and it was the subject that had vanished, not
 * the frame that was underexposed. What makes a car read at night from behind is
 * not the car. It is the pool of light in front of it.
 *
 * **It cannot be a spotlight.** CONTRACT.md §4 gives `Atmosphere` the only light
 * rig there is, and the reason is not bureaucratic: three.js evaluates every
 * light in the fragment shader for every lit surface, the corridor is one of the
 * heaviest boards in the app, and a `SpotLight` with a shadow camera is a second
 * shadow pass over all of it. So this is what `nightlights.ts` does for the
 * buildings, applied to a car: the road is not illuminated, the pool *is* the
 * light — one additive textured quad, two triangles, one draw call, no shadow
 * pass and nothing for the rig to reconcile.
 *
 * Two triangles rather than a tessellated fan with vertex colours, because the
 * whole shape of a beam is a smooth two-dimensional falloff and that is what a
 * texture is. A grid fine enough to interpolate this without banding is a couple
 * of thousand triangles for a thing that is never more than a few hundred pixels.
 *
 * The pattern is a real low beam and not a symmetrical blob:
 *
 *   - it is brightest a few metres ahead and falls away with distance, rather
 *     than being brightest at the lamp, because what you see is the *road*
 *     returning it and the road ahead is at a grazing angle;
 *   - it widens as it goes, from about a car's width at the bumper to three;
 *   - and it kicks to the **right**, which is what a low beam does in a
 *     right-hand-traffic country so that it lights the shoulder and the verge
 *     without putting the hot spot in an oncoming driver's eyes. That asymmetry
 *     is most of what makes the pool read as a headlamp beam rather than as a
 *     glow somebody painted under the car.
 *
 * Everything is in vehicle metres, so it scales with whatever the renderer
 * scales the rig to, exactly as the bodywork does.
 */
export interface HeadlampPool {
  mesh: THREE.Mesh;
  /** 0 = off, 1 = full. Nothing is drawn at 0. */
  setIntensity(level: number): void;
  dispose(): void;
}

/**
 * How far the pool reaches, in vehicle metres — and why the caller gets to say.
 *
 * A real low beam throws about 60 m, and that is the wrong number here. The
 * boards do not draw the world at the vehicle's own scale: the California
 * corridor is a *diorama*, with its carriageway about 5.7 m wide against a car
 * that is honestly 5.04 m long, so it is drawn at roughly half the scale of the
 * thing driving on it. A beam authored at its true range comes out twice as long
 * as it should relative to the road it is lying on, which is exactly what the
 * first render of this showed: a pool that ran off the top of the frame.
 *
 * So the shape below is authored once, in units of its own reach, and the reach
 * is a parameter. `roadTraffic.ts` picks about three carriageway-widths, which is
 * what a low beam looks like from a chase camera. Everything else scales with it,
 * so the pattern is the same pattern at any range.
 */
const POOL_REACH_M = 17;
/** Width of the plane, as a fraction of the reach. Wide enough to hold the lobe. */
const POOL_WIDTH_FRACTION = 0.62;
/** Where the returned brightness has halved, as a fraction of the reach. */
const POOL_FALLOFF_FRACTION = 0.40;
/** Half-width at the bumper, and how fast the beam opens out. */
const POOL_HALF_AT_NOSE_FRACTION = 0.045;
const POOL_SPREAD = 0.17;
/** How far right the hot spot walks over the whole pool, as a fraction of reach. */
const POOL_RIGHT_KICK_FRACTION = 0.075;

/**
 * A headlamp beam is not white on the road. Sodium-free modern optics are cool,
 * and asphalt returns them a little cooler still, but a pure blue-white pool
 * reads as moonlight rather than as a lamp — so this sits just on the warm side
 * of the LED and lets the marking paint inside it go properly white.
 */
const POOL_COLOR = 0xd8e4ff;

/**
 * Peak radiance of the pool.
 *
 * Under 1 for the same reason `WINDOW_GAIN` is in `nightlights.ts`: this blends
 * additively over an asphalt that already has a moonlit floor under it, and at 1
 * the near field clips to white and takes the lane markings inside it with it.
 * What has to survive is the *ratio* between the pool, the markings in it and
 * the black outside it.
 */
const POOL_GAIN = 0.8;

function headlampPoolTexture(): THREE.Texture {
  const width = 128;
  const height = 256;
  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext("2d");
  if (!ctx) throw new Error("2D canvas context unavailable");
  const image = ctx.createImageData(width, height);

  for (let row = 0; row < height; row += 1) {
    // Everything is in fractions of the reach, so one texture serves any range.
    // `flipY` is on by default, so image row 0 is v = 1, which the geometry
    // below puts at the far end of the pool.
    const ahead = 1 - row / (height - 1);
    // Grazing incidence plus inverse-square, collapsed into one curve fitted to
    // what a low beam looks like from behind the car: a bright throw that starts
    // just off the bumper, peaks about a fifth of the way out, and is gone at
    // the far end. The `smoothstep` at the near end is the bumper's own shadow.
    const near = Math.min(1, Math.max(0, ahead / 0.05));
    // …and a taper to nothing at the far edge of the quad. Without it the beam
    // still has an eighth of its brightness where the geometry stops, and a
    // headlamp pool that ends in a ruled horizontal line across the carriageway
    // is the single most obvious way this reads as a decal rather than as light.
    // The same is true across: `POOL_WIDTH_FRACTION` is wider than the lobe
    // needs so that the sides run out inside the quad rather than at its edge.
    const far = 1 - Math.min(1, Math.max(0, (ahead - 0.68) / 0.32));
    const along =
      (near * near * (3 - 2 * near) * (far * far * (3 - 2 * far))) /
      (1 + (ahead / POOL_FALLOFF_FRACTION) ** 2.1);
    const half = POOL_HALF_AT_NOSE_FRACTION + ahead * POOL_SPREAD * POOL_WIDTH_FRACTION;
    const centre = ahead * POOL_RIGHT_KICK_FRACTION;
    for (let column = 0; column < width; column += 1) {
      const across = (column / (width - 1) - 0.5) * POOL_WIDTH_FRACTION - centre;
      const lobe = Math.exp(-((across / half) ** 2) * 2.0);
      const value = Math.max(0, Math.min(1, along * lobe));
      const at = (row * width + column) * 4;
      image.data[at] = 255;
      image.data[at + 1] = 255;
      image.data[at + 2] = 255;
      image.data[at + 3] = Math.round(value * 255);
    }
  }

  ctx.putImageData(image, 0, 0);
  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  return texture;
}

export function buildHeadlampPool(options: { reachM?: number } = {}): HeadlampPool {
  const reach = options.reachM ?? POOL_REACH_M;
  const geometry = new THREE.PlaneGeometry(reach * POOL_WIDTH_FRACTION, reach);
  geometry.rotateX(-Math.PI / 2);
  /**
   * `+Y` of the plane becomes `-Z` after that rotation, which is the nose, so
   * the pool runs forward from the bumper.
   *
   * It sits **on the rig's own origin plane** — the tyre contact plane — and not
   * at the height of the asphalt, which took a magenta test quad to work out.
   * The corridor renderer lifts the car a whisker clear of the road, and that
   * lift is not decoration: the vehicle is placed from the *transport pack's*
   * route while the road ribbon is draped from the *city pack's* road, and the
   * two are close but not the same line, so the ground under the car and the
   * ground under the ribbon differ by a metre or two along a 700 km corridor.
   * At thirteen times vertical exaggeration that is more than enough to put a
   * pool authored at the asphalt's nominal height underneath it, where the depth
   * test quietly discards every pixel of it. Sitting where the tyres sit is the
   * one height that is right wherever the car is, and it is the same answer the
   * renderer already had to reach for the car itself.
   */
  geometry.translate(0, 0, -reach / 2 - MODEL_X_METRICS.length / 2);

  /**
   * No DOM, no pool — and that is the safe failure, not a degraded one.
   *
   * The beam pattern *is* the texture: every bit of shape this thing has lives
   * in the alpha channel, so a pool built without one is not a dimmer pool, it
   * is a hard-edged glowing rectangle lying across the carriageway. The road
   * layer is constructed in headless unit tests that have no `document` and no
   * renderer either, so the honest answer there is a pool that is never drawn.
   */
  const map = typeof document === "undefined" ? null : headlampPoolTexture();
  const material = new THREE.MeshBasicMaterial({
    name: "model-x.headlamp-pool",
    color: POOL_COLOR,
    map,
    transparent: true,
    opacity: 0,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
    side: THREE.DoubleSide,
  });

  const mesh = new THREE.Mesh(geometry, material);
  mesh.name = "model-x.headlamp-pool";
  mesh.visible = false;
  // The pool is a long way in front of its own origin, and a chase camera keeps
  // the car near the bottom of the frame with the beam filling the middle of it.
  // Culled on the car's bounding sphere it flickers out at exactly the framing
  // it exists for.
  mesh.frustumCulled = false;
  mesh.renderOrder = 2;

  return {
    mesh,
    setIntensity(level: number) {
      const clamped = Math.max(0, Math.min(1, level));
      material.opacity = clamped * POOL_GAIN;
      mesh.visible = map !== null && clamped > 0.002;
    },
    dispose() {
      geometry.dispose();
      map?.dispose();
      material.dispose();
    },
  };
}
