// Smooth, non-blocky character garments, armor pieces, and sculpted weapons for Frostcall Arena.
// Welds hard-edged outfit normals on Cast walkers, adds a procedural cloth/leather micro-normal texture,
// and attaches seamless bone-driven robes, cloaks, shoulder mantles, circlets, helms, and belts.
import * as THREE from "three";
import type { Walker } from "@/lib/cast/actors/walker";

export type GarmentRole = "hero" | "warrior" | "pyromancer" | "priestess" | "gladiator";

export interface GarmentController {
  update(dt: number, vx: number, vz: number, yaw: number, airborne: boolean): void;
  dispose(): void;
}

// ---------------------------------------------------------------------------
// 1. Procedural micro-detail normal map & pre-warmed shared materials
// ---------------------------------------------------------------------------

let clothNormalTex: THREE.DataTexture | null = null;
function getClothNormalMap(): THREE.DataTexture {
  if (clothNormalTex) return clothNormalTex;
  const size = 64;
  const data = new Uint8Array(size * size * 4);
  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      const u = (x / size) * Math.PI * 16;
      const v = (y / size) * Math.PI * 16;
      const weaveX = Math.sin(u) * Math.cos(v * 0.5) * 0.14 + Math.sin(u * 2 + v) * 0.06;
      const weaveY = Math.cos(v) * Math.sin(u * 0.5) * 0.14 + Math.cos(v * 2 - u) * 0.06;
      const idx = (y * size + x) * 4;
      data[idx] = Math.min(255, Math.max(0, Math.round((weaveX * 0.5 + 0.5) * 255)));
      data[idx + 1] = Math.min(255, Math.max(0, Math.round((weaveY * 0.5 + 0.5) * 255)));
      data[idx + 2] = 248;
      data[idx + 3] = 255;
    }
  }
  const tex = new THREE.DataTexture(data, size, size, THREE.RGBAFormat);
  tex.wrapS = THREE.RepeatWrapping;
  tex.wrapT = THREE.RepeatWrapping;
  tex.repeat.set(6, 6);
  tex.needsUpdate = true;
  clothNormalTex = tex;
  return tex;
}

interface MaterialBank {
  mageRobe: THREE.MeshStandardMaterial;
  mageMantle: THREE.MeshStandardMaterial;
  mageTrimGold: THREE.MeshStandardMaterial;
  mageTrimSilver: THREE.MeshStandardMaterial;
  mageCrystal: THREE.MeshStandardMaterial;
  mageLeather: THREE.MeshStandardMaterial;
  warriorSteel: THREE.MeshStandardMaterial;
  warriorDarkSteel: THREE.MeshStandardMaterial;
  warriorCrimson: THREE.MeshStandardMaterial;
  warriorGold: THREE.MeshStandardMaterial;
  pyroRobe: THREE.MeshStandardMaterial;
  pyroMantle: THREE.MeshStandardMaterial;
  pyroEmber: THREE.MeshStandardMaterial;
  priestRobe: THREE.MeshStandardMaterial;
  priestMantle: THREE.MeshStandardMaterial;
  priestVoid: THREE.MeshStandardMaterial;
  parchment: THREE.MeshStandardMaterial;
  bladeSteel: THREE.MeshStandardMaterial;
}

let bank: MaterialBank | null = null;
export function getGarmentMaterials(): MaterialBank {
  if (bank) return bank;
  const nmap = getClothNormalMap();
  const clothScale = new THREE.Vector2(0.22, 0.22);
  const metalScale = new THREE.Vector2(0.08, 0.08);

  bank = {
    mageRobe: new THREE.MeshStandardMaterial({
      color: "#162852",
      roughness: 0.62,
      metalness: 0.1,
      normalMap: nmap,
      normalScale: clothScale,
      side: THREE.DoubleSide,
    }),
    mageMantle: new THREE.MeshStandardMaterial({
      color: "#1f3870",
      roughness: 0.48,
      metalness: 0.22,
      normalMap: nmap,
      normalScale: clothScale,
      side: THREE.DoubleSide,
    }),
    mageTrimGold: new THREE.MeshStandardMaterial({
      color: "#ecc052",
      roughness: 0.28,
      metalness: 0.84,
      normalMap: nmap,
      normalScale: metalScale,
      side: THREE.DoubleSide,
    }),
    mageTrimSilver: new THREE.MeshStandardMaterial({
      color: "#c0d8f0",
      roughness: 0.25,
      metalness: 0.85,
    }),
    mageCrystal: new THREE.MeshStandardMaterial({
      color: "#80ecff",
      emissive: "#38c8ff",
      emissiveIntensity: 1.75,
      roughness: 0.16,
      metalness: 0.15,
    }),
    mageLeather: new THREE.MeshStandardMaterial({
      color: "#2b2018",
      roughness: 0.62,
      metalness: 0.12,
      normalMap: nmap,
      normalScale: clothScale,
    }),
    warriorSteel: new THREE.MeshStandardMaterial({
      color: "#8f99ad",
      roughness: 0.32,
      metalness: 0.82,
      normalMap: nmap,
      normalScale: metalScale,
    }),
    warriorDarkSteel: new THREE.MeshStandardMaterial({
      color: "#3b4252",
      roughness: 0.38,
      metalness: 0.78,
      normalMap: nmap,
      normalScale: metalScale,
    }),
    warriorCrimson: new THREE.MeshStandardMaterial({
      color: "#c42136",
      roughness: 0.58,
      metalness: 0.12,
      normalMap: nmap,
      normalScale: clothScale,
      side: THREE.DoubleSide,
    }),
    warriorGold: new THREE.MeshStandardMaterial({
      color: "#e8b944",
      roughness: 0.28,
      metalness: 0.85,
      side: THREE.DoubleSide,
    }),
    pyroRobe: new THREE.MeshStandardMaterial({
      color: "#331419",
      roughness: 0.64,
      metalness: 0.08,
      normalMap: nmap,
      normalScale: clothScale,
      side: THREE.DoubleSide,
    }),
    pyroMantle: new THREE.MeshStandardMaterial({
      color: "#8c2318",
      roughness: 0.42,
      metalness: 0.35,
      normalMap: nmap,
      normalScale: clothScale,
      side: THREE.DoubleSide,
    }),
    pyroEmber: new THREE.MeshStandardMaterial({
      color: "#ff9d3b",
      emissive: "#ff6a14",
      emissiveIntensity: 1.9,
      roughness: 0.18,
    }),
    priestRobe: new THREE.MeshStandardMaterial({
      color: "#1e1336",
      roughness: 0.64,
      metalness: 0.08,
      normalMap: nmap,
      normalScale: clothScale,
      side: THREE.DoubleSide,
    }),
    priestMantle: new THREE.MeshStandardMaterial({
      color: "#3b2166",
      roughness: 0.44,
      metalness: 0.3,
      normalMap: nmap,
      normalScale: clothScale,
      side: THREE.DoubleSide,
    }),
    priestVoid: new THREE.MeshStandardMaterial({
      color: "#b87aff",
      emissive: "#8f3dff",
      emissiveIntensity: 1.8,
      roughness: 0.18,
    }),
    parchment: new THREE.MeshStandardMaterial({
      color: "#eadab8",
      roughness: 0.75,
      metalness: 0.02,
    }),
    bladeSteel: new THREE.MeshStandardMaterial({
      color: "#d0daea",
      roughness: 0.22,
      metalness: 0.88,
    }),
  };
  return bank;
}

/** Hidden mesh group containing every garment material so warmShaders() compiles all programs at load. */
export function createGarmentWarmupGroup(): THREE.Group {
  const g = new THREE.Group();
  g.name = "garment-shader-warmup";
  const mats = Object.values(getGarmentMaterials());
  const geo = new THREE.SphereGeometry(0.02, 6, 6);
  for (const m of mats) {
    const mesh = new THREE.Mesh(geo, m);
    mesh.castShadow = true;
    mesh.receiveShadow = true;
    g.add(mesh);
  }
  return g;
}

// ---------------------------------------------------------------------------
// 2. Smooth vertex normal welding for blocky low-poly GLB outfit meshes
// ---------------------------------------------------------------------------

const smoothedGeoCache = new WeakMap<THREE.BufferGeometry, THREE.BufferGeometry>();

function getSmoothedGeometry(src: THREE.BufferGeometry, creaseCos = 0.34): THREE.BufferGeometry {
  const cached = smoothedGeoCache.get(src);
  if (cached) return cached;

  const geo = src.clone();
  const pos = geo.getAttribute("position");
  const index = geo.getIndex();
  if (!pos) return geo;

  const vCount = pos.count;
  const fNormals = new Float32Array(vCount * 3);
  const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
  const ab = new THREE.Vector3(), ac = new THREE.Vector3(), fn = new THREE.Vector3();

  const triCount = index ? index.count / 3 : Math.floor(vCount / 3);
  const ix = (i: number) => (index ? index.getX(i) : i);
  for (let t = 0; t < triCount; t++) {
    const i0 = ix(t * 3), i1 = ix(t * 3 + 1), i2 = ix(t * 3 + 2);
    a.fromBufferAttribute(pos, i0);
    b.fromBufferAttribute(pos, i1);
    c.fromBufferAttribute(pos, i2);
    ab.subVectors(b, a);
    ac.subVectors(c, a);
    fn.crossVectors(ab, ac);
    for (const iv of [i0, i1, i2]) {
      fNormals[iv * 3] += fn.x;
      fNormals[iv * 3 + 1] += fn.y;
      fNormals[iv * 3 + 2] += fn.z;
    }
  }
  for (let i = 0; i < vCount; i++) {
    const nx = fNormals[i * 3], ny = fNormals[i * 3 + 1], nz = fNormals[i * 3 + 2];
    const len = Math.hypot(nx, ny, nz) || 1;
    fNormals[i * 3] = nx / len;
    fNormals[i * 3 + 1] = ny / len;
    fNormals[i * 3 + 2] = nz / len;
  }

  const buckets = new Map<string, number[]>();
  for (let i = 0; i < vCount; i++) {
    const kx = Math.round(pos.getX(i) * 800);
    const ky = Math.round(pos.getY(i) * 800);
    const kz = Math.round(pos.getZ(i) * 800);
    const key = `${kx},${ky},${kz}`;
    let list = buckets.get(key);
    if (!list) {
      list = [];
      buckets.set(key, list);
    }
    list.push(i);
  }

  const outNormals = new Float32Array(vCount * 3);
  for (const list of buckets.values()) {
    if (list.length === 1) {
      const i = list[0];
      outNormals[i * 3] = fNormals[i * 3];
      outNormals[i * 3 + 1] = fNormals[i * 3 + 1];
      outNormals[i * 3 + 2] = fNormals[i * 3 + 2];
      continue;
    }
    for (let aIdx = 0; aIdx < list.length; aIdx++) {
      const i = list[aIdx];
      const nx0 = fNormals[i * 3], ny0 = fNormals[i * 3 + 1], nz0 = fNormals[i * 3 + 2];
      let sx = nx0, sy = ny0, sz = nz0;
      for (let bIdx = 0; bIdx < list.length; bIdx++) {
        if (aIdx === bIdx) continue;
        const j = list[bIdx];
        const nx1 = fNormals[j * 3], ny1 = fNormals[j * 3 + 1], nz1 = fNormals[j * 3 + 2];
        if (nx0 * nx1 + ny0 * ny1 + nz0 * nz1 >= creaseCos) {
          sx += nx1;
          sy += ny1;
          sz += nz1;
        }
      }
      const len = Math.hypot(sx, sy, sz) || 1;
      outNormals[i * 3] = sx / len;
      outNormals[i * 3 + 1] = sy / len;
      outNormals[i * 3 + 2] = sz / len;
    }
  }

  geo.setAttribute("normal", new THREE.BufferAttribute(outNormals, 3));
  smoothedGeoCache.set(src, geo);
  return geo;
}

// ---------------------------------------------------------------------------
// 3. Sculpted Non-Blocky Weapons & Off-Hand Props
// ---------------------------------------------------------------------------

const shadow = <T extends THREE.Object3D>(o: T): T => {
  o.traverse((c) => {
    if ((c as THREE.Mesh).isMesh) {
      c.castShadow = true;
      c.receiveShadow = true;
    }
  });
  return o;
};

/** Sculpted broadsword / gladiator greatsword replacing the BoxGeometry stick. */
export function createGladiatorSword(len = 0.92, elite = false): THREE.Group {
  const M = getGarmentMaterials();
  const g = new THREE.Group();
  g.name = elite ? "prop-greatsword" : "prop-sword";

  const pommel = new THREE.Mesh(new THREE.OctahedronGeometry(0.036, 1), M.warriorGold);
  pommel.scale.set(1, 0.85, 0.85);
  pommel.position.y = -0.09;
  const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.022, 0.019, 0.17, 14), M.mageLeather);
  grip.scale.set(1.15, 1, 0.82);
  grip.position.y = 0;
  g.add(pommel, grip);

  const ringGeo = new THREE.TorusGeometry(0.021, 0.0035, 8, 16);
  for (const ry of [-0.045, 0.0, 0.045]) {
    const r = new THREE.Mesh(ringGeo, M.warriorGold);
    r.rotation.x = Math.PI / 2;
    r.position.y = ry;
    g.add(r);
  }

  const guardCenter = new THREE.Mesh(new THREE.CylinderGeometry(0.034, 0.028, 0.048, 12), M.warriorGold);
  guardCenter.scale.set(1.2, 1, 0.72);
  guardCenter.position.y = 0.085;
  g.add(guardCenter);

  const wingGeo = new THREE.ConeGeometry(0.022, 0.14, 12);
  const wingL = new THREE.Mesh(wingGeo, M.warriorGold);
  wingL.rotation.z = -Math.PI / 2 + 0.18;
  wingL.scale.set(1, 1, 0.62);
  wingL.position.set(0.075, 0.092, 0);
  const wingR = new THREE.Mesh(wingGeo, M.warriorGold);
  wingR.rotation.z = Math.PI / 2 - 0.18;
  wingR.scale.set(1, 1, 0.62);
  wingR.position.set(-0.075, 0.092, 0);
  const gem = new THREE.Mesh(new THREE.OctahedronGeometry(0.022, 1), elite ? M.pyroEmber : M.warriorCrimson);
  gem.position.set(0, 0.088, 0);
  g.add(wingL, wingR, gem);

  const bladeLen = len * 0.78;
  const tipLen = len * 0.22;
  const wBase = elite ? 0.052 : 0.042;
  const wMid = elite ? 0.044 : 0.035;
  const bladeBody = new THREE.Mesh(new THREE.CylinderGeometry(wMid, wBase, bladeLen, 4, 4), M.bladeSteel);
  bladeBody.rotation.y = Math.PI / 4;
  bladeBody.scale.set(1.0, 1.0, 0.22);
  bladeBody.position.y = 0.105 + bladeLen * 0.5;

  const bladeTip = new THREE.Mesh(new THREE.ConeGeometry(wMid, tipLen, 4), M.bladeSteel);
  bladeTip.rotation.y = Math.PI / 4;
  bladeTip.scale.set(1.0, 1.0, 0.22);
  bladeTip.position.y = 0.105 + bladeLen + tipLen * 0.5;

  const fuller = new THREE.Mesh(
    new THREE.CylinderGeometry(0.011, 0.015, bladeLen * 0.82, 8),
    elite ? M.pyroEmber : M.warriorDarkSteel,
  );
  fuller.scale.set(1, 1, 0.55);
  fuller.position.y = 0.12 + bladeLen * 0.42;

  g.add(bladeBody, bladeTip, fuller);
  return shadow(g);
}

/** Domed convex Gladiator Bulwark Shield strapped to the outer left forearm. */
export function createGladiatorShield(): THREE.Group {
  const M = getGarmentMaterials();
  const g = new THREE.Group();
  g.name = "prop-shield";

  const shield = new THREE.Group();
  const pts = [
    new THREE.Vector2(0.0, 0.068),
    new THREE.Vector2(0.09, 0.062),
    new THREE.Vector2(0.20, 0.040),
    new THREE.Vector2(0.29, 0.012),
    new THREE.Vector2(0.31, 0.0),
    new THREE.Vector2(0.29, -0.012),
    new THREE.Vector2(0.0, 0.044),
  ];
  const dishGeo = new THREE.LatheGeometry(pts, 28);
  dishGeo.computeVertexNormals();
  const dish = new THREE.Mesh(dishGeo, M.warriorCrimson);
  dish.rotation.z = -Math.PI / 2;
  shield.add(dish);

  const rim = new THREE.Mesh(new THREE.TorusGeometry(0.30, 0.020, 14, 32), M.warriorGold);
  rim.rotation.y = Math.PI / 2;
  const innerRing = new THREE.Mesh(new THREE.TorusGeometry(0.13, 0.010, 10, 24), M.warriorGold);
  innerRing.rotation.y = Math.PI / 2;
  innerRing.position.x = 0.044;
  shield.add(rim, innerRing);

  const boss = new THREE.Mesh(
    new THREE.SphereGeometry(0.072, 18, 12, 0, Math.PI * 2, 0, Math.PI * 0.55),
    M.warriorGold,
  );
  boss.rotation.z = -Math.PI / 2;
  boss.position.x = 0.032;
  shield.add(boss);

  const chevGeo = new THREE.ConeGeometry(0.024, 0.12, 8);
  for (let i = 0; i < 4; i++) {
    const a = (i * Math.PI) / 2;
    const ch = new THREE.Mesh(chevGeo, M.warriorSteel);
    ch.position.set(0.035, Math.cos(a) * 0.19, Math.sin(a) * 0.19);
    ch.rotation.x = a;
    ch.scale.set(0.55, 1, 1);
    shield.add(ch);
  }

  // Orient outward along -X of hand_l and offset clear of the left fist/wrist
  shield.rotation.y = Math.PI;
  shield.position.set(-0.095, -0.06, 0.02);
  g.add(shield);
  return shadow(g);
}

/** Sculpted Dragonfire Scepter replacing the 6-sided stick wand. */
export function createPyroWand(): THREE.Group {
  const M = getGarmentMaterials();
  const g = new THREE.Group();
  g.name = "prop-wand";

  const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.014, 0.48, 14), M.mageLeather);
  shaft.position.y = 0.15;
  const collar = new THREE.Mesh(new THREE.CylinderGeometry(0.034, 0.021, 0.06, 14), M.warriorGold);
  collar.position.y = 0.39;
  const ring = new THREE.Mesh(new THREE.TorusGeometry(0.052, 0.009, 10, 20), M.warriorGold);
  ring.position.y = 0.45;
  const core = new THREE.Mesh(new THREE.OctahedronGeometry(0.042, 1), M.pyroEmber);
  core.scale.set(0.9, 1.35, 0.9);
  core.position.y = 0.45;
  g.add(shaft, collar, ring, core);

  const prongGeo = new THREE.ConeGeometry(0.012, 0.1, 8);
  for (const s of [-1, 1]) {
    const p = new THREE.Mesh(prongGeo, M.warriorGold);
    p.position.set(s * 0.048, 0.47, 0);
    p.rotation.z = -s * 0.25;
    g.add(p);
  }
  return shadow(g);
}

/** Sculpted Open Arcane Grimoire replacing the BoxGeometry brick. */
export function createPriestTome(): THREE.Group {
  const M = getGarmentMaterials();
  const g = new THREE.Group();
  g.name = "prop-tome";

  const spine = new THREE.Mesh(new THREE.CylinderGeometry(0.024, 0.024, 0.26, 12), M.priestMantle);
  spine.rotation.x = Math.PI / 2;
  g.add(spine);

  const coverGeo = new THREE.CylinderGeometry(0.095, 0.095, 0.26, 12, 1, false, 0, Math.PI);
  const pageGeo = new THREE.CylinderGeometry(0.084, 0.084, 0.24, 12, 1, false, 0, Math.PI);
  for (const side of [-1, 1]) {
    const wing = new THREE.Group();
    wing.rotation.z = side * 0.32;
    const cov = new THREE.Mesh(coverGeo, M.priestMantle);
    cov.rotation.x = Math.PI / 2;
    cov.scale.set(1.2, 1, 0.16);
    cov.position.set(side * 0.065, 0, 0);

    const pg = new THREE.Mesh(pageGeo, M.parchment);
    pg.rotation.x = Math.PI / 2;
    pg.scale.set(1.15, 1, 0.24);
    pg.position.set(side * 0.062, 0.012, 0);

    const trim = new THREE.Mesh(new THREE.CylinderGeometry(0.008, 0.008, 0.26, 8), M.mageTrimGold);
    trim.rotation.x = Math.PI / 2;
    trim.position.set(side * 0.125, 0.004, 0);

    wing.add(cov, pg, trim);
    g.add(wing);
  }

  const runeRing = new THREE.Mesh(new THREE.TorusGeometry(0.068, 0.006, 8, 24), M.priestVoid);
  runeRing.rotation.x = Math.PI / 2;
  runeRing.position.y = 0.055;
  const gem = new THREE.Mesh(new THREE.OctahedronGeometry(0.024, 1), M.priestVoid);
  gem.position.y = 0.068;
  g.add(runeRing, gem);

  g.position.set(0.03, 0.03, 0.04);
  return shadow(g);
}

// ---------------------------------------------------------------------------
// 4. Bone-Attached Sculpted Garments (Seamless Robe Skirt, Cloak, Mantles, Helms)
// ---------------------------------------------------------------------------

function makeLatheShell(
  pts: [number, number][],
  segments = 24,
  phiStart = 0,
  phiLength = Math.PI * 2,
): THREE.BufferGeometry {
  const v2 = pts.map(([r, y]) => new THREE.Vector2(r, y));
  const geo = new THREE.LatheGeometry(v2, segments, phiStart, phiLength);
  geo.computeVertexNormals();
  return geo;
}

/**
 * Build a curved spaulder (shoulder mantle) placed at the outer deltoid (x = +-0.215 m on spine_03).
 */
function buildPauldron(
  side: "l" | "r",
  bodyMat: THREE.Material,
  trimMat: THREE.Material,
  accentMat: THREE.Material,
  style: "mage" | "warrior" | "pyro" | "priest",
): THREE.Group {
  const s = side === "l" ? 1 : -1;
  const g = new THREE.Group();
  g.name = `garment-pauldron-${side}`;
  // Position directly over the outer left/right shoulder joint relative to spine_03
  g.position.set(s * 0.215, 0.135, -0.01);

  const domeGeo = new THREE.SphereGeometry(0.105, 20, 14, 0, Math.PI * 2, 0, Math.PI * 0.56);
  const dome = new THREE.Mesh(domeGeo, bodyMat);
  dome.scale.set(1.15, 0.78, 1.08);
  dome.rotation.z = -s * 0.42;

  const lowerDome = new THREE.Mesh(domeGeo, bodyMat);
  lowerDome.scale.set(0.95, 0.62, 0.94);
  lowerDome.position.set(s * 0.032, -0.038, 0);
  lowerDome.rotation.z = -s * 0.62;

  const rim = new THREE.Mesh(new THREE.TorusGeometry(0.098, 0.011, 10, 24), trimMat);
  rim.scale.set(1.14, 1.04, 0.85);
  rim.rotation.x = Math.PI / 2;
  rim.rotation.y = -s * 0.42;
  rim.position.set(s * 0.004, -0.012, 0);

  g.add(dome, lowerDome, rim);

  if (style === "mage" || style === "pyro" || style === "priest") {
    const wing = new THREE.Mesh(new THREE.ConeGeometry(0.028, 0.13, 10), trimMat);
    wing.scale.set(0.45, 1, 1.15);
    wing.position.set(-s * 0.01, 0.075, -0.015);
    wing.rotation.z = -s * 0.28;
    wing.rotation.x = -0.18;

    const shard = new THREE.Mesh(new THREE.OctahedronGeometry(0.026, 0), accentMat);
    shard.scale.set(0.72, 1.85, 0.72);
    shard.position.set(s * 0.025, 0.095, 0);
    shard.rotation.z = -s * 0.25;
    g.add(wing, shard);
  } else {
    const spike = new THREE.Mesh(new THREE.ConeGeometry(0.024, 0.12, 12), trimMat);
    spike.position.set(s * 0.038, 0.072, 0);
    spike.rotation.z = -s * 0.48;
    const stud = new THREE.Mesh(new THREE.SphereGeometry(0.022, 12, 10), accentMat);
    stud.position.set(0, 0.038, 0.068);
    g.add(spike, stud);
  }

  return shadow(g);
}

interface DynamicCloak {
  group: THREE.Group;
  update(billow: number, swayX: number, time: number, speed: number): void;
}

/**
 * Build a continuous, wrinkle-free pleated Royal Cloak with integrated gold bottom border.
 * Uses arc-length integration along rows so it never stretches, detaches, or produces NaN bounds.
 */
function buildRoyalCloak(
  cloakMat: THREE.Material,
  trimMat: THREE.Material,
  gemMat: THREE.Material,
  length = 0.84,
): DynamicCloak {
  const g = new THREE.Group();
  g.name = "garment-royal-cloak";
  // Anchor on upper back of spine_03
  g.position.set(0, 0.12, -0.115);

  const cols = 14;
  const rows = 12;
  const geo = new THREE.PlaneGeometry(1, length, cols, rows);
  const trimGeo = new THREE.PlaneGeometry(1, 0.045, cols, 1);

  const clothMesh = new THREE.Mesh(geo, cloakMat);
  const trimMesh = new THREE.Mesh(trimGeo, trimMat);
  g.add(clothMesh, trimMesh);

  // Two ornate shoulder clasps + crystal cabochons at the top corners
  for (const s of [-1, 1]) {
    const clasp = new THREE.Mesh(new THREE.CylinderGeometry(0.028, 0.028, 0.016, 14), trimMat);
    clasp.rotation.x = Math.PI / 2 - 0.3;
    clasp.position.set(s * 0.155, 0.01, 0.02);
    const gem = new THREE.Mesh(new THREE.OctahedronGeometry(0.016, 1), gemMat);
    gem.position.set(s * 0.155, 0.012, 0.01);
    g.add(clasp, gem);
  }

  const pos = geo.getAttribute("position") as THREE.BufferAttribute;
  const trimPos = trimGeo.getAttribute("position") as THREE.BufferAttribute;
  const ds = length / rows;

  const update = (billow: number, swayX: number, time: number, speed: number) => {
    const freq = 3.2 + speed * 0.85;
    const amp = 0.03 + Math.min(0.09, speed * 0.01);

    let curY = 0;
    let curZ = 0;
    let curX = 0;
    let prevY = 0;
    let prevZ = 0;
    let prevX = 0;

    for (let r = 0; r <= rows; r++) {
      const v = r / rows; // 0 at shoulders .. 1 at bottom hem
      if (r > 0) {
        const pitch = billow * (0.28 + v * 0.65) + Math.sin(time * freq - v * 2.4) * amp * v;
        const lat = swayX * 0.24 * v;
        prevY = curY;
        prevZ = curZ;
        prevX = curX;
        curY -= Math.cos(pitch) * ds;
        curZ -= Math.sin(pitch) * ds;
        curX += lat * ds;
      }

      const halfW = 0.185 + (0.34 - 0.185) * Math.pow(v, 0.85);
      for (let c = 0; c <= cols; c++) {
        const u = (c / cols) * 2 - 1; // -1 .. +1
        const pleat = Math.cos(u * Math.PI * 3.0) * 0.013 * v;
        const wrapZ = (1 - Math.cos(u * Math.PI * 0.5)) * 0.042 * (1 - v * 0.55);
        const vx = curX + u * halfW;
        const vy = curY - (1 - Math.cos(u * Math.PI * 0.5)) * 0.025 * v;
        const vz = curZ + wrapZ - pleat;
        const idx = r * (cols + 1) + c;
        pos.setXYZ(idx, vx, vy, vz);

        if (r === rows - 1) {
          trimPos.setXYZ(c, prevX + u * (halfW * 0.98), prevY, prevZ + wrapZ - pleat - 0.003);
        } else if (r === rows) {
          trimPos.setXYZ((cols + 1) + c, vx, vy, vz - 0.003);
        }
      }
    }
    pos.needsUpdate = true;
    trimPos.needsUpdate = true;
    geo.computeVertexNormals();
  };

  // Initialize vertices immediately so bounding box/sphere never see uninitialized coords
  update(0.12, 0, 0, 0);
  geo.computeBoundingSphere();
  trimGeo.computeBoundingSphere();

  return { group: shadow(g), update };
}

interface DynamicRobeSkirt {
  group: THREE.Group;
  update(pitchL: number, pitchR: number, swayZ: number, swayX: number, airborne: boolean, time: number, speed: number): void;
}

/**
 * Build a continuous, 360-degree flared Archmage Robe Skirt with integrated gold hem band and front/back runes.
 * Deforms smoothly with left and right leg swing (`pitchL`, `pitchR`) so legs never clip and hips never step.
 */
function buildSeamlessRobeSkirt(
  robeMat: THREE.Material,
  trimMat: THREE.Material,
  gemMat: THREE.Material,
): DynamicRobeSkirt {
  const g = new THREE.Group();
  g.name = "garment-robe-skirt";

  const segPhi = 28;
  const segY = 10;
  const height = 0.68; // from pelvis waist (y = +0.02) down to above boots (y = -0.66)

  const geo = new THREE.PlaneGeometry(1, height, segPhi, segY);
  const hemGeo = new THREE.PlaneGeometry(1, 0.038, segPhi, 1);
  const skirtMesh = new THREE.Mesh(geo, robeMat);
  const hemMesh = new THREE.Mesh(hemGeo, trimMat);

  // Glowing front & back vertical arcane crystal emblems on the skirt
  const frontRune = new THREE.Mesh(new THREE.OctahedronGeometry(0.026, 0), gemMat);
  frontRune.scale.set(0.85, 2.1, 0.25);
  const backRune = frontRune.clone();
  g.add(skirtMesh, hemMesh, frontRune, backRune);

  const pos = geo.getAttribute("position") as THREE.BufferAttribute;
  const hemPos = hemGeo.getAttribute("position") as THREE.BufferAttribute;

  const update = (
    pitchL: number,
    pitchR: number,
    swayZ: number,
    swayX: number,
    airborne: boolean,
    time: number,
    speed: number,
  ) => {
    const wave = Math.sin(time * (3.2 + speed * 0.8)) * (0.012 + Math.min(0.025, speed * 0.003));
    // Forward knee reach for left (+X) and right (-X) legs, and backward leg reach
    const fwdL = Math.max(0, Math.abs(pitchL)) * 0.26 + Math.max(0, swayZ) * 0.08 + (airborne ? 0.10 : 0);
    const fwdR = Math.max(0, Math.abs(pitchR)) * 0.26 + Math.max(0, swayZ) * 0.08 + (airborne ? 0.10 : 0);
    const backTrail = Math.max(fwdL, fwdR) * 0.85 + Math.max(0, swayZ) * 0.14 + (airborne ? 0.12 : 0);

    for (let r = 0; r <= segY; r++) {
      const v = r / segY; // 0 at waist .. 1 at ankle hem
      const vPow = Math.pow(v, 1.15);
      const baseR = 0.168 + (0.275 - 0.168) * Math.pow(v, 0.88);
      const y = 0.03 - v * height + (airborne ? v * 0.04 : 0);

      for (let c = 0; c <= segPhi; c++) {
        const theta = (c / segPhi) * Math.PI * 2; // 0 = +X (left), pi/2 = +Z (front), pi = -X (right), 3pi/2 = -Z (back)
        const cosT = Math.cos(theta);
        const sinT = Math.sin(theta);

        // Oval waist cross-section flaring to circular pleated hem
        const scaleX = 1.05;
        const scaleZ = 0.86 + v * 0.12;
        const pleat = Math.cos(theta * 6) * 0.011 * v;
        const rad = baseR + pleat;

        // Leg-clearing forward/back displacement around the skirt circumference
        const leftWeight = Math.max(0, cosT * 0.5 + 0.5);
        const frontLegPush = sinT > 0 ? sinT * (leftWeight * fwdL + (1 - leftWeight) * fwdR) : 0;
        const backLegPush = sinT < 0 ? -sinT * backTrail : 0;

        const zShift = (frontLegPush - backLegPush) * vPow + Math.sin(theta * 3 + time * 4) * wave * v;
        const xShift = -swayX * 0.09 * vPow + cosT * (frontLegPush + backLegPush) * 0.22 * vPow;

        const vx = cosT * rad * scaleX + xShift;
        const vz = sinT * rad * scaleZ + zShift;
        const vy = y + Math.abs(zShift) * 0.18;

        const idx = r * (segPhi + 1) + c;
        pos.setXYZ(idx, vx, vy, vz);

        if (r === segY - 1) {
          hemPos.setXYZ(c, vx * 1.006, vy, vz * 1.006);
        } else if (r === segY) {
          hemPos.setXYZ((segPhi + 1) + c, vx * 1.006, vy, vz * 1.006);
        }
      }
    }

    // Keep front/back arcane crystal emblems riding on the front (+Z) and back (-Z) of the skirt
    const midFwd = (fwdL + fwdR) * 0.5 * 0.45;
    frontRune.position.set(-swayX * 0.04, -0.34, 0.215 + midFwd);
    frontRune.rotation.x = -midFwd * 1.2;
    backRune.position.set(-swayX * 0.04, -0.34, -0.215 - backTrail * 0.45);
    backRune.rotation.x = backTrail * 0.9;

    pos.needsUpdate = true;
    hemPos.needsUpdate = true;
    geo.computeVertexNormals();
  };

  update(0, 0, 0, 0, false, 0, 0);
  geo.computeBoundingSphere();
  hemGeo.computeBoundingSphere();

  return { group: shadow(g), update };
}

/** Attach role-specific smooth garments and armor onto a Cast walker and return a per-frame controller. */
export function attachUnitGarments(walker: Walker, role: GarmentRole): GarmentController {
  const M = getGarmentMaterials();
  const root = walker.root;
  const groups: THREE.Object3D[] = [];
  let time = Math.random() * 6;
  let swayX = 0;
  let swayZ = 0;

  let thighL: THREE.Object3D | null = null;
  let thighR: THREE.Object3D | null = null;
  let thighLRestQ = new THREE.Quaternion();
  let thighRRestQ = new THREE.Quaternion();
  let robeSkirt: DynamicRobeSkirt | null = null;
  let cloak: DynamicCloak | null = null;
  let scrollCase: THREE.Object3D | null = null;
  let helmPlume: THREE.Object3D | null = null;
  let pauldronL: THREE.Object3D | null = null;
  let pauldronR: THREE.Object3D | null = null;

  const qTmp = new THREE.Quaternion();
  const eTmp = new THREE.Euler(0, 0, 0, "YXZ");

  void walker.ready.then(() => {
    const nmap = getClothNormalMap();
    const isCasterRobe = role === "hero" || role === "pyromancer" || role === "priestess";
    const bottomTint =
      role === "hero" ? "#111d3b" : role === "pyromancer" ? "#261015" : role === "priestess" ? "#1b1030" : null;
    const hoodMat =
      role === "hero" ? M.mageMantle : role === "pyromancer" ? M.pyroMantle : role === "priestess" ? M.priestMantle : null;

    // 1. Weld faceted normals on all SkinnedMeshes and enrich cloth/leather/hood materials
    root.traverse((o) => {
      const sm = o as THREE.SkinnedMesh;
      if (!sm.isSkinnedMesh || !sm.geometry) return;
      if (sm.name.includes("-cape")) {
        sm.visible = false;
        return;
      }
      if (sm.name.startsWith("cast-") || sm.name.startsWith("cloth-")) {
        sm.geometry = getSmoothedGeometry(sm.geometry, 0.32);
        // the kit names the ranger hood part by its region: "accent" (walker/kit.ts regionOfOutfit)
        if ((sm.name === "cast-hood" || sm.name === "cast-accent") && hoodMat) {
          sm.material = hoodMat;
          return;
        }
        const mat = sm.material as THREE.MeshStandardMaterial;
        if (mat && mat.isMeshStandardMaterial) {
          mat.flatShading = false;
          if (!mat.normalMap) {
            mat.normalMap = nmap;
            mat.normalScale = new THREE.Vector2(0.16, 0.16);
          }
          if (sm.name === "cast-top" || sm.name.startsWith("cloth-")) {
            if (role === "hero") {
              const c = mat.clone();
              c.color.set("#182c59");
              c.roughness = 0.56;
              c.metalness = 0.12;
              sm.material = c;
            } else {
              mat.roughness = 0.65;
              mat.metalness = 0.06;
            }
          } else if (sm.name === "cast-accent") {
            mat.roughness = 0.38;
            mat.metalness = 0.45;
          } else if (sm.name === "cast-bottom" && isCasterRobe && bottomTint) {
            const c = mat.clone();
            c.color.set(bottomTint);
            c.roughness = 0.68;
            sm.material = c;
          }
          mat.needsUpdate = true;
        }
      }
    });

    // Attach directly to pelvis, spine_03, and Head (whose local axes in the UBC rig are +X left, +Y up, +Z forward)
    const makeBoneSocket = (boneName: string): THREE.Group | null => {
      let bone: THREE.Object3D | null = null;
      const target = boneName.toLowerCase();
      root.traverse((o) => {
        if (!bone && (o as THREE.Bone).isBone && o.name.toLowerCase() === target) bone = o;
      });
      if (!bone) return null;
      const wrap = new THREE.Group();
      wrap.name = `garment-sock-${boneName}`;
      (bone as THREE.Object3D).add(wrap);
      groups.push(wrap);
      return wrap;
    };

    thighL = root.getObjectByName("thigh_l") ?? null;
    thighR = root.getObjectByName("thigh_r") ?? null;
    if (thighL) thighLRestQ.copy(thighL.quaternion);
    if (thighR) thighRRestQ.copy(thighR.quaternion);

    const pelvisSock = makeBoneSocket("pelvis");
    const chestSock = makeBoneSocket("spine_03");
    const headSock = makeBoneSocket("Head");

    // -----------------------------------------------------------------------
    // A. Caster Flowing Robe Assembly (Hero Frost Mage & Pyromancer)
    // -----------------------------------------------------------------------
    if (role === "hero" || role === "pyromancer") {
      const robeMat = role === "hero" ? M.mageRobe : M.pyroRobe;
      const mantleMat = role === "hero" ? M.mageMantle : M.pyroMantle;
      const trimMat = role === "hero" ? M.mageTrimGold : M.warriorGold;
      const gemMat = role === "hero" ? M.mageCrystal : M.pyroEmber;

      if (pelvisSock) {
        // Seamless 360-degree flared robe skirt + gold hem
        robeSkirt = buildSeamlessRobeSkirt(robeMat, trimMat, gemMat);
        pelvisSock.add(robeSkirt.group);

        // Bevelled gold-and-leather waist cincture belt
        const belt = new THREE.Mesh(new THREE.TorusGeometry(0.172, 0.024, 12, 28), M.mageLeather);
        belt.rotation.x = Math.PI / 2;
        belt.scale.set(1.06, 0.88, 1.1);
        belt.position.y = 0.03;

        const beltTrim = new THREE.Mesh(new THREE.TorusGeometry(0.176, 0.008, 10, 28), trimMat);
        beltTrim.rotation.x = Math.PI / 2;
        beltTrim.scale.set(1.06, 0.88, 1);
        beltTrim.position.y = 0.046;

        const buckle = new THREE.Mesh(new THREE.CylinderGeometry(0.042, 0.042, 0.022, 16), trimMat);
        buckle.rotation.x = Math.PI / 2;
        buckle.position.set(0, 0.03, 0.155);
        const buckleGem = new THREE.Mesh(new THREE.OctahedronGeometry(0.026, 1), gemMat);
        buckleGem.scale.set(0.85, 1.2, 0.65);
        buckleGem.position.set(0, 0.03, 0.168);

        const pouchGeo = new THREE.SphereGeometry(0.044, 14, 12);
        const pouchR = new THREE.Mesh(pouchGeo, M.mageLeather);
        pouchR.scale.set(0.85, 1.15, 0.72);
        pouchR.position.set(-0.172, -0.025, 0.075);
        const claspR = new THREE.Mesh(new THREE.SphereGeometry(0.012, 8, 8), trimMat);
        claspR.position.set(-0.19, -0.005, 0.092);

        const scrollGrp = new THREE.Group();
        scrollGrp.position.set(0.182, -0.015, 0.03);
        scrollGrp.rotation.set(0.25, 0, 0.22);
        const tube = new THREE.Mesh(new THREE.CylinderGeometry(0.024, 0.024, 0.18, 14), M.mageLeather);
        const capA = new THREE.Mesh(new THREE.CylinderGeometry(0.027, 0.027, 0.025, 14), trimMat);
        capA.position.y = 0.082;
        const capB = capA.clone();
        capB.position.y = -0.082;
        scrollGrp.add(tube, capA, capB);
        scrollCase = scrollGrp;

        pelvisSock.add(shadow(belt), shadow(beltTrim), shadow(buckle), buckleGem, shadow(pouchR), claspR, shadow(scrollGrp));
      }

      if (chestSock) {
        // Upturned Arcane Collar, Chest Medallion, Outer Shoulder Pauldrons & Flowing Royal Cloak
        const collarGeo = makeLatheShell(
          [
            [0.115, 0.10],
            [0.138, 0.16],
            [0.165, 0.23],
          ],
          20,
          Math.PI * 0.62,
          Math.PI * 1.76,
        );
        const collar = new THREE.Mesh(collarGeo, mantleMat);
        collar.scale.set(1.08, 1, 0.92);

        const medal = new THREE.Mesh(new THREE.CylinderGeometry(0.036, 0.036, 0.014, 16), trimMat);
        medal.rotation.x = Math.PI / 2;
        medal.position.set(0, 0.06, 0.145);
        const medalGem = new THREE.Mesh(new THREE.OctahedronGeometry(0.022, 1), gemMat);
        medalGem.position.set(0, 0.06, 0.155);

        const style = role === "hero" ? "mage" : "pyro";
        pauldronL = buildPauldron("l", mantleMat, trimMat, gemMat, style);
        pauldronR = buildPauldron("r", mantleMat, trimMat, gemMat, style);

        cloak = buildRoyalCloak(mantleMat, trimMat, gemMat, 0.84);

        chestSock.add(shadow(collar), shadow(medal), medalGem, pauldronL, pauldronR, cloak.group);
      }

      if (headSock) {
        const circlet = new THREE.Mesh(new THREE.TorusGeometry(0.106, 0.0085, 10, 24), trimMat);
        circlet.rotation.x = Math.PI / 2 - 0.12;
        circlet.scale.set(0.95, 1.06, 1);
        circlet.position.set(0, 0.115, 0.012);
        const browGem = new THREE.Mesh(new THREE.OctahedronGeometry(0.022, 0), gemMat);
        browGem.scale.set(0.75, 1.45, 0.6);
        browGem.position.set(0, 0.132, 0.118);
        headSock.add(shadow(circlet), browGem);
      }
    }

    // -----------------------------------------------------------------------
    // B. Priestess Shadowmoon Garments
    // -----------------------------------------------------------------------
    if (role === "priestess") {
      if (chestSock) {
        pauldronL = buildPauldron("l", M.priestMantle, M.mageTrimGold, M.priestVoid, "priest");
        pauldronR = buildPauldron("r", M.priestMantle, M.mageTrimGold, M.priestVoid, "priest");
        cloak = buildRoyalCloak(M.priestMantle, M.mageTrimGold, M.priestVoid, 0.80);
        chestSock.add(pauldronL, pauldronR, cloak.group);
      }
      if (headSock) {
        const halo = new THREE.Mesh(new THREE.TorusGeometry(0.135, 0.008, 10, 28), M.priestVoid);
        halo.rotation.x = Math.PI * 0.35;
        halo.position.set(0, 0.19, -0.04);
        headSock.add(shadow(halo));
      }
      if (pelvisSock) {
        const belt = new THREE.Mesh(new THREE.TorusGeometry(0.155, 0.018, 10, 24), M.mageTrimGold);
        belt.rotation.x = Math.PI / 2;
        belt.scale.set(1.04, 0.84, 1);
        belt.position.y = 0.02;
        pelvisSock.add(shadow(belt));
      }
    }

    // -----------------------------------------------------------------------
    // C. Gladiator Warrior & Elite Gladiator Armor (Pauldrons, Helm, Belt, Cloak)
    // -----------------------------------------------------------------------
    if (role === "warrior" || role === "gladiator") {
      const elite = role === "gladiator";
      const plateMat = elite ? M.warriorGold : M.warriorSteel;
      const trimMat = elite ? M.warriorCrimson : M.warriorGold;
      const crestMat = elite ? M.pyroEmber : M.warriorCrimson;

      if (chestSock) {
        pauldronL = buildPauldron("l", plateMat, trimMat, crestMat, "warrior");
        pauldronR = buildPauldron("r", plateMat, trimMat, crestMat, "warrior");
        cloak = buildRoyalCloak(M.warriorCrimson, M.warriorGold, crestMat, elite ? 0.82 : 0.72);
        chestSock.add(pauldronL, pauldronR, cloak.group);
      }

      if (pelvisSock) {
        const belt = new THREE.Mesh(new THREE.TorusGeometry(0.178, 0.028, 12, 24), M.warriorDarkSteel);
        belt.rotation.x = Math.PI / 2;
        belt.scale.set(1.08, 0.88, 1.15);
        belt.position.y = 0.02;
        const championshipBuckle = new THREE.Mesh(new THREE.CylinderGeometry(0.052, 0.052, 0.026, 16), M.warriorGold);
        championshipBuckle.rotation.x = Math.PI / 2;
        championshipBuckle.position.set(0, 0.02, 0.158);
        pelvisSock.add(shadow(belt), shadow(championshipBuckle));
      }

      if (headSock) {
        const helm = new THREE.Group();
        helm.name = "garment-gladiator-helm";
        const dome = new THREE.Mesh(
          new THREE.SphereGeometry(0.122, 20, 14, 0, Math.PI * 2, 0, Math.PI * 0.58),
          plateMat,
        );
        dome.scale.set(0.96, 1.05, 1.08);
        dome.position.set(0, 0.085, -0.005);

        const browBand = new THREE.Mesh(new THREE.TorusGeometry(0.116, 0.012, 10, 24), M.warriorGold);
        browBand.rotation.x = Math.PI / 2 - 0.08;
        browBand.scale.set(0.95, 1.08, 1);
        browBand.position.set(0, 0.088, 0.002);

        const noseGuard = new THREE.Mesh(new THREE.CylinderGeometry(0.009, 0.014, 0.085, 8), M.warriorGold);
        noseGuard.position.set(0, 0.055, 0.122);

        const plume = new THREE.Group();
        plume.position.set(0, 0.205, -0.01);
        const crestArc = new THREE.Mesh(
          new THREE.TorusGeometry(0.095, 0.026, 10, 18, Math.PI * 1.05),
          crestMat,
        );
        crestArc.rotation.y = Math.PI / 2;
        crestArc.rotation.z = -0.15;
        crestArc.scale.set(1.15, 0.85, 0.55);
        plume.add(crestArc);
        helmPlume = plume;

        helm.add(dome, browBand, noseGuard, plume);
        headSock.add(shadow(helm));
      }
    }
  });

  return {
    update(dt, vx, vz, yaw, airborne) {
      const step = Math.min(Math.max(dt, 0), 0.1);
      time += step;

      const sinY = Math.sin(yaw), cosY = Math.cos(yaw);
      const localFwd = vx * sinY + vz * cosY;
      const localLat = vx * cosY - vz * sinY;
      const speed = Math.hypot(vx, vz);

      const k = step > 0 ? Math.min(1, step * 10) : 1;
      swayZ += (Math.max(-1, Math.min(1, localFwd / 8)) - swayZ) * k;
      swayX += (Math.max(-1, Math.min(1, localLat / 8)) - swayX) * k;

      let pitchL = 0, pitchR = 0;
      if (thighL) {
        qTmp.copy(thighLRestQ).invert().multiply(thighL.quaternion);
        eTmp.setFromQuaternion(qTmp, "YXZ");
        pitchL = eTmp.x;
      }
      if (thighR) {
        qTmp.copy(thighRRestQ).invert().multiply(thighR.quaternion);
        eTmp.setFromQuaternion(qTmp, "YXZ");
        pitchR = eTmp.x;
      }

      const maxFwdLeg = Math.max(0, Math.abs(pitchL), Math.abs(pitchR));
      const wave = Math.sin(time * (3.2 + speed * 0.85)) * (0.03 + Math.min(0.08, speed * 0.01));

      if (robeSkirt) {
        robeSkirt.update(pitchL, pitchR, swayZ, swayX, airborne, time, speed);
      }
      if (cloak) {
        const billow = Math.max(0.12, Math.max(0, swayZ) * 0.55 + maxFwdLeg * 0.42 + (airborne ? 0.38 : 0));
        cloak.update(billow, swayX, time, speed);
      }
      if (scrollCase) {
        scrollCase.rotation.x = 0.25 + swayZ * 0.32 + wave * 1.4;
        scrollCase.rotation.z = 0.22 + Math.abs(swayX) * 0.18;
      }
      if (helmPlume) {
        helmPlume.rotation.x = swayZ * 0.22 + wave * 0.9;
        helmPlume.rotation.z = -swayX * 0.25;
      }
      if (pauldronL && pauldronR) {
        const breath = Math.sin(time * 2.4) * 0.025 + (airborne ? 0.08 : 0);
        pauldronL.rotation.z = -breath;
        pauldronR.rotation.z = breath;
      }
    },
    dispose() {
      for (const g of groups) g.removeFromParent();
      groups.length = 0;
    },
  };
}
