/* global React, THREE */
// ─────────────────────────────────────────────────────────────────
// 3D scene — procedural dental arch + orbit controls + view snaps
// ─────────────────────────────────────────────────────────────────

const { useEffect, useRef, useImperativeHandle, forwardRef } = React;

// Derive a cohesive gum + teeth color pair from a single theme color.
// The theme color is used directly for the gum; teeth are a lighter,
// slightly desaturated variant of the same hue.
function deriveTheme(themeColor) {
  const gum = new THREE.Color(themeColor);
  const hsl = {};
  gum.getHSL(hsl);
  const teeth = new THREE.Color().setHSL(
    hsl.h,
    hsl.s * 0.55,
    Math.min(0.93, hsl.l + 0.22)
  );
  return { gum, teeth };
}

// ── Anatomical tooth generation ───────────────────────────────────
const smoothstep = (a, b, x) => {
  const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
  return t * t * (3 - 2 * t);
};
const bump = (v, c, w) => { const t = (v - c) / w; return Math.exp(-t * t); };

// Crown dimensions (mesiodistal W, buccolingual D, height H) per tooth type.
const TOOTH_DIMS = {
  incisor:  { W: 1.62, D: 1.02, H: 1.95, tilt: -0.16 },
  canine:   { W: 1.50, D: 1.34, H: 2.08, tilt: -0.12 },
  premolar: { W: 1.78, D: 1.70, H: 1.66, tilt: -0.08 },
  molar:    { W: 2.18, D: 2.06, H: 1.46, tilt: -0.04 },
};

function classifyTooth(idx, count) {
  const d = Math.abs(idx - (count - 1) / 2);
  if (d < 1.2) return "incisor";
  if (d < 2.3) return "canine";
  if (d < 4.2) return "premolar";
  return "molar";
}

// Build a single crown geometry oriented with +Y occlusal (up),
// +X mesiodistal, +Z buccolingual. The crown is a rounded column —
// near-constant cross-section through the body with rounded shoulders —
// rather than an egg, so it reads as a real tooth from any angle.
function makeToothGeometry(type) {
  const dim = TOOTH_DIMS[type];
  const geo = new THREE.IcosahedronGeometry(1, 4);
  const pos = geo.attributes.position;
  const hW = dim.W * 0.5, hD = dim.D * 0.5, hH = dim.H * 0.5;
  for (let i = 0; i < pos.count; i++) {
    const vx = pos.getX(i), vy = pos.getY(i), vz = pos.getZ(i);
    const u = (vy + 1) / 2;               // 0 = cervical/root, 1 = occlusal

    // Columnar profile: constant horizontal cross-section through the body,
    // rounded shoulders near the top and bottom.
    const ay = Math.abs(vy);
    const prof = ay <= 0.6
      ? 1
      : Math.sqrt(Math.max(0, 1 - ((ay - 0.6) / 0.4) ** 2));
    let hx = vx, hz = vz;
    const hlen = Math.hypot(hx, hz) || 1e-6;
    hx /= hlen; hz /= hlen;

    // Cervical taper — narrow toward the neck/root.
    const taper = 0.66 + 0.34 * smoothstep(0.0, 0.55, u);
    let x = hx * prof * hW * taper;
    let z = hz * prof * hD * taper;
    let y = vy * hH;

    // Occlusal morphology on the upper portion.
    if (u > 0.55) {
      const w = smoothstep(0.55, 1.0, u);
      const nx = x / hW, nz = z / hD;
      let cusp = 0;
      if (type === "molar") {
        cusp = 0.30 * (bump(nx, 0.5, 0.5) + bump(nx, -0.5, 0.5)) *
                      (bump(nz, 0.5, 0.55) + bump(nz, -0.5, 0.55));
        cusp -= 0.16 * bump(nx, 0, 0.4) * bump(nz, 0, 0.42); // central fossa
      } else if (type === "premolar") {
        cusp = 0.34 * (bump(nz, 0.55, 0.5) + bump(nz, -0.55, 0.5));
        cusp -= 0.14 * bump(nz, 0, 0.36);                    // central groove
      } else if (type === "canine") {
        cusp = 0.34 * bump(nx, 0, 0.78) * bump(nz, 0, 0.86); // gentle single cusp
      } else { // incisor — subtle incisal ridge with faint mamelons
        cusp = 0.14 * (0.6 + 0.4 * (bump(nx, 0.4, 0.28) +
               bump(nx, -0.4, 0.28) + 0.7 * bump(nx, 0, 0.42))) * bump(nz, 0, 0.9);
      }
      y += cusp * dim.H * w;
    }

    // Faint enamel surface noise.
    const n = (Math.sin(vx * 8) + Math.cos(vz * 8)) * 0.008;
    pos.setXYZ(i, x * (1 + n), y, z * (1 + n));
  }
  geo.computeVertexNormals();
  return geo;
}

// ─── Build a single procedural arch (gum ridge + anatomical teeth) ───
// Every mesh is tagged with userData.part so visibility/opacity can be
// driven per anatomical region. The highlighted crown (#28) and its
// direction-arrow gizmo are tagged 'crown' so they stay independent of
// the rest of the arch; everything else gets `partBase`.
function buildArch(options = {}) {
  const {
    themeGum,
    themeTeeth,
    smooth = false,
    includeCrown = false,
    crownIndex = 9,
    showCrown = true,
    partBase = "mandible",
  } = options;

  const group = new THREE.Group();

  // Arch curve (ellipse half)
  const archA = 7.4;          // half-width (X)
  const archB = 5.8;          // half-depth (Z)
  const toothCount = 14;
  const tList = [];
  for (let i = 0; i < toothCount; i++) {
    const t = (i / (toothCount - 1)) * Math.PI - Math.PI / 2;
    tList.push(t);
  }

  // ─── Gum / alveolar ridge — tube along curve, then deformed ───
  const ridgePoints = [];
  for (let i = 0; i <= 80; i++) {
    const t = (i / 80) * Math.PI - Math.PI / 2;
    ridgePoints.push(new THREE.Vector3(
      archA * Math.sin(t),
      -0.7 + Math.sin(t * 3) * 0.1,
      -archB * Math.cos(t)
    ));
  }
  const ridgeCurve = new THREE.CatmullRomCurve3(ridgePoints);
  const ridgeGeo = new THREE.TubeGeometry(ridgeCurve, 120, 1.55, 14, false);
  // Add organic bumpy noise to ridge
  {
    const pos = ridgeGeo.attributes.position;
    for (let i = 0; i < pos.count; i++) {
      const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
      const n =
        Math.sin(x * 1.6) * 0.05 +
        Math.cos(z * 1.8) * 0.05 +
        Math.sin(x * 4 + z * 3) * 0.03 +
        (Math.random() - 0.5) * 0.04;
      pos.setXYZ(i, x * (1 + n * 0.4), y + n * 0.6, z * (1 + n * 0.4));
    }
    ridgeGeo.computeVertexNormals();
  }
  const gumMat = new THREE.MeshStandardMaterial({
    color: themeGum,
    roughness: smooth ? 0.45 : 0.78,
    metalness: 0.0,
    flatShading: false,
  });
  const gum = new THREE.Mesh(ridgeGeo, gumMat);
  gum.userData.isGum = true;
  gum.userData.part = partBase;
  group.add(gum);

  // ─── Teeth (anatomical crowns along the ridge) ───
  tList.forEach((t, idx) => {
    const isCrown = includeCrown && idx === crownIndex;
    if (isCrown && !showCrown) return;

    const x = archA * Math.sin(t);
    const z = -archB * Math.cos(t);
    // Outward normal from arch center (origin)
    const r = Math.hypot(x, z);
    const outX = x / r, outZ = z / r;

    const type = classifyTooth(idx, toothCount);
    const dim = TOOTH_DIMS[type];
    const toothGeo = makeToothGeometry(type);

    const baseColor = isCrown
      ? themeTeeth.clone().offsetHSL(0, 0, 0.05)
      : themeTeeth.clone();
    const mat = new THREE.MeshStandardMaterial({
      color: baseColor,
      roughness: isCrown ? 0.34 : (smooth ? 0.42 : 0.52),
      metalness: 0,
      emissive: isCrown ? new THREE.Color(0xffffff) : new THREE.Color(0x000000),
      emissiveIntensity: isCrown ? 0.05 : 0,
    });
    const tooth = new THREE.Mesh(toothGeo, mat);

    // Orient: local +X → arch tangent, local +Z → outward normal, +Y up.
    tooth.rotation.y = Math.atan2(outX, outZ);
    // Lean the crown slightly buccal (about the tangent / local X axis).
    tooth.rotateX(dim.tilt);

    const scale = 1.0;
    tooth.scale.setScalar(scale);
    // Seat the crown so its neck meets the gum line and the root embeds.
    tooth.position.set(x, 0.72 + dim.H * 0.2, z);

    tooth.userData.isTooth = true;
    tooth.userData.isCrown = isCrown;
    tooth.userData.idx = idx;
    // The #28 crown is its own controllable part; ordinary teeth follow the arch.
    tooth.userData.part = isCrown ? "crown" : partBase;
    group.add(tooth);

    // Gizmo arrows on the crown — tagged 'crown' so they fade/hide with #28.
    if (isCrown) {
      const gizmo = buildGizmo();
      gizmo.position.set(0, 0, 0);
      gizmo.traverse((o) => { o.userData.part = "crown"; });
      tooth.add(gizmo);
      tooth.userData.gizmo = gizmo;
    }
  });

  return group;
}

// ─── Real intra-oral scans (binary PLY, per-vertex color) ──────────
// The two scans were captured in one shared, Y-up coordinate frame: the
// maxilla already sits above the mandible in proper occlusion, so we apply
// the SAME center + scale transform to both and never touch their relative
// pose. Nested groups keep the math clean: `inner` recenters the shared
// bounding box on the origin, `outer` scales it to the viewer's working
// size and yaws the arch so buccal faces the default camera.
const SCAN_FILES = {
  mandible: "uploads/Mandible_Base%201.ply",
  maxilla:  "uploads/Maxilla_Base%201.ply",
};
// Combined bounding box of both scans (world units, from the raw data).
const SCAN_CENTER = new THREE.Vector3(0.75, 11.0, 0.0);
const SCAN_SCALE = 0.16;         // ~65u wide arch → ~10u, matching the viewer
const SCAN_YAW = Math.PI;        // face buccal toward the default 3/4 camera

let _scanPromise = null;
function loadScans() {
  if (_scanPromise) return _scanPromise;
  const loader = new THREE.PLYLoader();
  // Prefer the buffers the head script already fetched; fall back to a normal
  // load if that script didn't run (e.g. the file opened standalone).
  const pre = typeof window !== "undefined" && window.__scanData;
  const one = (which) => pre && pre[which]
    ? pre[which].then((buf) => loader.parse(buf))
    : new Promise((res, rej) => loader.load(SCAN_FILES[which], res, undefined, rej));
  _scanPromise = Promise.all([one("mandible"), one("maxilla")])
    .then(([mandible, maxilla]) => {
      // PLY carries no normals — derive smooth-shaded normals once, cached.
      mandible.computeVertexNormals();
      maxilla.computeVertexNormals();
      // Keep a handle to the scanner's captured per-vertex color so themed
      // recolors (below) can always rebuild from the true source data.
      [mandible, maxilla].forEach((geo) => {
        const src = geo.getAttribute("color");
        if (src) geo.userData.colorOriginal = src;
      });
      return { mandible, maxilla };
    });
  return _scanPromise;
}

// Recolor the captured scan as a two-tone gum/teeth theme (or restore the
// Build a clean, spatially-coherent teeth mask (0 = gum, 1 = crown) for one
// scan geometry, computed once and cached. The scan's captured colors don't
// split teeth from gum cleanly on their own (the histogram is continuous, so
// a raw per-vertex threshold looks like fuzzy noise). So we: (1) score each
// vertex from color — crowns scan brighter and less red than gingiva — gated
// to a crown-height window off the occlusal edge to reject the bright palate;
// (2) diffuse that score across the mesh surface so coherent regions emerge
// and per-vertex noise averages out; (3) threshold crisply. The result traces
// the gumline much like the eye reads it in the Natural view.
// Otsu threshold over a value array, on a 64-bin histogram spanning [lo,hi].
// Data-driven so the split adapts to each scan's own exposure instead of
// relying on hard-coded luminance/redness constants.
function otsuThreshold(vals, lo, hi) {
  const B = 64, h = new Float32Array(B), span = hi - lo || 1;
  for (let i = 0; i < vals.length; i++) {
    let t = (vals[i] - lo) / span; t = t < 0 ? 0 : t > 1 ? 1 : t;
    h[Math.min(B - 1, (t * B) | 0)]++;
  }
  let sum = 0; for (let i = 0; i < B; i++) sum += i * h[i];
  let wB = 0, sumB = 0, best = -1, thr = B >> 1;
  for (let i = 0; i < B; i++) {
    wB += h[i]; if (!wB) continue;
    const wF = vals.length - wB; if (wF <= 0) break;
    sumB += i * h[i];
    const d = sumB / wB - (sum - sumB) / wF;
    const v = wB * wF * d * d;
    if (v > best) { best = v; thr = i; }
  }
  return lo + ((thr + 0.5) / B) * span;
}

// Painted mask (authored, not inferred): unpack the 1-bit-per-vertex crown
// flag transferred from the vertex-painted Blender export. Because it shares
// the scan's topology, index i here IS vertex i of the scan — the gum/tooth
// boundary is exactly where it was painted, with no threshold to tune.
const PAINTED_MASKS = { maxilla: "MAXILLA_TOOTH_MASK", mandible: "MANDIBLE_TOOTH_MASK" };
const _paintedCache = {};
function paintedMask(key, count) {
  const global = PAINTED_MASKS[key];
  const spec = global && typeof window !== "undefined" && window[global];
  if (!spec || spec.count !== count) return null;
  if (_paintedCache[key]) return _paintedCache[key];
  const bin = atob(spec.b64);
  const m = new Float32Array(count);
  for (let i = 0; i < count; i++) {
    m[i] = (bin.charCodeAt(i >> 3) >> (i & 7)) & 1;
  }
  _paintedCache[key] = m;
  return m;
}

function computeToothMask(geo, src, edgeY, span) {
  const clamp01 = (x) => Math.min(1, Math.max(0, x));
  const pos = geo.getAttribute("position");
  const n = src.count;
  const lums = new Float32Array(n), reds = new Float32Array(n);
  let lMin = Infinity, lMax = -Infinity, rMin = Infinity, rMax = -Infinity;
  for (let i = 0; i < n; i++) {
    const r = src.getX(i), g = src.getY(i), b = src.getZ(i);
    const lum = r * 0.299 + g * 0.587 + b * 0.114;
    const red = r - b;
    lums[i] = lum; reds[i] = red;
    if (lum < lMin) lMin = lum; if (lum > lMax) lMax = lum;
    if (red < rMin) rMin = red; if (red > rMax) rMax = red;
  }
  const lumT = otsuThreshold(lums, lMin, lMax);
  const redT = otsuThreshold(reds, rMin, rMax);
  const lumBand = Math.max(0.02, 0.18 * (lMax - lMin));
  const redBand = Math.max(0.02, 0.18 * (rMax - rMin));
  const archSpan = span || 1;
  // Radial position within the arch ring: the palate/floor plateau sits near
  // the arch's XZ centre, teeth and gingiva sit out on the ring.
  let cx = 0, cz = 0;
  for (let i = 0; i < n; i++) { cx += pos.getX(i); cz += pos.getZ(i); }
  cx /= n; cz /= n;
  const rad = new Float32Array(n);
  let rMaxXZ = 0;
  for (let i = 0; i < n; i++) {
    const dx = pos.getX(i) - cx, dz = pos.getZ(i) - cz;
    const r = Math.sqrt(dx * dx + dz * dz);
    rad[i] = r; if (r > rMaxXZ) rMaxXZ = r;
  }
  const color = new Float32Array(n);
  for (let i = 0; i < n; i++) {
    const bright = clamp01((lums[i] - (lumT - lumBand)) / lumBand);        // teeth: bright
    const neutral = clamp01(((redT + 0.6 * redBand) - reds[i]) / redBand); // teeth: not red
    color[i] = 0.35 * bright + 0.65 * neutral;
  }
  // Mesh adjacency (from the triangle index) — used both to flood the crowns
  // and to smooth the result.
  const idx = geo.index ? geo.index.array : null;
  let cur;
  if (idx) {
    // Seeded region growing: start from high-confidence enamel vertices and
    // grow across the surface through anything that isn't distinctly gingival.
    // The gumline is a sharp redness ridge, so the flood stops there — the
    // boundary traces each tooth's real margin instead of a height plane, and
    // the palate never joins because gingiva walls it off.
    const deg = new Uint32Array(n + 1);
    for (let e = 0; e < idx.length; e += 3) {
      deg[idx[e]] += 2; deg[idx[e + 1]] += 2; deg[idx[e + 2]] += 2;
    }
    const off = new Uint32Array(n + 1);
    for (let i = 0; i < n; i++) off[i + 1] = off[i] + deg[i];
    const fill = new Uint32Array(n);
    const adj = new Uint32Array(off[n]);
    const push = (a, b) => { adj[off[a] + fill[a]++] = b; };
    for (let e = 0; e < idx.length; e += 3) {
      const a = idx[e], b = idx[e + 1], c = idx[e + 2];
      push(a, b); push(a, c); push(b, a); push(b, c); push(c, a); push(c, b);
    }
    // Anatomy-driven barrier: the gingival margin is a concave crease running
    // around each tooth, so measure per-vertex concavity from the mesh itself
    // and treat the sharpest creases as walls. Color alone can't do this — on
    // this scan the crown's gingival third is nearly gum-coloured, which is why
    // a purely color-based split cut a flat line across the teeth.
    const nor = geo.getAttribute("normal");
    let conc = new Float32Array(n);
    if (nor) {
      for (let i = 0; i < n; i++) {
        const px = pos.getX(i), py = pos.getY(i), pz = pos.getZ(i);
        const nx = nor.getX(i), ny = nor.getY(i), nz = nor.getZ(i);
        let acc = 0, k0 = off[i], k1 = off[i + 1];
        for (let k = k0; k < k1; k++) {
          const w = adj[k];
          const dx = pos.getX(w) - px, dy = pos.getY(w) - py, dz = pos.getZ(w) - pz;
          const len = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1;
          acc += (nx * dx + ny * dy + nz * dz) / len;
        }
        conc[i] = k1 > k0 ? acc / (k1 - k0) : 0;
      }
      for (let p = 0; p < 2; p++) {
        const next = new Float32Array(n);
        for (let i = 0; i < n; i++) {
          let acc = 0, k0 = off[i], k1 = off[i + 1];
          for (let k = k0; k < k1; k++) acc += conc[adj[k]];
          next[i] = k1 > k0 ? 0.35 * conc[i] + 0.65 * (acc / (k1 - k0)) : conc[i];
        }
        conc = next;
      }
    }
    // Crease wall = the most concave ~14% of the surface.
    const sorted = Float32Array.from(conc).sort();
    const concT = sorted[Math.floor(n * 0.86)];
    // Passable = not a crease wall, not unmistakably gingival, and out on the
    // arch ring (keeps the palate / model floor plateau out of play).
    const gumCut = redT + 0.85 * redBand;
    const passable = new Uint8Array(n);
    for (let i = 0; i < n; i++) {
      passable[i] = conc[i] < concT && reds[i] < gumCut && rad[i] / rMaxXZ > 0.42 ? 1 : 0;
    }
    const seen = new Uint8Array(n);
    const queue = new Uint32Array(n);
    let qh = 0, qt = 0;
    for (let i = 0; i < n; i++) {
      if (color[i] > 0.86 && passable[i]) { seen[i] = 1; queue[qt++] = i; }
    }
    while (qh < qt) {
      const v = queue[qh++];
      for (let k = off[v], end = off[v + 1]; k < end; k++) {
        const w = adj[k];
        if (!seen[w] && passable[w]) { seen[w] = 1; queue[qt++] = w; }
      }
    }
    // Drop stray patches: keep only flooded components that are big enough and
    // tooth-coloured on average, so bright gum spots don't survive.
    const comp = new Int32Array(n).fill(-1);
    let cid = 0;
    const keep = [];
    for (let s = 0; s < n; s++) {
      if (!seen[s] || comp[s] >= 0) continue;
      let h2 = 0, t2 = 0, size = 0, colSum = 0;
      queue[t2++] = s; comp[s] = cid;
      while (h2 < t2) {
        const v = queue[h2++]; size++; colSum += color[v];
        for (let k = off[v], end = off[v + 1]; k < end; k++) {
          const w = adj[k];
          if (seen[w] && comp[w] < 0) { comp[w] = cid; queue[t2++] = w; }
        }
      }
      keep[cid] = size >= 250 && colSum / size >= 0.42;
      cid++;
    }
    for (let i = 0; i < n; i++) if (seen[i] && !keep[comp[i]]) seen[i] = 0;
    // Smooth the binary region a little so the margin reads as a clean curve
    // rather than a jagged vertex boundary.
    cur = new Float32Array(n);
    for (let i = 0; i < n; i++) cur[i] = seen[i];
    for (let p = 0; p < 3; p++) {
      const sum = new Float32Array(n), cnt = new Float32Array(n);
      for (let e = 0; e < idx.length; e += 3) {
        const a = idx[e], b = idx[e + 1], c = idx[e + 2];
        sum[a] += cur[b] + cur[c]; cnt[a] += 2;
        sum[b] += cur[a] + cur[c]; cnt[b] += 2;
        sum[c] += cur[a] + cur[b]; cnt[c] += 2;
      }
      const next = new Float32Array(n);
      for (let i = 0; i < n; i++) next[i] = cnt[i] > 0 ? 0.4 * cur[i] + 0.6 * (sum[i] / cnt[i]) : cur[i];
      cur = next;
    }
  } else {
    cur = color;
  }
  const mask = new Float32Array(n);
  for (let i = 0; i < n; i++) mask[i] = smoothstep(0.35, 0.62, cur[i]);
  return mask;
}

// Recolor the captured scan as a two-tone gum/teeth theme (or restore the
// scanner's true captured color for "natural"). Teeth vs. gum come from a
// precomputed, topology-smoothed mask (see computeToothMask); the original
// brightness is kept as a shading multiplier so surface detail/AO survives.
function applyScanTint(group, theme) {
  if (!group) return;
  group.traverse((o) => {
    if (!(o.userData && o.userData.isScan && o.material)) return;
    o.material.color.setHex(0xffffff);
    const geo = o.geometry;
    const src = geo && geo.userData.colorOriginal;
    if (!geo || !src) return;
    if (!theme) {
      if (geo.getAttribute("color") !== src) {
        geo.setAttribute("color", src);
        geo.attributes.color.needsUpdate = true;
      }
      return;
    }
    // Occlusal edge (where the crowns sit), auto-detected once per geometry:
    // teeth scan brighter, so the arch's brighter Y-extreme is the crown side.
    if (!geo.userData.occl) {
      const pos = geo.getAttribute("position");
      let minY = Infinity, maxY = -Infinity;
      for (let i = 0; i < pos.count; i++) {
        const y = pos.getY(i); if (y < minY) minY = y; if (y > maxY) maxY = y;
      }
      const mid = (minY + maxY) / 2;
      let hiSum = 0, hiN = 0, loSum = 0, loN = 0;
      for (let i = 0; i < pos.count; i++) {
        const lum = src.getX(i) * 0.299 + src.getY(i) * 0.587 + src.getZ(i) * 0.114;
        if (pos.getY(i) >= mid) { hiSum += lum; hiN++; } else { loSum += lum; loN++; }
      }
      const teethHigh = (hiN ? hiSum / hiN : 0) >= (loN ? loSum / loN : 0);
      geo.userData.occl = { minY, maxY, teethHigh };
    }
    const { minY, maxY, teethHigh } = geo.userData.occl;
    const edgeY = teethHigh ? maxY : minY;
    if (!geo.userData.toothMask) {
      geo.userData.toothMask =
        paintedMask(geo.userData.maskKey, src.count) ||
        computeToothMask(geo, src, edgeY, maxY - minY);
    }
    const mask = geo.userData.toothMask;

    if (!geo.userData.tintCache) geo.userData.tintCache = {};
    // Mean captured luminance, once per geometry. The shading multiplier is
    // normalized around this so the arch's AVERAGE rendered color is the theme
    // color exactly as shown in the swatch; brighter/darker scan areas ride
    // above and below it. (Multiplying by raw luminance, as before, pulled the
    // whole model well below the swatch.)
    if (geo.userData.meanLum == null) {
      let acc = 0;
      for (let i = 0; i < src.count; i++) {
        acc += src.getX(i) * 0.299 + src.getY(i) * 0.587 + src.getZ(i) * 0.114;
      }
      geo.userData.meanLum = acc / Math.max(1, src.count);
    }
    const meanLum = geo.userData.meanLum;
    const SHADE_AMP = 0.55;   // how much surface detail modulates the flat color
    const cacheKey = theme.gum + "_" + theme.teeth;
    let attr = geo.userData.tintCache[cacheKey];
    if (!attr) {
      // Vertex colors are consumed in LINEAR space, then re-encoded to sRGB on
      // output. Feeding the sRGB swatch hex straight in therefore renders it
      // ~25% too light and desaturated, which is why the model never matched
      // the swatch. Convert to linear here so the pixel that lands on screen
      // IS the swatch color.
      const gum = new THREE.Color(theme.gum).convertSRGBToLinear();
      const teeth = new THREE.Color(theme.teeth).convertSRGBToLinear();
      const n = src.count, isz = src.itemSize;
      const arr = new Float32Array(n * isz);
      for (let i = 0; i < n; i++) {
        const r = src.getX(i), g = src.getY(i), b = src.getZ(i);
        const lum = r * 0.299 + g * 0.587 + b * 0.114;
        const t = mask[i];
        const shade = Math.min(1.18, Math.max(0.74, 1 + SHADE_AMP * (lum - meanLum)));
        arr[i * isz] = (gum.r * (1 - t) + teeth.r * t) * shade;
        arr[i * isz + 1] = (gum.g * (1 - t) + teeth.g * t) * shade;
        arr[i * isz + 2] = (gum.b * (1 - t) + teeth.b * t) * shade;
        if (isz > 3) arr[i * isz + 3] = src.getW(i);
      }
      attr = new THREE.BufferAttribute(arr, isz);
      geo.userData.tintCache[cacheKey] = attr;
    }
    if (geo.getAttribute("color") !== attr) {
      geo.setAttribute("color", attr);
      geo.attributes.color.needsUpdate = true;
    }
  });
}

// ─── Split tooth #8 (upper right central incisor) off the maxilla scan ───
// The scan is one watertight shell, so #8 is separated analytically: vertices
// are scored by an x-band across the anterior arch (the incisor's mesiodistal
// span, midline at the interproximal notch) times a color test that
// distinguishes enamel from gingiva, then diffused over the mesh topology so
// the selection follows a clean margin line rather than speckle.
//   • crownGeo — a copy of the maxilla vertex buffer indexed to just the #8
//     faces. It is the '#8 Crown' layer and sits exactly where the tooth was.
//     The maxilla scan is left untouched.
const T8 = { xOut: -6.4, xIn: -5.0, xMidIn: 1.6, xMidOut: 2.7, zMin: 15, yMax: 23.5 };

function splitToothEight(maxGeo, crownGeo) {
  const pos = maxGeo.getAttribute("position");
  const src = maxGeo.userData.colorOriginal;
  const idx = maxGeo.index ? maxGeo.index.array : null;
  if (!pos || !src || !idx) return null;
  const n = pos.count;
  const clamp01 = (v) => Math.min(1, Math.max(0, v));

  let w = new Float32Array(n);
  for (let i = 0; i < n; i++) {
    const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
    if (z < T8.zMin || y > T8.yMax) continue;
    const band = smoothstep(T8.xOut, T8.xIn, x) * (1 - smoothstep(T8.xMidIn, T8.xMidOut, x));
    if (band <= 0) continue;
    const r = src.getX(i), g = src.getY(i);
    // Enamel keeps green high relative to red; gingiva drops it sharply.
    const enamel = clamp01((g / Math.max(r, 1e-3) - 0.47) / 0.12);
    w[i] = band * enamel;
  }
  // Diffuse over the triangle graph, then re-threshold for a crisp boundary.
  for (let p = 0; p < 3; p++) {
    const sum = new Float32Array(n), cnt = new Float32Array(n);
    for (let e = 0; e < idx.length; e += 3) {
      const a = idx[e], b = idx[e + 1], c = idx[e + 2];
      sum[a] += w[b] + w[c]; cnt[a] += 2;
      sum[b] += w[a] + w[c]; cnt[b] += 2;
      sum[c] += w[a] + w[b]; cnt[c] += 2;
    }
    const next = new Float32Array(n);
    for (let i = 0; i < n; i++) next[i] = cnt[i] > 0 ? 0.45 * w[i] + 0.55 * (sum[i] / cnt[i]) : w[i];
    w = next;
  }
  for (let i = 0; i < n; i++) w[i] = smoothstep(0.30, 0.58, w[i]);

  // Crown layer: the same vertex buffer indexed to just the #8 faces. The
  // maxilla scan itself is left exactly as captured.
  let sel = 0;
  for (let i = 0; i < n; i++) if (w[i] >= 0.5) sel++;
  if (sel < 200) return null;
  const keep = [];
  for (let e = 0; e < idx.length; e += 3) {
    const a = idx[e], b = idx[e + 1], c = idx[e + 2];
    if (w[a] >= 0.5 && w[b] >= 0.5 && w[c] >= 0.5) keep.push(a, b, c);
  }
  crownGeo.setIndex(keep);
  return true;
}

// Build the model group. Meshes load asynchronously; the group is returned
// immediately (empty) and populated when the PLY data resolves, at which
// point `onReady` fires so visibility/opacity/tint can be re-applied.
function buildDentalArch(options = {}, onReady) {
  const { smooth = false } = options;

  const outer = new THREE.Group();
  outer.name = "DentalArch";
  outer.scale.setScalar(SCAN_SCALE);
  outer.rotation.y = SCAN_YAW;

  const inner = new THREE.Group();
  inner.position.set(-SCAN_CENTER.x, -SCAN_CENTER.y, -SCAN_CENTER.z);
  outer.add(inner);

  loadScans().then((cache) => {
    const makeMat = () => new THREE.MeshStandardMaterial({
      vertexColors: true,
      color: 0xffffff,
      roughness: smooth ? 0.42 : 0.62,
      metalness: 0.0,
      flatShading: false,
    });

    const manGeo = cache.mandible.clone();
    manGeo.userData = { colorOriginal: cache.mandible.userData.colorOriginal, maskKey: "mandible" };
    const mandible = new THREE.Mesh(manGeo, makeMat());
    mandible.name = "Mandible";
    mandible.userData.part = "mandible";
    mandible.userData.isScan = true;
    inner.add(mandible);

    const maxGeo = cache.maxilla.clone();
    maxGeo.userData = { colorOriginal: cache.maxilla.userData.colorOriginal, maskKey: "maxilla" };
    const crownGeo = cache.maxilla.clone();
    crownGeo.userData = { colorOriginal: cache.maxilla.userData.colorOriginal, maskKey: "maxilla" };
    const split = splitToothEight(maxGeo, crownGeo);

    const maxilla = new THREE.Mesh(maxGeo, makeMat());
    maxilla.name = "Maxilla";
    maxilla.userData.part = "maxilla";
    maxilla.userData.isScan = true;
    inner.add(maxilla);

    if (split) {
      const crownMat = makeMat();
      crownMat.polygonOffset = true;
      crownMat.polygonOffsetFactor = -1;
      crownMat.polygonOffsetUnits = -1;
      crownMat.roughness = 0.34;
      const crown = new THREE.Mesh(crownGeo, crownMat);
      crown.name = "Tooth8Crown";
      crown.userData.part = "crown";
      crown.userData.isScan = true;
      inner.add(crown);
    }

    onReady && onReady(outer);
  }).catch((err) => console.error("Scan load failed:", err));

  return outer;
}

// Subtle appear animation: opacity fades in first with an ease-out curve,
// finishing at 100% before the scale-up (also ease-out) settles — a slight
// stagger so the model reads as "arriving" rather than popping and fading
// at once. Restores correct opacity/depth flags at the end via onDone
// (re-applying the jaw visibility state).
// Model entrance. Runs only once the loading splash has fully dismissed —
// primeIntro() snaps the group to its pre-entrance state (small + invisible)
// the instant scans arrive, so nothing flashes at full size behind the splash;
// playIntro() then runs the actual reveal once the app explicitly starts it.
const INTRO_DUR = 1400;
const INTRO_FROM = 0.62;    // starting scale, as a fraction of final size
function primeIntro(group) {
  const baseScale = group.scale.x;
  group.scale.setScalar(baseScale * INTRO_FROM);
  group.userData._introBaseScale = baseScale;
}
function playIntro(state, group, onDone) {
  const baseScale = group.userData._introBaseScale || group.scale.x;
  const reduced = typeof matchMedia === "function" &&
    matchMedia("(prefers-reduced-motion: reduce)").matches;
  if (reduced) { group.scale.setScalar(baseScale); onDone && onDone(); return; }
  const easeOut = (k) => 1 - Math.pow(1 - k, 3);
  const startAt = performance.now();
  cancelAnimationFrame(state._introRaf);
  const apply = (k) => {
    const eScale = easeOut(k);
    group.scale.setScalar(baseScale * (INTRO_FROM + (1 - INTRO_FROM) * eScale));
  };
  apply(0);
  const step = () => {
    const k = Math.min(1, Math.max(0, (performance.now() - startAt) / INTRO_DUR));
    apply(k);
    if (k < 1) state._introRaf = requestAnimationFrame(step);
    else { group.scale.setScalar(baseScale); onDone && onDone(); }
  };
  step();
}

// ─── Apply per-part visibility + opacity from the jaw panel state ───
//   jaw = { maxArch, manArch, manCrown } each { shown, opacity(0–100) }.
// The Mandible control drives only 'mandible' meshes; #28 drives only
// 'crown' meshes (tooth + arrows); Maxilla drives only 'maxilla' meshes.
// A part is invisible when its eye is off OR its opacity slider is at 0.
function applyJaw(state, jaw) {
  if (!state || !state.modelGroup || !jaw) return;
  const cfgFor = {
    maxilla: jaw.maxArch,
    mandible: jaw.manArch,
    crown: jaw.manCrown,
  };
  state.modelGroup.traverse((o) => {
    if (!o.material) return;
    const part = o.userData && o.userData.part;
    const cfg = part && cfgFor[part];
    if (!cfg) return;
    const op = Math.max(0, Math.min(1, (cfg.opacity ?? 100) / 100));
    o.visible = !!cfg.shown && op > 0.001;
    const mats = Array.isArray(o.material) ? o.material : [o.material];
    mats.forEach((m) => {
      const wasTransparent = m.transparent;
      m.transparent = op < 0.999;
      m.opacity = op;
      m.depthWrite = op >= 0.999;
      if (wasTransparent !== m.transparent) m.needsUpdate = true;
    });
  });
}

// Small directional arrow gizmo on the crown
function buildGizmo() {
  const g = new THREE.Group();
  g.name = "Gizmo";
  const make = (color, axis) => {
    const arrow = new THREE.Group();
    const shaftGeo = new THREE.CylinderGeometry(0.03, 0.03, 0.6, 8);
    const shaftMat = new THREE.MeshBasicMaterial({ color });
    const shaft = new THREE.Mesh(shaftGeo, shaftMat);
    shaft.position.y = 0.4;
    arrow.add(shaft);
    const headGeo = new THREE.ConeGeometry(0.09, 0.18, 12);
    const head = new THREE.Mesh(headGeo, shaftMat);
    head.position.y = 0.78;
    arrow.add(head);
    // orient
    if (axis === "x+") arrow.rotation.z = -Math.PI / 2;
    if (axis === "x-") arrow.rotation.z = Math.PI / 2;
    if (axis === "y+") {}
    if (axis === "y-") arrow.rotation.z = Math.PI;
    if (axis === "z+") arrow.rotation.x = Math.PI / 2;
    if (axis === "z-") arrow.rotation.x = -Math.PI / 2;
    // larger scale so visible above tooth
    arrow.scale.setScalar(1.5);
    return arrow;
  };
  g.add(make(0x3FB3A8, "x+")); // mesial (teal)
  g.add(make(0x3FB3A8, "x-")); // distal
  g.add(make(0x9B5CFF, "y+")); // occlusal (purple)
  g.add(make(0x4CAF50, "y-")); // apical (green)
  g.scale.setScalar(1.1);
  g.position.y = 0.1;
  return g;
}

// ─── Camera direction targets for the DOM compass ───
//   Y up
//   X = mesial(+) / distal(-) — left-right
//   Z = buccal(+) / lingual(-) — front-back (buccal toward viewer)
const VIEW_DIRS = {
  O: new THREE.Vector3(0.0001, 1, 0.0001), // occlusal: top down
  B: new THREE.Vector3(0, 0.05, 1),        // buccal: front
  L: new THREE.Vector3(0, 0.05, -1),       // lingual: back
  M: new THREE.Vector3(-1, 0.05, 0.0001),  // mesial: left side
  D: new THREE.Vector3(1, 0.05, 0.0001),   // distal: right side
  MAXV: new THREE.Vector3(0.0001, -1, -0.15), // maxilla: from below looking up
  MANV: new THREE.Vector3(0.0001, 1, -0.15), // mandible: from above looking down
};

const DEFAULT_CAMERA = new THREE.Vector3(7, 11, 18); // pleasing 3/4
// Matches the floating right panel's footprint (340px width + 14px margin),
// halved — the same offset the bottom toolbar's CSS centering uses.
const PANEL_SHIFT_PX = 177;

// Distance at which the model's full bounds fit the CURRENT viewport when
// viewed down `dirVec` — view presets use this instead of a fixed distance so
// the arch is never cropped on a narrow (mobile) canvas.
function fitDistance(s, dirVec, padding = 1.15) {
  if (!s.camera || !s.modelGroup) return null;
  const box = new THREE.Box3();
  let any = false;
  s.modelGroup.traverse((o) => {
    if (o.isMesh && o.visible && o.geometry) { box.expandByObject(o); any = true; }
  });
  if (!any) return null;
  const center = box.getCenter(new THREE.Vector3());
  const fwd = dirVec.clone().normalize();
  const upRef = Math.abs(fwd.y) > 0.95 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(0, 1, 0);
  const right = new THREE.Vector3().crossVectors(fwd, upRef).normalize();
  const up = new THREE.Vector3().crossVectors(right, fwd).normalize();
  let halfW = 0, halfH = 0;
  [[box.min.x, box.min.y, box.min.z], [box.max.x, box.min.y, box.min.z],
   [box.min.x, box.max.y, box.min.z], [box.max.x, box.max.y, box.min.z],
   [box.min.x, box.min.y, box.max.z], [box.max.x, box.min.y, box.max.z],
   [box.min.x, box.max.y, box.max.z], [box.max.x, box.max.y, box.max.z]]
    .forEach(([x, y, z]) => {
      const rel = new THREE.Vector3(x, y, z).sub(center);
      halfW = Math.max(halfW, Math.abs(rel.dot(right)));
      halfH = Math.max(halfH, Math.abs(rel.dot(up)));
    });
  const fovV = s.camera.fov * Math.PI / 180;
  const fovH = 2 * Math.atan(Math.tan(fovV / 2) * s.camera.aspect);
  return Math.max(halfH / Math.tan(fovV / 2), halfW / Math.tan(fovH / 2),
    s.controls.minDistance) * padding;
}

// Frame the whole visible model within the current viewport — shared by the
// imperative fitToScreen() call and the post-intro settle so the model never
// finishes its entrance cropped on a screen size it wasn't laid out for.
function doFitToScreen(s, padding = 1.15, duration = 500) {
  if (!s || !s.camera || !s.modelGroup) return;
  const box = new THREE.Box3();
  let any = false;
  s.modelGroup.traverse((o) => {
    if (o.isMesh && o.visible && o.geometry) { box.expandByObject(o); any = true; }
  });
  if (!any) return;
  const center = box.getCenter(new THREE.Vector3());
  const e = s.camera.matrixWorld.elements;
  const right = new THREE.Vector3(e[0], e[1], e[2]).normalize();
  const up = new THREE.Vector3(e[4], e[5], e[6]).normalize();
  const corners = [
    [box.min.x, box.min.y, box.min.z], [box.max.x, box.min.y, box.min.z],
    [box.min.x, box.max.y, box.min.z], [box.max.x, box.max.y, box.min.z],
    [box.min.x, box.min.y, box.max.z], [box.max.x, box.min.y, box.max.z],
    [box.min.x, box.max.y, box.max.z], [box.max.x, box.max.y, box.max.z],
  ];
  let halfW = 0, halfH = 0;
  corners.forEach(([x, y, z]) => {
    const rel = new THREE.Vector3(x, y, z).sub(center);
    halfW = Math.max(halfW, Math.abs(rel.dot(right)));
    halfH = Math.max(halfH, Math.abs(rel.dot(up)));
  });
  const fovV = s.camera.fov * Math.PI / 180;
  const fovH = 2 * Math.atan(Math.tan(fovV / 2) * s.camera.aspect);
  const distV = halfH / Math.tan(fovV / 2);
  const distH = halfW / Math.tan(fovH / 2);
  const dist = Math.max(distV, distH, s.controls.minDistance) * padding;
  const dir = s.camera.position.clone().sub(s.controls.target).normalize();
  const pos = dir.multiplyScalar(dist).add(center);
  lerpCamera(s, pos, center.clone(), duration);
}

// Opposite-side view pairs where a straight-line camera lerp would cut
// through (or near) the model's center — looks like a jarring zoom-in/out.
// Keyed by the FROM view; the vector is the world axis to sweep around (its
// sign picked so every M<->D swap arcs through the frontal (L) view rather
// than the back, in both directions) and the invariant axis for MAXV/MANV.
const OPPOSITE_TO = { M: "D", D: "M", B: "L", L: "B", MAXV: "MANV", MANV: "MAXV" };
const OPPOSITE_AXIS = {
  M: new THREE.Vector3(0, -1, 0),
  D: new THREE.Vector3(0, 1, 0),
  B: new THREE.Vector3(0, 1, 0),
  L: new THREE.Vector3(0, -1, 0),
  MAXV: new THREE.Vector3(0, 0, -1),
  MANV: new THREE.Vector3(0, 0, 1),
};

// Swing the camera around the (possibly moving) target on a great-circle arc
// instead of a straight line — a straight chord between two points sharing a
// target dips inward (worst at 180° apart, but visible even at 90°), reading
// as a jarring zoom in/out mid-transition. Slerping direction and lerping
// distance separately keeps the camera-to-target distance smooth instead.
function arcLerpCamera(state, targetPos, targetLook, duration = 550, fallbackAxis) {
  const { camera, controls } = state;
  const startPos = camera.position.clone();
  const startTarget = controls.target.clone();
  const ds = startPos.clone().sub(startTarget);
  const de = targetPos.clone().sub(targetLook);
  const rs = ds.length(), re = de.length();
  if (rs < 1e-6 || re < 1e-6) { lerpCamera(state, targetPos, targetLook, duration); return; }
  const dsN = ds.clone().normalize(), deN = de.clone().normalize();
  let axis = new THREE.Vector3().crossVectors(dsN, deN);
  let angle = Math.acos(THREE.MathUtils.clamp(dsN.dot(deN), -1, 1));
  // Near-antiparallel vectors give a noisy, near-arbitrary cross product (tiny
  // floating differences dominate its direction) — trust the caller's chosen
  // sweep plane instead of that noise whenever the two views are close to
  // opposite, or the arc swings through a nonsensical plane (looks like a
  // flip instead of a clean horizontal/vertical turn).
  if (angle > Math.PI * 0.85 && fallbackAxis) {
    axis.copy(fallbackAxis);
  } else if (axis.lengthSq() < 1e-6) {
    axis.set(0, 1, 0); // already aligned; angle ~0, axis irrelevant
  } else axis.normalize();
  cancelAnimationFrame(state._tweenRaf);
  const t0 = performance.now();
  const easeOut = (k) => 1 - Math.pow(1 - k, 3);
  function step() {
    const k = Math.min(1, (performance.now() - t0) / duration);
    const e = easeOut(k);
    const dir = dsN.clone().applyAxisAngle(axis, angle * e).normalize();
    const dist = rs + (re - rs) * e;
    const target = startTarget.clone().lerp(targetLook, e);
    camera.position.copy(target.clone().addScaledVector(dir, dist));
    controls.target.copy(target);
    controls.update();
    if (k < 1) state._tweenRaf = requestAnimationFrame(step);
  }
  step();
}

// Quickly fades ONE part (the arch no longer wanted) from visible to hidden
// \u2014 used right as the up/down rotation into Maxilla/Mandible begins, so the
// arch that was shown during the frontal orientation pass disappears with a
// snappy cue instead of an instant pop, while the camera keeps rotating.
function fadeOutPart(state, part, duration, onDone) {
  const items = [];
  state.modelGroup.traverse((o) => {
    if (o.material && o.userData && o.userData.part === part && o.visible) {
      const mats = Array.isArray(o.material) ? o.material : [o.material];
      const opFrom = mats[0] ? mats[0].opacity : 1;
      mats.forEach((m) => { m.transparent = true; m.depthWrite = false; });
      items.push({ mats, opFrom });
    }
  });
  if (!items.length) { onDone && onDone(); return; }
  cancelAnimationFrame(state._jawFadeRaf);
  const t0 = performance.now();
  const easeIn = (k) => k * k * k;
  function step() {
    const k = Math.min(1, (performance.now() - t0) / duration);
    const e = easeIn(k);
    items.forEach(({ mats, opFrom }) => {
      const op = opFrom * (1 - e);
      mats.forEach((m) => { m.opacity = op; });
    });
    if (k < 1) state._jawFadeRaf = requestAnimationFrame(step);
    else onDone && onDone();
  }
  step();
}

function lerpCamera(state, targetPos, targetLook, duration = 600) {
  const { camera, controls } = state;
  const startPos = camera.position.clone();
  const startTarget = controls.target.clone();
  cancelAnimationFrame(state._tweenRaf);
  if (duration <= 0) {
    camera.position.copy(targetPos);
    controls.target.copy(targetLook);
    controls.update();
    return;
  }
  const t0 = performance.now();
  function step() {
    const k = Math.min(1, (performance.now() - t0) / duration);
    const e = 1 - Math.pow(1 - k, 3); // easeOutCubic
    camera.position.lerpVectors(startPos, targetPos, e);
    controls.target.lerpVectors(startTarget, targetLook, e);
    controls.update();
    if (k < 1) state._tweenRaf = requestAnimationFrame(step);
  }
  step();
}

const Viewer3D = forwardRef(function Viewer3D(props, ref) {
  const { modelOpts = {}, showGrid = false, jaw, onLoaded, onScansReady } = props;
  const containerRef = useRef(null);
  const stateRef = useRef({});
  // Latest jaw state, so a rebuild can re-apply current visibility/opacity.
  const jawRef = useRef(jaw);
  jawRef.current = jaw;

  // Build / rebuild model when options change
  const buildModel = (state, opts) => {
    if (state.modelGroup) {
      state.scene.remove(state.modelGroup);
      state.modelGroup.traverse(o => {
        if (o.geometry) o.geometry.dispose();
        if (o.material) {
          if (Array.isArray(o.material)) o.material.forEach(m => m.dispose());
          else o.material.dispose();
        }
      });
    }
    const m = buildDentalArch(opts, () => {
      // Scan meshes arrive asynchronously — re-apply visibility/opacity and
      // the active color tint once they're actually in the group, then snap
      // to the pre-entrance state and wait. The actual reveal only runs once
      // the app calls playEntrance(), after the loading splash is gone.
      onScansReady && onScansReady();
      applyJaw(state, jawRef.current);
      if (state._modelColor != null) applyScanTint(m, state._modelColor);
      primeIntro(m);
      state._pendingIntroGroup = m;
    });
    state.modelGroup = m;
    state.scene.add(m);
    // Re-apply per-part visibility/opacity after every (re)build.
    applyJaw(state, jawRef.current);
  };

  useEffect(() => {
    const container = containerRef.current;
    const w = container.clientWidth, h = container.clientHeight;
    const scene = new THREE.Scene();
    scene.background = null;

    const camera = new THREE.PerspectiveCamera(32, w / h, 0.1, 200);
    // The right panel floats over the canvas rather than reserving a track,
    // so shift the frustum left by the same amount the bottom toolbar shifts
    // (half the panel's width + margin) to keep both centered in the space
    // actually visible beside the panel.
    camera.setViewOffset(w, h, PANEL_SHIFT_PX, 0, w, h);
    stateRef.current._panelShift = PANEL_SHIFT_PX;
    stateRef.current._verticalShift = 0;
    // Start in the lingual perspective (looking at the tongue-side of the arch).
    {
      const target = new THREE.Vector3(0, 0.5, -1.5);
      camera.position.copy(VIEW_DIRS.L.clone().multiplyScalar(24).add(target));
    }

    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance", preserveDrawingBuffer: true });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    renderer.setSize(w, h);
    renderer.outputEncoding = THREE.sRGBEncoding;
    container.appendChild(renderer.domElement);

    // Lighting
    const hemi = new THREE.HemisphereLight(0xffffff, 0xb0c4dc, 0.55);
    scene.add(hemi);
    const key = new THREE.DirectionalLight(0xffffff, 0.95);
    key.position.set(8, 16, 10);
    scene.add(key);
    const fill = new THREE.DirectionalLight(0xc6d7ec, 0.45);
    fill.position.set(-12, 6, -8);
    scene.add(fill);
    const rim = new THREE.DirectionalLight(0xffffff, 0.3);
    rim.position.set(0, 6, -14);
    scene.add(rim);

    // Grid (toggle)
    const grid = new THREE.GridHelper(40, 40, 0x6080a8, 0xa6b8d0);
    grid.material.opacity = 0.35;
    grid.material.transparent = true;
    grid.position.y = -1.6;
    grid.visible = false;
    scene.add(grid);

    // Controls
    const controls = new THREE.OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;
    controls.dampingFactor = 0.08;
    controls.enablePan = true;
    controls.screenSpacePanning = true; // pan freely in the view plane
    // Left drag orbits, right drag (hold) pans, wheel/middle zooms.
    controls.mouseButtons = {
      LEFT: THREE.MOUSE.ROTATE,
      MIDDLE: THREE.MOUSE.DOLLY,
      RIGHT: THREE.MOUSE.PAN,
    };
    // Touch: one finger orbits, two fingers pinch-zoom and drag to pan.
    controls.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_PAN };
    controls.minDistance = 9;
    controls.maxDistance = 50;
    controls.target.set(0, 0.5, -1.5);

    // Two-finger twist rotates around the model's vertical axis. Stock
    // OrbitControls binds TWO to dolly+pan only (there is no combined
    // pan+rotate mode), so twist is layered on top of DOLLY_PAN here: we track
    // the angle between the two live touch points and spin the camera about
    // the target by its delta, leaving pinch-zoom and two-finger pan intact.
    const twistPts = new Map();
    let lastTwist = null;
    const twistAngle = () => {
      const p = Array.from(twistPts.values());
      return Math.atan2(p[1].y - p[0].y, p[1].x - p[0].x);
    };
    const el = renderer.domElement;
    const onTwistDown = (e) => {
      if (e.pointerType !== "touch") return;
      twistPts.set(e.pointerId, { x: e.clientX, y: e.clientY });
      lastTwist = twistPts.size === 2 ? twistAngle() : null;
    };
    const onTwistMove = (e) => {
      if (e.pointerType !== "touch" || !twistPts.has(e.pointerId)) return;
      twistPts.set(e.pointerId, { x: e.clientX, y: e.clientY });
      if (twistPts.size !== 2 || !controls.enabled) return;
      const a = twistAngle();
      if (lastTwist != null) {
        let d = a - lastTwist;
        if (d > Math.PI) d -= Math.PI * 2;
        else if (d < -Math.PI) d += Math.PI * 2;
        // Roll the model in the plane of the screen — spin the group about the
        // camera's own view axis, which is what a finger twist reads as.
        const grp = stateRef.current && stateRef.current.modelGroup;
        if (grp) {
          const axis = camera.getWorldDirection(new THREE.Vector3());
          grp.rotateOnWorldAxis(axis, d);
        }
      }
      lastTwist = a;
    };
    const onTwistUp = (e) => {
      twistPts.delete(e.pointerId);
      lastTwist = twistPts.size === 2 ? twistAngle() : null;
    };
    el.addEventListener("pointerdown", onTwistDown);
    el.addEventListener("pointermove", onTwistMove);
    el.addEventListener("pointerup", onTwistUp);
    el.addEventListener("pointercancel", onTwistUp);

    const state = {
      scene, camera, renderer, controls,
      grid, container, raf: null, _tweenRaf: null,
    };
    stateRef.current = state;

    // The moment the user grabs the model to orbit/zoom, abort any in-progress
    // view-snap animation so manual control is never fought or overridden.
    // Without this, dragging during a snap tween pulls the camera back to the
    // preset view. Now the user keeps full control of whatever angle they set.
    controls.addEventListener("start", () => {
      cancelAnimationFrame(state._tweenRaf);
    });

    // Initial model
    buildModel(state, modelOpts);

    // animate loop
    const animate = () => {
      controls.update();
      renderer.render(scene, camera);
      state.raf = requestAnimationFrame(animate);
    };
    animate();

    // Resize
    const ro = new ResizeObserver(() => {
      const w = container.clientWidth, h = container.clientHeight;
      if (w === 0 || h === 0) return;
      camera.aspect = w / h;
      camera.setViewOffset(w, h, stateRef.current._panelShift ?? PANEL_SHIFT_PX, stateRef.current._verticalShift ?? 0, w, h);
      camera.updateProjectionMatrix();
      renderer.setSize(w, h);
    });
    ro.observe(container);

    onLoaded && onLoaded();

    return () => {
      cancelAnimationFrame(state.raf);
      cancelAnimationFrame(state._tweenRaf);
      cancelAnimationFrame(state._introRaf);
      ro.disconnect();
      controls.dispose();
      renderer.dispose();
      if (renderer.domElement.parentNode) {
        renderer.domElement.parentNode.removeChild(renderer.domElement);
      }
    };
    // eslint-disable-next-line
  }, []);

  // React to grid toggle
  useEffect(() => {
    const s = stateRef.current;
    if (s.grid) s.grid.visible = !!showGrid;
  }, [showGrid]);

  // React to jaw visibility / opacity changes
  useEffect(() => {
    const s = stateRef.current;
    if (s.scene && !s._jawAnimating) applyJaw(s, jaw);
  }, [jaw]);

  // React to model option changes
  useEffect(() => {
    const s = stateRef.current;
    if (!s.scene) return;
    buildModel(s, modelOpts);
    // eslint-disable-next-line
  }, [modelOpts.showCrown, modelOpts.crownColor, modelOpts.smooth, modelOpts.color]);

  // Expose imperative methods
  useImperativeHandle(ref, () => ({
    __debugState() {
      const s = stateRef.current;
      return {
        camera: s.camera ? { view: JSON.parse(JSON.stringify(s.camera.view || null)), position: s.camera.position.toArray(), aspect: s.camera.aspect } : null,
        panelShift: s._panelShift, verticalShift: s._verticalShift,
        container: containerRef.current ? { w: containerRef.current.clientWidth, h: containerRef.current.clientHeight } : null,
      };
    },
    playEntrance() {
      const s = stateRef.current;
      if (!s._pendingIntroGroup) return;
      const group = s._pendingIntroGroup;
      s._pendingIntroGroup = null;
      // Frame the camera for the model's FINAL size up front — briefly restore
      // full scale to measure the fit, then drop back to the intro's starting
      // scale — so the reveal only ever grows the model into an
      // already-correct frame instead of dollying the camera again once the
      // scale animation ends (which read as an abrupt post-animation jump).
      const introScale = group.scale.x;
      const baseScale = group.userData._introBaseScale || introScale;
      group.scale.setScalar(baseScale);
      doFitToScreen(s, 2.4, 0);
      group.scale.setScalar(introScale);
      playIntro(s, group, () => applyJaw(s, jawRef.current));
    },
    // Same scale-in reveal as playEntrance(), but replayable — used to
    // re-play the entrance when returning from the confirmation screen,
    // where the model is already built (not sitting in _pendingIntroGroup).
    replayEntrance() {
      const s = stateRef.current;
      const group = s.modelGroup;
      if (!group) return;
      primeIntro(group);
      const introScale = group.scale.x;
      const baseScale = group.userData._introBaseScale;
      group.scale.setScalar(baseScale);
      doFitToScreen(s, 2.4, 0);
      group.scale.setScalar(introScale);
      playIntro(s, group, () => applyJaw(s, jawRef.current));
    },
    setControlsEnabled(on) {
      const st = stateRef.current;
      if (st && st.controls) st.controls.enabled = !!on;
    },
    setModelColor(color) {
      const s = stateRef.current;
      s._modelColor = color;
      applyScanTint(s.modelGroup, color);
    },
    snapToView(view) {
      const s = stateRef.current;
      if (!s.camera) return;
      const dir = VIEW_DIRS[view];
      if (!dir) return;
      if (view !== "MAXV" && view !== "MANV") s.camera.up.set(0, 1, 0);
      // Clear any two-finger twist applied to the model so preset views land
      // perfectly upright/level instead of inheriting a leftover roll — restore
      // the base scan orientation (SCAN_YAW), not identity, or the model's
      // built-in facing gets wiped out too (front/back and left/right swap).
      if (s.modelGroup) s.modelGroup.rotation.set(0, SCAN_YAW, 0);
      // Distance that actually fits the model in this viewport, rather than a
      // fixed 18 units — on a narrow mobile canvas that constant cropped it.
      const fit = fitDistance(s, dir.clone().negate(), 2.6);
      const dist = fit != null ? fit : Math.max(s.camera.position.distanceTo(s.controls.target), 18);
      const target = new THREE.Vector3(0, 0.5, -1.5);
      if (view === "M") target.z += 1.2;
      else if (view === "D") target.z += 1.2;
      if (view === "MANV") target.y += 1.2;
      else if (view === "MAXV") target.y -= 1.2;
      const pos = dir.clone().multiplyScalar(dist).add(target);
      const prevView = s._lastView;
      s._lastView = view;
      arcLerpCamera(s, pos, target, 550, prevView && OPPOSITE_AXIS[prevView]);
    },
    // If the target arch is ALREADY the only one visible, just swing the
    // camera straight to the correct upright framing (nothing about
    // visibility changes, so no detour). Otherwise (both arches visible, or
    // the OTHER arch is the one shown) swing to the frontal waypoint first
    // (keeping current visibility as-is), swap arches instantly right there,
    // then rotate up/down into the jaw view.
    snapToJawView(mode, fromJaw, toJaw) {
      const s = stateRef.current;
      if (!s.camera) return;
      if (s.modelGroup) s.modelGroup.rotation.set(0, SCAN_YAW, 0);
      const view = mode === "maxilla" ? "MAXV" : "MANV";
      const targetDir = VIEW_DIRS[view];
      const finalTarget = new THREE.Vector3(0, 0.5, -1.5);
      if (view === "MANV") finalTarget.y += 1.2; else finalTarget.y -= 1.2;
      const finalFit = fitDistance(s, targetDir.clone().negate(), 2.6);
      const finalDist = finalFit != null ? finalFit : Math.max(s.camera.position.distanceTo(s.controls.target), 18);
      const finalPos = targetDir.clone().multiplyScalar(finalDist).add(finalTarget);
      const targetShown = mode === "maxilla" ? fromJaw.maxArch.shown : fromJaw.manArch.shown;
      const oppositeShown = mode === "maxilla" ? fromJaw.manArch.shown : fromJaw.maxArch.shown;
      if (targetShown && !oppositeShown) {
        applyJaw(s, toJaw);
        arcLerpCamera(s, finalPos, finalTarget, 450, new THREE.Vector3(0, 0, 1));
        s._lastView = view;
        return;
      }
      const frontalDir = VIEW_DIRS.L;
      const frontalTarget = new THREE.Vector3(0, 0.5, -1.5);
      const frontalFit = fitDistance(s, frontalDir.clone().negate(), 2.6);
      const frontalDist = frontalFit != null ? frontalFit : Math.max(s.camera.position.distanceTo(s.controls.target), 18);
      const frontalPos = frontalDir.clone().multiplyScalar(frontalDist).add(frontalTarget);
      const PHASE1_DUR = 380, PHASE2_DUR = 550, FADE_DUR = 100;
      const prevView = s._lastView;
      const oppositePart = mode === "maxilla" ? "mandible" : "maxilla";
      // Show both arches instantly, right now, so the previously-hidden one
      // is already visible by the time the frontal waypoint is reached (no
      // detour with "nothing changed yet" feel). _jawAnimating blocks the
      // [jaw] effect (fired by the app's setJaw call to the FINAL config)
      // from instantly clobbering this before the up/down leg starts.
      s._jawAnimating = true;
      applyJaw(s, {
        maxArch: { ...toJaw.maxArch, shown: true },
        manArch: { ...toJaw.manArch, shown: true },
        manCrown: { ...toJaw.manCrown, shown: true },
      });
      const runPhase2 = () => {
        arcLerpCamera(s, finalPos, finalTarget, PHASE2_DUR, new THREE.Vector3(0, 0, 1));
        fadeOutPart(s, oppositePart, FADE_DUR, () => {
          s._jawAnimating = false;
          applyJaw(s, toJaw);
        });
        s._lastView = view;
      };
      if (prevView === "L") {
        runPhase2();
      } else {
        arcLerpCamera(s, frontalPos, frontalTarget, PHASE1_DUR, prevView && OPPOSITE_AXIS[prevView]);
        s._lastView = "L";
        clearTimeout(s._jawPhaseTimer);
        s._jawPhaseTimer = setTimeout(runPhase2, PHASE1_DUR);
      }
    },
    resetView() {
      const s = stateRef.current;
      if (!s.camera) return;
      s._lastView = null;
      if (s.modelGroup) s.modelGroup.rotation.set(0, SCAN_YAW, 0);
      const target = new THREE.Vector3(0, 0.5, -1.5);
      arcLerpCamera(s, DEFAULT_CAMERA.clone(), target, 600);
    },
    zoomIn() {
      const s = stateRef.current;
      const dir = s.camera.position.clone().sub(s.controls.target).normalize();
      const dist = Math.max(9, s.camera.position.distanceTo(s.controls.target) * 0.85);
      const pos = dir.multiplyScalar(dist).add(s.controls.target);
      lerpCamera(s, pos, s.controls.target.clone(), 250);
    },
    zoomOut() {
      const s = stateRef.current;
      const dir = s.camera.position.clone().sub(s.controls.target).normalize();
      const dist = Math.min(50, s.camera.position.distanceTo(s.controls.target) * 1.18);
      const pos = dir.multiplyScalar(dist).add(s.controls.target);
      lerpCamera(s, pos, s.controls.target.clone(), 250);
    },
    setPanelShift(px) {
      // Tweens the camera's lens shift (see PANEL_SHIFT_PX) so the model
      // recenters smoothly as the floating right panel slides in/out.
      const s = stateRef.current;
      if (!s.camera) return;
      cancelAnimationFrame(s._shiftRaf);
      const from = s._panelShift ?? px;
      const t0 = performance.now();
      const dur = 470;
      const easeOut = (k) => 1 - Math.pow(1 - k, 3);
      const step = () => {
        const k = Math.min(1, (performance.now() - t0) / dur);
        const cur = from + (px - from) * easeOut(k);
        s._panelShift = cur;
        const container = containerRef.current;
        const cw = container ? container.clientWidth : window.innerWidth;
        const ch = container ? container.clientHeight : window.innerHeight;
        s.camera.setViewOffset(cw, ch, cur, s._verticalShift ?? 0, cw, ch);
        s.camera.updateProjectionMatrix();
        if (k < 1) s._shiftRaf = requestAnimationFrame(step);
      };
      step();
    },
    setVerticalShift(px) {
      // Same lens-shift trick as setPanelShift, but on the Y axis — used to
      // lift the model clear of the mobile floating tool panel (Visibility
      // Controls / Color Options), which sits at the bottom of the screen
      // instead of the side. Positive px shifts the model up on screen.
      const s = stateRef.current;
      if (!s.camera) return;
      cancelAnimationFrame(s._vShiftRaf);
      const from = s._verticalShift ?? px;
      const t0 = performance.now();
      const dur = 470;
      const easeOut = (k) => 1 - Math.pow(1 - k, 3);
      const step = () => {
        const k = Math.min(1, (performance.now() - t0) / dur);
        const cur = from + (px - from) * easeOut(k);
        s._verticalShift = cur;
        const container = containerRef.current;
        const cw = container ? container.clientWidth : window.innerWidth;
        const ch = container ? container.clientHeight : window.innerHeight;
        s.camera.setViewOffset(cw, ch, s._panelShift ?? 0, cur, cw, ch);
        s.camera.updateProjectionMatrix();
        if (k < 1) s._vShiftRaf = requestAnimationFrame(step);
      };
      step();
    },
    fitToScreen(padding = 2.4) {
      doFitToScreen(stateRef.current, padding, 500);
    },
  }));

  return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
});

window.Viewer3D = Viewer3D;
