// Pseudo-3D voxel preview, rendered with CSS transforms.
// Each cube is six divs glued into a cuboid. We define a few "models"
// and pick one based on the current session.

const VOXEL_SIZE = 18; // px

// CSS-3D voxel preview parameters.
//
// PREVIEW_FIT: the target silhouette extent (in voxel-units) the largest
// model dimension should occupy after centering+scaling. Old value 5
// rendered a 24-unit tie fighter at ~3.8px per cube, which made cubes that
// overlap by ≤0.5 units (the prompt's connectivity rule) disappear into
// anti-alias seams — visually the model looked disconnected even though
// find_components said 1 component. Bumping to 14 puts cubes at ~10px each
// (~2.8× larger), so the same overlap is now ~4px wide and clearly
// visible. The .preview-model-area still has overflow:hidden, so this
// stays within the panel.
const PREVIEW_FIT = 14;

// Tiny per-cube inflate (multiplicative) to close residual hairline gaps
// from sub-pixel rounding + 3D rotation. 2% makes a 1-unit cube grow by
// ~0.18px on each side at the new scale, enough to overlap a neighbor
// touching face-to-face. Bigger cubes inflate more in absolute pixels but
// it's invisible because the texture stretches with them.
const CUBE_INFLATE = 1.02;

// CSS face → bbmodel face direction. The cube CSS faces "front/back/..." map to
// bbmodel's "north/south/east/west/up/down" — same convention used by the GLB
// exporter (see backend/app/tools/exporter_glb.py:_FACE_DIRS).
const _FACE_DIR_MAP = {
  front: "north",
  back:  "south",
  right: "east",
  left:  "west",
  top:   "up",
  bottom:"down",
};

/**
 * Build a CSS background style for one cube face from its bbmodel uv rect.
 * `uv` is `[u1, v1, u2, v2]` in atlas pixels. `atlasW/atlasH` are the atlas
 * dimensions (from bbmodel.resolution). `texSrc` is the data URL.
 *
 * The trick: scale the atlas image so the cropped (u2-u1)×(v2-v1) rectangle
 * exactly fills the face's CSS pixel size, then offset the background so
 * that rectangle is in the visible window.
 */
function _faceTextureStyle(uv, atlasW, atlasH, texSrc, faceW, faceH) {
  if (!uv || !texSrc || !atlasW || !atlasH) return null;
  const u1 = +uv[0], v1 = +uv[1], u2 = +uv[2], v2 = +uv[3];
  const cropW = Math.abs(u2 - u1);
  const cropH = Math.abs(v2 - v1);
  if (!(cropW > 0) || !(cropH > 0)) return null;
  const scaleX = faceW / cropW;
  const scaleY = faceH / cropH;
  // Origin of the visible crop within the atlas (top-left in atlas px).
  const u0 = Math.min(u1, u2);
  const v0 = Math.min(v1, v2);
  const flipX = u2 < u1;
  const flipY = v2 < v1;
  return {
    backgroundImage: `url("${texSrc}")`,
    backgroundRepeat: "no-repeat",
    backgroundSize: `${atlasW * scaleX}px ${atlasH * scaleY}px`,
    backgroundPosition: `-${u0 * scaleX}px -${v0 * scaleY}px`,
    imageRendering: "pixelated",
    // Mirror in U/V if uv was specified flipped (rare but legal in bbmodel).
    transformOrigin: "center",
    // No need to merge transform here — transformed by the parent CSS rule.
    // Apply the per-face flip via filter is wrong; we do it via a child div
    // when needed. For first cut just leave flipX/flipY without mirror —
    // it's almost always normal-orientation in our generated atlases.
    backgroundColor: "transparent",
  };
}

// CSS-frame mapping for bbmodel Euler rotations.
//
// bbmodel: right-handed, Y up, -Z forward, extrinsic XYZ Euler in degrees
// (rotation = R_z · R_y · R_x). The preview (see bbmodelToVoxelCubes /
// _Bone below) mirrors bbmodel-space into the CSS frame via M = diag(1,
// -1, -1) — Y up→down, and -Z forward→+Z toward viewer. Conjugating a
// bbmodel rotation R_b by this mirror gives R_css = M · R_b · M, which
// componentwise is rx → +rx, ry → -ry, rz → -rz (verified by matrix
// computation and three concrete cases: 90° around X, Y, Z respectively).
//
// CSS transform functions compose right-to-left when applied to a point,
// so `rotateZ(a) rotateY(b) rotateX(c)` builds the matrix R_z(a)·R_y(b)·
// R_x(c) — the same order as bbmodel's extrinsic XYZ. Output the three
// rotateZ/Y/X functions in that order so the composition matches.
function _bbRotationToCss(rot) {
  if (!rot) return "";
  const rx = Number(rot[0]) || 0;
  const ry = Number(rot[1]) || 0;
  const rz = Number(rot[2]) || 0;
  if (!rx && !ry && !rz) return "";
  return `rotateZ(${-rz}deg) rotateY(${-ry}deg) rotateX(${rx}deg)`;
}

// Pivot offset comes from `bbmodelToVoxelCubes` / `_Bone` in preview-Y-up
// units (X same, Y up positive, Z toward viewer positive — the same frame
// that Cube's x/y/z params live in). Convert to CSS pixels (Y down):
function _pivotToCssPx(pivotOffset) {
  if (!pivotOffset) return null;
  const dx = (Number(pivotOffset[0]) || 0) * VOXEL_SIZE;
  const dy = -(Number(pivotOffset[1]) || 0) * VOXEL_SIZE;
  const dz = (Number(pivotOffset[2]) || 0) * VOXEL_SIZE;
  if (Math.abs(dx) < 1e-6 && Math.abs(dy) < 1e-6 && Math.abs(dz) < 1e-6) return null;
  return { dx, dy, dz };
}

function Cube({ x, y, z, w = 1, h = 1, d = 1, color, animate, faces, element, atlas,
                rotation = null, pivotOffset = null }) {
  const W = w * VOXEL_SIZE, H = h * VOXEL_SIZE, D = d * VOXEL_SIZE;
  const tx = x * VOXEL_SIZE - W / 2;
  const ty = -y * VOXEL_SIZE - H / 2;
  // NOTE: NO `- D/2` here. The vox div has 2D extent (width W, height H) and
  // no intrinsic Z extent — its transform-origin sits at local (W/2, H/2, 0),
  // and the 6 face children are themselves at translateZ(±D/2). So the cube's
  // geometric Z center IS at local Z=0, not local Z=D/2. Subtracting D/2 here
  // would shift the cube's geometric center to world Z = z·VS − D/2, i.e.
  // D/2 BEHIND where bbmodel says the cube center is. With same-depth cubes
  // this constant per-cube shift was invisible; once we introduced rotation
  // around a non-center pivot (element.origin), rotating around the
  // off-by-D/2 transform-origin produced exactly the displacement users
  // reported. Putting the cube center at world Z = z·VS aligns transform-
  // origin with the bbmodel pivot frame and also fixes the long-standing
  // (but mostly invisible) varying-depth misalignment.
  const tz = z * VOXEL_SIZE;

  // Resolve the per-face UV rectangle (in atlas pixels). bbmodel has two
  // mutually-exclusive UV modes; pick by the cube's `box_uv` flag — same
  // contract as backend bbmodel_to_glb._atlas_rect_for_face.
  //   box_uv: true  → derive from `uv_offset + (w,h,d)` (Minecraft T-unwrap).
  //                   `faces[dir].uv` is unused / placeholder.
  //   box_uv: false → use `faces[dir].uv = [u1,v1,u2,v2]`.
  // Layout for box_uv (matches backend exporter):
  //     row 0 (height d):  [d wide pad] [up: w×d] [down: w×d]
  //     row 1 (height h):  [east: d×h] [north: w×h] [west: d×h] [south: w×h]
  const _boxUv = (bbDir) => {
    if (!element) return null;
    const off = element.uv_offset || [0, 0];
    const f = element.from || [0, 0, 0], t = element.to || [0, 0, 0];
    const u = +off[0] || 0, v = +off[1] || 0;
    const cw = Math.max(1, Math.ceil(Math.abs(t[0] - f[0])));
    const ch = Math.max(1, Math.ceil(Math.abs(t[1] - f[1])));
    const cd = Math.max(1, Math.ceil(Math.abs(t[2] - f[2])));
    switch (bbDir) {
      case "up":    return [u + cd,         v,        u + cd + cw,         v + cd];
      case "down":  return [u + cd + cw,    v,        u + cd + cw + cw,    v + cd];
      case "east":  return [u,              v + cd,   u + cd,              v + cd + ch];
      case "north": return [u + cd,         v + cd,   u + cd + cw,         v + cd + ch];
      case "west":  return [u + cd + cw,    v + cd,   u + cd + cw + cd,    v + cd + ch];
      case "south": return [u + cd + cw + cd, v + cd, u + cd + cw + cd + cw, v + cd + ch];
      default: return null;
    }
  };

  const _faceUv = (bbDir) => {
    if (element && element.box_uv) return _boxUv(bbDir);
    const f = faces && faces[bbDir];
    return (f && f.uv) || null;
  };

  const faceStyle = (cssDir, faceW, faceH) => {
    const base = { "--c": color };
    if (!atlas || !atlas.src) return base;
    const bbDir = _FACE_DIR_MAP[cssDir];
    const uv = _faceUv(bbDir);
    if (!uv) return base;
    const tex = _faceTextureStyle(uv, atlas.w, atlas.h, atlas.src, faceW, faceH);
    return tex ? { ...base, ...tex } : base;
  };

  // Compose a rotation around the bbmodel pivot (element.origin) if the
  // caller passed one. The CSS transform order, applied right-to-left to
  // the cube's local vertices:
  //   scale3d        → uniform inflate around the cube's center
  //   -pivot         → move pivot to local origin
  //   rotateZYX      → rotate around local origin (== pivot)
  //   +pivot         → move pivot back
  //   translate(tx)  → world placement
  // The pivot here is offset-from-cube-center in CSS px (Y already flipped
  // by _pivotToCssPx). When rotation/pivot are absent the rotation block
  // collapses to nothing and we keep the original (translate · scale)
  // transform.
  const rotCss = _bbRotationToCss(rotation);
  const pivotCss = rotCss ? _pivotToCssPx(pivotOffset) : null;
  const rotateBlock = rotCss
    ? (pivotCss
        ? ` translate3d(${pivotCss.dx}px, ${pivotCss.dy}px, ${pivotCss.dz}px) ${rotCss} translate3d(${-pivotCss.dx}px, ${-pivotCss.dy}px, ${-pivotCss.dz}px)`
        : ` ${rotCss}`)
    : "";

  return (
    <div
      className={"vox" + (animate ? " " + animate : "")}
      style={{
        // scale3d around the cube center (default transform-origin 50%/50%)
        // grows the cube uniformly so it overlaps face-touching neighbors
        // by 1–4px — kills the hairline gap that the connectivity-confused
        // user mistook for floating clusters.
        transform: `translate3d(${tx}px, ${ty}px, ${tz}px)${rotateBlock} scale3d(${CUBE_INFLATE}, ${CUBE_INFLATE}, ${CUBE_INFLATE})`,
        width: W, height: H,
      }}
    >
      <div className="vox-face front"  style={{ ...faceStyle("front",  W, H), width: W, height: H, transform: `translateZ(${D / 2}px)` }} />
      <div className="vox-face back"   style={{ ...faceStyle("back",   W, H), width: W, height: H, transform: `translateZ(${-D / 2}px) rotateY(180deg)` }} />
      <div className="vox-face right"  style={{ ...faceStyle("right",  D, H), width: D, height: H, left: W / 2 - D / 2, transform: `rotateY(90deg) translateZ(${W / 2}px)` }} />
      <div className="vox-face left"   style={{ ...faceStyle("left",   D, H), width: D, height: H, left: W / 2 - D / 2, transform: `rotateY(-90deg) translateZ(${W / 2}px)` }} />
      <div className="vox-face top"    style={{ ...faceStyle("top",    W, D), width: W, height: D, top: H / 2 - D / 2, transform: `rotateX(90deg) translateZ(${H / 2}px)` }} />
      <div className="vox-face bottom" style={{ ...faceStyle("bottom", W, D), width: W, height: D, top: H / 2 - D / 2, transform: `rotateX(-90deg) translateZ(${H / 2}px)` }} />
    </div>
  );
}

/** Resolve the atlas {src, w, h} from a session ctx. Returns null when
 *  texture isn't ready yet. */
function _resolveAtlas(bbmodel, textureB64) {
  if (!textureB64 || !bbmodel) return null;
  const res = bbmodel.resolution || {};
  const w = +res.width || 16;
  const h = +res.height || 16;
  const src = String(textureB64).startsWith("data:")
    ? textureB64
    : `data:image/png;base64,${textureB64}`;
  return { src, w, h };
}

function _originVec(raw, fallback = [0, 0, 0]) {
  if (!Array.isArray(raw)) return fallback;
  return [
    Number(raw[0]) || 0,
    Number(raw[1]) || 0,
    Number(raw[2]) || 0,
  ];
}

function _walkRigNodes(nodes, parentOrigin = null, depth = 0, out = []) {
  for (const node of (nodes || [])) {
    if (!isObject(node)) continue;
    const origin = _originVec(node.origin, parentOrigin || [0, 0, 0]);
    out.push({
      id: node.uuid || `${node.name || "bone"}-${out.length}`,
      name: node.name || "bone",
      origin,
      parentOrigin,
      depth,
    });
    _walkRigNodes(node.children || [], origin, depth + 1, out);
  }
  return out;
}

function _hasRigPreview(bbmodel) {
  return !!(bbmodel && (bbmodel.outliner || []).some((n) => isObject(n)));
}

function _rigBounds(nodes) {
  let minX = +Infinity, minY = +Infinity, minZ = +Infinity;
  let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
  for (const n of nodes) {
    const o = n.origin || [0, 0, 0];
    minX = Math.min(minX, o[0]); maxX = Math.max(maxX, o[0]);
    minY = Math.min(minY, o[1]); maxY = Math.max(maxY, o[1]);
    minZ = Math.min(minZ, o[2]); maxZ = Math.max(maxZ, o[2]);
  }
  if (!Number.isFinite(minX)) {
    minX = minY = minZ = -1;
    maxX = maxY = maxZ = 1;
  }
  const span = Math.max(maxX - minX, maxY - minY, maxZ - minZ, 1);
  return {
    cx: (minX + maxX) / 2,
    cy: (minY + maxY) / 2,
    cz: (minZ + maxZ) / 2,
    scale: Math.max(4, Math.min(18, 220 / span)),
  };
}

function RigPreview({ bbmodel }) {
  const view = useViewControls();
  const nodes = React.useMemo(
    () => _walkRigNodes(bbmodel?.outliner || []),
    [bbmodel],
  );
  const bounds = React.useMemo(() => _rigBounds(nodes), [nodes]);

  const project = (origin) => ({
    x: (origin[0] - bounds.cx) * bounds.scale,
    y: -(origin[1] - bounds.cy) * bounds.scale,
    z: -(origin[2] - bounds.cz) * bounds.scale,
  });
  const projected = nodes.map((n) => ({ ...n, pos: project(n.origin) }));

  if (!projected.length) {
    return (
      <div className="voxel-stage" style={{ display: "grid", placeItems: "center" }}>
        <div style={{ color: "var(--ink-4)", fontSize: 13 }}>No rig data</div>
      </div>
    );
  }

  return (
    <div
      className="voxel-stage"
      {...view.stageProps}
      title="左键拖拽=平移 · 右键拖拽=旋转 · 滚轮=缩放 · 双击复位"
    >
      <div
        className="voxel-scene-wrap"
        style={{
          position: "absolute", inset: 0,
          display: "grid", placeItems: "center",
          transformStyle: "preserve-3d",
          transform: view.wrapTransform,
        }}
      >
        <div className="voxel-scene rig-preview-scene" style={view.sceneStyle}>
          <div className="rig-preview-root">
            {projected.map((n) => {
              if (!n.parentOrigin) return null;
              const parent = project(n.parentOrigin);
              const dx = n.pos.x - parent.x;
              const dy = n.pos.y - parent.y;
              const dz = n.pos.z - parent.z;
              const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
              if (dist < 2) return null;
              const yaw = Math.atan2(-dz, dx);
              const pitch = Math.atan2(dy, Math.sqrt(dx * dx + dz * dz));
              return (
                <div
                  key={`line-${n.id}`}
                  className="rig-bone-line"
                  style={{
                    width: dist,
                    transform: `translate3d(${parent.x}px, ${parent.y}px, ${parent.z}px) rotateY(${yaw}rad) rotateZ(${pitch}rad)`,
                  }}
                />
              );
            })}
            {projected.map((n) => (
              <div
                key={n.id}
                className={"rig-node" + (n.depth === 0 ? " root" : "")}
                style={{ transform: `translate3d(${n.pos.x}px, ${n.pos.y}px, ${n.pos.z}px)` }}
                title={n.name}
              >
                <span className="rig-node-core" />
                {n.depth === 0 && (
                  <span className="rig-node-label">
                    {String(n.name).slice(0, 18)}
                  </span>
                )}
              </div>
            ))}
          </div>
        </div>
      </div>
      <div className="ground-shadow" />
      <div style={{
        position: "absolute", right: 10, bottom: 10,
        fontFamily: "var(--font-mono)", fontSize: 10,
        color: "var(--ink-4)", padding: "2px 8px",
        background: "rgba(255,255,255,0.6)", borderRadius: 999,
        pointerEvents: "none",
      }}>
        rig {projected.length} bones · zoom {view.zCam >= 0 ? "+" : ""}{view.zCam.toFixed(0)}
      </div>
    </div>
  );
}

// Empty by default — the only legitimate preview source is the user's
// own ctx.bbmodel.elements (rendered via the `cubes` prop). The old
// hardcoded trex/mech/watcher stubs are gone; if `cubes` is null /
// empty, VoxelPreview shows an empty-state placeholder instead.
const MODELS = {
  // Tombstones — empty arrays so the !cubes branch falls through to
  // the empty-state UI cleanly. Keeping the keys so callers that pass
  // `model="trex"` don't crash.
  trex: [],
  mech: [],
  watcher: [],
};

// (legacy hardcoded models removed — voxel-preview is now driven by
// real bbmodel data only, see bbmodelToVoxelCubes below.)
const _DEAD_MODELS = {
  trex_legacy: [
    // Body
    [0, 1.2, 0, 3.2, 2, 5, "#7a8c4e"],
    // Tail
    [0, 1.4, 3.4, 1.4, 1.2, 2.4, "#6f8048"],
    [0, 1.5, 4.8, 0.8, 0.8, 1.6, "#647441"],
    // Head + neck
    [0, 2.2, -2.6, 1.4, 1.6, 1.6, "#8a9c5c"],
    [0, 2.6, -3.8, 1.8, 1.6, 1.6, "#9aa86a"],
    // Jaw
    [0, 1.8, -3.8, 1.6, 0.5, 1.4, "#cfc7a8"],
    // Eye
    [0.55, 2.95, -4.45, 0.25, 0.25, 0.18, "#1a1a17"],
    [-0.55, 2.95, -4.45, 0.25, 0.25, 0.18, "#1a1a17"],
    // Arms
    [1.0, 1.6, -1.4, 0.5, 1.0, 0.5, "#6f8048"],
    [-1.0, 1.6, -1.4, 0.5, 1.0, 0.5, "#6f8048"],
    // Legs
    [0.9, -0.4, 0.6, 1.0, 2.4, 1.4, "#7a8c4e"],
    [-0.9, -0.4, 0.6, 1.0, 2.4, 1.4, "#7a8c4e"],
    // Feet
    [0.9, -1.6, 0.4, 1.2, 0.5, 1.8, "#5a6a3c"],
    [-0.9, -1.6, 0.4, 1.2, 0.5, 1.8, "#5a6a3c"],
    // Back spikes
    [0, 2.6, -1.0, 0.3, 0.6, 0.4, "#a87b3e"],
    [0, 2.6, 0.2, 0.3, 0.6, 0.4, "#a87b3e"],
    [0, 2.6, 1.4, 0.3, 0.6, 0.4, "#a87b3e"],
    [0, 2.5, 2.6, 0.3, 0.5, 0.4, "#a87b3e"],
  ],
  mech: [
    // Torso
    [0, 1.0, 0, 3.4, 2.6, 2.4, "#a8a59a"],
    // Cockpit
    [0, 2.6, 0, 1.6, 1.0, 1.4, "#3b4655"],
    // Cannons
    [1.4, 2.0, -1.6, 0.6, 0.6, 2.4, "#34302a"],
    [-1.4, 2.0, -1.6, 0.6, 0.6, 2.4, "#34302a"],
    // Hip
    [0, -0.6, 0, 2.6, 0.6, 2.0, "#7c7a72"],
    // Legs (4)
    [1.4, -1.6, 1.0, 0.8, 1.6, 0.8, "#8a8780"],
    [-1.4, -1.6, 1.0, 0.8, 1.6, 0.8, "#8a8780"],
    [1.4, -1.6, -1.0, 0.8, 1.6, 0.8, "#8a8780"],
    [-1.4, -1.6, -1.0, 0.8, 1.6, 0.8, "#8a8780"],
    // Feet
    [1.4, -2.6, 1.0, 1.2, 0.4, 1.4, "#5a574e"],
    [-1.4, -2.6, 1.0, 1.2, 0.4, 1.4, "#5a574e"],
    [1.4, -2.6, -1.0, 1.2, 0.4, 1.4, "#5a574e"],
    [-1.4, -2.6, -1.0, 1.2, 0.4, 1.4, "#5a574e"],
    // Antenna
    [0, 3.5, 0, 0.18, 1.0, 0.18, "#34302a"],
  ],
  watcher: [
    // Floating eye
    [0, 1.4, 0, 2.6, 2.0, 2.6, "#3b3a55"],
    // Iris
    [0, 1.4, -1.5, 1.4, 1.2, 0.18, "#dddccd"],
    [0, 1.4, -1.62, 0.6, 0.6, 0.18, "#1a1a17"],
    // Top fin
    [0, 2.7, 0, 0.4, 0.6, 1.6, "#5a587a"],
    // Antennae
    [1.1, 2.6, 0.4, 0.18, 0.7, 0.18, "#5a587a"],
    [-1.1, 2.6, 0.4, 0.18, 0.7, 0.18, "#5a587a"],
    // Hover ring
    [0, -0.4, 0, 3.0, 0.18, 3.0, "#a98b4e"],
  ],
};

function VoxelPreview({ model = "trex", cubes, bbmodel = null, textureB64 = null }) {
  const atlas = _resolveAtlas(bbmodel, textureB64);
  const view = useViewControls();
  // The only valid render source is `cubes` (real bbmodel.elements
  // converted by bbmodelToVoxelCubes). When the parent doesn't have a
  // ctx yet, render an empty-state placeholder instead of fake stubs.
  if (!cubes || !cubes.length) {
    if (_hasRigPreview(bbmodel)) {
      return <RigPreview bbmodel={bbmodel} />;
    }
    return (
      <div className="voxel-stage" style={{ display: "grid", placeItems: "center" }}>
        <div style={{
          padding: "24px 32px",
          color: "var(--ink-4)",
          textAlign: "center",
          fontSize: 13,
          maxWidth: 280,
        }}>
          <div style={{ fontSize: 32, opacity: 0.3, marginBottom: 8 }}>◇</div>
          <div>还没有模型 — 发个提示词或上传 .glb 后会出现在这里</div>
        </div>
      </div>
    );
  }
  const list = cubes;
  // Wheel "zoom" = camera dolly (translateZ on a wrapper inside the
  // stage's perspective). Moves the SCENE closer/farther in 3D space —
  // the model itself stays the same size; only its projection grows
  // because the perspective camera sees it from a different distance.
  // Range: [-600, +800] px. Default 0 (native view). Double-click → reset.
  return (
    <div
      className="voxel-stage"
      {...view.stageProps}
      title="左键拖拽=平移 · 右键拖拽=旋转 · 滚轮=缩放 · 双击复位"
    >
      <div
        className="voxel-scene-wrap"
        style={{
          position: "absolute", inset: 0,
          display: "grid", placeItems: "center",
          transformStyle: "preserve-3d",
          transform: view.wrapTransform,
        }}
      >
        <div className="voxel-scene" style={view.sceneStyle}>
          {list.map((c, i) => (
            <Cube key={i} x={c[0]} y={c[1]} z={c[2]} w={c[3]} h={c[4]} d={c[5]}
                  color={c[6]} faces={c[7] || null} element={c[8] || null} atlas={atlas}
                  rotation={c[9] || null} pivotOffset={c[10] || null} />
          ))}
        </div>
      </div>
      <div className="ground-shadow" />
      <div style={{
        position: "absolute", right: 10, bottom: 10,
        fontFamily: "var(--font-mono)", fontSize: 10,
        color: "var(--ink-4)", padding: "2px 8px",
        background: "rgba(255,255,255,0.6)", borderRadius: 999,
        pointerEvents: "none",
      }}>
        视距 {view.zCam >= 0 ? "+" : ""}{view.zCam.toFixed(0)}
      </div>
    </div>
  );
}

/** Walk the outliner once and return a map { cube_uuid → accumulated
 *  group rotation } (additive Euler in degrees). Matches the backend
 *  exporter's `_build_tree`: each group adds its own rotation to the
 *  running sum, then cubes inside inherit the sum. This is mathematically
 *  approximate (Euler addition only commutes when axes align) but it's
 *  exactly what backend/app/tools/exporter_glb.py bakes into the GLB at
 *  bind pose, so the preview must replicate it to look the same. */
function _accumulatedGroupRotations(bbmodel) {
  const out = {};
  const walk = (node, accRot) => {
    if (typeof node === "string") {
      out[node] = accRot;
      return;
    }
    if (!node || typeof node !== "object") return;
    const gr = node.rotation || [0, 0, 0];
    const next = [
      accRot[0] + (Number(gr[0]) || 0),
      accRot[1] + (Number(gr[1]) || 0),
      accRot[2] + (Number(gr[2]) || 0),
    ];
    for (const child of (node.children || [])) walk(child, next);
  };
  for (const root of ((bbmodel && bbmodel.outliner) || [])) {
    walk(root, [0, 0, 0]);
  }
  return out;
}

/** Convert a backend bbmodel (with `elements: [{from,to,...}]`) into the
 *  flat [x,y,z,w,h,d,color,faces,element,effRot,pivotOffset] rows
 *  VoxelPreview expects. The model is centered + uniformly scaled so
 *  even big creatures fit the preview viewport.
 *
 *  effRot: extrinsic XYZ degrees in bbmodel space — the cube's own
 *  rotation PLUS every group rotation above it in the outliner, summed.
 *  pivotOffset: (element.origin - cube_center) in preview-Y-up units
 *  (matching the cube's x/y/z params); Cube converts to CSS px and
 *  flips Y at render time. */
function bbmodelToVoxelCubes(bbmodel) {
  const els = (bbmodel && bbmodel.elements) || [];
  if (!els.length) return null;
  const accRotByUuid = _accumulatedGroupRotations(bbmodel);
  let minX = +Infinity, minY = +Infinity, minZ = +Infinity;
  let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
  for (const e of els) {
    const f = e.from || [0, 0, 0], t = e.to || [0, 0, 0];
    if (f[0] < minX) minX = f[0]; if (t[0] < minX) minX = t[0];
    if (f[0] > maxX) maxX = f[0]; if (t[0] > maxX) maxX = t[0];
    if (f[1] < minY) minY = f[1]; if (t[1] < minY) minY = t[1];
    if (f[1] > maxY) maxY = f[1]; if (t[1] > maxY) maxY = t[1];
    if (f[2] < minZ) minZ = f[2]; if (t[2] < minZ) minZ = t[2];
    if (f[2] > maxZ) maxZ = f[2]; if (t[2] > maxZ) maxZ = t[2];
  }
  const cx = (minX + maxX) / 2;
  const cy = (minY + maxY) / 2;
  const cz = (minZ + maxZ) / 2;
  const span = Math.max(maxX - minX, maxY - minY, maxZ - minZ) || 1;
  // Larger silhouette so individual cubes are big enough that adjacent
  // overlap+touch connections (≥0.5 unit per the prompt rule) read clearly
  // instead of disappearing into anti-alias seams.
  const scale = PREVIEW_FIT / span;
  // Per-cube hash → distinguishable palette so adjacent body parts read.
  const palette = ["#7a8c4e","#a8a59a","#3b3a55","#cf7a3f","#5a587a",
                   "#6f8048","#8a9c5c","#7c7a72","#3b4655","#a98b4e"];
  return els.map((e, idx) => {
    const f = e.from || [0,0,0], t = e.to || [0,0,0];
    const ax = ((f[0] + t[0]) / 2 - cx) * scale;
    const ay = ((f[1] + t[1]) / 2 - cy) * scale;
    // bbmodel z-forward is -Z; flip so the preview's "front" matches.
    const az = -((f[2] + t[2]) / 2 - cz) * scale;
    const w = Math.max(0.001, Math.abs(t[0] - f[0]) * scale);
    const h = Math.max(0.001, Math.abs(t[1] - f[1]) * scale);
    const d = Math.max(0.001, Math.abs(t[2] - f[2]) * scale);
    const color = palette[(idx * 7 + (e.uuid ? e.uuid.charCodeAt(0) : 0)) % palette.length];

    // Combine the cube's own rotation with everything accumulated from
    // outliner groups above it — same additive bake the backend does at
    // bind pose (see exporter_glb._build_tree). bbmodel default `origin`
    // is the cube center, so pivotOffset = 0 when origin is unset.
    const baseRot = e.rotation || [0, 0, 0];
    const accRot = (e.uuid && accRotByUuid[e.uuid]) || [0, 0, 0];
    const effRot = [
      (Number(baseRot[0]) || 0) + accRot[0],
      (Number(baseRot[1]) || 0) + accRot[1],
      (Number(baseRot[2]) || 0) + accRot[2],
    ];
    const cubeCenterBb = [
      (f[0] + t[0]) / 2,
      (f[1] + t[1]) / 2,
      (f[2] + t[2]) / 2,
    ];
    const originBb = e.origin || cubeCenterBb;
    // Preview-Y-up frame matching ax/ay/az (Z flipped, Y NOT flipped — the
    // CSS Y-down flip happens later in Cube).
    const pivotOffset = [
      ((Number(originBb[0]) || 0) - cubeCenterBb[0]) * scale,
      ((Number(originBb[1]) || 0) - cubeCenterBb[1]) * scale,
      -((Number(originBb[2]) || 0) - cubeCenterBb[2]) * scale,
    ];

    // Stash the per-face uv dict AND the raw element so Cube can compute
    // box-uv from `uv_offset` + (from/to) when faces.uv is the zero
    // placeholder (the common case for our generated models).
    return [ax, ay, az, w, h, d, color, e.faces || null, e, effRot, pivotOffset];
  });
}

// ---------------- Animated bone-driven preview ----------------
//
// Walks ctx.bbmodel.outliner to build a hierarchy of <Bone> divs (each
// with its own pivot transform), then drops cubes inside the bone they
// belong to. At animation time, each bone's local rotation/translation
// is interpolated between keyframes and applied as a CSS transform.
// CSS preserve-3d composes transforms down the tree so child cubes
// follow their parent bone correctly.
//
// Coord system note: bbmodel uses -Z forward and Y up; CSS uses +Z
// forward (toward camera) and Y *down*. We z-negate translations + y-
// negate so the visual matches the data — same convention used in the
// non-animated path (see bbmodelToVoxelCubes).

function _findKeyframes(animator, channel) {
  // Collect + sort keyframes for one channel of one bone.
  const out = [];
  for (const kf of (animator?.keyframes || [])) {
    if (kf.channel !== channel) continue;
    const p = (kf.data_points && kf.data_points[0]) || {};
    out.push({
      t: Number(kf.time) || 0,
      x: Number(p.x) || 0,
      y: Number(p.y) || 0,
      z: Number(p.z) || 0,
    });
  }
  out.sort((a, b) => a.t - b.t);
  return out;
}

function _lerpKeyframes(kfs, t) {
  if (!kfs.length) return null;
  if (t <= kfs[0].t) return kfs[0];
  if (t >= kfs[kfs.length - 1].t) return kfs[kfs.length - 1];
  // Linear binary search would be faster but lists are usually short.
  for (let i = 1; i < kfs.length; i++) {
    if (t < kfs[i].t) {
      const a = kfs[i - 1], b = kfs[i];
      const r = (t - a.t) / Math.max(1e-6, b.t - a.t);
      return {
        t,
        x: a.x + (b.x - a.x) * r,
        y: a.y + (b.y - a.y) * r,
        z: a.z + (b.z - a.z) * r,
      };
    }
  }
  return kfs[kfs.length - 1];
}

function _animatorForBone(anim, boneName) {
  // Animator entries are keyed by bone uuid (not name); search by name.
  const animators = anim?.animators || {};
  for (const k in animators) {
    const a = animators[k];
    if (a && a.name === boneName) return a;
  }
  return null;
}

function _Bone({ node, byUuid, anim, time, scale, parentOrigin = [0, 0, 0], atlas = null }) {
  // node.origin = world-space pivot in bbmodel coords. Converted to
  // local-relative-to-parent so nested transforms compose correctly.
  const origin = node.origin || [0, 0, 0];
  const localPivot = [
    (origin[0] - parentOrigin[0]) * scale,
    (origin[1] - parentOrigin[1]) * scale,
    -(origin[2] - parentOrigin[2]) * scale,    // Z-flip
  ];

  // Animation channels for this bone, evaluated at `time`.
  let rot = { x: 0, y: 0, z: 0 };
  let pos = { x: 0, y: 0, z: 0 };
  if (anim) {
    const a = _animatorForBone(anim, node.name);
    if (a) {
      const r = _lerpKeyframes(_findKeyframes(a, "rotation"), time);
      const p = _lerpKeyframes(_findKeyframes(a, "position"), time);
      if (r) rot = r;
      if (p) pos = p;
    }
  }

  // Compose: translate to pivot → rest rotation → animate rotation/translation
  // → so cubes are positioned around the pivot. The rest rotation
  // (node.rotation) is the bone's bind-pose orientation — previously
  // ignored, so a rigged-in-place model imported as bind-rotated bones
  // (e.g. raised-arm idle, tilted head) would show un-rotated in preview
  // even though the GLB renders rotated. CSS coord mapping for bbmodel
  // extrinsic XYZ Euler under the preview's Y/Z mirror: rx_css = +rx,
  // ry_css = -ry, rz_css = -rz (see _bbRotationToCss for derivation).
  //
  // The existing anim rotation lines (rot.x/y/z below) are kept as-is to
  // preserve whatever convention the animation pipeline was authored
  // against — flipping signs here could regress currently-playable anims.
  const restRot = node.rotation || [0, 0, 0];
  const restRx = Number(restRot[0]) || 0;
  const restRy = Number(restRot[1]) || 0;
  const restRz = Number(restRot[2]) || 0;
  const restRotCss = (restRx || restRy || restRz)
    ? ` rotateZ(${-restRz}deg) rotateY(${-restRy}deg) rotateX(${restRx}deg)`
    : "";
  const transform = [
    `translate3d(${localPivot[0]}px, ${-localPivot[1]}px, ${localPivot[2]}px)`,
    `translate3d(${pos.x * scale}px, ${-pos.y * scale}px, ${-pos.z * scale}px)`,
    `rotateZ(${rot.z}deg)`,
    `rotateY(${rot.y}deg)`,
    `rotateX(${rot.x}deg)`,
  ].join(" ") + restRotCss;

  // Cubes attached directly to THIS bone (children that are uuid strings).
  const cubeChildren = (node.children || []).filter((c) => typeof c === "string");
  const groupChildren = (node.children || []).filter((c) => typeof c === "object" && c);

  return (
    <div
      className="bone"
      style={{
        position: "absolute",
        transformStyle: "preserve-3d",
        transform,
      }}
    >
      {cubeChildren.map((uuid) => {
        const el = byUuid[uuid];
        if (!el) return null;
        const f = el.from || [0, 0, 0];
        const t = el.to || [0, 0, 0];
        // Cube position relative to the BONE's pivot (which is now origin
        // in our local space — so we subtract `origin`).
        const cx = ((f[0] + t[0]) / 2 - origin[0]) * scale;
        const cy = ((f[1] + t[1]) / 2 - origin[1]) * scale;
        const cz = -((f[2] + t[2]) / 2 - origin[2]) * scale;
        const w = Math.abs(t[0] - f[0]) * scale;
        const h = Math.abs(t[1] - f[1]) * scale;
        const d = Math.abs(t[2] - f[2]) * scale;
        // Cube hue: position-based hash so per-cube colors are stable.
        const hash = (Math.abs(f[0] * 31 + f[1] * 17 + f[2] * 7) | 0) >>> 0;
        const palette = ["#7a8c4e","#a8a59a","#3b3a55","#cf7a3f","#5a587a",
                         "#6f8048","#8a9c5c","#7c7a72","#3b4655","#a98b4e"];
        const color = palette[hash % palette.length];
        // Per-cube rotation pivot: element.origin minus cube center, in
        // preview-Y-up units (matching Cube's x/y/z param frame). The
        // bone's own rotation is handled by the wrapper transform above;
        // here we only need the cube-local pivot offset.
        const cubeCenterBb = [
          (f[0] + t[0]) / 2,
          (f[1] + t[1]) / 2,
          (f[2] + t[2]) / 2,
        ];
        const originBb = el.origin || cubeCenterBb;
        const fitScale = scale / VOXEL_SIZE;
        const pivotOffset = [
          ((Number(originBb[0]) || 0) - cubeCenterBb[0]) * fitScale,
          ((Number(originBb[1]) || 0) - cubeCenterBb[1]) * fitScale,
          -((Number(originBb[2]) || 0) - cubeCenterBb[2]) * fitScale,
        ];
        return (
          <Cube key={uuid}
                x={cx / VOXEL_SIZE} y={cy / VOXEL_SIZE} z={cz / VOXEL_SIZE}
                w={w / VOXEL_SIZE} h={h / VOXEL_SIZE} d={d / VOXEL_SIZE}
                color={color} faces={el.faces || null} element={el} atlas={atlas}
                rotation={el.rotation || null} pivotOffset={pivotOffset} />
        );
      })}
      {groupChildren.map((g, i) => (
        <_Bone key={g.uuid || i}
               node={g} byUuid={byUuid} anim={anim}
               time={time} scale={scale}
               parentOrigin={origin} atlas={atlas} />
      ))}
    </div>
  );
}

/** Drop-in replacement for VoxelPreview that animates real bone TRS.
 *  Falls back to the static VoxelPreview cubes path when bbmodel has
 *  no outliner / animations (e.g. before the first generate). */
function AnimatedVoxelPreview({ bbmodel, animation, time = 0, autoSpin = true, textureB64 = null }) {
  const atlas = _resolveAtlas(bbmodel, textureB64);
  const view = useViewControls();
  if (!bbmodel || !(bbmodel.outliner || []).length) {
    // Empty state: degrade to the empty placeholder VoxelPreview ships.
    return <VoxelPreview cubes={null} />;
  }
  if (!(bbmodel.elements || []).length) {
    return <RigPreview bbmodel={bbmodel} />;
  }

  // Build {uuid: element}.
  const byUuid = {};
  for (const el of (bbmodel.elements || [])) {
    if (el && el.uuid) byUuid[el.uuid] = el;
  }

  // Center and scale the rig from its visual bounds instead of the model origin.
  let minX = +Infinity, maxX = -Infinity;
  let minY = +Infinity, maxY = -Infinity;
  let minZ = +Infinity, maxZ = -Infinity;
  for (const el of (bbmodel.elements || [])) {
    const f = el.from || [0, 0, 0], t = el.to || [0, 0, 0];
    minX = Math.min(minX, f[0], t[0]); maxX = Math.max(maxX, f[0], t[0]);
    minY = Math.min(minY, f[1], t[1]); maxY = Math.max(maxY, f[1], t[1]);
    minZ = Math.min(minZ, f[2], t[2]); maxZ = Math.max(maxZ, f[2], t[2]);
  }
  const boundsSpan = Math.max(maxX - minX, maxY - minY, maxZ - minZ, 1);
  // Match the static path's silhouette scale so swapping between static
  // and animated previews doesn't snap the model to a different size.
  const fitScale = PREVIEW_FIT / boundsSpan;
  const scale = fitScale * VOXEL_SIZE;
  const cx = (minX + maxX) / 2;
  const cy = (minY + maxY) / 2;
  const cz = (minZ + maxZ) / 2;

  // When a real animation is playing OR user has dragged, suppress the
  // CSS turnaround keyframe — we control the scene transform directly.
  const sceneStyle = animation
    ? { ...view.sceneStyle, animation: "none" }
    : view.sceneStyle;

  return (
    <div className="voxel-stage" {...view.stageProps}
         title="左键拖拽=平移 · 右键拖拽=旋转 · 滚轮=缩放 · 双击复位">
      <div
        className="voxel-scene-wrap"
        style={{
          position: "absolute", inset: 0,
          display: "grid", placeItems: "center",
          transformStyle: "preserve-3d",
          transform: view.wrapTransform,
        }}
      >
        <div className="voxel-scene" style={sceneStyle}>
          {/* Recenter rig at origin */}
          <div style={{
            transform: `translate3d(${-cx * scale}px, ${cy * scale}px, ${cz * scale}px)`,
            transformStyle: "preserve-3d",
          }}>
            {(bbmodel.outliner || []).map((node, i) =>
              isObject(node) ? (
                <_Bone key={node.uuid || i}
                       node={node} byUuid={byUuid}
                       anim={animation} time={time}
                       scale={scale} parentOrigin={[0, 0, 0]}
                       atlas={atlas} />
              ) : null,
            )}
          </div>
        </div>
      </div>
      <div className="ground-shadow" />
      <div style={{
        position: "absolute", right: 10, bottom: 10,
        fontFamily: "var(--font-mono)", fontSize: 10,
        color: "var(--ink-4)", padding: "2px 8px",
        background: "rgba(255,255,255,0.6)", borderRadius: 999,
        pointerEvents: "none",
      }}>
        视距 {view.zCam >= 0 ? "+" : ""}{view.zCam.toFixed(0)}
      </div>
    </div>
  );
}

function isObject(x) { return x && typeof x === "object"; }

/**
 * Shared mouse / wheel interaction state for the voxel preview.
 *  - Wheel: dolly (translateZ camera). Range [-600, 800].
 *  - Left drag: pan (translate the scene wrap on screen X/Y). Reset on dblclick.
 *  - Right drag: orbit (yaw/pitch rotation of the scene). Reset on dblclick.
 *  - Once the user starts dragging, the CSS `turnaround` keyframe is
 *    suppressed via inline animation:none so user input doesn't fight
 *    the auto-spin.
 *
 * Returns:
 *   stageProps     — bind to .voxel-stage (onWheel, onMouseDown, onContextMenu, onDoubleClick)
 *   wrapTransform  — string to set on .voxel-scene-wrap (existing translateZ + new pan)
 *   sceneStyle     — { animation, transform } to set on .voxel-scene
 *   zCam           — current dolly value (for the bottom-right HUD pill)
 */
function useViewControls(initialYaw = 35, initialPitch = -25) {
  const [zCam, setZCam] = React.useState(0);
  const [pan, setPan] = React.useState({ x: 0, y: 0 });
  const [rot, setRot] = React.useState({ yaw: initialYaw, pitch: initialPitch });
  const [interacted, setInteracted] = React.useState(false);
  const dragRef = React.useRef(null);  // {button, lastX, lastY} during drag

  const onWheel = (e) => {
    e.preventDefault();
    setZCam((z) => Math.max(-600, Math.min(800, z - e.deltaY * 0.5)));
  };

  const onMouseDown = (e) => {
    // Only react to left (0) and right (2). Middle (1) reserved.
    if (e.button !== 0 && e.button !== 2) return;
    e.preventDefault();
    dragRef.current = { button: e.button, lastX: e.clientX, lastY: e.clientY };
    setInteracted(true);
    const onMove = (mv) => {
      const st = dragRef.current;
      if (!st) return;
      const dx = mv.clientX - st.lastX;
      const dy = mv.clientY - st.lastY;
      st.lastX = mv.clientX;
      st.lastY = mv.clientY;
      if (st.button === 0) {
        // Left: pan in screen space.
        setPan((p) => ({ x: p.x + dx, y: p.y + dy }));
      } else {
        // Right: orbit. dx → yaw (Y rotation), dy → pitch (X rotation).
        // Clamp pitch to ±89° so the scene doesn't flip past vertical.
        setRot((r) => ({
          yaw: r.yaw + dx * 0.4,
          pitch: Math.max(-89, Math.min(89, r.pitch - dy * 0.4)),
        }));
      }
    };
    const onUp = () => {
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
      dragRef.current = null;
    };
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
  };

  const onContextMenu = (e) => {
    // Suppress the browser context menu so right-drag-orbit doesn't get
    // interrupted by it.
    e.preventDefault();
  };

  const onDoubleClick = () => {
    setZCam(0);
    setPan({ x: 0, y: 0 });
    setRot({ yaw: initialYaw, pitch: initialPitch });
    setInteracted(false);
  };

  const stageProps = { onWheel, onMouseDown, onContextMenu, onDoubleClick };
  const wrapTransform = `translate(${pan.x}px, ${pan.y}px) translateZ(${zCam}px)`;
  const sceneStyle = {
    animation: interacted ? "none" : undefined,
    transform: `rotateX(${rot.pitch}deg) rotateY(${rot.yaw}deg)`,
  };
  return { stageProps, wrapTransform, sceneStyle, zCam, interacted };
}

window.VoxelPreview = VoxelPreview;
window.AnimatedVoxelPreview = AnimatedVoxelPreview;
window.VOXEL_MODELS = Object.keys(MODELS);
window.bbmodelToVoxelCubes = bbmodelToVoxelCubes;
