/* global React, THREE */
const { useRef, useEffect } = React;

// API:
//   <EmojiViewer
//     base={{ url, color }}
//     superior={{ url, color }}
//     onPress={() => {}}    // fires on click; host plays sound
//   />
//
// Re-initializes when either url changes (user picks a different emoji).
// Drag rotates trackball-style, wheel zooms, click pushes the superior
// down into the base briefly and triggers onPress.

const _geomCache = new Map();   // for STLs (BufferGeometry by url)
const _modelCache = new Map();  // for 3MFs (THREE.Group by url)

function loadGeometry(url) {
  if (_geomCache.has(url)) return Promise.resolve(_geomCache.get(url));
  return new Promise((resolve, reject) => {
    const loader = new THREE.STLLoader();
    loader.load(url, (geom) => {
      geom.computeBoundingBox();
      _geomCache.set(url, geom);
      resolve(geom);
    }, undefined, reject);
  });
}

// Loads either an .stl (returns { type: "stl", geom }) or a .3mf
// (returns { type: "3mf", object }) so the caller can branch on the result.
function loadPart(url) {
  if (typeof url === "string" && url.toLowerCase().endsWith(".3mf")) {
    if (_modelCache.has(url)) {
      return Promise.resolve({ type: "3mf", object: _modelCache.get(url).clone(true) });
    }
    return loadCustom3MF(url).then((obj) => {
      _modelCache.set(url, obj);
      return { type: "3mf", object: obj.clone(true) };
    });
  }
  return loadGeometry(url).then((geom) => ({ type: "stl", geom }));
}

// --- Custom 3MF parser ---
// three.js's r149 3MFLoader chokes on Bambu Studio-style 3MFs where the build
// object is composite and its components reference meshes in separate .model
// files inside the zip via p:path. This parser handles that case: unzip with
// fflate, parse the XML, walk the build → components → mesh chain, and emit
// a THREE.Group of meshes with the per-component and build-item transforms
// baked in. Materials are left as default Lambert; the caller overrides them.
function _xmlDirectChildren(parent, tagName) {
  const out = [];
  for (const child of parent.children) {
    if (child.localName === tagName) out.push(child);
  }
  return out;
}
function _xmlDirectChild(parent, tagName) {
  for (const child of parent.children) {
    if (child.localName === tagName) return child;
  }
  return null;
}
function _parse3MFTransform(str) {
  if (!str) return null;
  const m = str.trim().split(/\s+/).map(parseFloat);
  if (m.length < 12 || m.some(isNaN)) return null;
  // 3MF transform string: 12 numbers row-major 4x3 (rows 1-3 = rotation,
  // row 4 = translation). Maps to a THREE.Matrix4 with that affine layout.
  const mat = new THREE.Matrix4();
  mat.set(
    m[0], m[3], m[6], m[9],
    m[1], m[4], m[7], m[10],
    m[2], m[5], m[8], m[11],
    0,    0,    0,    1,
  );
  return mat;
}
function _parse3MFMesh(meshEl) {
  const vertsEl = _xmlDirectChild(meshEl, "vertices");
  const trisEl  = _xmlDirectChild(meshEl, "triangles");
  if (!vertsEl || !trisEl) return null;
  const vertEls = _xmlDirectChildren(vertsEl, "vertex");
  const positions = new Float32Array(vertEls.length * 3);
  for (let i = 0; i < vertEls.length; i++) {
    const v = vertEls[i];
    positions[i * 3 + 0] = parseFloat(v.getAttribute("x"));
    positions[i * 3 + 1] = parseFloat(v.getAttribute("y"));
    positions[i * 3 + 2] = parseFloat(v.getAttribute("z"));
  }
  const triEls = _xmlDirectChildren(trisEl, "triangle");
  const indices = new Uint32Array(triEls.length * 3);
  for (let i = 0; i < triEls.length; i++) {
    const t = triEls[i];
    indices[i * 3 + 0] = parseInt(t.getAttribute("v1"), 10);
    indices[i * 3 + 1] = parseInt(t.getAttribute("v2"), 10);
    indices[i * 3 + 2] = parseInt(t.getAttribute("v3"), 10);
  }
  let geom = new THREE.BufferGeometry();
  geom.setAttribute("position", new THREE.BufferAttribute(positions, 3));
  geom.setIndex(new THREE.BufferAttribute(indices, 1));
  const vBefore = geom.attributes.position.count;
  // Bambu's exporter often duplicates vertices on shared edges, which breaks
  // smooth shading after computeVertexNormals. Merge co-located vertices first
  // so normals get averaged across adjacent triangles. Try a generous tolerance.
  if (window.THREE.BufferGeometryUtils && window.THREE.BufferGeometryUtils.mergeVertices) {
    geom = window.THREE.BufferGeometryUtils.mergeVertices(geom, 1e-3);
  }
  const vAfter = geom.attributes.position.count;
  console.log("[clickamojis] mergeVertices", { before: vBefore, after: vAfter, tris: indices.length / 3 });
  geom.computeVertexNormals();
  return geom;
}
async function loadCustom3MF(url) {
  if (!window.fflate) throw new Error("fflate not loaded — cannot parse .3mf");
  const response = await fetch(url);
  if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`);
  const buffer = await response.arrayBuffer();
  const files = window.fflate.unzipSync(new Uint8Array(buffer));
  const decoder = new TextDecoder();
  const parser = new DOMParser();
  const mainPath = "3D/3dmodel.model";
  if (!files[mainPath]) throw new Error("Missing 3D/3dmodel.model in 3MF");
  const docCache = new Map();
  function getDoc(path) {
    if (docCache.has(path)) return docCache.get(path);
    if (!files[path]) return null;
    const doc = parser.parseFromString(decoder.decode(files[path]), "application/xml");
    docCache.set(path, doc);
    return doc;
  }
  const mainDoc = getDoc(mainPath);
  // Build a Group of meshes by walking from the <build><item> down through
  // components, accumulating the affine transform along the way.
  const result = new THREE.Group();
  function findObject(path, id) {
    const doc = getDoc(path);
    if (!doc) return null;
    const resources = _xmlDirectChild(doc.documentElement, "resources");
    if (!resources) return null;
    for (const obj of _xmlDirectChildren(resources, "object")) {
      if (obj.getAttribute("id") === id) return obj;
    }
    return null;
  }
  function walkObject(path, id, accumMat) {
    const obj = findObject(path, id);
    if (!obj) return;
    const meshEl = _xmlDirectChild(obj, "mesh");
    if (meshEl) {
      const geom = _parse3MFMesh(meshEl);
      if (!geom) return;
      const mat = new THREE.MeshLambertMaterial({ color: 0xffffff });
      const mesh = new THREE.Mesh(geom, mat);
      if (accumMat) mesh.applyMatrix4(accumMat);
      result.add(mesh);
      return;
    }
    const componentsEl = _xmlDirectChild(obj, "components");
    if (!componentsEl) return;
    for (const comp of _xmlDirectChildren(componentsEl, "component")) {
      let compPath = comp.getAttribute("p:path") || comp.getAttribute("path") || path;
      if (compPath.startsWith("/")) compPath = compPath.slice(1);
      const compId = comp.getAttribute("objectid");
      const compMat = _parse3MFTransform(comp.getAttribute("transform"));
      const childMat = accumMat ? accumMat.clone() : new THREE.Matrix4();
      if (compMat) childMat.multiply(compMat);
      walkObject(compPath, compId, childMat);
    }
  }
  const buildEl = _xmlDirectChild(mainDoc.documentElement, "build");
  if (!buildEl) throw new Error("Missing <build> in 3MF");
  for (const item of _xmlDirectChildren(buildEl, "item")) {
    const itemId = item.getAttribute("objectid");
    const itemMat = _parse3MFTransform(item.getAttribute("transform"));
    walkObject(mainPath, itemId, itemMat);
  }
  return result;
}

// Returns every Mesh inside an Object3D (recursive). Used for raycasting
// when the superior is a 3MF group with multiple child meshes.
function collectMeshes(object) {
  const out = [];
  if (!object) return out;
  if (object.isMesh) { out.push(object); return out; }
  object.traverse((o) => { if (o.isMesh) out.push(o); });
  return out;
}

// Center each part on the origin and apply the standard authored-top orientation.
function prepareEmojiPart(rawGeom) {
  const g = rawGeom.clone();
  g.computeBoundingBox();
  const c = new THREE.Vector3();
  g.boundingBox.getCenter(c);
  g.translate(-c.x, -c.y, -c.z);
  g.rotateX(-Math.PI / 2);
  g.computeBoundingBox();
  return g;
}

// Banded color presets keyed by name. Each band covers a normalized radius
// range from center (t=0) to bbox edge (t=1). Sharp transitions, not lerped.
const GRADIENT_PRESETS = {
  flame: [
    { maxT: 0.20, color: new THREE.Color(0xFFFFFF) }, // white core (innermost 20%)
    { maxT: 0.40, color: new THREE.Color(0xFFD33F) }, // yellow band (20%)
    { maxT: 0.60, color: new THREE.Color(0xFF8C1A) }, // orange band (20%)
    { maxT: 1.00, color: new THREE.Color(0xDD2E44) }, // red outer (extends out)
  ],
};

function bandColor(bands, t) {
  for (const band of bands) {
    if (t <= band.maxT) return band.color;
  }
  return bands[bands.length - 1].color;
}

// Shape-aware radial gradient applied as per-vertex colors. Two passes:
//  1. Project the geometry onto its face plane (auto-detected as the plane
//     formed by the two LARGEST bbox dimensions). Walk around the gradient
//     origin in angular bins and record the farthest vertex distance in each
//     bin — that's the flame's silhouette in that direction.
//  2. For each vertex, color it based on its distance to the origin divided
//     by the silhouette distance at the same angle. Result: bands follow the
//     flame's actual outline (not an ellipse).
// The origin is offset toward the "base" of the flame (one end of the larger
// face-plane axis) so the white core sits at the rounded bottom, not the
// geometric center.
function applyRadialGradient(geom, preset) {
  const stops = GRADIENT_PRESETS[preset];
  if (!stops) return;
  geom.computeBoundingBox();
  const center = new THREE.Vector3();
  geom.boundingBox.getCenter(center);
  const size = new THREE.Vector3();
  geom.boundingBox.getSize(size);
  const positions = geom.attributes.position;

  // Face plane = two largest bbox dimensions; depth = the thinnest.
  const axes = [
    { idx: 0, half: (size.x / 2) || 1 },
    { idx: 1, half: (size.y / 2) || 1 },
    { idx: 2, half: (size.z / 2) || 1 },
  ].sort((a, b) => b.half - a.half);
  const ax1 = axes[0], ax2 = axes[1];
  const getCoord = (i, idx) =>
    idx === 0 ? positions.getX(i) - center.x
  : idx === 1 ? positions.getY(i) - center.y
              : positions.getZ(i) - center.z;

  // Anchor the gradient origin to the "base" of the flame: 50% of the larger
  // axis toward the negative end. (If this lands at the wrong end after the
  // emoji's flips, flip the sign of ORIGIN_OFFSET.)
  const ORIGIN_OFFSET = -0.5 * ax1.half;

  // Pass 1: per-angle silhouette radius.
  const NUM_BINS = 32;
  const maxRadius = new Float32Array(NUM_BINS);
  for (let i = 0; i < positions.count; i++) {
    const a1 = getCoord(i, ax1.idx) - ORIGIN_OFFSET;
    const a2 = getCoord(i, ax2.idx);
    const r = Math.sqrt(a1 * a1 + a2 * a2);
    const bin = Math.floor(((Math.atan2(a2, a1) + Math.PI) / (2 * Math.PI)) * NUM_BINS) % NUM_BINS;
    if (r > maxRadius[bin]) maxRadius[bin] = r;
  }
  // Smooth across bins to avoid jagged transitions between sparse bins.
  const smoothed = new Float32Array(NUM_BINS);
  for (let i = 0; i < NUM_BINS; i++) {
    const prev = (i - 1 + NUM_BINS) % NUM_BINS;
    const next = (i + 1) % NUM_BINS;
    smoothed[i] = (maxRadius[prev] + maxRadius[i] + maxRadius[next]) / 3 || 1;
  }

  // Pass 2: color each vertex by r / silhouette-radius.
  const colors = new Float32Array(positions.count * 3);
  for (let i = 0; i < positions.count; i++) {
    const a1 = getCoord(i, ax1.idx) - ORIGIN_OFFSET;
    const a2 = getCoord(i, ax2.idx);
    const r = Math.sqrt(a1 * a1 + a2 * a2);
    const bin = Math.floor(((Math.atan2(a2, a1) + Math.PI) / (2 * Math.PI)) * NUM_BINS) % NUM_BINS;
    const t = Math.min(1, r / (smoothed[bin] || 1));
    const col = bandColor(stops, t);
    colors[i * 3 + 0] = col.r;
    colors[i * 3 + 1] = col.g;
    colors[i * 3 + 2] = col.b;
  }
  geom.setAttribute("color", new THREE.BufferAttribute(colors, 3));
}

// Patches a MeshLambertMaterial with a fresnel rim shader. Behaves in two
// modes:
//   - Standard rim:  orange tint hugs the silhouette, fading toward the face
//                    center. Use `width` only (or set `innerWidth` very high).
//   - Band rim:      orange forms a band INSIDE the silhouette by subtracting
//                    two fresnel curves: pow(rimDot, width) - pow(rimDot,
//                    innerWidth). The band peaks somewhere between the center
//                    and the edge, leaving both the very edge and the center
//                    in the base color.
function applyRimShader(material, rim) {
  const rimColor      = new THREE.Color(rim.color || "#FF8C1A");
  const rimWidth      = rim.width      != null ? rim.width      : 2.0;
  const rimInnerWidth = rim.innerWidth != null ? rim.innerWidth : 1000.0;
  const rimStrength   = rim.strength   != null ? rim.strength   : 1.0;
  material.onBeforeCompile = (shader) => {
    shader.uniforms.rimColor      = { value: rimColor };
    shader.uniforms.rimWidth      = { value: rimWidth };
    shader.uniforms.rimInnerWidth = { value: rimInnerWidth };
    shader.uniforms.rimStrength   = { value: rimStrength };
    shader.fragmentShader = shader.fragmentShader
      .replace(
        "#include <common>",
        "#include <common>\n" +
        "uniform vec3 rimColor;\n" +
        "uniform float rimWidth;\n" +
        "uniform float rimInnerWidth;\n" +
        "uniform float rimStrength;"
      )
      .replace(
        "#include <dithering_fragment>",
        "#include <dithering_fragment>\n" +
        "float rimDot = 1.0 - max(dot(normalize(vViewPosition), normalize(vNormal)), 0.0);\n" +
        "float rimOuter = pow(rimDot, rimWidth);\n" +
        "float rimInner = pow(rimDot, rimInnerWidth);\n" +
        "float rimFactor = clamp((rimOuter - rimInner) * rimStrength, 0.0, 1.0);\n" +
        "gl_FragColor.rgb = mix(gl_FragColor.rgb, rimColor, rimFactor);"
      );
  };
}

function EmojiViewer({ base, superior, baseRotZ, baseMoveY, superiorRotZ, superiorRotY, superiorFlipY, superiorFlipX, superiorMoveY, superiorGradient, superiorRim, superiorClones, superiorMeshColors, splitParts, onPress }) {
  const mountRef = useRef(null);
  const stateRef = useRef(null);
  const onPressRef = useRef(null);
  useEffect(() => { onPressRef.current = onPress; }, [onPress]);

  // Init (re-runs whenever either URL changes).
  useEffect(() => {
    const mount = mountRef.current;
    if (!mount || !window.THREE || !base || !superior) return;

    let raf = 0;
    let cancelled = false;

    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    if (THREE.ColorManagement) THREE.ColorManagement.enabled = false;
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    const W = mount.clientWidth, H = mount.clientHeight;
    renderer.setSize(W, H);
    renderer.toneMapping = THREE.NoToneMapping;
    mount.appendChild(renderer.domElement);

    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(32, W / H, 0.1, 5000);
    scene.add(new THREE.AmbientLight(0xffffff, 0.92));
    const d1 = new THREE.DirectionalLight(0xffffff, 0.18); d1.position.set(2, 5, 3); scene.add(d1);
    const d2 = new THREE.DirectionalLight(0xffffff, 0.08); d2.position.set(-3, 2, -2); scene.add(d2);

    // Outer group: drag-rotate. Inner stackGroup: press animation.
    const group = new THREE.Group();
    scene.add(group);
    const stackGroup = new THREE.Group();
    group.add(stackGroup);

    const ctrl = {
      dragging: false,
      lastX: 0, lastY: 0,
      camDist: null,
      camDistInitial: null,
    };
    const X_AXIS = new THREE.Vector3(1, 0, 0);
    const Y_AXIS = new THREE.Vector3(0, 1, 0);
    const dragQuat = new THREE.Quaternion();
    // Initial tilt: same as macaron — ~30° below horizontal for a 3/4 view.
    group.quaternion.setFromAxisAngle(X_AXIS, Math.PI / 2 - 0.9);

    const baseMat     = new THREE.MeshLambertMaterial({ color: base.color || "#cccccc" });
    // When using a vertex-color gradient, keep the material color white so the
    // gradient renders as-authored (vertex colors multiply with the material).
    const superiorMat = superiorGradient
      ? new THREE.MeshLambertMaterial({ vertexColors: true, color: 0xffffff })
      : new THREE.MeshLambertMaterial({ color: superior.color || "#cccccc" });
    if (superiorRim) applyRimShader(superiorMat, superiorRim);

    const state = {
      renderer, scene, camera, group, stackGroup, ctrl,
      baseMat, superiorMat,
      baseMesh: null, superiorMesh: null,
      superiorRestY: 0,
      superiorSize: 0,
      maxRadialSize: 0,
      stackHeight: 0,
      pressing: false,
      pressStart: 0,
      ready: false,
      destroyed: false,
    };
    stateRef.current = state;

    Promise.all([
      loadGeometry(base.url),
      loadPart(superior.url),
    ]).then(([baseRaw, superiorResult]) => {
      if (cancelled || state.destroyed) return;

      const baseGeom = prepareEmojiPart(baseRaw);
      if (baseRotZ) { baseGeom.rotateZ(baseRotZ); baseGeom.computeBoundingBox(); }

      // Compute the reference dimension once — used for forward pushes and
      // moveY scaling. Driven by the base's bbox so it stays stable across
      // STL vs 3MF supports.
      const baseSizeTmp = new THREE.Vector3();
      baseGeom.boundingBox.getSize(baseSizeTmp);
      const refDim = Math.max(baseSizeTmp.x, baseSizeTmp.y, baseSizeTmp.z);

      if (baseMoveY) { baseGeom.translate(0, baseMoveY * refDim, 0); baseGeom.computeBoundingBox(); }

      // We end up with `state.superiorMesh` being either a Mesh (STL) or a
      // Group (3MF). Both have .position / .rotation / .scale so the press
      // animation and raycasting work uniformly.
      let superiorGeom = null; // only populated for STL
      if (superiorResult.type === "stl") {
        superiorGeom = prepareEmojiPart(superiorResult.geom);
        if (superiorFlipY) { superiorGeom.rotateY(Math.PI); superiorGeom.computeBoundingBox(); }
        if (superiorFlipX) { superiorGeom.rotateX(Math.PI); superiorGeom.computeBoundingBox(); }
        if (superiorMoveY) { superiorGeom.translate(0, superiorMoveY * refDim, 0); superiorGeom.computeBoundingBox(); }
        if (superiorRotY)  { superiorGeom.rotateY(superiorRotY); superiorGeom.computeBoundingBox(); }
        if (superiorRotZ)  { superiorGeom.rotateZ(superiorRotZ); superiorGeom.computeBoundingBox(); }
        if (superiorGradient) applyRadialGradient(superiorGeom, superiorGradient);
        state.superiorMesh = new THREE.Mesh(superiorGeom, superiorMat);
      } else {
        // 3MF: the loader returns a Group of child meshes. We override their
        // materials with our own Lambert (in `superior.color`) so they render
        // visibly in our lighting setup — Bambu Studio's filament colors live
        // in proprietary metadata that three.js's 3MFLoader doesn't parse,
        // so the loader-supplied materials are usually default-white and
        // disappear against the page background.
        const inner = superiorResult.object;
        const fallbackColor = (superior && superior.color) || "#cccccc";
        // First pass: collect every child mesh and give it the fallback color.
        const meshList = [];
        inner.traverse((obj) => {
          if (obj.isMesh) {
            obj.material = new THREE.MeshPhongMaterial({ color: fallbackColor, specular: 0x000000, shininess: 0, flatShading: false });
            meshList.push(obj);
          }
        });
        // Second pass: if per-layer colors are specified, sort meshes by
        // triangle count (smallest = innermost) and apply colors in order.
        // Meshes beyond the color list keep the fallback color.
        if (superiorMeshColors && superiorMeshColors.length > 0) {
          const triCount = (m) => (m.geometry.index
            ? m.geometry.index.count / 3
            : m.geometry.attributes.position.count / 3);
          meshList.sort((a, b) => triCount(a) - triCount(b));
          meshList.forEach((mesh, i) => {
            if (i < superiorMeshColors.length) {
              mesh.material = new THREE.MeshPhongMaterial({ color: superiorMeshColors[i], specular: 0x000000, shininess: 0, flatShading: false });
            }
          });
        }
        // eslint-disable-next-line no-console
        console.log("[clickamojis] 3MF loaded", { url: superior.url, meshes: meshList.length, faces: meshList.map((m) => m.geometry.index ? m.geometry.index.count / 3 : m.geometry.attributes.position.count / 3) });

        // Center the loaded geometry on the wrapper's origin.
        inner.updateMatrixWorld(true);
        const initialBox = new THREE.Box3().setFromObject(inner);
        const initialCenter = new THREE.Vector3();
        initialBox.getCenter(initialCenter);
        inner.position.sub(initialCenter);

        const wrapper = new THREE.Group();
        wrapper.add(inner);
        // Standard prep: rotate so authored "+Z up" faces +Y world.
        wrapper.rotateX(-Math.PI / 2);
        if (superiorFlipY) wrapper.rotateY(Math.PI);
        if (superiorFlipX) wrapper.rotateX(Math.PI);
        if (superiorRotY)  wrapper.rotateY(superiorRotY);
        if (superiorRotZ)  wrapper.rotateZ(superiorRotZ);
        if (superiorMoveY) wrapper.position.y += superiorMoveY * refDim;
        state.superiorMesh = wrapper;
      }

      state.baseMesh = new THREE.Mesh(baseGeom, baseMat);
      stackGroup.add(state.baseMesh, state.superiorMesh);

      // Optional stack of scaled clones of the superior — each smaller and
      // aligned at the bottom of the flame, creating nested flame layers
      // (red → orange → yellow → white core, all sharing the rounded base).
      // All clones are children of the superior mesh so they inherit the
      // press animation. Polygon offset is used to ensure each subsequent
      // layer renders on top of the previous one.
      // NOTE: only supported for STL-loaded superiors (we need a single
      // BufferGeometry to clone and scale).
      if (superiorGeom && superiorClones && superiorClones.length > 0) {
        const cloneCenter = new THREE.Vector3();
        superiorGeom.boundingBox.getCenter(cloneCenter);
        const supBboxSize = new THREE.Vector3();
        superiorGeom.boundingBox.getSize(supBboxSize);
        const halfHeight = supBboxSize.y / 2;
        const halfDepth  = supBboxSize.z / 2;

        // "Screen down" direction in stackGroup local frame, given the initial
        // group rotation Rx(π/2 - 0.9). Computed once outside the loop:
        // world -Y in local = (0, -cos(tilt), +sin(tilt)).
        const TILT = Math.PI / 2 - 0.9;
        const downY = -Math.cos(TILT);
        const downZ =  Math.sin(TILT);
        // How far the bbox extends below its center along screen-down.
        const downExtent = Math.cos(TILT) * halfHeight + Math.sin(TILT) * halfDepth;

        state.cloneMeshes = [];
        superiorClones.forEach((layer, i) => {
          const scale = layer.scale != null ? layer.scale : 0.75;
          const forward = layer.forward != null ? layer.forward : 0.05;
          const color = layer.color || "#FF8C1A";

          const cloneGeom = superiorGeom.clone();
          // Scale around the geometry's bbox center to keep it co-located.
          cloneGeom.translate(-cloneCenter.x, -cloneCenter.y, -cloneCenter.z);
          cloneGeom.scale(scale, scale, scale);
          cloneGeom.translate(cloneCenter.x, cloneCenter.y, cloneCenter.z);
          // Small forward push (toward camera-ish via +Y in our setup).
          cloneGeom.translate(0, forward * refDim, 0);
          // Bottom alignment: translate each clone along the SCREEN-DOWN
          // direction (a tilted vector mixing -Y and +Z in stackGroup local)
          // so its visual bottom on screen matches the superior's. Magnitude
          // is (1 - scale) × the bbox's extent in that direction.
          const A = (1 - scale) * downExtent;
          cloneGeom.translate(0, downY * A, downZ * A);
          cloneGeom.computeBoundingBox();

          const cloneMat = new THREE.MeshLambertMaterial({ color: color });
          // Negative polygon offset pulls the layer forward in z-buffer
          // comparison so each inner layer wins the depth test against the
          // previous one. Without this, the bottom-aligned clones would be
          // hidden behind the larger red mesh.
          cloneMat.polygonOffset = true;
          cloneMat.polygonOffsetFactor = -(i + 1);
          cloneMat.polygonOffsetUnits  = -(i + 1);

          const cloneMesh = new THREE.Mesh(cloneGeom, cloneMat);
          cloneMesh.renderOrder = i + 1;
          state.superiorMesh.add(cloneMesh);
          state.cloneMeshes.push(cloneMesh);
        });
      }

      const baseBB = baseGeom.boundingBox;
      const baseSize = new THREE.Vector3(); baseBB.getSize(baseSize);
      // Superior size: for STL we already have the bbox on the geometry;
      // for 3MF we need to compute it from the actual object in the scene
      // (after wrapper transforms have been applied).
      const supSize = new THREE.Vector3();
      if (superiorGeom) {
        superiorGeom.boundingBox.getSize(supSize);
      } else {
        state.superiorMesh.updateMatrixWorld(true);
        new THREE.Box3().setFromObject(state.superiorMesh).getSize(supSize);
      }

      if (splitParts) {
        // Place base on the left, superior on the right with a small gap.
        const gap = Math.max(baseSize.x, supSize.x) * 0.2;
        state.baseMesh.position.x     = -(baseSize.x / 2 + gap / 2);
        state.superiorMesh.position.x =  (supSize.x  / 2 + gap / 2);
        state.maxRadialSize = baseSize.x + supSize.x + gap;
        state.stackHeight   = Math.max(baseSize.y, supSize.y);
      } else {
        // Default: parts authored co-planar, both centered at origin.
        state.maxRadialSize = Math.max(baseSize.x, baseSize.z, supSize.x, supSize.z);
        state.stackHeight   = Math.max(baseSize.y, supSize.y);
      }
      state.superiorSize  = supSize.y;
      state.superiorRestY = state.superiorMesh.position.y;

      // Frame the camera around the emoji.
      const fov = camera.fov * (Math.PI / 180);
      const frameSize = Math.max(state.maxRadialSize, state.stackHeight) * 1.6;
      const dist = (frameSize / 2) / Math.tan(fov / 2) * 1.4 * 1.2;
      camera.position.set(0, 0, dist);
      camera.lookAt(0, 0, 0);
      ctrl.camDist = dist;
      ctrl.camDistInitial = dist;

      state.ready = true;
    }).catch((e) => console.error("[clickamojis] superior load failed", e));

    // ---------- input ----------
    const dom = renderer.domElement;
    dom.style.cursor = "grab";
    dom.style.touchAction = "none";

    let moveDist = 0;
    const pointers = new Map();
    let pinchDist = null;
    const raycaster = new THREE.Raycaster();
    const ndc = new THREE.Vector2();

    function pickAnyPart(clientX, clientY) {
      const s = stateRef.current;
      if (!s || !s.ready) return false;
      const rect = dom.getBoundingClientRect();
      ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
      ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
      raycaster.setFromCamera(ndc, camera);
      // intersectObjects with `true` recurses into groups, so a 3MF superior
      // (which is a Group with child meshes) is picked correctly.
      const targets = [s.baseMesh, s.superiorMesh].filter(Boolean);
      const hits = raycaster.intersectObjects(targets, true);
      return hits.length > 0;
    }

    function pressStack() {
      const s = stateRef.current;
      if (!s || !s.ready || s.pressing) return;
      s.pressing = true;
      s.pressStart = performance.now();
      if (onPressRef.current) onPressRef.current();
    }

    dom.addEventListener("wheel", (e) => {
      e.preventDefault();
      if (ctrl.camDist == null) return;
      const factor = Math.exp(e.deltaY * 0.0015);
      ctrl.camDist = Math.max(ctrl.camDist * 0.2, Math.min(ctrl.camDist * 8, camera.position.length() * factor));
      const d = camera.position.clone().normalize();
      camera.position.copy(d.multiplyScalar(ctrl.camDist));
    }, { passive: false });
    dom.addEventListener("pointerdown", (e) => {
      pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
      try { dom.setPointerCapture(e.pointerId); } catch (_) {}
      if (pointers.size === 1) {
        ctrl.dragging = true;
        ctrl.lastX = e.clientX;
        ctrl.lastY = e.clientY;
        moveDist = 0;
        dom.style.cursor = "grabbing";
      } else if (pointers.size === 2) {
        ctrl.dragging = false;
        const pts = Array.from(pointers.values());
        pinchDist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
      }
    });
    dom.addEventListener("pointermove", (e) => {
      if (!pointers.has(e.pointerId)) return;
      pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });

      if (pointers.size === 2 && pinchDist != null) {
        const pts = Array.from(pointers.values());
        const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
        const factor = pinchDist / Math.max(dist, 0.0001);
        if (ctrl.camDist != null) {
          const init = ctrl.camDistInitial || camera.position.length();
          ctrl.camDist = Math.max(init * 0.2, Math.min(init * 8, ctrl.camDist * factor));
          const d = camera.position.clone().normalize();
          camera.position.copy(d.multiplyScalar(ctrl.camDist));
        }
        pinchDist = dist;
        return;
      }

      if (!ctrl.dragging || pointers.size !== 1) return;
      const dx = e.clientX - ctrl.lastX;
      const dy = e.clientY - ctrl.lastY;
      moveDist += Math.abs(dx) + Math.abs(dy);
      dragQuat.setFromAxisAngle(Y_AXIS, dx * 0.01);
      group.quaternion.premultiply(dragQuat);
      dragQuat.setFromAxisAngle(X_AXIS, dy * 0.01);
      group.quaternion.premultiply(dragQuat);
      ctrl.lastX = e.clientX; ctrl.lastY = e.clientY;
    });
    function endPointer(e) {
      pointers.delete(e.pointerId);
      try { dom.releasePointerCapture(e.pointerId); } catch (_) {}
      if (pointers.size < 2) pinchDist = null;
      if (pointers.size === 0) {
        ctrl.dragging = false;
        dom.style.cursor = "grab";
        if (moveDist < 5) {
          if (pickAnyPart(e.clientX, e.clientY)) pressStack();
        }
      }
    }
    dom.addEventListener("pointerup", endPointer);
    dom.addEventListener("pointercancel", endPointer);

    const ro = new ResizeObserver(() => {
      const w = mount.clientWidth, h = mount.clientHeight;
      renderer.setSize(w, h);
      camera.aspect = w / h;
      camera.updateProjectionMatrix();
    });
    ro.observe(mount);

    function animate() {
      raf = requestAnimationFrame(animate);
      const s = stateRef.current;
      if (s && s.ready && s.pressing) {
        const downMs = 60, holdMs = 70, upMs = 90;
        const total = downMs + holdMs + upMs;
        // Push the superior down into the base; base stays.
        const depth = (s.superiorSize || s.stackHeight) * 0.25;
        const t = performance.now() - s.pressStart;
        let drop = 0;
        if      (t < downMs)            drop = depth * (t / downMs);
        else if (t < downMs + holdMs)   drop = depth;
        else if (t < total)             drop = depth * (1 - (t - downMs - holdMs) / upMs);
        else { drop = 0; s.pressing = false; }
        s.superiorMesh.position.y = s.superiorRestY - drop;
      }
      renderer.render(scene, camera);
    }
    animate();

    return () => {
      cancelled = true;
      state.destroyed = true;
      cancelAnimationFrame(raf);
      ro.disconnect();
      stateRef.current = null;
      renderer.dispose();
      if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement);
    };
    // Re-init whenever URLs, rotations or layout change.
  }, [base && base.url, superior && superior.url, baseRotZ, baseMoveY, superiorRotZ, superiorRotY, superiorFlipY, superiorFlipX, superiorMoveY, superiorGradient, superiorRim, superiorClones, superiorMeshColors, splitParts]);

  // Live color updates — no scene rebuild. Skip the superior when it uses a
  // vertex-color gradient (would tint the gradient).
  useEffect(() => {
    const s = stateRef.current;
    if (!s) return;
    if (base     && base.color)                       s.baseMat.color.set(base.color);
    if (superior && superior.color && !superiorGradient) s.superiorMat.color.set(superior.color);
  }, [base && base.color, superior && superior.color, superiorGradient]);

  return React.createElement("div", { className: "three-mount", ref: mountRef });
}

window.EmojiViewer = EmojiViewer;
