/* kit/voxel.jsx — o vídeo GRAVADO em 3D, no visual de blocos (voxel).
 *
 * {"tipo":"kit","peca":"voxel","partitura":{…},"largura":1080,"altura":1920}
 *
 *   "esse 3D que é como eu quero e não tudo fudido como tá lá" (Joel, 11/10,
 *    com o vídeo de referência: bonecos de blocos, o bichinho laranja do
 *    Claude flutuando, chão de ladrilhos roxo, a parede de telas e o fundo
 *    desfocado de lente de cinema)
 *
 * A partitura é a mesma do kit/personagem.jsx (lib/estudio/personagem.js: quem
 * fala quando, os planos, a aparência). Muda o desenho:
 *  - tudo é bloco com a quina arredondada (RoundedBoxGeometry), nada de esfera;
 *  - o rosto é pixel: olho quadrado, sobrancelha grossa, a boca que abre com a voz;
 *  - quem é IA vira o mascote: o corpo laranja, os olhos em fenda, quatro
 *    perninhas, flutuando — e "fala" esticando o corpo no ritmo da voz;
 *  - o set: chão de ladrilhos, a parede de telas com os cartões do ASSUNTO do
 *    vídeo (os títulos saem da fala), luz de recorte roxa;
 *  - a lente: o desfoque de profundidade (BokehPass) e o brilho das telas
 *    (UnrealBloomPass), que é o que faz parecer render de cinema e não jogo;
 *  - a legenda é a da referência: frase inteira, branca, no meio, sem caixa alta.
 * Tudo é geometria do three.js: nada é baixado. */
import React, { useMemo } from 'react';
import { ThreeCanvas } from '@remotion/three';
import * as THREE from 'three';
import { useThree, useFrame } from '@react-three/fiber';
import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
import { BokehPass } from 'three/examples/jsm/postprocessing/BokehPass.js';
import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js';
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
import { Mundo, Animal, Coisa, alturaDoBicho, frenteDoBicho, useIcones } from './mundo.jsx';
// O movimento com peso (mola, antecipação, sobreposição, respiração, salto, passo): kit/natural.js.
import { ruido, envelope, golpe, respiracao, salto, passo } from './natural';

const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
const lerp = (a, b, k) => a + (b - a) * k;
const suave = (x) => { const k = clamp(x); return k * k * (3 - 2 * k); };
const hash = (n) => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); };

/* ── O BLOCO ─────────────────────────────────────────────────────────────
   Toda peça é uma caixa de quina arredondada. A geometria é guardada pelo
   tamanho: o mesmo ladrilho repetido 200 vezes é UMA geometria. */
const GEOS = new Map();
function geo(w, h, d, r) {
  const raio = Math.min(r, w / 2 - 0.001, h / 2 - 0.001, d / 2 - 0.001);
  const k = `${w}|${h}|${d}|${raio}`;
  if (!GEOS.has(k)) GEOS.set(k, new RoundedBoxGeometry(w, h, d, 3, Math.max(0.001, raio)));
  return GEOS.get(k);
}
const Bloco = ({ s, p = [0, 0, 0], rot, c, r = 0.03, rough = 0.62, metal = 0, e, ei = 1, sombra = true }) => (
  <mesh position={p} rotation={rot} geometry={geo(s[0], s[1], s[2], r)} castShadow={sombra} receiveShadow>
    <meshStandardMaterial color={c} roughness={rough} metalness={metal} {...(e ? { emissive: e, emissiveIntensity: ei } : {})} />
  </mesh>
);
// O que é chapado no rosto (olho, boca, sobrancelha): fino, sem quina e sem sombra.
const Chapa = ({ s, p, c, rot }) => (
  <mesh position={p} rotation={rot}><boxGeometry args={s} /><meshStandardMaterial color={c} roughness={0.7} /></mesh>
);

/* ── A CENA POR TRÁS: a luz de ambiente e a câmera ───────────────────────── */
function Ambiente() {
  const { gl, scene } = useThree();
  useMemo(() => {
    const pm = new THREE.PMREMGenerator(gl);
    scene.environment = pm.fromScene(new RoomEnvironment(), 0.04).texture;
    scene.environmentIntensity = 0.22;
    pm.dispose();
  }, [gl, scene]);
  return null;
}

function Camera({ pos, alvo, fov }) {
  const { camera } = useThree();
  camera.position.set(pos[0], pos[1], pos[2]);
  camera.fov = fov;
  camera.near = 0.05;
  camera.far = 60;
  camera.lookAt(alvo[0], alvo[1], alvo[2]);
  camera.updateProjectionMatrix();
  return null;
}

/* A LENTE. O compositor desenha a cena, o brilho das telas e o desfoque de
   profundidade, nesta ordem. O useFrame com prioridade 1 tira o desenho do
   R3F e passa para o compositor — o Remotion chama o advance() a cada quadro,
   e é ele que dispara isto. */
function Lente({ W, H, foco, abertura, desfoqueMax, brilho }) {
  const { gl, scene, camera } = useThree();
  const fx = useMemo(() => {
    const comp = new EffectComposer(gl);
    comp.setPixelRatio(1);
    comp.setSize(W, H);
    comp.addPass(new RenderPass(scene, camera));
    const bloom = new UnrealBloomPass(new THREE.Vector2(W, H), 0.55, 0.65, 0.92);
    comp.addPass(bloom);
    const bokeh = new BokehPass(scene, camera, { focus: 3, aperture: 0.004, maxblur: 0.01 });
    comp.addPass(bokeh);
    comp.addPass(new OutputPass());
    return { comp, bokeh, bloom };
  }, [gl, scene, camera, W, H]);
  fx.bokeh.uniforms.focus.value = foco;
  fx.bokeh.uniforms.aperture.value = abertura;
  fx.bokeh.uniforms.maxblur.value = desfoqueMax;
  fx.bloom.strength = brilho;
  useFrame(() => { fx.comp.render(); }, 1);
  return null;
}

/* ── A BOCA, O GESTO E A PISCADA (os mesmos do kit/personagem.jsx) ──────── */
const VISEMA = (ch) => {
  if (/[aáàãâ]/.test(ch)) return [1, 0.6];
  if (/[eéê]/.test(ch)) return [0.6, 0.85];
  if (/[iíy]/.test(ch)) return [0.32, 1];
  if (/[oóôõ]/.test(ch)) return [0.78, 0.3];
  if (/[uúw]/.test(ch)) return [0.45, 0.15];
  if (/[mbp]/.test(ch)) return [0, 0.5];
  if (/[fv]/.test(ch)) return [0.12, 0.62];
  if (/[lnrdt]/.test(ch)) return [0.3, 0.6];
  return [0.22, 0.55];
};

function bocaEm(falas, quem, t) {
  for (const f of falas) {
    if (f.quem !== quem || t < f.de - 0.05 || t > f.ate + 0.05) continue;
    const w = f.palavras.find((x) => t >= x.de && t <= x.ate);
    if (!w) return [0, 0.55];
    const letras = String(w.t).toLowerCase().replace(/[^a-záàãâéêíóôõúçy]/g, '') || 'a';
    const p = clamp((t - w.de) / Math.max(0.05, w.ate - w.de), 0, 0.999);
    const pos = p * letras.length;
    const [a, l] = VISEMA(letras[Math.floor(pos)]);
    return [a * (0.62 + 0.38 * Math.sin(Math.PI * (pos % 1))), l];
  }
  return [0, 0.55];
}

function boca(falas, quem, t) {
  const amostras = [0, 1 / 60, 1 / 30].map((d) => bocaEm(falas, quem, t - d));
  return [amostras.reduce((s, x) => s + x[0], 0) / 3, amostras.reduce((s, x) => s + x[1], 0) / 3];
}

function gesto(falas, quem, t) {
  let esq = 0, dir = 0;
  for (const f of falas) {
    if (f.quem !== quem || t < f.de - 0.5 || t > f.ate + 1) continue;
    f.palavras.forEach((w, k) => {
      if (String(w.t).replace(/[^\p{L}\d]/gu, '').length < 6 && !/\d/.test(w.t)) return;
      // O braço recua, sobe passando do ponto e desce devagar (natural.golpe), não um seno.
      const b = golpe(t - (w.de - 0.15));
      if (b === 0) return;
      if (k % 2) esq = Math.abs(b) > Math.abs(esq) ? b : esq; else dir = Math.abs(b) > Math.abs(dir) ? b : dir;
    });
  }
  return [esq, dir];
}

function piscada(quem, t) {
  let base = 0.6 + quem * 1.3, k = 0;
  while (base < t + 1) {
    const d = t - base;
    if (d >= 0 && d < 0.14) return Math.sin((d / 0.14) * Math.PI);
    if (hash(quem * 31 + k) < 0.2) { const d2 = t - (base + 0.28); if (d2 >= 0 && d2 < 0.14) return Math.sin((d2 / 0.14) * Math.PI); }
    base += 2.4 + hash(quem * 17 + k) * 2.6;
    k++;
  }
  return 0;
}

const EMO = {
  neutro: { sobr: 0, inclina: 0 },
  feliz: { sobr: 0.3, inclina: 0.04 },
  animado: { sobr: 0.6, inclina: 0 },
  serio: { sobr: -0.5, inclina: 0 },
  surpreso: { sobr: 0.9, inclina: 0 },
  pensativo: { sobr: -0.15, inclina: 0.12 },
  curioso: { sobr: 0.45, inclina: 0.1 },
};

/* ── A PESSOA DE BLOCOS ─────────────────────────────────────────────────
   A cabeça é um cubo de 0,62: o rosto mora na face da frente (z = 0,31). */
const CAB = 0.62;
const FACE = CAB / 2 + 0.004;
const escurecer = (c, k) => new THREE.Color(c).multiplyScalar(k).getStyle();

function Cabelo({ estilo, cor }) {
  const s = CAB;
  const topo = <Bloco s={[s + 0.04, 0.16, s + 0.04]} p={[0, s / 2 + 0.02, -0.005]} c={cor} r={0.03} rough={0.85} />;
  const nuca = <Bloco s={[s + 0.04, s * 0.62, 0.1]} p={[0, s * 0.14, -s / 2 - 0.02]} c={cor} r={0.03} rough={0.85} />;
  const lados = [-1, 1].map((k) => <Bloco key={k} s={[0.06, s * 0.42, s * 0.7]} p={[k * (s / 2 + 0.02), s * 0.24, -0.08]} c={cor} r={0.02} rough={0.85} />);
  if (estilo === 'careca') return null;
  if (estilo === 'cacheado') {
    return (
      <group>
        {nuca}{lados}
        {[...Array(9)].map((_, i) => {
          const x = ((i % 3) - 1) * 0.22, z = (Math.floor(i / 3) - 1) * 0.22;
          return <Bloco key={i} s={[0.24, 0.2 + hash(i) * 0.06, 0.24]} p={[x, s / 2 + 0.06 + hash(i + 3) * 0.03, z - 0.01]} c={cor} r={0.05} rough={0.9} />;
        })}
      </group>
    );
  }
  return (
    <group>
      {topo}{nuca}{lados}
      {/* a franja: um degrau na testa, o que dá a cara do boneco da referência */}
      <Bloco s={[s * 0.62, 0.1, 0.1]} p={[-s * 0.16, s / 2 - 0.05, s / 2 - 0.02]} c={cor} r={0.02} rough={0.85} />
      {estilo === 'topete' && <Bloco s={[s * 0.7, 0.16, 0.26]} p={[0.04, s / 2 + 0.13, s / 2 - 0.12]} c={cor} r={0.04} rough={0.85} />}
      {estilo === 'coque' && <Bloco s={[0.24, 0.24, 0.24]} p={[0, s / 2 + 0.08, -s / 2 + 0.02]} c={cor} r={0.05} rough={0.85} />}
      {estilo === 'longo' && (
        <>
          <Bloco s={[s + 0.06, s * 1.05, 0.12]} p={[0, -0.06, -s / 2 - 0.04]} c={cor} r={0.03} rough={0.85} />
          {[-1, 1].map((k) => <Bloco key={k} s={[0.08, s * 0.95, s * 0.6]} p={[k * (s / 2 + 0.03), -0.02, -0.1]} c={cor} r={0.02} rough={0.85} />)}
        </>
      )}
    </group>
  );
}

function RostoPixel({ p, est }) {
  const e = EMO[est.emocao] || EMO.neutro;
  const sobr = e.sobr + est.enfase * 0.4;
  const [abre, larg] = est.boca;
  const pisca = clamp(est.pisca);
  const escuro = '#141016';
  const olhoH = 0.14 * (1 - pisca * 0.9);
  // O olhar: a pupila anda dentro do branco do olho.
  const ox = clamp(est.olhar[0], -1, 1) * 0.025, oy = clamp(est.olhar[1], -1, 1) * 0.02;
  return (
    <group>
      {[-1, 1].map((k) => (
        <group key={k} position={[k * 0.135, 0.035, FACE]}>
          <Chapa s={[0.13, olhoH, 0.012]} p={[0, 0, 0]} c="#d6d2cc" />
          {pisca < 0.6 && <Chapa s={[0.065, Math.min(olhoH, 0.085), 0.016]} p={[ox + k * 0.012, oy - 0.005, 0.002]} c={escuro} />}
          {/* a sobrancelha grossa: sobe e desce com a emoção, inclina no sério */}
          <Chapa s={[0.17, 0.05, 0.02]} p={[0, 0.125 + sobr * 0.03, 0.004]}
            rot={[0, 0, k * (est.emocao === 'serio' ? 0.22 : est.emocao === 'surpreso' ? -0.08 : 0.04) - k * sobr * 0.06]} c={p.corCabelo} />
        </group>
      ))}
      {/* o nariz: um degrauzinho de pele mais escura */}
      <Bloco s={[0.07, 0.11, 0.05]} p={[0, -0.04, FACE + 0.015]} c={escurecer(p.pele, 0.86)} r={0.012} sombra={false} />
      {/* a boca: um retângulo escuro que abre com a vogal e estica no "i" */}
      <Chapa s={[0.1 + 0.1 * larg, 0.028 + 0.1 * abre, 0.012]} p={[0, -0.165 - 0.03 * abre, FACE]} c="#3a1416" />
      {abre > 0.35 && <Chapa s={[0.08 + 0.06 * larg, 0.022, 0.014]} p={[0, -0.155 + 0.02 * abre, FACE + 0.001]} c="#efe9e1" />}
      {p.barba && (
        <>
          <Chapa s={[CAB + 0.006, 0.17, 0.008]} p={[0, -CAB / 2 + 0.085, FACE - 0.002]} c={escurecer(p.corCabelo, 1)} />
          {[-1, 1].map((k) => <Chapa key={k} s={[0.008, 0.26, CAB * 0.7]} p={[k * (CAB / 2 + 0.003), -CAB / 2 + 0.13, 0.04]} c={p.corCabelo} />)}
        </>
      )}
      {p.oculos && (
        <group position={[0, 0.035, FACE + 0.03]}>
          {[-1, 1].map((k) => (
            <group key={k} position={[k * 0.135, 0, 0]}>
              {[[0, 0.085, 0.2, 0.025], [0, -0.085, 0.2, 0.025], [-0.0875, 0, 0.025, 0.19], [0.0875, 0, 0.025, 0.19]].map(([x, y, w, h], i) => (
                <Chapa key={i} s={[w, h, 0.02]} p={[x, y, 0]} c="#111114" />
              ))}
            </group>
          ))}
          <Chapa s={[0.07, 0.022, 0.02]} p={[0, 0.03, 0]} c="#111114" />
        </group>
      )}
    </group>
  );
}


/* ── A CABEÇA FACETADA (o padrão desde a 2ª referência, 11/10) ────────────
   "olha como quero o 3D": o rosto da referência é LOW-POLY — uma esfera de
   poucas faces com sombreado chapado, nariz de pirâmide, sobrancelha grossa,
   o cabelo cacheado feito de dezenas de pedrinhas facetadas. O corpo segue
   de blocos. Desde 13/10 só sai com `cabeca: 'facetada'`: o padrão voltou a
   ser o cubo com rosto de pixel da 10.5 (partitura sem o campo também). */
const GEO_FACETA = new Map();
function faceta(det) {
  if (!GEO_FACETA.has(det)) GEO_FACETA.set(det, new THREE.IcosahedronGeometry(1, det));
  return GEO_FACETA.get(det);
}
const Faceta = ({ p = [0, 0, 0], s = [1, 1, 1], rot, c, det = 1, rough = 0.75, sombra = true }) => (
  <mesh position={p} scale={s} rotation={rot} geometry={faceta(det)} castShadow={sombra} receiveShadow>
    <meshStandardMaterial color={c} roughness={rough} flatShading />
  </mesh>
);

function CabeloFacetado({ estilo, cor }) {
  const pedras = useMemo(() => {
    if (estilo === 'careca') return [];
    const out = [];
    const n = estilo === 'cacheado' ? 46 : estilo === 'longo' ? 34 : estilo === 'coque' ? 22 : 24;
    for (let i = 0; i < n; i++) {
      // Espalhadas na calota de cima e de trás da cabeça (nunca na testa baixa nem no rosto).
      const u = hash(i * 3.7 + 1), v = hash(i * 9.1 + 4);
      const az = u * Math.PI * 2;
      const el = estilo === 'longo' ? -0.9 + v * 2.2 : 0.15 + v * 1.25;
      /* O rosto olha para +z (fz = 0.335 na CabecaFacetada). Até a 10.7 o
         filtro guardava cos(az) < 0.35 — z positivo, a FRENTE — e os cachos
         caíam no olho e na bochecha (12/10). Agora a pedra fica atrás da linha
         das orelhas, ou no alto da cabeça, acima da testa. */
      const z = -Math.cos(az) * Math.cos(el) * 0.34;
      const y = 0.04 + Math.sin(el) * 0.38;
      if (z > 0.05 && y < 0.3) continue;
      if (z > 0.18) continue;
      const r = estilo === 'cacheado' ? 0.105 + hash(i) * 0.05 : 0.09 + hash(i) * 0.03;
      out.push({ p: [Math.sin(az) * Math.cos(el) * 0.33, y, z - 0.02], r, k: i });
    }
    // O coque no alto e um pouco para trás: visível de frente (atrás, sumia e virava cabelo curto).
    if (estilo === 'coque') out.push({ p: [0, 0.46, -0.12], r: 0.17, k: 99 });
    return out;
  }, [estilo]);
  // A franja: a frente do cabelo, um pouco acima da sobrancelha.
  return (
    <group>
      {estilo !== 'careca' && <Faceta p={[0, 0.2, -0.02]} s={[0.36, 0.26, 0.36]} c={cor} det={1} rough={0.9} />}
      {/* O LONGO cai pelos lados até o ombro, por FORA da cabeça — as pedrinhas
          ficavam dentro do crânio e, de frente, ela parecia de cabelo curto (12/10). */}
      {estilo === 'longo' && <>
        <Faceta p={[-0.33, -0.1, -0.04]} s={[0.11, 0.34, 0.24]} c={cor} det={1} rough={0.9} />
        <Faceta p={[0.33, -0.1, -0.04]} s={[0.11, 0.34, 0.24]} c={cor} det={1} rough={0.9} />
        <Faceta p={[0, -0.08, -0.26]} s={[0.34, 0.38, 0.13]} c={cor} det={1} rough={0.9} />
      </>}
      {pedras.map((x) => <Faceta key={x.k} p={x.p} s={[x.r, x.r * 0.9, x.r]} rot={[hash(x.k) * 3, hash(x.k + 1) * 3, 0]} c={escurecer(cor, 0.85 + hash(x.k + 2) * 0.3)} det={0} rough={0.95} />)}
    </group>
  );
}

function CabecaFacetada({ p, est }) {
  const e = EMO[est.emocao] || EMO.neutro;
  const sobr = e.sobr + est.enfase * 0.4;
  const [abre, larg] = est.boca;
  const pisca = clamp(est.pisca);
  const escuro = '#17110f';
  const fz = 0.335;   // a frente do rosto
  return (
    <group>
      {/* o crânio e o queixo: dois volumes facetados */}
      <Faceta s={[0.34, 0.39, 0.35]} c={p.pele} det={1} rough={0.7} />
      <Faceta p={[0, -0.17, 0.05]} s={[0.26, 0.2, 0.27]} c={p.pele} det={1} rough={0.7} />
      {[-1, 1].map((k) => <Faceta key={'o' + k} p={[k * 0.335, -0.01, -0.02]} s={[0.05, 0.08, 0.06]} c={escurecer(p.pele, 0.9)} det={0} />)}
      {/* os olhos: amêndoa escura que fecha na piscada, e a sobrancelha grossa */}
      {[-1, 1].map((k) => (
        <group key={k} position={[k * 0.125, 0.04, fz - 0.03]} rotation={[0, k * 0.28, 0]}>
          <mesh scale={[0.045, 0.05 * (1 - pisca * 0.88), 0.02]}><sphereGeometry args={[1, 8, 6]} /><meshStandardMaterial color={escuro} roughness={0.4} /></mesh>
          <mesh position={[0.012, 0.015, 0.016]} scale={[0.012, 0.012, 0.006]}><sphereGeometry args={[1, 6, 4]} /><meshStandardMaterial color="#ffffff" roughness={0.3} /></mesh>
          <mesh position={[0, 0.085 + sobr * 0.025, 0.012]} rotation={[0, 0, k * (est.emocao === 'serio' ? 0.25 : 0.05) - k * sobr * 0.06]}>
            <boxGeometry args={[0.14, 0.035, 0.04]} /><meshStandardMaterial color={p.corCabelo} roughness={0.9} flatShading />
          </mesh>
        </group>
      ))}
      {/* o nariz: uma pirâmide de quatro faces */}
      <mesh position={[0, -0.045, fz + 0.025]} rotation={[Math.PI / 2 - 0.25, Math.PI / 4, 0]} scale={[0.05, 0.11, 0.05]} castShadow>
        <coneGeometry args={[1, 1, 4]} /><meshStandardMaterial color={escurecer(p.pele, 0.95)} roughness={0.7} flatShading />
      </mesh>
      {/* a boca: abre com a vogal e estica no "i" */}
      <mesh position={[0, -0.17 - abre * 0.012, fz - 0.03]} scale={[0.05 + 0.04 * larg, 0.008 + 0.04 * abre, 0.02]}>
        <sphereGeometry args={[1, 8, 6]} /><meshStandardMaterial color="#3a1416" roughness={0.6} />
      </mesh>
      {p.bigode && [-1, 1].map((k) => (
        <mesh key={'b' + k} position={[k * 0.06, -0.115, fz + 0.005]} rotation={[0, 0, k * -0.35]} scale={[0.075, 0.03, 0.03]}>
          <icosahedronGeometry args={[1, 0]} /><meshStandardMaterial color={p.corCabelo} roughness={0.9} flatShading />
        </mesh>
      ))}
      {p.barba && <Faceta p={[0, -0.2, 0.08]} s={[0.27, 0.17, 0.26]} c={p.corCabelo} det={1} rough={0.95} />}
      {p.oculos && (
        <group position={[0, 0.04, fz + 0.02]}>
          {[-1, 1].map((k) => <mesh key={k} position={[k * 0.125, 0, 0]}><torusGeometry args={[0.065, 0.012, 6, 12]} /><meshStandardMaterial color="#111114" roughness={0.4} /></mesh>)}
          <mesh><boxGeometry args={[0.06, 0.012, 0.012]} /><meshStandardMaterial color="#111114" /></mesh>
        </group>
      )}
      <CabeloFacetado estilo={p.cabelo} cor={p.corCabelo} />
    </group>
  );
}

/* A AÇÃO da fala mexe o braço por cima do gesto: acenar (a mão direita no
   alto, abanando), apontar (o braço esticado para a frente), comemorar (os
   dois para cima), pensar (a mão no queixo), dançar (os dois balançando). */
function poseDoBraco(acao, lado, t, k, sem = 0) {
  if (!acao || k <= 0) return null;
  const dir = lado > 0;
  // O antebraço vem ATRASADO do braço (sobreposição): a mesma onda, a fase depois.
  if (acao === 'acenar' && dir) return { x: -0.25, z: 2.55 + Math.sin(t * 9) * 0.22, c: -0.55 + Math.sin(t * 9 - 1.1) * 0.4 };
  if (acao === 'apontar' && dir) return { x: -1.45 + ruido(t * 0.8, sem + 3) * 0.04, z: 0.12, c: -0.05 };
  if (acao === 'comemorar') return { x: -0.2, z: lado * (2.7 + Math.sin(t * 8 + lado) * 0.15), c: -0.25 + Math.sin(t * 8 + lado - 1) * 0.2 };
  if (acao === 'pensar' && dir) return { x: -1.5 + ruido(t * 0.5, sem + 4) * 0.05, z: -0.35, c: -2.1 };
  if (acao === 'dancar') return { x: -0.6 + Math.sin(t * 6 + lado) * 0.5, z: lado * (0.6 + Math.sin(t * 6 - 0.5) * 0.3), c: -1.1 + Math.sin(t * 6 + lado - 0.9) * 0.3 };
  // Andar e correr: o braço oposto à perna, atrasado dela (natural.passo).
  if (acao === 'correr' || acao === 'andar') {
    const ps = passo(t, { correr: acao === 'correr', semente: sem });
    const b = ps.braco[dir ? 1 : 0];
    return acao === 'correr' ? { x: b * 1.2, z: lado * 0.1, c: -1.3 + b * 0.25 } : { x: b, z: lado * 0.06, c: -0.25 + Math.min(0, b) * 0.5 };
  }
  return null;
}

function BracoBloco({ lado, g, gc = g, cor, pele, t, acao, k = 1, kc = k, ar = 0, sem = 0 }) {
  // Repouso: o braço caído, um tico para fora — e nunca parado: ele acompanha o
  // fôlego (abre na inspiração) e deriva pelo ruído. O cotovelo vem depois do
  // ombro (gc e kc são o gesto e a ação de ~0,1 s antes): a sobreposição.
  const deriva = ruido(t * 0.45, sem + lado * 5) * 0.035;
  let ombroX = lerp(0.08, -0.85, g) + Math.sin(t * 6 + lado) * 0.06 * Math.max(0, g) + deriva;
  let ombroZ = lado * (lerp(0.06, 0.22, Math.max(0, g)) + ar * 0.025);
  let cotovelo = lerp(-0.12, -1.05, gc) + Math.sin(t * 7.3 + lado * 2 - 0.8) * 0.14 * Math.max(0, gc) + deriva * 0.6;
  const pose = poseDoBraco(acao, lado, t, k, sem);
  const poseC = poseDoBraco(acao, lado, t - 0.09, kc, sem);
  if (pose) { ombroX = lerp(ombroX, pose.x, k); ombroZ = lerp(ombroZ, pose.z, k); }
  if (poseC) cotovelo = lerp(cotovelo, poseC.c, kc);
  return (
    <group position={[lado * 0.47, 1.4, 0]} rotation={[ombroX, 0, ombroZ]}>
      <Bloco s={[0.2, 0.36, 0.2]} p={[0, -0.17, 0]} c={cor} r={0.04} rough={0.8} />
      <group position={[0, -0.34, 0]} rotation={[cotovelo, 0, 0]}>
        <Bloco s={[0.19, 0.3, 0.19]} p={[0, -0.14, 0]} c={cor} r={0.04} rough={0.8} />
        <Bloco s={[0.17, 0.17, 0.17]} p={[0, -0.36, 0]} c={pele} r={0.035} />
      </group>
    </group>
  );
}

function PessoaBloco({ p, est }) {
  const t = est.t, sem = est.semente || 0;
  // O fôlego: inspira rápido, expira devagar, o ritmo varia (natural.respiracao).
  const ar = respiracao(t, sem);
  const resp = ar * 0.012;
  const [cy, cp, cr] = est.cabeca;
  const calca = escurecer(p.roupa, 0.35);
  const a = est.acao, ka = est.forcaAcao || 0;
  // O corpo inteiro. Pular e comemorar: agacha, estica, cai acelerando e amassa
  // no pouso (natural.salto). Dançar: o quadril leva, o tronco e a cabeça vêm
  // atrasados. Andar e correr: o passo com quadril, contato e contragiro.
  const pula = a === 'pular' || a === 'comemorar';
  const sl = pula ? salto(t, a === 'comemorar' ? { periodo: 0.8, altura: 0.22 } : { periodo: 0.95, altura: 0.36 }) : null;
  const anda = a === 'andar' || a === 'correr';
  const ps = anda ? passo(t, { correr: a === 'correr', semente: sem }) : null;
  const danca = a === 'dancar';
  // Parado, o peso troca de perna devagar: nunca congelado.
  const peso = ruido(t * 0.22, sem + 11) * 0.018;
  const y = (sl ? sl.y * ka : 0) + (ps ? ps.sobe * ka : 0) + (danca ? (0.5 + 0.5 * Math.cos(t * 12)) * 0.04 * ka : 0);
  const sy = sl ? lerp(1, sl.sy, Math.min(1, ka)) : 1, sx = sl ? lerp(1, sl.sx, Math.min(1, ka)) : 1;
  const quadril = peso + (ps ? ps.quadril * ka : 0) + (danca ? Math.sin(t * 6) * 0.14 * ka : 0);
  // O tronco gira contra o quadril (andar) ou vem atrasado dele (dançar).
  const troncoZ = danca ? -Math.sin(t * 6 - 0.6) * 0.07 * ka : -peso * 0.5;
  const troncoY = ps ? ps.giro * ka : 0;
  const troncoX = (ps ? ps.inclina * ka : 0) + ar * -0.006;
  // A cabeça segura o olhar: desfaz parte do giro do tronco, e balança por último.
  const cabecaZ = cr + (danca ? Math.sin(t * 6 - 1.2) * 0.08 * ka : 0) - quadril * 0.4;
  return (
    <group position={[0, y, 0]} rotation={[0, 0, quadril]} scale={[sx, sy, sx]}>
      {/* as pernas e o sapato */}
      {[-1, 1].map((k) => {
        const perna = ps ? ps.perna[k > 0 ? 1 : 0] * ka : 0;
        return (
          <group key={k} position={[k * 0.15, 0.76, 0]} rotation={[perna, 0, -quadril * 0.8]}>
            <Bloco s={[0.25, 0.72, 0.27]} p={[0, -0.36, 0]} c={calca} r={0.03} rough={0.85} />
            <Bloco s={[0.27, 0.1, 0.36]} p={[0, -0.71, 0.04]} c="#1a1a1f" r={0.03} />
          </group>
        );
      })}
      {/* do quadril para cima: o tronco, os braços e a cabeça giram juntos no quadril */}
      <group position={[0, 0.76, 0]} rotation={[troncoX, troncoY, troncoZ]}>
        <group position={[0, -0.76, 0]}>
          <group position={[0, resp, 0]} scale={[1 + ar * 0.008, 1 + ar * 0.014, 1 + ar * 0.02]}>
            {/* o tronco, com a faixa branca da camisa da referência */}
            <Bloco s={[0.76, 0.66, 0.4]} p={[0, 1.1, 0]} c={p.roupa} r={0.05} rough={0.8} />
            <Bloco s={[0.06, 0.5, 0.02]} p={[0.17, 1.12, 0.205]} c="#dcd8d0" r={0.008} sombra={false} />
            <Bloco s={[0.22, 0.08, 0.22]} p={[0, 1.46, 0]} c={escurecer(p.pele, 0.88)} r={0.02} />
          </group>
          <BracoBloco lado={-1} g={est.gesto[0]} gc={(est.gestoAtras || est.gesto)[0]} cor={p.roupa} pele={p.pele} t={t} acao={a} k={ka} kc={est.forcaAtras ?? ka} ar={ar} sem={sem} />
          <BracoBloco lado={1} g={est.gesto[1]} gc={(est.gestoAtras || est.gesto)[1]} cor={p.roupa} pele={p.pele} t={t} acao={a} k={ka} kc={est.forcaAtras ?? ka} ar={ar} sem={sem} />
          <group position={[0, 1.8 + resp * 1.2, 0]} rotation={[cp - troncoX * 0.6, cy - troncoY * 0.6, cabecaZ]}>
            {p.cabeca !== 'facetada' ? (
              <>
                <Bloco s={[CAB, CAB, CAB]} c={p.pele} r={0.04} rough={0.6} />
                <RostoPixel p={p} est={est} />
                <Cabelo estilo={p.cabelo} cor={p.corCabelo} />
              </>
            ) : <CabecaFacetada p={p} est={est} />}
          </group>
        </group>
      </group>
    </group>
  );
}

/* ── O MASCOTE (quem é IA) ──────────────────────────────────────────────
   O da 2ª referência (11/10): um CUBO laranja de quina viva, dois olhos
   quadrados pretos, uma orelha de bloco de cada lado e uma fileira de
   perninhas, em pé no chão. Não tem boca: ele fala com o CORPO — estica para
   cima na vogal aberta, encolhe no "m" — e pisca fechando o olho em traço.
   `bigode: true` põe o bigode da referência. */
function Mascote({ p, est }) {
  const cor = p.cor || '#d9532b';
  const [abre] = est.boca;
  const ka = Math.min(1, est.forcaAcao || 0);
  // O pulo com peso: agacha, estica no impulso, cai acelerando e amassa no pouso (natural.salto).
  const sl = (est.acao === 'pular' || est.acao === 'comemorar') ? salto(est.t, { periodo: 0.85, altura: 0.3 }) : { y: 0, sx: 1, sy: 1 };
  const pulo = sl.y * ka;
  const respira = (respiracao(est.t, (est.semente || 0) + 0.7) - 0.5) * 0.024;
  const estica = (1 + abre * 0.07 + respira) * lerp(1, sl.sy, ka), achata = (1 - abre * 0.035) * lerp(1, sl.sx, ka);
  const pisca = clamp(est.pisca);
  const [cy, cp, cr] = est.cabeca;
  const W = 1.0, H = 0.92, D = 0.78, perna = 0.46;
  const pernas = [-0.38, -0.19, 0, 0.19, 0.38];
  // O corpo balança de leve sobre as perninhas, atrasado da cabeça (sobreposição).
  const ginga = ruido(est.t * 0.3, (est.semente || 0) + 21) * 0.025;
  return (
    <group position={[0, pulo, 0]} rotation={[0, cy * 0.8, cr * 0.5 + ginga]}>
      {pernas.map((x, i) => (
        <group key={i}>
          <Bloco s={[0.13, perna, 0.13]} p={[x, perna / 2 + Math.max(0, Math.sin(est.t * 9 + i)) * (est.falando ? 0.015 : 0), D / 2 - 0.12]} c={escurecer(cor, 0.9)} r={0.015} rough={0.6} />
          {i % 2 === 0 && <Bloco s={[0.13, perna, 0.13]} p={[x, perna / 2, -D / 2 + 0.12]} c={escurecer(cor, 0.8)} r={0.015} rough={0.6} />}
        </group>
      ))}
      <group position={[0, perna, 0]} rotation={[cp * 0.5, 0, 0]}>
        <group scale={[achata, estica, achata]}>
          <Bloco s={[W, H, D]} p={[0, H / 2, 0]} c={cor} r={0.025} rough={0.55} />
          {[-1, 1].map((k) => (
            <Bloco key={k} s={[0.16, 0.34, 0.3]} p={[k * (W / 2 + 0.08), H * 0.5 + (est.gesto[k > 0 ? 1 : 0] * 0.08), 0]} c={escurecer(cor, 0.93)} r={0.015} rough={0.55} />
          ))}
          {[-1, 1].map((k) => (
            <Chapa key={'o' + k} s={[0.13, pisca > 0.5 ? 0.025 : 0.155, 0.012]} p={[k * 0.23, H * 0.66, D / 2 + 0.006]} c="#0d0a0b" />
          ))}
          {/* o bigode da referência: a barra grossa no meio e as pontas que sobem em curva */}
          {p.bigode && (
            <group position={[0, H * 0.43, D / 2 + 0.012]}>
              {/* grosso no meio (dois gomos), afinando e subindo nas pontas */}
              {[-1, 1].map((k) => (
                <group key={k}>
                  <Chapa s={[0.2, 0.14, 0.024]} p={[k * 0.09, -0.005, 0]} rot={[0, 0, k * 0.12]} c="#0d0a0b" />
                  <Chapa s={[0.14, 0.1, 0.024]} p={[k * 0.235, 0.012, 0]} rot={[0, 0, k * 0.38]} c="#0d0a0b" />
                  <Chapa s={[0.08, 0.07, 0.024]} p={[k * 0.32, 0.07, 0]} rot={[0, 0, k * 0.95]} c="#0d0a0b" />
                </group>
              ))}
            </group>
          )}
        </group>
      </group>
    </group>
  );
}

/* ── O SET ─────────────────────────────────────────────────────────────── */

/* Um cartão de tela: o painel escuro com o ícone, o título do assunto, duas
   linhas de texto, a barra de progresso e a bolinha verde de "rodando". É
   desenhado num canvas 2D e vira textura que BRILHA (o bloom pega). */
function texturaCartao(titulo, linha, cor, acento, k) {
  const cv = document.createElement('canvas');
  cv.width = 512; cv.height = 320;
  const g = cv.getContext('2d');
  const r = 26;
  g.fillStyle = cor; g.beginPath(); g.roundRect(6, 6, 500, 308, r); g.fill();
  g.strokeStyle = 'rgba(160,175,255,0.35)'; g.lineWidth = 3; g.stroke();
  g.fillStyle = acento; g.beginPath(); g.roundRect(34, 34, 44, 44, 8); g.fill();
  g.fillStyle = '#eef1ff'; g.font = '600 34px Inter, "Helvetica Neue", Arial, sans-serif';
  g.fillText(String(titulo).slice(0, 18), 96, 68);
  g.fillStyle = '#3ddc84'; g.beginPath(); g.arc(462, 56, 10, 0, Math.PI * 2); g.fill();
  g.fillStyle = 'rgba(205,212,255,0.75)'; g.font = '400 24px Inter, "Helvetica Neue", Arial, sans-serif';
  g.fillText(String(linha).slice(0, 30), 36, 132);
  g.fillStyle = 'rgba(205,212,255,0.28)';
  g.beginPath(); g.roundRect(36, 160, 300 + hash(k) * 120, 16, 8); g.fill();
  g.beginPath(); g.roundRect(36, 192, 200 + hash(k + 4) * 160, 16, 8); g.fill();
  g.fillStyle = 'rgba(205,212,255,0.18)'; g.beginPath(); g.roundRect(36, 262, 440, 10, 5); g.fill();
  g.fillStyle = '#8fa2ff'; g.beginPath(); g.roundRect(36, 262, 120 + hash(k + 9) * 300, 10, 5); g.fill();
  const tx = new THREE.CanvasTexture(cv);
  tx.colorSpace = THREE.SRGBColorSpace;
  tx.anisotropy = 4;
  return tx;
}

function ParedeDeTelas({ c, paineis, t }) {
  const texturas = useMemo(() => {
    const lista = paineis && paineis.length ? paineis : [{ titulo: 'Agora', linha: '' }];
    return Array.from({ length: 16 }, (_, i) => {
      const pn = lista[i % lista.length];
      return texturaCartao(pn.titulo, pn.linha || '', c.tela, c.acento, i);
    });
  }, [paineis, c.tela, c.acento]);
  // Duas fileiras de seis telas, num arco suave atrás do set.
  const telas = [];
  for (let fila = 0; fila < 2; fila++) {
    for (let i = 0; i < 8; i++) {
      const x = (i - 3.5) * 1.62;
      const ang = -x * 0.07;
      const z = -3.4 + Math.abs(x) * 0.12;
      telas.push({ k: fila * 8 + i, x, y: 1.25 + fila * 1.08, z, ang });
    }
  }
  return (
    <group>
      {/* a estrutura escura onde as telas moram */}
      <Bloco s={[16, 3.2, 0.3]} p={[0, 1.78, -3.75]} c={c.parede} r={0.05} rough={0.9} sombra={false} />
      {telas.map((s) => {
        const vivo = 0.82 + 0.18 * Math.sin(t * 2 + s.k * 1.7);
        return (
          <group key={s.k} position={[s.x, s.y, s.z]} rotation={[0, s.ang, 0]}>
            <Bloco s={[1.54, 0.98, 0.07]} c="#0a0c18" r={0.04} rough={0.4} sombra={false} />
            <mesh position={[0, 0, 0.04]}>
              <planeGeometry args={[1.46, 0.9]} />
              <meshStandardMaterial map={texturas[s.k]} emissiveMap={texturas[s.k]} emissive="#ffffff" emissiveIntensity={0.55 * vivo} roughness={0.35} toneMapped />
            </mesh>
          </group>
        );
      })}
      {/* a fileira de luzinhas em cima da parede, como na referência */}
      {Array.from({ length: 9 }, (_, i) => (
        <Bloco key={'l' + i} s={[0.16, 0.1, 0.1]} p={[(i - 4) * 1.15, 3.15, -3.55]} c={c.acento} e={c.acento} ei={1.6 + 0.6 * Math.sin(t * 3 + i)} r={0.02} sombra={false} />
      ))}
    </group>
  );
}

function ChaoDeLadrilhos({ c }) {
  // Ladrilhos de 0,9 com junta escura: a grade que dá escala ao set.
  const pecas = useMemo(() => {
    const out = [];
    for (let i = -7; i <= 7; i++) for (let j = -5; j <= 6; j++) out.push([i * 0.92 + (j % 2 ? 0.46 : 0), j * 0.6, hash(i * 13 + j * 7)]);
    return out;
  }, []);
  return (
    <group>
      <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.02, 0]} receiveShadow>
        <planeGeometry args={[40, 40]} /><meshStandardMaterial color={escurecer(c.chao, 0.45)} roughness={0.9} />
      </mesh>
      {pecas.map(([x, z, k], i) => (
        <mesh key={i} position={[x, -0.03, z]} geometry={geo(0.88, 0.1, 0.56, 0.035)} receiveShadow>
          <meshStandardMaterial color={k > 0.5 ? c.chao : c.chao2} roughness={0.42} metalness={0.05} />
        </mesh>
      ))}
    </group>
  );
}

/* ── A CÂMERA ──────────────────────────────────────────────────────────── */
/* O LUGAR de cada um: um sozinho no meio; dois frente a frente; três e
   quatro num arco aberto para a câmera, todos virados para o centro. Bicho
   pequeno fica um passo à frente, para não sumir atrás de quem é alto. */
const ARCO = { 1: [[0, 0]], 2: [[-0.78, 0], [0.78, 0]], 3: [[-1.35, 0.1], [0, -0.35], [1.35, 0.1]], 4: [[-1.9, 0.25], [-0.65, -0.25], [0.65, -0.25], [1.9, 0.25]] };
function lugarDe(el, i) {
  const n = Math.min(4, el.length) || 1;
  const [x, z] = (ARCO[n] || ARCO[1])[Math.min(i, n - 1)];
  const baixo = el[i] && (el[i].tipo === 'animal' || el[i].tipo === 'coisa') && alturaDaCabeca(el[i]) < 1.2;
  return [x, 0, z + (baixo && n > 1 ? 0.35 : 0)];
}
function giroDe(el, i) {
  const n = el.length;
  if (n < 2) return 0;
  const [x] = lugarDe(el, i);
  return clamp(-x * 0.5, -0.45, 0.45);
}
function alturaDaCabeca(p) {
  if (!p) return 1.8;
  if (p.tipo === 'robo') return 0.46 + 0.62;   // o meio dos olhos do cubo
  if (p.tipo === 'animal') return alturaDoBicho(p.especie);
  if (p.tipo === 'coisa') return 1.1;
  return 1.8;
}
/* Os figurantes ficam nas pontas do arco, um pouco atrás: estão na cena, mas não roubam o plano. */
function lugarDoFigurante(el, fig, j, vert = false) {
  const n = Math.max(1, el.length);
  /* No vertical o quadro é estreito: na ponta do arco (1,15) o gato pedido
     ficava fora da tela o vídeo inteiro (12/10). Vem para perto, aos pés. */
  const borda = vert ? (n === 1 ? 0.62 : n === 2 ? 1.1 : 1.6) : n === 1 ? 1.15 : n === 2 ? 1.75 : 2.5;
  const lado = j % 2 ? 1 : -1;
  const baixo = fig.tipo === 'animal' || fig.tipo === 'coisa';
  return [lado * (borda + Math.floor(j / 2) * 0.9), 0, baixo ? 0.45 : -0.5];
}

/* A CÂMERA NÃO ATRAVESSA NINGUÉM (13/10): no close de quem fala, outro do
   elenco ficava entre a lente e o rosto, ou com a lente dentro dele (o close
   do cachorro com o braço do outro cortando o quadro). `bate` diz se alguém
   do elenco, um cilindro do chão até acima da cabeça, corta o caminho. */
function bate(el, quem, pos, alvo) {
  const dx = alvo[0] - pos[0], dz = alvo[2] - pos[2], L2 = dx * dx + dz * dz || 1;
  for (let j = 0; j < el.length; j++) {
    if (j === quem) continue;
    const [x, , z] = lugarDe(el, j);
    const u = clamp(((x - pos[0]) * dx + (z - pos[2]) * dz) / L2);
    const py = pos[1] + (alvo[1] - pos[1]) * u;
    if (Math.hypot(pos[0] + dx * u - x, pos[2] + dz * u - z) < 0.55 && py < alturaDaCabeca(el[j]) + 0.3) return true;
  }
  return false;
}

function cameraDe(pl, t, part, aspecto) {
  const vert = aspecto < 1;
  const el = part.elenco;
  const dois = el.length > 1;
  const quem = el[pl.alvo] ? pl.alvo : 0;
  // O bicho de quatro patas tem a cabeça NA FRENTE do corpo, e o rosto virado para onde o corpo vira.
  const doBicho = (i) => (el[i] && el[i].tipo === 'animal' ? frenteDoBicho(el[i].especie) : { giro: 0, dist: 0 });
  const cabeca = (i) => {
    const p = lugarDe(el, i), b = doBicho(i), g = giroDe(el, i) + b.giro;
    return [p[0] + Math.sin(g) * b.dist, alturaDaCabeca(el[i]), p[2] + Math.cos(g) * b.dist];
  };
  const frente = (i) => { const g = giroDe(el, i) + doBicho(i).giro * 0.7; return [Math.sin(g), 0, Math.cos(g)]; };
  const k = clamp((t - pl.de) / Math.max(0.5, pl.ate - pl.de));
  const fovK = vert ? 1.45 : 1;
  let pos, alvo, fov;
  // Bicho e coisa falam DE FRENTE: por cima do ombro, o focinho ficava de perfil.
  if (pl.tipo === 'ombro' && el[quem] && (el[quem].tipo === 'animal' || el[quem].tipo === 'coisa')) pl = { ...pl, tipo: 'medio' };
  if (pl.tipo === 'aberto' || pl.tipo === 'largo') {
    // O plano aberto de cima, como o da referência: o set inteiro e a parede de telas.
    fov = (pl.tipo === 'largo' ? 38 : 34) * fovK;
    // Mais gente no set, a câmera recua; elenco baixo (bichos), ela desce.
    const largura = el.length > 2 ? 1 + (el.length - 2) * (vert ? 0.42 : 0.28) : 1;
    const dist = (pl.tipo === 'largo' ? (vert ? 8.2 : 6.6) : (vert ? 6.6 : 5.2)) * largura;
    const alturaMedia = el.length ? el.reduce((s, p) => s + alturaDaCabeca(p), 0) / el.length : 1.8;
    pos = [pl.lado * (el.length > 2 ? 0.2 : 0.5), (pl.tipo === 'largo' ? 3.1 : 2.5) * (0.55 + 0.45 * alturaMedia / 1.8), dist];
    alvo = [0, Math.max(0.6, alturaMedia - 0.35), -0.6];
  } else if (pl.tipo === 'ombro' && dois) {
    // Por cima do ombro de quem está MAIS PERTO de quem fala (com 3 ou 4 no set).
    let o = quem === 0 ? 1 : 0;
    for (let j = 0; j < el.length; j++) if (j !== quem && Math.abs(lugarDe(el, j)[0] - lugarDe(el, quem)[0]) < Math.abs(lugarDe(el, o)[0] - lugarDe(el, quem)[0])) o = j;
    const h = cabeca(o), s = cabeca(quem);
    const d = [h[0] - s[0], h[2] - s[2]], n = Math.hypot(d[0], d[1]) || 1;
    pos = [h[0] + (d[0] / n) * 0.9, h[1] + 0.25, h[2] + 1.6];
    alvo = [s[0], s[1] - 0.1, s[2]]; fov = 32 * fovK;
  } else {
    /* O close da referência é de BAIXO e perto, com a lente aberta: o rosto
       enorme e o resto derretendo no desfoque. */
    const dist = (pl.tipo === 'detalhe' ? 1.25 : pl.tipo === 'close' ? 1.7 : 2.7) * (vert ? 1.25 : 1);
    fov = (pl.tipo === 'detalhe' ? 30 : pl.tipo === 'close' ? 32 : 34) * fovK;
    const h = cabeca(quem), f = frente(quem);
    const baixo = pl.tipo === 'medio' ? 0.05 : -0.32;
    alvo = [h[0], h[1] + (pl.tipo === 'medio' ? -0.35 : -0.02), h[2]];
    const de = (ld) => {
      const lado = ld * 0.42;
      const dir = [f[0] * Math.cos(lado) - f[2] * Math.sin(lado), 0, f[0] * Math.sin(lado) + f[2] * Math.cos(lado)];
      return [h[0] + dir[0] * dist, h[1] + baixo, h[2] + dir[2] * dist];
    };
    // O lado pedido; se alguém corta, o outro lado, de frente, mais aberto; senão, a câmera sobe por cima.
    pos = [pl.lado, -pl.lado, 0, 1.6, -1.6].map(de).find((q) => !bate(el, quem, q, alvo));
    if (!pos) { const q = de(pl.lado); pos = [q[0], q[1] + 0.9, q[2]]; }
  }
  // No vertical a legenda está no alto: a câmera mira acima, e o rosto desce no quadro.
  if (vert) alvo = [alvo[0], alvo[1] + (pl.tipo === 'aberto' || pl.tipo === 'largo' ? 0.45 : pl.tipo === 'medio' ? 0.3 : pl.tipo === 'ombro' ? 0.22 : 0.17), alvo[2]];
  const m = suave(k);
  if (pl.mov === 'entra' || pl.mov === 'sai') {
    const s = pl.mov === 'entra' ? m : 1 - m;
    pos = pos.map((v, i) => lerp(v, alvo[i], 0.12 * s));
  } else if (pl.mov === 'lateral') {
    pos = [pos[0] + (m - 0.5) * 0.45 * pl.lado, pos[1], pos[2]];
  }
  const mao = [Math.sin(t * 1.3) * 0.01 + Math.sin(t * 3.1) * 0.004, Math.sin(t * 1.1 + 1) * 0.008 + Math.sin(t * 2.7) * 0.003, 0];
  pos = pos.map((v, i) => v + (mao[i] || 0));
  alvo = alvo.map((v, i) => v + (mao[i] || 0) * 0.5);
  /* O FOCO é no ROSTO, não no centro da cabeça: a cara fica 0,3 m mais perto
     da câmera que o alvo. No plano aberto, no meio do set. A abertura é
     pequena (a lente de perto já borra muito por metro), e o teto do
     desfoque é que dá o fundo derretido da referência. */
  const aoAlvo = Math.hypot(pos[0] - alvo[0], pos[1] - alvo[1], pos[2] - alvo[2]);
  const deRosto = pl.tipo !== 'aberto' && pl.tipo !== 'largo';
  const foco = deRosto ? Math.max(0.4, aoAlvo - 0.28) : aoAlvo;
  const perto = pl.tipo === 'detalhe' || pl.tipo === 'close';
  return { pos, alvo, fov, foco, abertura: perto ? 0.012 : pl.tipo === 'ombro' ? 0.01 : pl.tipo === 'medio' ? 0.008 : 0.003, desfoqueMax: perto ? 0.03 : pl.tipo === 'ombro' ? 0.024 : 0.012 };
}

/* ── POR CIMA: a legenda da referência ─────────────────────────────────── */
const FONTE = "'Inter','SF Pro Display','Helvetica Neue',Arial,sans-serif";

// Palavra que não fecha frase: o pedaço da legenda não termina nela ("uma fogueira no").
const FRACA = /^(no|na|nos|nas|de|do|da|dos|das|o|a|os|as|e|que|um|uma|com|em|pra|pro|por|para|ao|se|mais|muito|meu|minha|seu|sua)$/i;
function pedacos(palavras) {
  const out = [];
  let cur = [];
  for (const w of palavras) {
    cur.push(w);
    const letras = cur.reduce((n, x) => n + String(x.t).length + 1, 0);
    if (letras > 30 || /[.!?;:]$/.test(w.t) || (cur.length >= 4 && /,$/.test(w.t))) {
      // Terminou numa palavra fraca: ela desce para o próximo pedaço.
      if (cur.length > 2 && FRACA.test(String(w.t).replace(/[^\p{L}]/gu, '')) && !/[.!?;:,]$/.test(w.t)) { out.push(cur.slice(0, -1)); cur = [w]; continue; }
      out.push(cur); cur = [];
    }
  }
  if (cur.length) out.push(cur);
  return out;
}

/* O LAYOUT DO QUE VAI POR CIMA (11/10, "nada fora do lugar nem sobrepondo"):
   - a legenda mora no ALTO no vertical (25% da altura, como na 2ª
     referência) e embaixo no horizontal (80%); a câmera do vertical mira
     acima do rosto (cameraDe), então o rosto fica do meio para baixo e a
     legenda nunca passa por cima dele;
   - cabe em no máximo DUAS linhas na largura útil (84%): a fonte encolhe para
     o pedaço mais longo, em vez de quebrar em três ou vazar da tela;
   - o cartão de destaque fica no alto (12% / 8%), numa linha só, e também
     encolhe para caber — os dois nunca dividem a mesma faixa. */
const LARGURA_LETRA = 0.54;   // a largura média de uma letra da Inter, em "em"
function tamanhoQueCabe(texto, base, largura, linhas) {
  const porLinha = Math.ceil(String(texto).length / linhas) + 2;
  return Math.max(Math.round(base * 0.6), Math.min(base, Math.floor(largura / (porLinha * LARGURA_LETRA))));
}

function Legenda({ part, t, W, H }) {
  const f = part.falas.find((x) => t >= x.de - 0.1 && t <= x.ate + 0.35);
  if (!f) return null;
  const grupos = pedacos(f.palavras);
  const g = grupos.find((gr) => t <= gr[gr.length - 1].ate + 0.2) || grupos[grupos.length - 1];
  const entra = suave((t - (g[0].de - 0.08)) / 0.18);
  const sai = 1 - suave((t - (f.ate + 0.15)) / 0.2);
  const vertical = H > W;
  const util = W * 0.84;
  const texto = g.map((w) => w.t).join(' ');
  const tam = tamanhoQueCabe(texto, Math.round(vertical ? W * 0.05 : H * 0.05), util, 2);
  return (
    <div style={{ position: 'absolute', left: 0, right: 0, top: H * (vertical ? 0.25 : 0.8), display: 'flex', justifyContent: 'center' }}>
      <div style={{ maxWidth: util, fontFamily: FONTE, fontWeight: 600, fontSize: tam, lineHeight: 1.18, color: '#ffffff', textAlign: 'center', letterSpacing: '-0.005em',
        overflowWrap: 'break-word', textWrap: 'balance', opacity: entra * sai, transform: `translateY(${(1 - entra) * tam * 0.15}px)`,
        textShadow: '0 2px 12px rgba(0,0,0,0.55), 0 1px 2px rgba(0,0,0,0.6)' }}>{texto}</div>
    </div>
  );
}

function Destaque({ part, t, W, H, acento }) {
  const f = part.falas.find((x) => x.destaque && t >= x.de + 0.3 && t <= Math.min(x.ate + 0.3, x.de + 3));
  if (!f) return null;
  const k = t - (f.de + 0.3);
  const fim = Math.min(f.ate + 0.3, f.de + 3) - (f.de + 0.3);
  const s = suave(k / 0.3) * (1 - suave((k - fim + 0.3) / 0.3));
  const util = W * 0.86;
  const tam = tamanhoQueCabe(f.destaque, Math.round(Math.min(W, H) * 0.045), util - Math.min(W, H) * 0.07, 1);
  return (
    <div style={{ position: 'absolute', left: 0, right: 0, top: H * (H > W ? 0.12 : 0.08), display: 'flex', justifyContent: 'center' }}>
      <div style={{ maxWidth: util, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', opacity: s, transform: `translateY(${(1 - s) * -12}px)`, background: 'rgba(12,14,32,0.72)', border: `2px solid ${acento}`, borderRadius: tam * 0.5,
        padding: `${tam * 0.3}px ${tam * 0.7}px`, fontFamily: FONTE, fontWeight: 700, fontSize: tam, color: '#fff', boxShadow: `0 0 ${tam * 0.8}px ${acento}66` }}>{f.destaque}</div>
    </div>
  );
}

/* ── A PEÇA ────────────────────────────────────────────────────────────── */
export function VoxelCena({ el, t }) {
  const part = el.partitura || {};
  const W = el.largura || part.largura || 1080, H = el.altura || part.altura || 1920;
  const c = { parede: '#07061a', chao: '#4338ca', chao2: '#3b32b8', acento: '#ff7a45', luz: '#ffd2b0', tela: '#101734', recorte: '#8b7bff', ...(part.cenario || {}) };
  const elenco = part.elenco || [];
  const falas = part.falas || [];
  const planos = part.planos || [{ de: 0, ate: part.duracao || 10, tipo: 'aberto', alvo: 0, mov: 'fixo', lado: 1 }];
  const dois = elenco.length > 1;
  const pl = planos.find((x) => t >= x.de && t < x.ate) || planos[planos.length - 1];
  const cam = cameraDe(pl, t, part, W / H);

  const falandoAgora = (i) => falas.some((f) => f.quem === i && t >= f.de && t <= f.ate);
  const emocaoDe = (i) => {
    let e = 'neutro';
    for (const f of falas) if (f.quem === i && f.de <= t + 0.2) e = t <= f.ate + 2.5 ? f.emocao : 'neutro';
    return e;
  };
  // A ação da fala vale da voz até 0,6 s depois: entra em mola (passa do ponto e
  // assenta) e sai devagar. O cotovelo pega a mesma ação ~0,1 s depois (sobreposição).
  const acaoDe = (i) => {
    for (const f of falas) {
      if (f.quem !== i || !f.acao || f.acao === 'falar') continue;
      if (t >= f.de - 0.3 && t <= f.ate + 0.6) {
        const opc = { entra: 2.2, sai: 0.5, amort: 0.55 };
        return [f.acao, envelope(t, f.de - 0.3, f.ate + 0.1, opc), envelope(t, f.de - 0.3, f.ate + 0.1, { ...opc, atraso: 0.09 })];
      }
    }
    return [null, 0, 0];
  };
  // O quanto cada um está falando (0 a 1, sem degrau): o aceno de cabeça de quem
  // fala e o de quem ouve trocam com mola, não num corte seco.
  const pesoDaFala = (i, reacao = 0) => falas.reduce((m, f) => (f.quem === i ? Math.max(m, Math.min(1, envelope(t, f.de + reacao, f.ate, { entra: 2.6, sai: 0.45, amort: 0.7 }))) : m), 0);
  const estado = (i, figurante = false) => {
    const sem = i * 13 + (figurante ? 101 : 1);
    const fala = !figurante && falandoAgora(i);
    const wFala = figurante ? 0 : pesoDaFala(i);
    const enfase = figurante ? [0, 0] : gesto(falas, i, t);
    const enfaseAtras = figurante ? [0, 0] : gesto(falas, i, t - 0.09);
    // Quem ouve vira para quem fala com ~0,2 s de reação e mola, e volta devagar.
    let paraQuemFala = 0, wOuve = 0, dx = 0;
    if (!figurante) {
      for (const f of falas) {
        if (f.quem === i) continue;
        const w = Math.min(1, envelope(t, f.de + 0.2, f.ate + 0.3, { entra: 1.6, sai: 0.7, amort: 0.62 }));
        if (w > wOuve) { wOuve = w; dx = lugarDe(elenco, f.quem)[0] - lugarDe(elenco, i)[0]; }
      }
      paraQuemFala = clamp(dx * 0.35, -0.35, 0.35) * wOuve;
    }
    const nodFala = Math.sin(t * 5.2 + i) * 0.03 + ruido(t * 2.4, sem + 2) * 0.02;
    const nodQuieto = ruido(t * 0.5, sem + 3) * 0.018;
    const nod = lerp(nodQuieto, nodFala, wFala) + wOuve * Math.max(0, Math.sin(t * 2.1 + i)) * 0.04;
    const emo = figurante ? 'feliz' : emocaoDe(i);
    // A inclinação da emoção chega e vai embora com peso, não troca no quadro.
    let inclina = 0;
    if (!figurante) {
      for (const f of falas) {
        if (f.quem !== i || f.de > t + 0.2) continue;
        const ef = EMO[f.emocao] || EMO.neutro;
        inclina = (ef.inclina || 0) * Math.min(1.05, envelope(t, f.de - 0.2, f.ate + 2.5, { entra: 1.4, sai: 0.8, amort: 0.6 }));
      }
    } else inclina = (EMO.feliz.inclina || 0);
    const micro = (hash(Math.floor(t * 1.5 + hash(i) * 0.9) + i * 7) - 0.5) * 0.6;
    const [acao, forcaAcao, forcaAtras] = figurante ? [null, 0, 0] : acaoDe(i);
    return {
      t, semente: sem, falando: fala, emocao: emo, boca: figurante ? [0, 0.55] : boca(falas, i, t), pisca: piscada(i + (figurante ? 5 : 0), t),
      gesto: enfase, gestoAtras: enfaseAtras, enfase: Math.max(enfase[0], enfase[1], 0), acao, forcaAcao, forcaAtras,
      olhar: [micro + Math.sign(dx) * 0.8 * wOuve, 0.1],
      // A cabeça deriva pelo ruído (nunca a mesma volta) e vem ~0,06 s atrás do corpo.
      cabeca: [ruido((t - 0.06) * 0.35, sem + 5) * 0.08 + paraQuemFala, nod, inclina * (i % 2 ? -1 : 1) + ruido((t - 0.06) * 0.25, sem + 7) * 0.025],
    };
  };
  const desenhar = (p, est) => (p.tipo === 'robo' ? <Mascote p={p} est={est} />
    : p.tipo === 'animal' ? <Animal p={p} est={est} />
    : p.tipo === 'coisa' ? <Coisa p={p} est={est} icones={icones} />
    : <PessoaBloco p={p} est={est} />);
  /* O MUNDO da hora: o da fala que está (ou acabou de estar) no ar. A troca
     acontece no começo da fala — junto com o corte de câmera. */
  const mundos = part.mundos || [];
  let iMundo = 0;
  for (const f of falas) if (f.de - 0.1 <= t && f.mundo != null) iMundo = f.mundo;
  const mundo = mundos[iMundo];
  const ehTelas = !mundo || mundo.telas;
  const figurantes = part.figurantes || [];
  // Todo PNG que vira bloco (peças fora do catálogo e coisas que falam), lido antes do quadro.
  const srcs = useMemo(() => [...new Set([...mundos.flatMap((m) => m.props.map((p) => p.src)), ...elenco.map((e) => e.src), ...figurantes.map((e) => e.src)].filter(Boolean))].sort(), [part]);
  const icones = useIcones(srcs);
  const fadeIn = clamp(t / 0.5), fadeOut = clamp((part.duracao - t) / 0.6);

  return (
    <div style={{ position: 'absolute', left: el.x || 0, top: el.y || 0, width: W, height: H, overflow: 'hidden', background: '#000' }}>
      <div style={{ position: 'absolute', inset: 0, opacity: Math.min(fadeIn, fadeOut) }}>
        <ThreeCanvas width={W} height={H} shadows camera={{ position: [0, 2, 6], fov: 36 }}
          gl={{ antialias: true, preserveDrawingBuffer: true, toneMapping: THREE.ACESFilmicToneMapping, toneMappingExposure: 1.0 }}>
          <Camera pos={cam.pos} alvo={cam.alvo} fov={cam.fov} />
          <Ambiente />
          {ehTelas ? (
            <>
              <color attach="background" args={[c.parede]} />
              <fog attach="fog" args={[c.parede, 9, 20]} />
              <hemisphereLight args={[c.recorte, escurecer(c.chao, 0.4), 0.55]} />
              {/* a luz principal, quente, de cima e da frente: o que dá volume aos blocos */}
              <spotLight position={[2.2, 5.2, 4.4]} angle={0.5} penumbra={0.8} intensity={120} distance={22} decay={2} color={c.luz} castShadow
                shadow-mapSize-width={2048} shadow-mapSize-height={2048} shadow-bias={-0.0003} shadow-radius={4} />
              {/* o recorte roxo por trás, que separa o boneco do fundo */}
              <spotLight position={[-2.5, 3.6, -2.4]} angle={0.7} penumbra={1} intensity={60} distance={14} decay={2} color={c.recorte} />
              <pointLight position={[0, 2, -2.6]} intensity={16} distance={8} color="#6f7dff" />
              <directionalLight position={[-3, 2, 4]} intensity={0.35} color="#c9d4ff" />
              <ChaoDeLadrilhos c={c} />
              <ParedeDeTelas c={c} paineis={part.paineis} t={t} />
            </>
          ) : <Mundo key={iMundo} m={mundo} t={t} icones={icones} paineis={part.paineis} />}
          {elenco.map((p, i) => (
            <group key={i} position={lugarDe(elenco, i)} rotation={[0, giroDe(elenco, i), 0]}>
              {desenhar(p, estado(i))}
            </group>
          ))}
          {figurantes.map((p, j) => (
            <group key={'f' + j} position={lugarDoFigurante(elenco, p, j, W < H)} rotation={[0, (j % 2 ? -1 : 1) * 0.5, 0]}>
              {desenhar(p, estado(10 + j, true))}
            </group>
          ))}
          {/* Fora do set de telas o céu é claro: o brilho cai, senão o céu inteiro estoura. */}
          <Lente W={W} H={H} foco={cam.foco} abertura={cam.abertura} desfoqueMax={cam.desfoqueMax} brilho={ehTelas ? 0.5 : mundo.cobertura ? 0.5 : mundo.estrelas || mundo.hora === 'noite' ? 0.45 : 0.18} />
        </ThreeCanvas>
        <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse at center, rgba(0,0,0,0) 60%, rgba(0,0,0,0.45) 100%)' }} />
        <Destaque part={part} t={t} W={W} H={H} acento={c.acento} />
        {part.legenda !== false && <Legenda part={part} t={t} W={W} H={H} />}
      </div>
    </div>
  );
}

export const VOXEL = { voxel: VoxelCena };
