import { useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { Canvas, useFrame } from "@react-three/fiber"; import { OrbitControls, Grid, Environment, PerspectiveCamera, } from "@react-three/drei"; import * as THREE from "three"; import { Beaker, ArrowLeft, Lock, Unlock, RotateCcw, Hand, } from "lucide-react"; /** * ============================================================ * 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 * ============================================================ */ const STICK_DEADZONE = 0.12; const TRIGGER_DEADZONE = 0.05; const MOVE_SPEED = 1.5; const ROT_SPEED = 1.2; const SCALE_SPEED = 0.6; const CUBE_SIZE = 1; type AlignPhase = 'IDLE' | 'WAIT_SURFACE' | 'WAIT_FACE2' | 'WAIT_EDGE'; // =================================================================== // COMPONENTE: CuboControlado // =================================================================== const CubeControlado = ({ posRef, rotRef, 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); const materials = [ 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 gamepads = navigator.getGamepads ? navigator.getGamepads() : []; const gp = gamepads[0]; 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; const lt = gp.buttons[6]?.value ?? 0; const rt = gp.buttons[7]?.value ?? 0; const aBtn = gp.buttons[0]?.pressed; 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; posRef.current.x -= ax * MOVE_SPEED * delta; posRef.current.z -= ay * MOVE_SPEED * delta; posRef.current.y = Math.max(0.5, posRef.current.y); 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); 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)); } if (aBtn && !gamepadRef.current.a) { posRef.current = { x: 0, y: 0.5, z: 0 }; rotRef.current = { x: 0, y: 0, z: 0 }; scaleRef.current = 1; } gamepadRef.current.a = aBtn; } else if (gp && lockedRef.current) { const rx = gp.axes[2] ?? 0; const ry = gp.axes[3] ?? 0; const arx = Math.abs(rx) > STICK_DEADZONE ? rx : 0; const ary = Math.abs(ry) > STICK_DEADZONE ? ry : 0; 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); } 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) => ( ))} ); }; // =================================================================== // 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(() => { const handler = () => { const pads = navigator.getGamepads?.() ?? []; setConnected(Array.from(pads).some((p) => p !== null)); }; window.addEventListener("gamepadconnected", handler); window.addEventListener("gamepaddisconnected", handler); handler(); const i = window.setInterval(handler, 1000); return () => { window.removeEventListener("gamepadconnected", handler); window.removeEventListener("gamepaddisconnected", handler); window.clearInterval(i); }; }, []); return connected; }; // =================================================================== // Página principal // =================================================================== const QuestCubeLab = () => { const joystickConnected = useGamepadStatus(); 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 any).xr?.isSessionSupported === "function" ); const posRef = useRef(cubePos); const rotRef = useRef(cubeRot); const scaleRef = useRef(cubeScale); const lockedRef = useRef(locked); const gamepadRef = useRef({ a: false }); useEffect(() => { posRef.current = cubePos; }, [cubePos]); useEffect(() => { rotRef.current = cubeRot; }, [cubeRot]); useEffect(() => { scaleRef.current = cubeScale; }, [cubeScale]); useEffect(() => { lockedRef.current = locked; }, [locked]); useEffect(() => { const id = window.setInterval(() => { setCubePos({ ...posRef.current }); setCubeRot({ ...rotRef.current }); setCubeScale(scaleRef.current); }, 100); return () => window.clearInterval(id); }, []); const reset = () => { posRef.current = { x: 0, y: 0.5, z: 0 }; rotRef.current = { x: 0, y: 0, z: 0 }; scaleRef.current = 1; setCubePos({ x: 0, y: 0.5, z: 0 }); setCubeRot({ x: 0, y: 0, z: 0 }); setCubeScale(1); setAlignPhase('IDLE'); }; const enterXR = async () => { try { const xr = (navigator as any).xr; const session = await xr.requestSession("immersive-ar"); void session; 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

QUEST CUBE LAB (alpha)

{joystickConnected ? "Joystick OK" : "Sem joystick"}
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.
{alignPhase !== 'IDLE' && ( )}
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.'}
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

); }; const Phase = ({ n, status, title }: { n: string; status?: "done" | "active"; title: string }) => (
  • {n} {title}
  • ); export default QuestCubeLab;