import type { Rgb } from "../image/Raster.js";

/**
 * Minimal indexed triangle mesh with vertex colours.
 *
 * Vertex colours rather than a texture atlas: the PSX drew plenty of untextured
 * gouraud geometry, and the palette we extract from the reference is per-region
 * anyway. Faceted normals keep the Silent Hill / early-3D look without textures.
 */

export interface MeshData {
  positions: number[];
  normals: number[];
  colors: number[];
  /** Optional TEXCOORD_0 (u,v per vertex). Empty until unwrap runs. */
  uvs: number[];
  indices: number[];
  /** One entry per vertex, filled in by the skinning pass. */
  joints: number[];
  weights: number[];
  /**
   * Body region id per vertex, and where along that region's loft the vertex
   * sits (0 at the root ring, 1 at the tip). Written by the tagging helpers in
   * `creator/coverage.ts`; garments use them to claim the skin they cover.
   * Build-time only — the GLB exporter ignores both.
   */
  parts: number[];
  ts: number[];
}

export const createMesh = (): MeshData => ({
  positions: [],
  normals: [],
  colors: [],
  uvs: [],
  indices: [],
  joints: [],
  weights: [],
  parts: [],
  ts: [],
});

/** Append `source` geometry into `target`, remapping indices. Skin weights cleared. */
export function appendMesh(target: MeshData, source: MeshData): void {
  const base = vertexCount(target);
  target.positions.push(...source.positions);
  target.normals.push(...source.normals);
  target.colors.push(...source.colors);
  if (source.uvs.length > 0) {
    // Keep UV streams aligned when present; leave empty if neither side has them.
    if (target.uvs.length === 0 && base > 0) {
      for (let i = 0; i < base; i++) target.uvs.push(0, 0);
    }
    target.uvs.push(...source.uvs);
  } else if (target.uvs.length > 0) {
    const n = vertexCount(source);
    for (let i = 0; i < n; i++) target.uvs.push(0, 0);
  }
  // Region tags are sparse — pad both sides to full length so vertex i in the
  // merged mesh still names vertex i's region.
  padTags(target, base);
  padTags(source, vertexCount(source));
  target.parts.push(...source.parts);
  target.ts.push(...source.ts);

  for (const index of source.indices) target.indices.push(index + base);
  // Weights are recomputed for the combined mesh after assembly.
  target.joints = [];
  target.weights = [];
}

/** Fill any untagged tail with the "no region" sentinel. */
export function padTags(mesh: MeshData, count: number): void {
  while (mesh.parts.length < count) mesh.parts.push(0);
  while (mesh.ts.length < count) mesh.ts.push(0);
}

export type Vec3Tuple = [number, number, number];

export const vertexCount = (mesh: MeshData): number => mesh.positions.length / 3;
export const triangleCount = (mesh: MeshData): number => mesh.indices.length / 3;

function pushVertex(mesh: MeshData, position: Vec3Tuple, color: Rgb): number {
  const index = vertexCount(mesh);
  mesh.positions.push(position[0], position[1], position[2]);
  mesh.normals.push(0, 0, 0);
  // glTF COLOR_0 is linear float; the reference is sRGB bytes.
  mesh.colors.push(srgbToLinear(color.r), srgbToLinear(color.g), srgbToLinear(color.b), 1);
  return index;
}

const srgbToLinear = (channel: number): number => {
  const c = channel / 255;
  return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};

function pushTriangle(mesh: MeshData, a: number, b: number, c: number): void {
  mesh.indices.push(a, b, c);
}

function pushQuad(mesh: MeshData, a: number, b: number, c: number, d: number): void {
  pushTriangle(mesh, a, b, c);
  pushTriangle(mesh, a, c, d);
}

export function add(a: Vec3Tuple, b: Vec3Tuple): Vec3Tuple {
  return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
}

export function sub(a: Vec3Tuple, b: Vec3Tuple): Vec3Tuple {
  return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}

export function scale(v: Vec3Tuple, s: number): Vec3Tuple {
  return [v[0] * s, v[1] * s, v[2] * s];
}

export function lerp(a: Vec3Tuple, b: Vec3Tuple, t: number): Vec3Tuple {
  return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}

export function length(v: Vec3Tuple): number {
  return Math.hypot(v[0], v[1], v[2]);
}

export function normalise(v: Vec3Tuple): Vec3Tuple {
  const len = length(v);
  return len > 1e-8 ? [v[0] / len, v[1] / len, v[2] / len] : [0, 1, 0];
}

export function cross(a: Vec3Tuple, b: Vec3Tuple): Vec3Tuple {
  return [
    a[1] * b[2] - a[2] * b[1],
    a[2] * b[0] - a[0] * b[2],
    a[0] * b[1] - a[1] * b[0],
  ];
}

export function dot(a: Vec3Tuple, b: Vec3Tuple): number {
  return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}

/**
 * Local frame with `axis` as the primary direction (limb length).
 *
 * Pass `hintRight` from the previous ring to **parallel-transport** the frame
 * along a bent limb. Without that, each ring picks world-up independently and
 * the right vector can spin the long way around the bone (classic tube twist —
 * sleeves looking wound 2–3 turns along an A-pose arm).
 */
export function basisFromAxis(
  axis: Vec3Tuple,
  hintRight?: Vec3Tuple,
): { forward: Vec3Tuple; right: Vec3Tuple; up: Vec3Tuple } {
  const forward = normalise(axis);

  if (hintRight) {
    // Project previous right onto the plane ⊥ forward, then take the short flip.
    let right = sub(hintRight, scale(forward, dot(hintRight, forward)));
    if (length(right) > 1e-6) {
      right = normalise(right);
      if (dot(right, hintRight) < 0) right = scale(right, -1);
      const up = normalise(cross(forward, right));
      return { forward, right, up };
    }
  }

  const worldUp: Vec3Tuple = Math.abs(forward[1]) > 0.9 ? [0, 0, 1] : [0, 1, 0];
  const right = normalise(cross(worldUp, forward));
  const up = normalise(cross(forward, right));
  return { forward, right, up };
}

interface Ring {
  indices: number[];
  /** Frame right vector — fed into the next ring for parallel transport. */
  right: Vec3Tuple;
}

/**
 * Builds a cross-section ring in a plane perpendicular to `axis`.
 *
 * `sides` 4 = classic PSX box limb; 6 is the Silent Hill sweet spot (reads
 * round at 240p without burning the triangle budget).
 */
function orientedRing(
  mesh: MeshData,
  centre: Vec3Tuple,
  axis: Vec3Tuple,
  halfWidth: number,
  halfDepth: number,
  color: Rgb,
  sides: number,
  hintRight?: Vec3Tuple,
): Ring {
  const { right, up } = basisFromAxis(axis, hintRight);
  const indices: number[] = [];

  for (let i = 0; i < sides; i++) {
    const angle = (i / sides) * Math.PI * 2 + Math.PI / sides;
    // Elliptical profile: width across the body, depth front-to-back.
    const rx = Math.cos(angle) * halfWidth;
    const ry = Math.sin(angle) * halfDepth;
    const position = add(centre, add(scale(right, rx), scale(up, ry)));
    indices.push(pushVertex(mesh, position, color));
  }

  return { indices, right };
}

/**
 * Skins the side wall between two rings.
 *
 * Rings are emitted CCW when looking *along* the extrusion axis (from `lower`
 * toward `upper`). For three.js / glTF (CCW front faces) the outward side of
 * the tube is then the winding lower[i] → lower[next] → upper[next] → upper[i].
 * The previous order (lower → upper → upper_next → lower_next) produced
 * inward-facing fronts, so the runtime only showed the mesh interior.
 */
function bridgeRings(mesh: MeshData, lower: Ring, upper: Ring): void {
  const n = lower.indices.length;
  for (let i = 0; i < n; i++) {
    const next = (i + 1) % n;
    pushQuad(
      mesh,
      lower.indices[i]!,
      lower.indices[next]!,
      upper.indices[next]!,
      upper.indices[i]!,
    );
  }
}

function capRing(mesh: MeshData, ring: Ring, axis: Vec3Tuple, outward: boolean): void {
  // Fan from the first vertex. Fine for convex rings of 4–8 sides.
  const [first, ...rest] = ring.indices;
  if (first === undefined || rest.length < 2) return;

  // Ring is CCW looking along +axis. The +axis end cap (outward) keeps that
  // order; the −axis end cap reverses it so the normal points out of the tube.
  for (let i = 0; i < rest.length - 1; i++) {
    if (outward) pushTriangle(mesh, first, rest[i]!, rest[i + 1]!);
    else pushTriangle(mesh, first, rest[i + 1]!, rest[i]!);
  }
  void axis;
}

export interface TubeOptions {
  /** Cross-section sides. 4 = blocky, 6 = SH1-ish. */
  sides?: number;
  /** Intermediate rings for skinning bend. */
  sections?: number;
  /** Cap the ends. */
  caps?: boolean;
  /**
   * Optional per-section width/depth scale curve, t in 0..1.
   * Used for muscle/joint bulges (elbow, knee, calf).
   */
  profile?: (t: number) => { width: number; depth: number };
}

/**
 * A tapered limb between two points. Rings sit perpendicular to the bone so
 * diagonal arms stay round instead of becoming stair-stepped world boxes.
 */
export function tube(
  mesh: MeshData,
  from: Vec3Tuple,
  to: Vec3Tuple,
  fromHalfWidth: number,
  toHalfWidth: number,
  depthRatio: number,
  color: Rgb,
  options: TubeOptions | number = {},
): void {
  // Back-compat: older call sites passed `sections` as a bare number.
  const opts: TubeOptions = typeof options === "number" ? { sections: options } : options;
  const sections = Math.max(1, opts.sections ?? 3);
  const sides = Math.max(4, opts.sides ?? 6);
  const caps = opts.caps ?? true;
  const axis = sub(to, from);
  if (length(axis) < 1e-6) return;

  const rings: Ring[] = [];
  let prevRight: Vec3Tuple | undefined;

  for (let s = 0; s <= sections; s++) {
    const t = s / sections;
    const centre = lerp(from, to, t);
    const baseWidth = fromHalfWidth + (toHalfWidth - fromHalfWidth) * t;
    const profile = opts.profile?.(t) ?? { width: 1, depth: 1 };
    const halfWidth = baseWidth * profile.width;
    const halfDepth = baseWidth * depthRatio * profile.depth;
    const ring = orientedRing(mesh, centre, axis, halfWidth, halfDepth, color, sides, prevRight);
    prevRight = ring.right;
    rings.push(ring);
  }

  for (let s = 0; s < sections; s++) bridgeRings(mesh, rings[s]!, rings[s + 1]!);

  if (caps) {
    capRing(mesh, rings[0]!, axis, false);
    capRing(mesh, rings[rings.length - 1]!, axis, true);
  }
}

/**
 * Multi-segment path (e.g. full arm shoulder→elbow→wrist) with continuous
 * skinning rings so joints don't show a hard seam.
 */
export function tubePath(
  mesh: MeshData,
  points: Vec3Tuple[],
  halfWidths: number[],
  depthRatio: number,
  color: Rgb,
  options: { sides?: number; sectionsPerSegment?: number; caps?: boolean } = {},
): void {
  if (points.length < 2) return;
  const sides = options.sides ?? 6;
  const sectionsPerSegment = options.sectionsPerSegment ?? 2;
  const caps = options.caps ?? true;

  const rings: Ring[] = [];
  const axes: Vec3Tuple[] = [];
  // Parallel-transport the ring frame along the whole path so elbows don't
  // introduce a multi-turn twist (right·prev would otherwise jump when the
  // bone axis crosses the world-up singularity).
  let prevRight: Vec3Tuple | undefined;

  for (let segment = 0; segment < points.length - 1; segment++) {
    const from = points[segment]!;
    const to = points[segment + 1]!;
    const fromW = halfWidths[segment] ?? halfWidths[halfWidths.length - 1]!;
    const toW = halfWidths[segment + 1] ?? fromW;
    const axis = sub(to, from);
    if (length(axis) < 1e-6) continue;

    const start = segment === 0 ? 0 : 1; // skip duplicate ring at shared joints
    for (let s = start; s <= sectionsPerSegment; s++) {
      const t = s / sectionsPerSegment;
      const centre = lerp(from, to, t);
      // Slight bulge mid-segment so elbows/knees don't look like broomsticks.
      const bulge = 1 + 0.06 * Math.sin(t * Math.PI);
      const halfWidth = (fromW + (toW - fromW) * t) * bulge;
      const ring = orientedRing(
        mesh,
        centre,
        axis,
        halfWidth,
        halfWidth * depthRatio,
        color,
        sides,
        prevRight,
      );
      prevRight = ring.right;
      rings.push(ring);
      axes.push(axis);
    }
  }

  for (let s = 0; s < rings.length - 1; s++) bridgeRings(mesh, rings[s]!, rings[s + 1]!);
  if (caps && rings.length > 0) {
    capRing(mesh, rings[0]!, axes[0] ?? [0, 1, 0], false);
    capRing(mesh, rings[rings.length - 1]!, axes[axes.length - 1] ?? [0, 1, 0], true);
  }
}

/**
 * Keyframed cross-section for {@link loft} — same idea as ludus `loftPart`.
 * `rx` is half-width on the ring right axis, `rz` half-depth on ring up.
 */
export interface LoftKey {
  /** 0..1 path parameter (keys must be sorted ascending). */
  t: number;
  c: Vec3Tuple;
  rx: number;
  rz: number;
}

export interface LoftOptions {
  /** Cross-section sides. 4 = PSX diamond limb, 6 = angular torso. */
  sides?: number;
  /** Sampled rings along the key path (defaults to keys.length). */
  rings?: number;
  /** Cap both ends (default true). Overridden by capStart / capEnd. */
  caps?: boolean;
  /** Cap the first ring (limb root). Default = caps. */
  capStart?: boolean;
  /** Cap the last ring (limb tip). Default = caps. */
  capEnd?: boolean;
  /**
   * Seed for the first ring's right axis (`rx` runs along it; `rz` along
   * forward × right). Without it the first ring picks world-up, which flips
   * 90° as a limb crosses ~26° from vertical — fine for round sections, wrong
   * for flat ones like a palm or a foot.
   */
  startRight?: Vec3Tuple;
}

const hermite01 = (t: number): number => t * t * (3 - 2 * t);

/**
 * Loft an angular tube through keyframed rings (ludus gladiator style).
 *
 * Centres + independent rx/rz give real anatomical silhouettes; low `sides`
 * (4–6) keeps the PS1 / FF8 faceted look. Ring frames are parallel-transported
 * along the path so bent limbs don't twist.
 */
/** Where a loft's vertices landed, so callers can tag them by region. */
export interface LoftResult {
  firstVertex: number;
  rings: number;
  sides: number;
}

export function loft(
  mesh: MeshData,
  keys: LoftKey[],
  color: Rgb,
  options: LoftOptions = {},
): LoftResult {
  const firstVertex = vertexCount(mesh);
  if (keys.length < 2) return { firstVertex, rings: 0, sides: 0 };
  const sides = Math.max(4, options.sides ?? 6);
  const ringCount = Math.max(2, options.rings ?? keys.length);
  const caps = options.caps ?? true;
  const capStart = options.capStart ?? caps;
  const capEnd = options.capEnd ?? caps;

  // Sample smooth centres / radii along the key curve.
  type Sample = { c: Vec3Tuple; rx: number; rz: number };
  const samples: Sample[] = [];
  for (let i = 0; i < ringCount; i++) {
    const t = i / (ringCount - 1);
    let k = 0;
    while (k < keys.length - 2 && (keys[k + 1]?.t ?? 1) < t) k++;
    const a = keys[k]!;
    const b = keys[k + 1]!;
    const span = Math.max(1e-6, b.t - a.t);
    const ft = hermite01(Math.min(1, Math.max(0, (t - a.t) / span)));
    samples.push({
      c: lerp(a.c, b.c, ft),
      rx: a.rx + (b.rx - a.rx) * ft,
      rz: a.rz + (b.rz - a.rz) * ft,
    });
  }

  const rings: Ring[] = [];
  const axes: Vec3Tuple[] = [];
  let prevRight: Vec3Tuple | undefined = options.startRight;

  for (let i = 0; i < ringCount; i++) {
    const prev = samples[Math.max(0, i - 1)]!;
    const next = samples[Math.min(ringCount - 1, i + 1)]!;
    const axis = sub(next.c, prev.c);
    if (length(axis) < 1e-8) {
      // Degenerate — fall back to vertical.
      const fallback: Vec3Tuple = [0, 1, 0];
      const ring = orientedRing(
        mesh,
        samples[i]!.c,
        fallback,
        samples[i]!.rx,
        samples[i]!.rz,
        color,
        sides,
        prevRight,
      );
      prevRight = ring.right;
      rings.push(ring);
      axes.push(fallback);
      continue;
    }
    const ring = orientedRing(
      mesh,
      samples[i]!.c,
      axis,
      samples[i]!.rx,
      samples[i]!.rz,
      color,
      sides,
      prevRight,
    );
    prevRight = ring.right;
    rings.push(ring);
    axes.push(axis);
  }

  for (let s = 0; s < rings.length - 1; s++) bridgeRings(mesh, rings[s]!, rings[s + 1]!);
  if (rings.length > 0) {
    if (capStart) capRing(mesh, rings[0]!, axes[0] ?? [0, 1, 0], false);
    if (capEnd) capRing(mesh, rings[rings.length - 1]!, axes[axes.length - 1] ?? [0, 1, 0], true);
  }

  // Caps reuse ring vertices, so the vertex block is exactly rings x sides.
  return { firstVertex, rings: ringCount, sides };
}

/** Rectangular face opening on lower rings — clears forehead / eyes. */
export interface DomeFaceCut {
  /**
   * How far up the cap (0 at hairline → 1 at crown) the cut extends.
   * Default 0.62 — open through the eye band, closed over the upper scalp.
   */
  height?: number;
  /**
   * Half-width of the cut as a fraction of the ring half-width (0–1).
   * Default 0.62 — temples still wrap; centre front is open.
   */
  halfWidth?: number;
  /**
   * Face-depth of the cut plane as a fraction of halfDepth.
   * Default 0.14 — flat “window” pulled back off the face.
   */
  depth?: number;
}

export interface DomeShellOptions {
  /** Number of rings along the skull axis (not counting the apex). */
  sections?: number;
  /** Ring sides. 6–8 reads as a helmet at PSX resolution. */
  sides?: number;
  /**
   * How far the face half of each ring extends relative to halfDepth
   * at the hairline. ~0.85–1.0 covers forehead scalp.
   */
  frontScale?: number;
  /** Front scale at the crown end of the cap. */
  frontScaleTop?: number;
  /** How far the occiput half extends relative to halfDepth. */
  backScale?: number;
  /** Radial scale at the crown ring (keep high so the top isn't bald). */
  topScale?: number;
  /** World-space face direction (default +Z). */
  faceDir?: Vec3Tuple;
  /**
   * Rectangular cut on the face side of lower rings so forehead and eyes
   * stay clear. Temples and crown remain covered.
   */
  faceCut?: DomeFaceCut | true;
  /** Close the crown end with an apex fan. */
  apex?: boolean;
}

/**
 * Skull-cap shell extruded along an arbitrary axis (hairline → crown).
 *
 * Rings lie in planes perpendicular to `from→to`, so the cap follows the
 * angled top of the head instead of stacking as a vertical chimney. Face/back
 * asymmetry uses the projection of `faceDir` into each ring plane.
 */
export function domeShell(
  mesh: MeshData,
  from: Vec3Tuple,
  to: Vec3Tuple,
  halfWidth: number,
  halfDepth: number,
  color: Rgb,
  options: DomeShellOptions = {},
): void {
  const axis = sub(to, from);
  const axisLen = length(axis);
  if (axisLen < 1e-6) return;

  const sections = Math.max(2, options.sections ?? 4);
  const sides = Math.max(6, options.sides ?? 8);
  const frontScaleBottom = options.frontScale ?? 0.88;
  const frontScaleTop = options.frontScaleTop ?? frontScaleBottom * 0.72;
  const backScale = options.backScale ?? 1.12;
  const topScale = options.topScale ?? 0.82;
  const withApex = options.apex ?? true;

  const faceCutOpt = options.faceCut === true ? {} : options.faceCut;
  const cutHeight = faceCutOpt?.height ?? (faceCutOpt ? 0.62 : 0);
  const cutHalfWidthFrac = faceCutOpt?.halfWidth ?? 0.62;
  const cutDepthFrac = faceCutOpt?.depth ?? 0.14;
  const useFaceCut = faceCutOpt != null && cutHeight > 0;

  const axisN = normalise(axis);
  const faceWanted = options.faceDir ?? ([0, 0, 1] as Vec3Tuple);
  // Face vector in the ring plane (perp to skull axis).
  let face = sub(faceWanted, scale(axisN, dot(faceWanted, axisN)));
  if (length(face) < 1e-5) {
    // Axis parallel to faceDir — pick a stable fallback.
    const alt: Vec3Tuple = Math.abs(axisN[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0];
    face = sub(alt, scale(axisN, dot(alt, axisN)));
  }
  face = normalise(face);
  // side × face should align with axis for a right-handed frame.
  // side = axis × face  →  looking along axis, face is "up" of the ring, side is "right".
  const side = normalise(cross(axisN, face));

  const rings: Ring[] = [];

  for (let s = 0; s <= sections; s++) {
    const t = s / sections;
    const radial = 1 - (1 - topScale) * (t * t * 0.85);
    const bulge = 1 + 0.05 * Math.sin(t * Math.PI);
    const centre = lerp(from, to, t);
    const hw = halfWidth * radial * bulge;
    const hd = halfDepth * radial * bulge;
    const frontScale = frontScaleBottom + (frontScaleTop - frontScaleBottom) * t;

    // Soft falloff of the cut as we approach its top edge (keeps crown solid).
    const cutBlend =
      useFaceCut && t < cutHeight ? 1 - smoothstep(cutHeight * 0.72, cutHeight, t) : 0;
    const cutW = hw * cutHalfWidthFrac;
    const cutD = hd * cutDepthFrac;

    const indices: number[] = [];
    for (let i = 0; i < sides; i++) {
      // CCW looking along axis: match tube orientedRing convention
      // (cos,sin) → (−side, +face) so bridgeRings faces outward.
      const angle = (i / sides) * Math.PI * 2 + Math.PI / sides;
      const cos = Math.cos(angle);
      const sin = Math.sin(angle);
      const depthScale = sin >= 0 ? frontScale : backScale;
      let sideAmt = -cos * hw;
      let faceAmt = sin * hd * depthScale;

      // Rectangular face window: pull centre-front verts back off the face so
      // forehead + eyes read clear; temples (|side| > cutW) still wrap forward.
      if (cutBlend > 0 && sin > 0.02) {
        const sideAbs = Math.abs(sideAmt);
        if (sideAbs <= cutW) {
          // Hard rectangle in the middle; slight ease at the vertical edges.
          const edge = sideAbs / Math.max(cutW, 1e-6);
          const rect = edge < 0.85 ? 1 : 1 - (edge - 0.85) / 0.15;
          const pull = cutBlend * rect;
          faceAmt = faceAmt * (1 - pull) + cutD * pull;
        }
      }

      const offset = add(scale(side, sideAmt), scale(face, faceAmt));
      indices.push(pushVertex(mesh, add(centre, offset), color));
    }
    rings.push({ indices, right: side });
  }

  for (let s = 0; s < rings.length - 1; s++) {
    bridgeRings(mesh, rings[s]!, rings[s + 1]!);
  }

  if (withApex) {
    const top = rings[rings.length - 1]!;
    // Slight past `to` along the skull axis.
    const apex = pushVertex(mesh, add(to, scale(axisN, axisLen * 0.06)), color);
    const n = top.indices.length;
    for (let i = 0; i < n; i++) {
      const next = (i + 1) % n;
      // Ring is CCW looking along +axis (from→to). Apex is on the +axis end:
      // keep ring order so the fan normal points outward along +axisN.
      pushTriangle(mesh, apex, top.indices[i]!, top.indices[next]!);
    }
  }
}

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

/** Axis-aligned box, still useful for props and simple feet. */
export function box(
  mesh: MeshData,
  centre: Vec3Tuple,
  half: Vec3Tuple,
  color: Rgb,
): void {
  const axis: Vec3Tuple = [0, 1, 0];
  const lower = orientedRing(
    mesh,
    [centre[0], centre[1] - half[1], centre[2]],
    axis,
    half[0],
    half[2],
    color,
    4,
  );
  const upper = orientedRing(
    mesh,
    [centre[0], centre[1] + half[1], centre[2]],
    axis,
    half[0],
    half[2],
    color,
    4,
  );
  bridgeRings(mesh, lower, upper);
  capRing(mesh, lower, axis, false);
  capRing(mesh, upper, axis, true);
}

/**
 * Oriented box — centre, half-extents in local right/up/forward, and a forward
 * axis. Used for hands and boots that must point along the limb.
 *
 * Each face is wound CCW when viewed from outside so front-face culling keeps
 * the exterior visible.
 */
export function orientedBox(
  mesh: MeshData,
  centre: Vec3Tuple,
  halfRight: number,
  halfUp: number,
  halfForward: number,
  forwardAxis: Vec3Tuple,
  color: Rgb,
): void {
  const { forward, right, up } = basisFromAxis(forwardAxis);

  // Index map: bit0 = +right, bit1 = +up, bit2 = +forward
  const corner = (r: number, u: number, f: number): Vec3Tuple =>
    add(
      centre,
      add(scale(right, r * halfRight), add(scale(up, u * halfUp), scale(forward, f * halfForward))),
    );

  const idx = [
    pushVertex(mesh, corner(-1, -1, -1), color), // 0
    pushVertex(mesh, corner(1, -1, -1), color), // 1
    pushVertex(mesh, corner(1, 1, -1), color), // 2
    pushVertex(mesh, corner(-1, 1, -1), color), // 3
    pushVertex(mesh, corner(-1, -1, 1), color), // 4
    pushVertex(mesh, corner(1, -1, 1), color), // 5
    pushVertex(mesh, corner(1, 1, 1), color), // 6
    pushVertex(mesh, corner(-1, 1, 1), color), // 7
  ];

  // −forward (looking toward −F): CCW is 0→3→2→1
  pushQuad(mesh, idx[0]!, idx[3]!, idx[2]!, idx[1]!);
  // +forward
  pushQuad(mesh, idx[4]!, idx[5]!, idx[6]!, idx[7]!);
  // −up
  pushQuad(mesh, idx[0]!, idx[1]!, idx[5]!, idx[4]!);
  // +up
  pushQuad(mesh, idx[3]!, idx[7]!, idx[6]!, idx[2]!);
  // −right
  pushQuad(mesh, idx[0]!, idx[4]!, idx[7]!, idx[3]!);
  // +right
  pushQuad(mesh, idx[1]!, idx[2]!, idx[6]!, idx[5]!);
}

/**
 * Flat normals, computed per triangle and accumulated per shared vertex then
 * re-normalised. Shared vertices on a flat face stay faceted enough for the
 * era; adjacent coplanar quads don't get a hard crease through the middle.
 */
export function computeFlatNormals(mesh: MeshData): void {
  const positions = mesh.positions;
  const normals = new Array<number>(positions.length).fill(0);

  for (let i = 0; i < mesh.indices.length; i += 3) {
    const a = mesh.indices[i]! * 3;
    const b = mesh.indices[i + 1]! * 3;
    const c = mesh.indices[i + 2]! * 3;

    const abx = positions[b]! - positions[a]!;
    const aby = positions[b + 1]! - positions[a + 1]!;
    const abz = positions[b + 2]! - positions[a + 2]!;
    const acx = positions[c]! - positions[a]!;
    const acy = positions[c + 1]! - positions[a + 1]!;
    const acz = positions[c + 2]! - positions[a + 2]!;

    const nx = aby * acz - abz * acy;
    const ny = abz * acx - abx * acz;
    const nz = abx * acy - aby * acx;

    for (const vertex of [a, b, c]) {
      normals[vertex] = normals[vertex]! + nx;
      normals[vertex + 1] = normals[vertex + 1]! + ny;
      normals[vertex + 2] = normals[vertex + 2]! + nz;
    }
  }

  for (let i = 0; i < normals.length; i += 3) {
    const x = normals[i]!;
    const y = normals[i + 1]!;
    const z = normals[i + 2]!;
    const len = Math.hypot(x, y, z);
    if (len > 1e-8) {
      normals[i] = x / len;
      normals[i + 1] = y / len;
      normals[i + 2] = z / len;
    } else {
      normals[i] = 0;
      normals[i + 1] = 1;
      normals[i + 2] = 0;
    }
  }

  mesh.normals = normals;
}

/** Axis-aligned bounds, needed for the GLB accessor min/max. */
export function bounds(mesh: MeshData): { min: Vec3Tuple; max: Vec3Tuple } {
  const min: Vec3Tuple = [Infinity, Infinity, Infinity];
  const max: Vec3Tuple = [-Infinity, -Infinity, -Infinity];

  for (let i = 0; i < mesh.positions.length; i += 3) {
    for (let axis = 0; axis < 3; axis++) {
      const value = mesh.positions[i + axis]!;
      if (value < min[axis]!) min[axis] = value;
      if (value > max[axis]!) max[axis] = value;
    }
  }
  return { min, max };
}
