Laboratório ao vivo "robo-cena-3d" do curso Frontends com Vibecoding (capítulo bônus). Código-fonte completo, copiado de src/components/course/blocks/live-labs/ por scripts/export-fvc-labs-txt.mjs. Licença de uso: a mesma do repositório do site. Cada arquivo abaixo começa com uma linha '==== nome ===='. ==== RoboCena3DLab.tsx ==== "use client"; import { useCallback, useEffect, useId, useRef, useState, type ComponentType } from "react"; import type { LiveLabMeta, LiveLabProps } from "./registry"; import type { EstadoCena, FaseCiclo, PresetCamera, RoboCena3DSceneProps } from "./RoboCena3DScene"; import styles from "./RoboCena3D.module.css"; /** * Laboratório "robo-cena-3d": um braço robótico simulado, com física e cinemática * inversa, dentro da página, e o botão de entrar em realidade virtual quando o * aparelho oferece a sessão. * * Este módulo é LEVE de propósito: não importa three, fiber, drei, rapier nem xr. * A cena inteira vive em `RoboCena3DScene.tsx` e só é importada quando a pessoa * aperta "Carregar a cena 3D". Até lá a página mostra uma figura estática em SVG, * pintada por token do tema, que explica o que a cena faz. É a mesma regra da * escada do movimento: peso desce só depois de um pedido explícito. * * Regras da casa: sem WebGL a figura fica e um aviso explica; com movimento * reduzido a cena carrega parada e avança um ciclo por botão; todo controle tem * 44 px de alvo e foco visível; cor só por token; nenhuma rede a terceiro. */ type Estagio = "inicial" | "sem-webgl" | "carregando" | "pronta" | "erro"; interface ModuloCena { default: ComponentType; entrarVR: () => Promise; } type NavegadorComXR = Navigator & { xr?: { isSessionSupported(modo: string): Promise }; }; const FASES: Record = { parado: "parado, aguardando o primeiro ciclo", aproximar: "aproximar da fila de cubos", descer: "descer até o cubo", pegar: "pegar o cubo", erguer: "erguer o cubo", levar: "levar até a pilha", soltar: "soltar sobre a pilha", voltar: "voltar à altura de passeio", concluido: "pilha completa", }; const VELOCIDADES = [0.5, 0.75, 1, 1.5, 2] as const; function temWebGL(): boolean { try { const canvas = document.createElement("canvas"); return Boolean(canvas.getContext("webgl2") || canvas.getContext("webgl")); } catch { return false; } } const grau = (v: number) => `${v.toLocaleString("pt-BR", { minimumFractionDigits: 1, maximumFractionDigits: 1 })}°`; function FiguraEstatica({ titulo }: { titulo: string }) { return ( /* viewBox 16:9 de 400 unidades: em contêiner de 320 a 600 px o texto de 13 e 14 unidades fica entre 10 e 21 px na tela, e a linha mais longa (49 caracteres a 13) mede cerca de 385 unidades, dentro da largura. O desenho é a versão de 320 unidades escalada em 1,25 e deslocada para baixo das duas linhas de texto. */ {titulo} {"Braço de três juntas, fila e pilha de cubos"} {"A cena 3D com física entra no lugar desta figura."} {/* bancada */} {/* pilha de destino */} {/* fila de origem */} {/* base do braço */} {/* elo 1 */} {/* elo 2 */} {/* juntas */} {/* garra e cubo em trânsito */} ); } export default function RoboCena3DLab(_props: LiveLabProps) { const uid = useId(); const [estagio, setEstagio] = useState("inicial"); const [Cena, setCena] = useState | null>(null); const entrarVRRef = useRef<(() => Promise) | null>(null); const [reduzido, setReduzido] = useState(false); const [xrSuportado, setXrSuportado] = useState(null); const [emVR, setEmVR] = useState(false); const [rodando, setRodando] = useState(false); const [avancando, setAvancando] = useState(false); const [passo, setPasso] = useState(0); const [reinicio, setReinicio] = useState(0); const [velocidade, setVelocidade] = useState(1); const [mostrarAngulos, setMostrarAngulos] = useState(false); const [camera, setCamera] = useState("frente"); const [estado, setEstado] = useState({ fase: "parado", empilhados: 0, angulos: { base: 0, ombro: 0, cotovelo: 0 }, }); useEffect(() => { const consulta = window.matchMedia("(prefers-reduced-motion: reduce)"); const aplicar = () => setReduzido(consulta.matches); aplicar(); consulta.addEventListener("change", aplicar); return () => consulta.removeEventListener("change", aplicar); }, []); useEffect(() => { let ativo = true; const xr = (navigator as NavegadorComXR).xr; if (!xr) { setXrSuportado(false); return; } xr.isSessionSupported("immersive-vr") .then((ok) => { if (ativo) setXrSuportado(ok); }) .catch(() => { if (ativo) setXrSuportado(false); }); return () => { ativo = false; }; }, []); const carregar = () => { if (!temWebGL()) { setEstagio("sem-webgl"); return; } setEstagio("carregando"); import("./RoboCena3DScene") .then((m: ModuloCena) => { setCena(() => m.default); entrarVRRef.current = m.entrarVR; setEstagio("pronta"); }) .catch(() => setEstagio("erro")); }; const aoEstado = useCallback((e: EstadoCena) => setEstado(e), []); const aoCicloConcluido = useCallback(() => setAvancando(false), []); const pedirCiclo = () => { setPasso((n) => n + 1); setAvancando(true); }; const reiniciar = () => { setRodando(false); setAvancando(false); setReinicio((n) => n + 1); }; const entrarVR = () => { const fn = entrarVRRef.current; if (!fn) return; fn() .then((sessaoXR) => { if (!sessaoXR) return; setEmVR(true); setRodando(true); sessaoXR.addEventListener("end", () => setEmVR(false)); }) .catch(() => setEmVR(false)); }; const leitura = `Fase: ${FASES[estado.fase]}. Cubos empilhados: ${estado.empilhados} de 4.${ rodando ? " Ciclo contínuo em andamento." : avancando ? " Avançando um ciclo." : "" }`; return (
{"Cena 3D com física"}

{ "O braço, a física e a realidade virtual pesam cerca de 2,5 MB de código. Nada disso desce antes do seu pedido: a figura abaixo explica a cena, e o botão traz a cena de verdade." }

{reduzido ? "Movimento reduzido ativo no seu aparelho: a cena carrega parada e avança um ciclo por vez, pelo botão. A troca de câmera é imediata." : "Movimento completo ativo. Com movimento reduzido no sistema, a cena carrega parada e avança um ciclo por vez."}

{estagio === "pronta" && Cena ? ( ) : ( )}
{estagio !== "pronta" ? (

{estagio === "sem-webgl" ? "Este navegador não oferece WebGL, a base do desenho 3D. A figura estática fica no lugar da cena." : estagio === "erro" ? "A cena não pôde ser carregada neste navegador. A figura estática descreve o que ela mostra." : estagio === "carregando" ? "Baixando o desenho 3D e o motor de física." : "Sem o botão, esta página não gasta a sua banda com a cena."}

) : ( <>
{!reduzido ? ( ) : null}
{"Câmera:"} {(["frente", "cima", "lado"] as PresetCamera[]).map((p) => ( ))}

{leitura}

{mostrarAngulos ? (
{"Base"}
{grau(estado.angulos.base)}
{"Ombro"}
{grau(estado.angulos.ombro)}
{"Cotovelo"}
{grau(estado.angulos.cotovelo)}
) : null}
{xrSuportado ? ( ) : (

{ "Este aparelho não oferece sessão imersiva. A cena continua navegável com o dedo ou o mouse; num visor compatível, o botão de entrar aparece aqui." }

)}
)}
); } export const meta: LiveLabMeta = { biblioteca: "Three.js com React Three Fiber, drei, Rapier e XR", pacote: "three, @react-three/fiber, @react-three/drei, @react-three/rapier, @react-three/xr", versao: "^0.184.0, ^9.6.1, ^10.7.7, ^2.2.0 e ^6.6.30", docsUrl: "https://r3f.docs.pmnd.rs/", npmUrl: "https://www.npmjs.com/package/@react-three/fiber", trechoLinguagem: "tsx", trecho: `import { Canvas, useFrame } from "@react-three/fiber"; import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier"; import { XR, createXRStore } from "@react-three/xr"; const store = createXRStore(); // botão: store.enterVR() function Braco({ alvo }) { // dois elos: L1 = 1,0 m e L2 = 0,8 m useFrame(() => { const r = Math.hypot(alvo.x, alvo.z), h = alvo.y + 0.25 - 0.45; const d = Math.min(1.78, Math.hypot(r, h)); const cotovelo = Math.acos((1 + 0.64 - d * d) / (2 * 1 * 0.8)); const ombro = Math.atan2(h, r) + Math.acos((1 + d * d - 0.64) / (2 * d)); base.rotation.y = Math.atan2(alvo.x, alvo.z); junta1.rotation.x = -ombro; junta2.rotation.x = Math.PI - cotovelo; }); return {/* elos, garra */}; } `, observar: [ "O peso da cena só desce depois do botão: até lá a página entrega uma figura em SVG e nenhum byte de three ou de física.", "Pausar, esconder a aba ou rolar a cena para fora da tela para o laço de quadros, e a física para junto com ele.", "O botão de entrar em realidade virtual só existe quando o aparelho responde que oferece a sessão imersiva; no resto, a cena segue navegável com o dedo ou o mouse.", "Com movimento reduzido no sistema, a cena carrega parada e cada ciclo de pegar e soltar acontece por um botão, sem troca de câmera animada.", ], }; ==== RoboCena3DScene.tsx ==== "use client"; /* RoboCena3DScene: a CENA pesada do laboratório "robo-cena-3d" (three, @react-three/fiber, drei, @react-three/rapier e @react-three/xr). Vive em arquivo separado de propósito: o invólucro `RoboCena3DLab` só faz `import("./RoboCena3DScene")` quando a pessoa aperta o botão de carregar, então nada disto (nem o WASM da física, cerca de 1,4 MB) desce antes do pedido. Orçamento aplicado aqui dentro: - Geometria procedural: base, dois elos, garra e quatro cubos. Nenhum .glb. - `dpr={[1, 1.5]}`, sem sombras dinâmicas, fundo transparente (alpha) para o fundo da página aparecer nos dois temas. - O laço de quadros só fica em "always" enquanto o ciclo roda, a aba está visível e o canvas está na tela; fora disso volta a "demand" e a física para junto com o quadro (`paused`). Em sessão XR o laço fica ligado. - A cinemática do braço é inversa e analítica (dois elos, lei dos cossenos); o cubo carregado vira corpo cinemático e segue a garra; ao soltar volta a dinâmico e cai na pilha pela gravidade do Rapier. Cores: hex equivalentes dos tokens do site, porque material WebGL não resolve `var(--...)` nem `oklch()`: acento #0070ca, acento claro #1b96ff, sucesso #2e844a, grafite #1f2937, coral #f2635b (cubo em trânsito) e off-white #f7f7f5 (bancada). */ import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { Canvas, useFrame, useThree } from "@react-three/fiber"; import { OrbitControls } from "@react-three/drei"; import { CuboidCollider, Physics, RigidBody, useRapier, type RapierRigidBody } from "@react-three/rapier"; import { XR, createXRStore, useXR, type XRStore } from "@react-three/xr"; import * as THREE from "three"; import type { OrbitControls as OrbitControlsImpl } from "three-stdlib"; export type PresetCamera = "frente" | "cima" | "lado"; export type FaseCiclo = "parado" | "aproximar" | "descer" | "pegar" | "erguer" | "levar" | "soltar" | "voltar" | "concluido"; export interface AngulosJuntas { base: number; ombro: number; cotovelo: number; } export interface EstadoCena { fase: FaseCiclo; empilhados: number; angulos: AngulosJuntas; } export interface RoboCena3DSceneProps { /** Ciclo contínuo (botão Iniciar). */ rodando: boolean; /** Um ciclo pedido pelo botão Avançar; o invólucro zera ao receber onCicloConcluido. */ avancando: boolean; /** Contador: cada incremento pede um ciclo a mais. */ passo: number; /** Contador: cada incremento devolve os cubos à origem. */ reinicio: number; /** 0,5 a 2: multiplica o relógio do ciclo. */ velocidade: number; camera: PresetCamera; /** Movimento reduzido: troca de câmera imediata, sem transição. */ reduzido: boolean; onEstado: (estado: EstadoCena) => void; onCicloConcluido: () => void; } const COR = { acento: "#0070ca", acentoClaro: "#1b96ff", sucesso: "#2e844a", grafite: "#1f2937", coral: "#f2635b", bancada: "#f7f7f5", }; /* Medidas do braço, em metros da cena. */ const OMBRO_Y = 0.45; const L1 = 1.0; const L2 = 0.8; const GARRA = 0.25; const ALCANCE_MAX = L1 + L2 - 0.02; const ALCANCE_MIN = Math.abs(L1 - L2) + 0.05; const LADO_CUBO = 0.3; const N_CUBOS = 4; /* Origem A: fila de quatro cubos na frente da bancada. Destino B: pilha à direita. */ const ORIGENS: [number, number, number][] = Array.from({ length: N_CUBOS }, (_, i) => [-0.75 + 0.5 * i, LADO_CUBO / 2, 1.1]); const DESTINO: [number, number] = [1.1, -0.6]; const ALTURA_PASSEIO = 0.95; const CAMERAS: Record = { frente: [0, 1.6, 4.2], cima: [0.01, 5.2, 0.6], lado: [4.4, 1.5, 0.2], }; const POSICAO_INICIAL: [number, number, number] = [3.2, 2.4, 3.6]; const ALVO_CAMERA = new THREE.Vector3(0.2, 0.4, 0.2); /* Store do XR criado uma vez, no cliente (este módulo só carrega depois do botão). `emulate: false` desliga o emulador de visor que o @pmndrs/xr injeta em localhost: ele traz @iwer/devui e @iwer/sem com uma segunda cópia do three (0.165), e duas cópias executadas disparam o aviso "Multiple instances of Three.js" no console. */ let store: XRStore | null = null; export function obterStore(): XRStore { if (!store) store = createXRStore({ hand: false, controller: true, emulate: false }); return store; } export function entrarVR(): Promise { return obterStore().enterVR(); } interface Segmento { fase: FaseCiclo; de: THREE.Vector3; para: THREE.Vector3; duracao: number; aoTerminar?: "pegar" | "soltar"; } const suavizar = (t: number) => t * t * (3 - 2 * t); /** Cinemática inversa de dois elos: ângulos para a garra (ponta) chegar em `ponta`. */ function resolverIK(ponta: THREE.Vector3): AngulosJuntas & { alcancavel: boolean } { const wx = ponta.x; const wz = ponta.z; const wy = ponta.y + GARRA - OMBRO_Y; const base = Math.atan2(wx, wz); const r = Math.hypot(wx, wz); const dBruto = Math.hypot(r, wy); const d = Math.min(ALCANCE_MAX, Math.max(ALCANCE_MIN, dBruto)); const cosCotovelo = (L1 * L1 + L2 * L2 - d * d) / (2 * L1 * L2); const interior = Math.acos(Math.min(1, Math.max(-1, cosCotovelo))); const alfa = Math.atan2(wy, r); const cosBeta = (L1 * L1 + d * d - L2 * L2) / (2 * L1 * d); const beta = Math.acos(Math.min(1, Math.max(-1, cosBeta))); return { base, ombro: alfa + beta, cotovelo: interior, alcancavel: dBruto === d }; } /* Controla o laço de quadros conforme atividade, visibilidade da aba e posição do canvas na tela. Em "demand", pede um último quadro para a cena não sumir. */ function ControleDeQuadro({ ativo }: { ativo: boolean }) { const setFrameloop = useThree((s) => s.setFrameloop); const invalidate = useThree((s) => s.invalidate); const gl = useThree((s) => s.gl); const sessaoXR = useXR((s) => s.session); useEffect(() => { let visivel = document.visibilityState === "visible"; let naTela = true; const aplicar = () => { const rodar = sessaoXR != null || (ativo && visivel && naTela); setFrameloop(rodar ? "always" : "demand"); if (!rodar) invalidate(); }; const aoMudarVisibilidade = () => { visivel = document.visibilityState === "visible"; aplicar(); }; document.addEventListener("visibilitychange", aoMudarVisibilidade); const io = new IntersectionObserver( (entradas) => { naTela = entradas.some((e) => e.isIntersecting); aplicar(); }, { threshold: 0 }, ); io.observe(gl.domElement); aplicar(); return () => { document.removeEventListener("visibilitychange", aoMudarVisibilidade); io.disconnect(); }; }, [ativo, sessaoXR, gl, invalidate, setFrameloop]); return null; } /* Troca de câmera por preset: transição curta, ou imediata com movimento reduzido. */ function Camera({ preset, reduzido }: { preset: PresetCamera; reduzido: boolean }) { const camera = useThree((s) => s.camera); const controles = useThree((s) => s.controls) as OrbitControlsImpl | null; const invalidate = useThree((s) => s.invalidate); const destino = useRef(new THREE.Vector3(...POSICAO_INICIAL)); const origem = useRef(new THREE.Vector3(...POSICAO_INICIAL)); const progresso = useRef(1); const primeiro = useRef(true); useEffect(() => { if (primeiro.current) { primeiro.current = false; return; } origem.current.copy(camera.position); destino.current.set(...CAMERAS[preset]); progresso.current = reduzido ? 1 : 0; if (reduzido) { camera.position.copy(destino.current); camera.lookAt(ALVO_CAMERA); controles?.update(); } invalidate(); }, [preset, reduzido, camera, controles, invalidate]); useFrame((_, delta) => { if (progresso.current >= 1) return; progresso.current = Math.min(1, progresso.current + delta / 0.6); camera.position.lerpVectors(origem.current, destino.current, suavizar(progresso.current)); camera.lookAt(ALVO_CAMERA); controles?.update(); invalidate(); }); return null; } interface CuboProps { indice: number; emTransito: boolean; registrar: (indice: number, corpo: RapierRigidBody | null) => void; } function Cubo({ indice, emTransito, registrar }: CuboProps) { return ( registrar(indice, corpo)} colliders="cuboid" position={ORIGENS[indice]} friction={1} restitution={0} linearDamping={0.2} angularDamping={0.4} > ); } interface BracoProps { ativo: boolean; rodando: boolean; passo: number; reinicio: number; velocidade: number; onEstado: (estado: EstadoCena) => void; onCicloConcluido: () => void; } function Braco({ ativo, rodando, passo, reinicio, velocidade, onEstado, onCicloConcluido }: BracoProps) { const { rapier } = useRapier(); const invalidate = useThree((s) => s.invalidate); const baseRef = useRef(null); const ombroRef = useRef(null); const cotoveloRef = useRef(null); const pulsoRef = useRef(null); const corpos = useRef<(RapierRigidBody | null)[]>(Array(N_CUBOS).fill(null)); const [emTransito, setEmTransito] = useState(null); const ponta = useRef(new THREE.Vector3(0.6, ALTURA_PASSEIO, 1.1)); const fila = useRef([]); const tempo = useRef(0); const proximoCubo = useRef(0); const empilhados = useRef(0); const carregando = useRef(null); const cicloPendente = useRef(false); const cicloAberto = useRef(false); const esperaFinal = useRef(0); const fase = useRef("parado"); const ultimoRelato = useRef(0); const passoVisto = useRef(passo); const reinicioVisto = useRef(reinicio); const ativoRef = useRef(ativo); const rodandoRef = useRef(rodando); const velocidadeRef = useRef(velocidade); const onEstadoRef = useRef(onEstado); const onCicloRef = useRef(onCicloConcluido); ativoRef.current = ativo; rodandoRef.current = rodando; velocidadeRef.current = velocidade; onEstadoRef.current = onEstado; onCicloRef.current = onCicloConcluido; const registrar = useMemo( () => (indice: number, corpo: RapierRigidBody | null) => { corpos.current[indice] = corpo; }, [], ); const relatar = (forcar: boolean, agora: number) => { if (!forcar && agora - ultimoRelato.current < 0.1) return; ultimoRelato.current = agora; const ik = resolverIK(ponta.current); onEstadoRef.current({ fase: fase.current, empilhados: empilhados.current, angulos: { base: THREE.MathUtils.radToDeg(ik.base), ombro: THREE.MathUtils.radToDeg(ik.ombro), cotovelo: THREE.MathUtils.radToDeg(ik.cotovelo), }, }); }; const montarCiclo = (indice: number, jaEmpilhados: number): Segmento[] => { const [ax, , az] = ORIGENS[indice]; const [bx, bz] = DESTINO; const topoPilha = LADO_CUBO * jaEmpilhados; const sobreA = new THREE.Vector3(ax, ALTURA_PASSEIO, az); const noCuboA = new THREE.Vector3(ax, LADO_CUBO, az); /* 0,45 acima do topo da pilha mantém o alvo dentro do alcance (d < 1,78 m) mesmo no quarto cubo. */ const sobreB = new THREE.Vector3(bx, Math.max(ALTURA_PASSEIO, topoPilha + 0.45), bz); const soltarB = new THREE.Vector3(bx, topoPilha + LADO_CUBO + 0.04, bz); const inicio = ponta.current.clone(); return [ { fase: "aproximar", de: inicio, para: sobreA, duracao: 1.2 }, { fase: "descer", de: sobreA, para: noCuboA, duracao: 0.6 }, { fase: "pegar", de: noCuboA, para: noCuboA, duracao: 0.35, aoTerminar: "pegar" }, { fase: "erguer", de: noCuboA, para: sobreA, duracao: 0.6 }, { fase: "levar", de: sobreA, para: sobreB, duracao: 1.3 }, { fase: "soltar", de: sobreB, para: soltarB, duracao: 0.7, aoTerminar: "soltar" }, { fase: "voltar", de: soltarB, para: sobreB, duracao: 0.6 }, ]; }; const reposicionar = () => { corpos.current.forEach((corpo, i) => { if (!corpo) return; corpo.setBodyType(rapier.RigidBodyType.Dynamic, true); const [x, y, z] = ORIGENS[i]; corpo.setTranslation({ x, y, z }, true); corpo.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true); corpo.setLinvel({ x: 0, y: 0, z: 0 }, true); corpo.setAngvel({ x: 0, y: 0, z: 0 }, true); }); carregando.current = null; setEmTransito(null); proximoCubo.current = 0; empilhados.current = 0; fila.current = []; tempo.current = 0; esperaFinal.current = 0; fase.current = "parado"; ponta.current.set(0.6, ALTURA_PASSEIO, 1.1); }; useEffect(() => { if (passo !== passoVisto.current) { passoVisto.current = passo; cicloPendente.current = true; invalidate(); } }, [passo, invalidate]); useEffect(() => { if (reinicio !== reinicioVisto.current) { reinicioVisto.current = reinicio; reposicionar(); relatar(true, 0); invalidate(); } // reposicionar e relatar leem só refs; recriá-los a cada render não muda o efeito. // eslint-disable-next-line react-hooks/exhaustive-deps }, [reinicio, invalidate]); useFrame((estado, delta) => { const agora = estado.clock.getElapsedTime(); const querAndar = rodandoRef.current || cicloPendente.current || cicloAberto.current; if (ativoRef.current && querAndar) { const dt = Math.min(delta, 0.05) * velocidadeRef.current; if (fila.current.length === 0) { if (empilhados.current >= N_CUBOS) { /* Pilha completa: em ciclo contínuo espera um pouco e recomeça; por passo, o próximo pedido recomeça. */ fase.current = "concluido"; esperaFinal.current += dt; if (rodandoRef.current && esperaFinal.current > 1.5) reposicionar(); else if (!rodandoRef.current && cicloPendente.current) reposicionar(); else { if (cicloAberto.current) { cicloAberto.current = false; onCicloRef.current(); } cicloPendente.current = false; relatar(false, agora); return; } } fila.current = montarCiclo(proximoCubo.current, empilhados.current); tempo.current = 0; cicloAberto.current = true; cicloPendente.current = false; } tempo.current += dt; const seg = fila.current[0]; const t = Math.min(1, tempo.current / seg.duracao); fase.current = seg.fase; ponta.current.lerpVectors(seg.de, seg.para, suavizar(t)); if (t >= 1) { if (seg.aoTerminar === "pegar") { const corpo = corpos.current[proximoCubo.current]; if (corpo) { corpo.setBodyType(rapier.RigidBodyType.KinematicPositionBased, true); carregando.current = proximoCubo.current; setEmTransito(proximoCubo.current); } } else if (seg.aoTerminar === "soltar") { const i = carregando.current; const corpo = i === null ? null : corpos.current[i]; if (corpo) { corpo.setBodyType(rapier.RigidBodyType.Dynamic, true); corpo.setLinvel({ x: 0, y: 0, z: 0 }, true); corpo.setAngvel({ x: 0, y: 0, z: 0 }, true); } carregando.current = null; setEmTransito(null); empilhados.current += 1; proximoCubo.current += 1; } fila.current.shift(); tempo.current = 0; if (fila.current.length === 0) { cicloAberto.current = false; if (!rodandoRef.current) onCicloRef.current(); if (empilhados.current >= N_CUBOS) fase.current = "concluido"; } relatar(true, agora); } else { relatar(false, agora); } } /* Pose do braço pela cinemática inversa, em todo quadro desenhado. */ const ik = resolverIK(ponta.current); if (baseRef.current) baseRef.current.rotation.y = ik.base; if (ombroRef.current) ombroRef.current.rotation.x = -ik.ombro; if (cotoveloRef.current) cotoveloRef.current.rotation.x = Math.PI - ik.cotovelo; if (pulsoRef.current) pulsoRef.current.rotation.x = ik.ombro - (Math.PI - ik.cotovelo); /* O cubo carregado é cinemático e acompanha a garra. */ const i = carregando.current; if (i !== null) { const corpo = corpos.current[i]; if (corpo) { corpo.setNextKinematicTranslation({ x: ponta.current.x, y: ponta.current.y - LADO_CUBO / 2, z: ponta.current.z }); corpo.setNextKinematicRotation({ x: 0, y: 0, z: 0, w: 1 }); } } }); return ( <> {/* Bancada: visual e colisor fixo. */} {/* Marca do destino da pilha. */} {/* Braço: base gira em Y; ombro e cotovelo giram em X no plano do braço. */} {ORIGENS.map((_, i) => ( ))} ); } export default function RoboCena3DScene({ rodando, avancando, passo, reinicio, velocidade, camera, reduzido, onEstado, onCicloConcluido, }: RoboCena3DSceneProps) { const ativo = rodando || avancando; const xrStore = obterStore(); return ( ); } ==== RoboCena3D.module.css ==== /* Laboratório robo-cena-3d (curso Frontends com Vibecoding). Cor só por token do tema: contraste AA nos temas claro e escuro sem cor fixa. Alvo de toque de 44 px em todo controle e foco visível pelo acento do curso. */ .lab { display: grid; gap: 1rem; color: var(--text); container-type: inline-size; } .topo { display: grid; gap: 0.5rem; } .degrau { display: inline-flex; align-items: center; width: fit-content; min-height: 28px; padding: 0 0.7rem; border: 1px solid var(--course-accent, var(--accent)); border-radius: 999px; background: var(--bg-white); color: var(--course-accent, var(--accent)); font-size: 13px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; } .regra { margin: 0; font-size: 14px; line-height: 1.55; color: var(--text); } .modo { margin: 0; padding: 0.6rem 0.8rem; border-left: 3px solid var(--warning); border-radius: var(--radius, 8px); background: var(--bg-muted); font-size: 13px; line-height: 1.5; color: var(--text); } .modo[data-reduzido="true"] { border-left-color: var(--success); } /* Palco da cena: proporção fixa para não deslocar a página quando a cena chega. */ .palco { position: relative; width: 100%; aspect-ratio: 4 / 3; min-height: 260px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-muted); } @media (min-width: 640px) { .palco { aspect-ratio: 16 / 9; } } .palco[data-estagio="carregando"] { opacity: 0.8; } .figura { display: block; width: 100%; height: 100%; font-family: inherit; } .controles { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; } .rotulo { font-size: 13px; font-weight: 700; color: var(--text); } .botao, .primario { min-height: 44px; min-width: 44px; padding: 0 1rem; border-radius: var(--radius, 8px); font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; touch-action: manipulation; } .botao { border: 1px solid var(--border); background: var(--bg-white); color: var(--text); } .botao:hover { border-color: var(--course-accent, var(--accent)); } .botao[aria-pressed="true"] { border-color: var(--course-accent, var(--accent)); box-shadow: inset 0 0 0 1px var(--course-accent, var(--accent)); } .primario { border: 0; background: var(--course-accent, var(--accent)); color: var(--course-accent-fg, var(--bg-white)); font-weight: 700; } .botao:disabled, .primario:disabled { cursor: not-allowed; opacity: 0.55; } .botao:focus-visible, .primario:focus-visible, .faixa input:focus-visible, .alternador input:focus-visible { outline: 2px solid var(--course-accent, var(--accent)); outline-offset: 2px; } .faixa { display: grid; gap: 0.3rem; flex: 1 1 200px; font-size: 13px; font-weight: 600; color: var(--text); } .faixa input { width: 100%; min-height: 44px; margin: 0; accent-color: var(--course-accent, var(--accent)); cursor: pointer; } .alternador { display: inline-flex; align-items: center; gap: 0.5rem; min-height: 44px; padding: 0 0.4rem; font-size: 14px; color: var(--text); cursor: pointer; } .alternador input { width: 22px; height: 22px; margin: 0; accent-color: var(--course-accent, var(--accent)); cursor: pointer; } .status, .aviso, .nota { margin: 0; font-size: 13px; line-height: 1.5; } .status { padding: 0.55rem 0.8rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); color: var(--text); font-variant-numeric: tabular-nums; } .aviso { color: var(--text); flex: 1 1 220px; } .nota { color: var(--text-muted); } .angulos { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.5rem; margin: 0; } .angulos div { padding: 0.5rem 0.7rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-muted); } .angulos dt { font-size: 13px; color: var(--text-muted); } .angulos dd { margin: 0; font-size: 15px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }