{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lathe-object",
  "title": "Lathe Object",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Produto de revolução em WebGL a partir de um perfil de meia silhueta (frasco, vela, pote, difusor com varetas), com giro sozinho, arrasto para orbitar e vento no hover.",
  "dependencies": [
    "three@^0.170.0"
  ],
  "devDependencies": [
    "@types/three@^0.170.0"
  ],
  "registryDependencies": [
    "utils",
    "@visant/css-color"
  ],
  "files": [
    {
      "path": "registry/visant/ui/lathe-object.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport type * as THREE_NS from \"three\";\n\nimport { cn } from \"@/lib/utils\";\nimport { onThemeChange, resolveCssColor } from \"@/lib/css-color\";\n\n/**\n * Lathe Object — um produto de revolução em 3D, a partir de um perfil.\n *\n * Colhido do rodapé de um site de cliente (2026-10), onde um difusor de varetas girava ao\n * lado do fecho. Quase todo objeto de embalagem é um torno: frasco, vela, pote,\n * garrafa, xícara. Um perfil de meia silhueta (`[x, y][]`) revolvido em 64\n * segmentos dá o objeto inteiro, sem modelo baixado, sem GLTF, sem textura.\n *\n * Decisões que sobrevivem à colheita:\n *\n * 1. **O three só chega perto da tela.** O módulo é importado dentro do\n *    observer (200px de margem), não no topo do arquivo: contexto WebGL é\n *    recurso contado pelo navegador, e o chunk do three pesa mais que a página\n *    de rodapé onde a peça costuma morar.\n * 2. **Cor é token, relido a cada tema.** `body` e `detail` aceitam qualquer cor\n *    CSS; o padrão é `--muted-foreground` para o corpo (cinza médio nos dois\n *    temas, que é onde a luz da cena tem gradação para mostrar a forma) e\n *    `--foreground` para as varetas — vareta tem 0.03 de raio, e só o par\n *    foreground/background garante contraste para uma linha tão fina.\n * 3. **A câmera enquadra o objeto, não o contrário.** O grupo é centrado pela\n *    caixa envolvente e a distância sai do FOV, então trocar o perfil (pote\n *    baixo, garrafa alta) não exige reposicionar câmera nenhuma.\n * 4. **Arrastar gira, mas não aproxima nem desloca.** Zoom e pan num objeto de\n *    vitrine só servem para o visitante perdê-lo de vista; o ângulo polar fica\n *    preso para nunca mostrar o fundo chapado do torno.\n * 5. **Vento no hover.** As varetas balançam com fase própria cada uma; o\n *    ponteiro em cima quadruplica a amplitude, e ao sair ela assenta devagar.\n * 6. **Para quando ninguém vê** (fora da tela ou aba escondida). Com movimento\n *    reduzido não há laço nenhum: sem giro, sem vento, e a cena só redesenha\n *    quando o usuário arrasta.\n *\n * Requer `three`.\n */\n\nexport type LatheProfile = [number, number][];\n\n/** O frasco do difusor: base arredondada, ombro, gargalo e tampa. */\nexport const DIFFUSER_PROFILE: LatheProfile = [\n  [0, 0], [0.86, 0], [0.98, 0.06], [1, 0.2], [1, 2.05], [0.96, 2.35], [0.82, 2.58],\n  [0.5, 2.74], [0.4, 2.8], [0.4, 3.02], [0.48, 3.04], [0.5, 3.1], [0.5, 3.36], [0.44, 3.4], [0, 3.4],\n];\n\nexport interface LatheSticks {\n  /** Quantas varetas, em leque a partir do gargalo. */\n  count?: number;\n  /** Comprimento, nas mesmas unidades do perfil. */\n  length?: number;\n}\n\nexport interface LatheObjectProps extends React.HTMLAttributes<HTMLCanvasElement> {\n  /** Meia silhueta, de baixo para cima: `[raio, altura]`. Feche no eixo (`x = 0`) nas pontas. */\n  profile?: LatheProfile;\n  /** Varetas de difusor saindo do topo. Ausente = só o objeto. */\n  sticks?: LatheSticks | boolean;\n  /** Cor do corpo. Qualquer cor CSS ou `var(--token)`. */\n  body?: string;\n  /** Cor das varetas. Qualquer cor CSS ou `var(--token)`. */\n  detail?: string;\n  /** Gira sozinho enquanto ninguém arrasta. Ignorado com movimento reduzido. */\n  autoRotate?: boolean;\n  /** Descrição para leitor de tela. O canvas é `role=\"img\"`. */\n  \"aria-label\"?: string;\n  /**\n   * Mantém o último quadro no buffer. Ligue quando outro efeito lê este canvas\n   * (ex.: `shader-riso` com `source`): sem isso o navegador pode entregar o buffer\n   * já limpo. Custa um pouco de desempenho, por isso vem desligado.\n   */\n  preserveDrawingBuffer?: boolean;\n  /** Recebe o canvas da peça (e `null` ao desmontar), pra usá-lo como fonte de outro efeito. */\n  onCanvas?: (canvas: HTMLCanvasElement | null) => void;\n}\n\ninterface Cena {\n  setColors(body: string, detail: string): void;\n  setRunning(on: boolean): void;\n  setWind(on: boolean): void;\n  dispose(): void;\n}\n\nasync function criarCena(\n  canvas: HTMLCanvasElement,\n  opts: {\n    profile: LatheProfile;\n    sticks: Required<LatheSticks> | null;\n    autoRotate: boolean;\n    reduce: boolean;\n    preserveDrawingBuffer: boolean;\n  },\n): Promise<Cena> {\n  const [THREE, { OrbitControls }] = await Promise.all([\n    import(\"three\"),\n    import(\"three/examples/jsm/controls/OrbitControls.js\"),\n  ]);\n\n  const renderer = new THREE.WebGLRenderer({\n    canvas,\n    antialias: true,\n    alpha: true,\n    preserveDrawingBuffer: opts.preserveDrawingBuffer,\n  });\n  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));\n  renderer.outputColorSpace = THREE.SRGBColorSpace;\n  // Transparente: o fundo é o da página, então a peça vive em qualquer tema.\n  renderer.setClearColor(0, 0);\n\n  const scene = new THREE.Scene();\n  const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 100);\n\n  // Luz neutra (branco e cinza em matemática pura): quem tinge é o material.\n  scene.add(new THREE.HemisphereLight(new THREE.Color(1, 1, 1), new THREE.Color(0.27, 0.27, 0.27), 1.6));\n  const luz = new THREE.DirectionalLight(new THREE.Color(1, 1, 1), 1.8);\n  luz.position.set(4, 6, 5);\n  scene.add(luz);\n\n  const matCorpo = new THREE.MeshStandardMaterial({ roughness: 0.55, metalness: 0 });\n  const matVareta = new THREE.MeshStandardMaterial({ roughness: 0.9 });\n\n  const geoCorpo = new THREE.LatheGeometry(\n    opts.profile.map(([x, y]) => new THREE.Vector2(x, y)),\n    64,\n  );\n  const corpo = new THREE.Mesh(geoCorpo, matCorpo);\n\n  const peca = new THREE.Group();\n  peca.add(corpo);\n\n  // Varetas: leque a partir do gargalo, cada uma com fase própria para o vento.\n  const varetas = new THREE.Group();\n  let geoVareta: THREE_NS.CylinderGeometry | null = null;\n  const fases: number[] = [];\n  if (opts.sticks) {\n    const { count, length } = opts.sticks;\n    const topo = Math.max(...opts.profile.map(([, y]) => y));\n    // O gargalo é o maior raio no alto do perfil (descontado o fecho no eixo).\n    const noTopo = opts.profile.filter(([x, y]) => x > 0 && y > topo - 0.5);\n    const gargalo = noTopo.length ? Math.min(...noTopo.map(([x]) => x)) : 0.4;\n    varetas.position.y = topo - 0.1;\n    geoVareta = new THREE.CylinderGeometry(0.028, 0.028, length, 8);\n    // Pivô na base: a vareta balança presa no gargalo, não pelo meio.\n    geoVareta.translate(0, length / 2, 0);\n    const leque = gargalo * 0.3;\n    for (let i = 0; i < count; i++) {\n      const v = new THREE.Mesh(geoVareta, matVareta);\n      const a = (i / count) * Math.PI * 2;\n      v.position.set(Math.cos(a) * leque, 0, Math.sin(a) * leque);\n      v.rotation.z = Math.cos(a) * 0.16;\n      v.rotation.x = Math.sin(a) * 0.16;\n      v.userData.base = { x: v.rotation.x, z: v.rotation.z };\n      fases.push(i * 0.9);\n      varetas.add(v);\n    }\n    peca.add(varetas);\n  }\n\n  // Centraliza pela caixa e guarda o tamanho para o enquadramento.\n  const caixa = new THREE.Box3().setFromObject(peca);\n  const tamanho = caixa.getSize(new THREE.Vector3());\n  const centro = caixa.getCenter(new THREE.Vector3());\n  peca.position.sub(centro);\n  scene.add(peca);\n\n  const controls = new OrbitControls(camera, canvas);\n  // O OrbitControls escreve `none` ao conectar: de lado gira, em pé rola a página.\n  canvas.style.touchAction = \"pan-y\";\n  controls.enableZoom = false;\n  controls.enablePan = false;\n  controls.enableDamping = !opts.reduce;\n  controls.dampingFactor = 0.06;\n  controls.rotateSpeed = 0.8;\n  controls.minPolarAngle = Math.PI * 0.3;\n  controls.maxPolarAngle = Math.PI * 0.62;\n  const giroLivre = opts.autoRotate && !opts.reduce;\n  controls.autoRotate = giroLivre;\n  controls.autoRotateSpeed = 1.2;\n  const pegar = () => {\n    controls.autoRotate = false;\n  };\n  const soltar = () => {\n    controls.autoRotate = giroLivre;\n  };\n  controls.addEventListener(\"start\", pegar);\n  controls.addEventListener(\"end\", soltar);\n\n  const quadroUnico = () => renderer.render(scene, camera);\n  // Sem laço (movimento reduzido), o arrasto redesenha sob demanda.\n  if (opts.reduce) controls.addEventListener(\"change\", quadroUnico);\n\n  const resize = () => {\n    const w = canvas.clientWidth || 1;\n    const h = canvas.clientHeight || 1;\n    renderer.setSize(w, h, false);\n    camera.aspect = w / h;\n    // Enquadra a ESFERA que envolve a peça, não a caixa vista de frente: a\n    // distância r / sen(meio campo) faz caber em qualquer ângulo da órbita e em\n    // qualquer perfil. As tentativas com folga fixa (4%, 20%, meio raio, um raio)\n    // sempre deixavam um caso encostando: vareta no topo, pote no pé, porque a\n    // câmera vem de cima e a borda da frente cresce na projeção. Visto em\n    // tamanho real na vitrine. O 1.04 final é só respiro.\n    const meiaVert = THREE.MathUtils.degToRad(camera.fov / 2);\n    const meiaHoriz = Math.atan(Math.tan(meiaVert) * camera.aspect);\n    const esfera = tamanho.length() / 2;\n    const dist = (esfera / Math.sin(Math.min(meiaVert, meiaHoriz))) * 1.04;\n    const dir = new THREE.Vector3(0, 0.237, 1).normalize();\n    camera.position.copy(dir.multiplyScalar(dist));\n    camera.updateProjectionMatrix();\n    controls.update();\n    if (!rodando) quadroUnico();\n  };\n  const ro = new ResizeObserver(resize);\n  ro.observe(canvas);\n\n  let rodando = false;\n  let vento = 0;\n  let ventoAlvo = 0.25;\n  const relogio = new THREE.Clock();\n\n  const quadro = () => {\n    const t = relogio.getElapsedTime();\n    vento += (ventoAlvo - vento) * 0.05;\n    varetas.children.forEach((v, i) => {\n      const b = v.userData.base as { x: number; z: number };\n      v.rotation.z = b.z + Math.sin(t * 1.3 + fases[i]) * 0.05 * vento;\n      v.rotation.x = b.x + Math.cos(t * 1.1 + fases[i]) * 0.05 * vento;\n    });\n    controls.update();\n    renderer.render(scene, camera);\n  };\n\n  resize();\n\n  return {\n    setColors: (body, detail) => {\n      matCorpo.color.setRGB(...resolveCssColor(body, canvas), THREE.SRGBColorSpace);\n      matVareta.color.setRGB(...resolveCssColor(detail, canvas), THREE.SRGBColorSpace);\n      if (!rodando) quadroUnico();\n    },\n    setRunning: (on) => {\n      const deve = on && !opts.reduce;\n      if (deve === rodando) return;\n      rodando = deve;\n      renderer.setAnimationLoop(deve ? quadro : null);\n      if (!deve) quadroUnico();\n    },\n    setWind: (on) => {\n      ventoAlvo = on ? 1 : 0.25;\n    },\n    dispose: () => {\n      rodando = false;\n      renderer.setAnimationLoop(null);\n      ro.disconnect();\n      controls.removeEventListener(\"start\", pegar);\n      controls.removeEventListener(\"end\", soltar);\n      controls.removeEventListener(\"change\", quadroUnico);\n      controls.dispose();\n      geoCorpo.dispose();\n      geoVareta?.dispose();\n      matCorpo.dispose();\n      matVareta.dispose();\n      renderer.dispose();\n    },\n  };\n}\n\nexport function LatheObject({\n  profile = DIFFUSER_PROFILE,\n  sticks,\n  body = \"var(--muted-foreground)\",\n  detail = \"var(--foreground)\",\n  autoRotate = true,\n  \"aria-label\": ariaLabel = \"Objeto em 3D, arraste para girar\",\n  preserveDrawingBuffer = false,\n  onCanvas,\n  className,\n  onPointerEnter,\n  onPointerLeave,\n  ...props\n}: LatheObjectProps) {\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const cenaRef = React.useRef<Cena | null>(null);\n  const coresRef = React.useRef({ body, detail });\n  coresRef.current = { body, detail };\n\n  // Geometria entra por valor: um `profile` literal no JSX muda de identidade a\n  // cada render, e recriar o renderer por isso derrubaria o contexto WebGL.\n  const chave = JSON.stringify({ profile, sticks, autoRotate, preserveDrawingBuffer });\n  const onCanvasRef = React.useRef(onCanvas);\n  onCanvasRef.current = onCanvas;\n  React.useEffect(() => {\n    onCanvasRef.current?.(canvasRef.current);\n    return () => onCanvasRef.current?.(null);\n  }, []);\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const cfg = JSON.parse(chave) as {\n      profile: LatheProfile;\n      sticks?: LatheSticks | boolean;\n      autoRotate: boolean;\n      preserveDrawingBuffer: boolean;\n    };\n    const st = cfg.sticks\n      ? { count: 7, length: 3.2, ...(cfg.sticks === true ? {} : cfg.sticks) }\n      : null;\n    const reduce = matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    let cena: Cena | null = null;\n    let criando = false;\n    let vivo = true;\n    let naTela = false;\n    const aplicar = () => cena?.setRunning(naTela && !document.hidden);\n\n    const io = new IntersectionObserver(\n      async ([e]) => {\n        naTela = e.isIntersecting;\n        if (naTela && !cena && !criando) {\n          criando = true;\n          const nova = await criarCena(canvas, {\n            profile: cfg.profile,\n            sticks: st,\n            autoRotate: cfg.autoRotate,\n            reduce,\n            preserveDrawingBuffer: cfg.preserveDrawingBuffer,\n          });\n          // Desmontou enquanto o three baixava: descarta em vez de vazar o contexto.\n          if (!vivo) return nova.dispose();\n          cena = nova;\n          cenaRef.current = nova;\n          nova.setColors(coresRef.current.body, coresRef.current.detail);\n        }\n        aplicar();\n      },\n      { rootMargin: \"200px 0px\" },\n    );\n    io.observe(canvas);\n    document.addEventListener(\"visibilitychange\", aplicar);\n    const pararTema = onThemeChange(() =>\n      cena?.setColors(coresRef.current.body, coresRef.current.detail),\n    );\n\n    return () => {\n      vivo = false;\n      io.disconnect();\n      pararTema();\n      document.removeEventListener(\"visibilitychange\", aplicar);\n      cena?.dispose();\n      cenaRef.current = null;\n    };\n  }, [chave]);\n\n  React.useEffect(() => {\n    cenaRef.current?.setColors(body, detail);\n  }, [body, detail]);\n\n  return (\n    <canvas\n      ref={canvasRef}\n      role=\"img\"\n      aria-label={ariaLabel}\n      onPointerEnter={(e) => {\n        onPointerEnter?.(e);\n        cenaRef.current?.setWind(true);\n      }}\n      onPointerLeave={(e) => {\n        onPointerLeave?.(e);\n        cenaRef.current?.setWind(false);\n      }}\n      // `touch-pan-y`: de lado gira, em pé a página continua rolando.\n      className={cn(\"block h-full w-full cursor-grab touch-pan-y active:cursor-grabbing\", className)}\n      {...props}\n    />\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Mostrar produto de revolução, como frasco, vela ou pote, em 3D que gira e orbita no arrasto",
      "evite": "Livro ou catálogo: book-3d; produto com foto real, plate ou tilt-card"
    }
  },
  "categories": [
    "conteudo",
    "3d",
    "webgl",
    "produto",
    "embalagem",
    "objeto",
    "difusor"
  ],
  "type": "registry:ui"
}