diff --git a/src/lab/QuestCubeLab.tsx b/src/lab/QuestCubeLab.tsx index 58c57f1..6259d38 100644 --- a/src/lab/QuestCubeLab.tsx +++ b/src/lab/QuestCubeLab.tsx @@ -14,37 +14,32 @@ import { Lock, Unlock, RotateCcw, - Box, - Gamepad2, Hand, } from "lucide-react"; /** * ============================================================ - * QUEST CUBE LAB — Fase 2 - * - Cubo 3D real (Three.js via @react-three/fiber) - * - Grid fino no plano y=0 (Drei ) - * - Toggle Travar/Destravar (upgrades decididos pelo Marcos) - * - Joystick funcional (gamepad API) — analógico esq translação, dir rotação, - * triggers LB/RB escala, A reseta cubo - * - OrbitControls pra teste em PC (amigo pode testar com mouse antes de VR) - * - WebXR entry button: suportado se browser for MetaQuest Browser + * QUEST CUBE LAB — Fase 3 e 4 + * - Cubo 3D real + Mesa de dois níveis + * - Sistema de alinhamento por clique duplo: + * 1. Seleciona face do cubo + * 2. Seleciona superfície da mesa (tampo ou prateleira) -> cubo deita na superfície + * 3. Seleciona outra face do cubo (ortogonal à primeira) + * 4. Seleciona aresta (lateral/frente) da mesa -> cubo rotaciona e alinha face * ============================================================ */ -// Limiares analógicos (deadzone do joystick) const STICK_DEADZONE = 0.12; const TRIGGER_DEADZONE = 0.05; -const MOVE_SPEED = 1.5; // m/s lógico -const ROT_SPEED = 1.2; // rad/s +const MOVE_SPEED = 1.5; +const ROT_SPEED = 1.2; const SCALE_SPEED = 0.6; - -// Dimensão inicial do cubo (1m x 1m x 1m) const CUBE_SIZE = 1; +type AlignPhase = 'IDLE' | 'WAIT_SURFACE' | 'WAIT_FACE2' | 'WAIT_EDGE'; + // =================================================================== // COMPONENTE: CuboControlado -// Recebe refs compartilhadas pro estado, atualiza a cada frame. // =================================================================== const CubeControlado = ({ posRef, @@ -52,73 +47,64 @@ const CubeControlado = ({ scaleRef, lockedRef, gamepadRef, + alignPhase, + setAlignPhase, + face1Ref, + face2Ref, }: { posRef: React.MutableRefObject<{ x: number; y: number; z: number }>; rotRef: React.MutableRefObject<{ x: number; y: number; z: number }>; scaleRef: React.MutableRefObject; lockedRef: React.MutableRefObject; gamepadRef: React.MutableRefObject<{ a: boolean }>; + alignPhase: AlignPhase; + setAlignPhase: (p: AlignPhase) => void; + face1Ref: React.MutableRefObject; + face2Ref: React.MutableRefObject; }) => { const meshRef = useRef(null); - // Material do cubo com 6 faces coloridas (Pra identificar cada face no AR) const materials = [ - new THREE.MeshStandardMaterial({ color: "#ef4444" }), // +x - vermelho - new THREE.MeshStandardMaterial({ color: "#22c55e" }), // -x - verde - new THREE.MeshStandardMaterial({ color: "#3b82f6" }), // +y - azul - new THREE.MeshStandardMaterial({ color: "#facc15" }), // -y - amarelo - new THREE.MeshStandardMaterial({ color: "#a855f7" }), // +z - roxo - new THREE.MeshStandardMaterial({ color: "#06b6d4" }), // -z - ciano + new THREE.MeshStandardMaterial({ color: "#ef4444" }), // +x + new THREE.MeshStandardMaterial({ color: "#22c55e" }), // -x + new THREE.MeshStandardMaterial({ color: "#3b82f6" }), // +y + new THREE.MeshStandardMaterial({ color: "#facc15" }), // -y + new THREE.MeshStandardMaterial({ color: "#a855f7" }), // +z + new THREE.MeshStandardMaterial({ color: "#06b6d4" }), // -z ]; useFrame((state, delta) => { if (!meshRef.current) return; - const pad = gamepadRef.current.a ? null : null; // A=reseta handled em outro lugar - // Lê gamepad a cada frame (não usar useEffect pq só roda uma vez) const gamepads = navigator.getGamepads ? navigator.getGamepads() : []; const gp = gamepads[0]; - if (gp && !lockedRef.current) { - // Stick esquerdo -> translação XZ (FRente/tras, esquerda/direita) - const lx = gp.axes[0] ?? 0; // esquerda(+)/direita(-) - const ly = gp.axes[1] ?? 0; // frente(-)/tras(+) - - // Stick direito -> rotação yaw (Y) e pitch (X) + if (gp && !lockedRef.current && alignPhase === 'IDLE') { + const lx = gp.axes[0] ?? 0; + const ly = gp.axes[1] ?? 0; const rx = gp.axes[2] ?? 0; const ry = gp.axes[3] ?? 0; - - // Botões 6 e 7 tipicamente são LT/RT em controles padrão Quest 3 const lt = gp.buttons[6]?.value ?? 0; const rt = gp.buttons[7]?.value ?? 0; - // btn 0 = A (X no controle Quest, segundo a doc), btn 1 = B const aBtn = gp.buttons[0]?.pressed; - // Deadzone + escala const ax = Math.abs(lx) > STICK_DEADZONE ? lx : 0; const ay = Math.abs(ly) > STICK_DEADZONE ? ly : 0; const arx = Math.abs(rx) > STICK_DEADZONE ? rx : 0; const ary = Math.abs(ry) > STICK_DEADZONE ? ry : 0; - // Aplica translação posRef.current.x -= ax * MOVE_SPEED * delta; posRef.current.z -= ay * MOVE_SPEED * delta; - posRef.current.y = Math.max(0.5, posRef.current.y); // mantém acima do chão + posRef.current.y = Math.max(0.5, posRef.current.y); - // Aplica rotação rotRef.current.x = (rotRef.current.x + ary * ROT_SPEED * delta) % (Math.PI * 2); rotRef.current.y = (rotRef.current.y - arx * ROT_SPEED * delta) % (Math.PI * 2); - // Triggers -> escala (LT encolhe, RT cresce) const scaleDelta = rt - lt; if (Math.abs(scaleDelta) > TRIGGER_DEADZONE) { - scaleRef.current = Math.max( - 0.2, - Math.min(3, scaleRef.current + scaleDelta * SCALE_SPEED * delta) - ); + scaleRef.current = Math.max(0.2, Math.min(3, scaleRef.current + scaleDelta * SCALE_SPEED * delta)); } - // Botão A -> reseta posição/rotação/escala (mantém y=0.5) if (aBtn && !gamepadRef.current.a) { posRef.current = { x: 0, y: 0.5, z: 0 }; rotRef.current = { x: 0, y: 0, z: 0 }; @@ -126,7 +112,6 @@ const CubeControlado = ({ } gamepadRef.current.a = aBtn; } else if (gp && lockedRef.current) { - // Se travado, só rotação por joystick (yaw/pitch) const rx = gp.axes[2] ?? 0; const ry = gp.axes[3] ?? 0; const arx = Math.abs(rx) > STICK_DEADZONE ? rx : 0; @@ -135,25 +120,88 @@ const CubeControlado = ({ rotRef.current.y = (rotRef.current.y - arx * ROT_SPEED * delta) % (Math.PI * 2); } - // Aplica ao mesh do Three.js meshRef.current.position.set(posRef.current.x, posRef.current.y, posRef.current.z); meshRef.current.rotation.set(rotRef.current.x, rotRef.current.y, rotRef.current.z); meshRef.current.scale.setScalar(scaleRef.current); }); return ( - + { + e.stopPropagation(); + if (!e.intersections[0].face) return; + const localNormal = e.intersections[0].face.normal; + + if (alignPhase === 'IDLE') { + face1Ref.current.copy(localNormal); + setAlignPhase('WAIT_SURFACE'); + } else if (alignPhase === 'WAIT_FACE2') { + if (Math.abs(face1Ref.current.dot(localNormal)) < 0.1) { + face2Ref.current.copy(localNormal); + setAlignPhase('WAIT_EDGE'); + } else { + alert("Por favor, selecione uma face diferente da primeira (ortogonal) para alinhar a direção."); + } + } + }} + onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }} + onPointerOut={() => { document.body.style.cursor = 'auto'; }} + > {materials.map((m, i) => ( - + ))} ); }; // =================================================================== -// Detector de gamepad (sinaliza status pra UI) +// COMPONENTE: Mesa (Table) // =================================================================== +const Table = ({ + onSurfaceClick, + onEdgeClick, +}: { + onSurfaceClick: (p: THREE.Vector3, n: THREE.Vector3) => void; + onEdgeClick: (p: THREE.Vector3, n: THREE.Vector3) => void; +}) => { + const handleClick = (e: any) => { + e.stopPropagation(); + if (!e.intersections[0].face) return; + const n = e.intersections[0].face.normal; + // Identifica se clicou no topo (Y forte) ou nas laterais (X ou Z fortes) + if (Math.abs(n.y) > 0.9) { + onSurfaceClick(e.point, n); + } else { + onEdgeClick(e.point, n); + } + }; + + return ( + + {/* Tampo Superior */} + + + + + {/* Prateleira Inferior */} + + + + + {/* Pés */} + + + + + + ); +}; + const useGamepadStatus = () => { const [connected, setConnected] = useState(false); useEffect(() => { @@ -164,7 +212,7 @@ const useGamepadStatus = () => { window.addEventListener("gamepadconnected", handler); window.addEventListener("gamepaddisconnected", handler); handler(); - const i = window.setInterval(handler, 1000); // poll, porque alguns headsets só conectam após entrar em XR + const i = window.setInterval(handler, 1000); return () => { window.removeEventListener("gamepadconnected", handler); window.removeEventListener("gamepaddisconnected", handler); @@ -175,44 +223,38 @@ const useGamepadStatus = () => { }; // =================================================================== -// Página principal QuestCubeLab +// Página principal // =================================================================== const QuestCubeLab = () => { const joystickConnected = useGamepadStatus(); - // Estado editável via UI (ControlCards e sliders) const [cubePos, setCubePos] = useState({ x: 0, y: 0.5, z: 0 }); const [cubeRot, setCubeRot] = useState({ x: 0, y: 0, z: 0 }); const [cubeScale, setCubeScale] = useState(1); const [locked, setLocked] = useState(false); + const [alignPhase, setAlignPhase] = useState('IDLE'); + + const face1Ref = useRef(new THREE.Vector3()); + const face2Ref = useRef(new THREE.Vector3()); + const surfaceNormalRef = useRef(new THREE.Vector3()); + const [xrSupported] = useState(() => typeof navigator !== "undefined" && "xr" in navigator && - typeof (navigator as unknown as { xr?: { isSessionSupported?: (m: string) => Promise } }).xr?.isSessionSupported === "function" + typeof (navigator as any).xr?.isSessionSupported === "function" ); - // Refs pra o gamepad loop no useFrame ler/escrever sem re-render const posRef = useRef(cubePos); const rotRef = useRef(cubeRot); const scaleRef = useRef(cubeScale); const lockedRef = useRef(locked); const gamepadRef = useRef({ a: false }); - // Sincroniza refs com state (escrita do state -> refs; leitura do gamepad -> state) - useEffect(() => { - posRef.current = cubePos; - }, [cubePos]); - useEffect(() => { - rotRef.current = cubeRot; - }, [cubeRot]); - useEffect(() => { - scaleRef.current = cubeScale; - }, [cubeScale]); - useEffect(() => { - lockedRef.current = locked; - }, [locked]); + useEffect(() => { posRef.current = cubePos; }, [cubePos]); + useEffect(() => { rotRef.current = cubeRot; }, [cubeRot]); + useEffect(() => { scaleRef.current = cubeScale; }, [cubeScale]); + useEffect(() => { lockedRef.current = locked; }, [locked]); - // Loop de polling separado pra sincronizar state <- refs (porque useFrame altera refs) useEffect(() => { const id = window.setInterval(() => { setCubePos({ ...posRef.current }); @@ -229,33 +271,72 @@ const QuestCubeLab = () => { setCubePos({ x: 0, y: 0.5, z: 0 }); setCubeRot({ x: 0, y: 0, z: 0 }); setCubeScale(1); + setAlignPhase('IDLE'); }; - // Helper pra entrar em XR (sessão imersiva-ar com Passthrough do Quest 3) const enterXR = async () => { try { - const xr = (navigator as unknown as { xr: { requestSession: (m: string) => Promise } }).xr; + const xr = (navigator as any).xr; const session = await xr.requestSession("immersive-ar"); - // A integração XR com R3F é feita via @react-three/xr (não instalada). - // Por ora, só abre a sessão; o Canvas atualiza quando detecta. - // Importante: o Canvas precisa de wrapper pra usar sessionMode. - // Workaround mínimo: setamos a session no Canvas via gl.xr.setSession. - // Como o setup não está instalado, exibimos mensagem informativa. void session; - alert("XR session requisitada. (Para integração completa com R3F, falta instalar @react-three/xr — Fase 2.5)."); + alert("XR session requisitada."); } catch (e) { alert("Falha ao entrar em XR: " + (e as Error).message); } }; + const handleSurfaceClick = (point: THREE.Vector3, normal: THREE.Vector3) => { + if (alignPhase === 'WAIT_SURFACE') { + surfaceNormalRef.current.copy(normal); + + const t1 = normal.clone().negate(); + const q1 = new THREE.Quaternion().setFromUnitVectors(face1Ref.current, t1); + + const euler = new THREE.Euler().setFromQuaternion(q1, 'XYZ'); + setCubeRot({ x: euler.x, y: euler.y, z: euler.z }); + rotRef.current = { x: euler.x, y: euler.y, z: euler.z }; + + const halfSize = (CUBE_SIZE / 2) * scaleRef.current; + const newPos = point.clone().add(normal.clone().multiplyScalar(halfSize)); + setCubePos({ x: newPos.x, y: newPos.y, z: newPos.z }); + posRef.current = { x: newPos.x, y: newPos.y, z: newPos.z }; + + setAlignPhase('WAIT_FACE2'); + } + }; + + const handleEdgeClick = (point: THREE.Vector3, edgeNormal: THREE.Vector3) => { + if (alignPhase === 'WAIT_EDGE') { + const t1 = surfaceNormalRef.current.clone().negate(); + const q1 = new THREE.Quaternion().setFromUnitVectors(face1Ref.current, t1); + + const w2 = face2Ref.current.clone().applyQuaternion(q1); + const t2 = edgeNormal.clone(); + + w2.projectOnPlane(surfaceNormalRef.current).normalize(); + t2.projectOnPlane(surfaceNormalRef.current).normalize(); + + const angle = Math.atan2( + w2.clone().cross(t2).dot(surfaceNormalRef.current), + w2.dot(t2) + ); + + const q2 = new THREE.Quaternion().setFromAxisAngle(surfaceNormalRef.current, angle); + const finalQ = q2.multiply(q1); + + const euler = new THREE.Euler().setFromQuaternion(finalQ, 'XYZ'); + setCubeRot({ x: euler.x, y: euler.y, z: euler.z }); + rotRef.current = { x: euler.x, y: euler.y, z: euler.z }; + + setAlignPhase('IDLE'); + } + }; + return (
- + Voltar ao SteelXR
@@ -265,95 +346,96 @@ const QuestCubeLab = () => {
- - - {joystickConnected ? "Joystick OK" : "Sem joystick"} - + + {joystickConnected ? "Joystick OK" : "Sem joystick"}
-
-
- Sandbox experimental — Fase 2. Cubo Three.js real (6 faces coloridas), - grid fino no chão, travar/destravar, joystick funcional. +
+
+
+ Fase 3/4 — Sistema de Alinhamento: +
    +
  • 1. Clique numa face do cubo; depois, numa superfície da mesa (Tampo ou Prateleira).
  • +
  • 2. Clique noutra face ortogonal do cubo; depois, numa borda/lateral da mesa.
  • +
+
- {/* Toolbar com Travar/Destravar + Reset + XR */}
- + + {alignPhase !== 'IDLE' && ( + + )}
- {/* Viewport 3D real */} -
-
+
+
+
+ Status Alinhamento +
+
+ {alignPhase === 'IDLE' && 'Livre! Clique em uma face do cubo para iniciar o contato.'} + {alignPhase === 'WAIT_SURFACE' && 'Face gravada! Agora clique em uma superfície da mesa (Tampo/Prateleira) para apoiar o cubo.'} + {alignPhase === 'WAIT_FACE2' && 'Apoiado! Agora clique em outra face do cubo para alinhamento lateral.'} + {alignPhase === 'WAIT_EDGE' && 'Último passo! Clique em uma borda ou lateral da mesa para alinhar o cubo.'} +
+
+ +
- - - + + + + - + + @@ -365,33 +447,20 @@ const QuestCubeLab = () => {
- pos({cubePos.x.toFixed(2)}, {cubePos.y.toFixed(2)}, {cubePos.z.toFixed(2)}) · rot( - {((cubeRot.y * 180) / Math.PI).toFixed(0)}°) · scale {cubeScale.toFixed(2)}x - {locked && " · 🔒 posição fixa"} -
-
- {locked ? "modo: rotação livre" : "modo: livre"} + pos({cubePos.x.toFixed(2)}, {cubePos.y.toFixed(2)}, {cubePos.z.toFixed(2)}) · rot({((cubeRot.y * 180) / Math.PI).toFixed(0)}°) · scale {cubeScale.toFixed(2)}x
- -
-

- Próximas fases -

+

Próximas fases

    - - - - + + + +
@@ -399,25 +468,11 @@ const QuestCubeLab = () => { ); }; -const Maximize2Inline = () => ( - - - - - - - -); - const Phase = ({ n, status, title }: { n: string; status?: "done" | "active"; title: string }) => (
  • {n} @@ -426,6 +481,4 @@ const Phase = ({ n, status, title }: { n: string; status?: "done" | "active"; ti
  • ); - - export default QuestCubeLab;