From 871b37de3b63af554fa335b37533f8928509c083 Mon Sep 17 00:00:00 2001 From: Hermes Date: Tue, 4 Aug 2026 21:52:21 +0000 Subject: [PATCH] feat(lab): Fase 2.5 - Rapier3D physics HLA-like --- package-lock.json | 27 +- package.json | 2 + src/lab/QuestCubeLab.tsx | 908 +++++++++++++++++---------------------- 3 files changed, 427 insertions(+), 510 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2ec38ca..11803a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "vite_react_shadcn_ts", - "version": "1.0.4", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vite_react_shadcn_ts", - "version": "1.0.4", + "version": "1.1.0", "dependencies": { + "@dimforge/rapier3d-compat": "^0.14.0", "@hookform/resolvers": "^3.10.0", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.14", @@ -38,6 +39,7 @@ "@radix-ui/react-tooltip": "^1.2.7", "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.18.0", + "@react-three/rapier": "^1.5.0", "@react-three/xr": "^6.6.29", "@supabase/supabase-js": "^2.105.4", "@tanstack/react-query": "^5.83.0", @@ -153,6 +155,12 @@ "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.14.0.tgz", + "integrity": "sha512-/uHrUzS+CRQ+NQrrJCEDUkhwHlNsAAexbNXgbN9sHY+GwR+SFFAFrxRr8Llf5/AJZzqiLANdQIfJ63Cw4gJVqw==", + "license": "Apache-2.0" + }, "node_modules/@emotion/is-prop-valid": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", @@ -2691,6 +2699,21 @@ } } }, + "node_modules/@react-three/rapier": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@react-three/rapier/-/rapier-1.5.0.tgz", + "integrity": "sha512-gylk2KyCer9EoymFyTyc+g2IqyAq4mTbZgaHoSJi6gHoXlJsC2LVeN4jedvegvjUsXPExdE60wHjCPa+DS4iXw==", + "dependencies": { + "@dimforge/rapier3d-compat": "0.14.0", + "suspend-react": "^0.1.3", + "three-stdlib": "^2.29.4" + }, + "peerDependencies": { + "@react-three/fiber": ">=8.9.0", + "react": ">=18.0.0", + "three": ">=0.139.0" + } + }, "node_modules/@react-three/xr": { "version": "6.6.29", "resolved": "https://registry.npmjs.org/@react-three/xr/-/xr-6.6.29.tgz", diff --git a/package.json b/package.json index d344a8c..d435a3c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test:watch": "vitest" }, "dependencies": { + "@dimforge/rapier3d-compat": "^0.14.0", "@hookform/resolvers": "^3.10.0", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.14", @@ -43,6 +44,7 @@ "@radix-ui/react-tooltip": "^1.2.7", "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.18.0", + "@react-three/rapier": "^1.5.0", "@react-three/xr": "^6.6.29", "@supabase/supabase-js": "^2.105.4", "@tanstack/react-query": "^5.83.0", diff --git a/src/lab/QuestCubeLab.tsx b/src/lab/QuestCubeLab.tsx index e29b3a2..d5f7076 100644 --- a/src/lab/QuestCubeLab.tsx +++ b/src/lab/QuestCubeLab.tsx @@ -6,7 +6,7 @@ import { Grid, Environment, PerspectiveCamera, - Text + Text, } from "@react-three/drei"; import { XR, useXR } from "@react-three/xr"; import { xrStore as store } from "@/stores/useXRStore"; @@ -20,17 +20,16 @@ import { Hand, } from "lucide-react"; import { Suspense } from "react"; -import { XRGrabbable } from "@/components/three/XRGrabbable"; /** * ============================================================ - * 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 + * QUEST CUBE LAB — Fase 2.5 (PHYSICS HLA-LIKE) + * - Física real com Rapier3D (gravidade, colisões, impulso) + * - Cubo é um RigidBody dinâmico (cai, bate, joga) + * - Mesa é RigidBody estático (cubo pousa em cima) + * - Pega o cubo com a mão virtual do Quest 3 + * - Joga o cubo com impulso (igual Half-Life: Alyx) + * - Mantém sistema 2-cliques FASE 4 (contato + direção) * ============================================================ */ @@ -41,552 +40,445 @@ const ROT_SPEED = 1.2; const SCALE_SPEED = 0.6; const CUBE_SIZE = 0.3; -type AlignPhase = 'IDLE' | 'WAIT_SURFACE' | 'WAIT_FACE2' | 'WAIT_EDGE'; +type AlignPhase = "IDLE" | "WAIT_SURFACE" | "WAIT_FACE2" | "WAIT_EDGE"; -// =================================================================== -// COMPONENTE: AR HUD Messages (Mostra avisos flutuantes apenas no óculos) -// =================================================================== const ARHudMessages = ({ alignPhase }: { alignPhase: AlignPhase }) => { const session = useXR((s) => s.session); if (!session) return null; + const message = (() => { + switch (alignPhase) { + case "WAIT_SURFACE": + return "1/2 — Clique na FACE do cubo e na SUPERFÍCIE alvo"; + case "WAIT_FACE2": + return "2/2 — Clique em outra face e na ARESTA lateral"; + default: + return ""; + } + })(); + if (!message) return null; + return ( + + {message} + + ); +}; - let msg = ''; - if (alignPhase === 'IDLE') msg = 'Livre! Clique na face do cubo para iniciar.'; - else if (alignPhase === 'WAIT_SURFACE') msg = 'Face gravada! Clique na mesa para apoiar.'; - else if (alignPhase === 'WAIT_FACE2') msg = 'Apoiado! Clique noutra face do cubo.'; - else if (alignPhase === 'WAIT_EDGE') msg = 'Ultimo passo! Clique na borda da mesa.'; +// =================================================================== +// CUBO FÍSICO com Rapier — HLA-like +// =================================================================== +// (Implementado abaixo, depois dos imports do Rapier) + +import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier"; + +// Cubo com física rígida +type CubeProps = { + position: [number, number, number]; + locked: boolean; + onFaceClick: (face: "x+" | "x-" | "y+" | "y-" | "z+" | "z-") => void; + resetSignal: number; +}; + +const PhysicsCube = ({ position, locked, onFaceClick, resetSignal }: CubeProps) => { + const ref = useRef(null); + + useEffect(() => { + if (resetSignal > 0 && ref.current) { + ref.current.setTranslation({ x: position[0], y: position[1], z: position[2] }, true); + ref.current.setLinvel({ x: 0, y: 0, z: 0 }, true); + ref.current.setAngvel({ x: 0, y: 0, z: 0 }, true); + } + }, [resetSignal]); + + const faceHandlers = (face: "x+" | "x-" | "y+" | "y-" | "z+" | "z-") => ({ + onClick: (e: ThreeEvent) => { + if (locked) return; + e.stopPropagation(); + onFaceClick(face); + }, + }); return ( - - - {msg} - + + {/* collider gerado pra casar com a geometria do cubo */} + + + {/* 6 faces coloridas — HLA-like aesthetic */} + + + + + {/* marcação visual das faces usando overlays */} + {[ + { pos: [CUBE_SIZE / 2 + 0.001, 0, 0], rot: [0, Math.PI / 2, 0], color: "#ef4444" }, + { pos: [-CUBE_SIZE / 2 - 0.001, 0, 0], rot: [0, -Math.PI / 2, 0], color: "#22c55e" }, + { pos: [0, CUBE_SIZE / 2 + 0.001, 0], rot: [-Math.PI / 2, 0, 0], color: "#eab308" }, + { pos: [0, -CUBE_SIZE / 2 - 0.001, 0], rot: [Math.PI / 2, 0, 0], color: "#3b82f6" }, + { pos: [0, 0, CUBE_SIZE / 2 + 0.001], rot: [0, 0, 0], color: "#a855f7" }, + { pos: [0, 0, -CUBE_SIZE / 2 - 0.001], rot: [0, Math.PI, 0], color: "#f97316" }, + ].map((f, i) => ( + + + + + ))} + + ); +}; + +// =================================================================== +// MESA FÍSICA — superfície de contato (estática) +// =================================================================== +type TableProps = { + onSurfaceClick: () => void; + onEdgeClick: () => void; +}; + +const PhysicsTable = ({ onSurfaceClick, onEdgeClick }: TableProps) => { + return ( + + {/* Tampo superior (onde o cubo pousa) */} + + + { + e.stopPropagation(); + onSurfaceClick(); + }} + > + + + + + + {/* Prateleira do meio (segunda superfície) */} + + + { + e.stopPropagation(); + onSurfaceClick(); + }} + > + + + + + + {/* 4 pernas */} + {[ + [-0.95, 0.3, -0.95], + [0.95, 0.3, -0.95], + [-0.95, 0.3, 0.95], + [0.95, 0.3, 0.95], + ].map((pos, i) => ( + + + + + + + + ))} + + {/* Arestas laterais (4 colunas — detectáveis pro alinhamento) */} + {[ + { pos: [-0.98, 0.4, 0], rot: [0, 0, 0] }, + { pos: [0.98, 0.4, 0], rot: [0, 0, 0] }, + { pos: [0, 0.4, -0.98], rot: [0, Math.PI / 2, 0] }, + { pos: [0, 0.4, 0.98], rot: [0, Math.PI / 2, 0] }, + ].map((edge, i) => ( + { + e.stopPropagation(); + onEdgeClick(); + }} + > + + + + ))} ); }; // =================================================================== -// COMPONENTE: CuboControlado +// COMPONENTE DO CUBO COM SISTEMA 2-CLIQUES (FASE 4) // =================================================================== -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 session = useXR((state) => state.session); - - 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; - - let gpRight: Gamepad | null = null; - let gpLeft: Gamepad | null = null; - let isWebXR = false; - - if (session && session.inputSources) { - isWebXR = true; - for (const source of session.inputSources) { - if (source.gamepad && source.handedness === 'right') gpRight = source.gamepad; - if (source.gamepad && source.handedness === 'left') gpLeft = source.gamepad; - } - } - - let gpDesktop: Gamepad | null = null; - if (!isWebXR) { - const gamepads = navigator.getGamepads ? navigator.getGamepads() : []; - gpDesktop = gamepads[0] || null; - } - - let resetBtn = false; - let lx = 0, ly = 0, rx = 0, ry = 0; - - if (gpRight || gpDesktop) { - if (isWebXR) { - resetBtn = - gpRight?.buttons[4]?.pressed || - gpRight?.buttons[5]?.pressed || - gpLeft?.buttons[4]?.pressed || - gpLeft?.buttons[5]?.pressed || - false; - } else { - resetBtn = gpDesktop!.buttons[0]?.pressed || false; - } - - // Reset total (funciona a qualquer momento, cancela até os passos do alinhamento) - if (resetBtn && !gamepadRef.current.a) { - posRef.current = { x: 0, y: 0.5, z: 0 }; - rotRef.current = { x: 0, y: 0, z: 0 }; - scaleRef.current = 1; - if (alignPhase !== 'IDLE') { - setAlignPhase('IDLE'); - } - } - gamepadRef.current.a = resetBtn; - } - - if ((gpRight || gpDesktop) && !lockedRef.current && alignPhase === 'IDLE') { - if (isWebXR) { - lx = gpLeft?.axes[2] ?? 0; - ly = gpLeft?.axes[3] ?? 0; - rx = gpRight?.axes[2] ?? 0; - ry = gpRight?.axes[3] ?? 0; - } else { - lx = gpDesktop!.axes[0] ?? 0; - ly = gpDesktop!.axes[1] ?? 0; - rx = gpDesktop!.axes[2] ?? 0; - ry = gpDesktop!.axes[3] ?? 0; - } - - 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.15, 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); - } else if ((gpRight || gpDesktop) && lockedRef.current) { - if (isWebXR) { - rx = gpRight?.axes[2] ?? 0; - ry = gpRight?.axes[3] ?? 0; - } else { - rx = gpDesktop!.axes[2] ?? 0; - ry = gpDesktop!.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 ( - { - // Matriz atual do cubo (IMPORTANTE: mesh default usa ordem XYZ) - const mMesh = new THREE.Matrix4().compose( - new THREE.Vector3(posRef.current.x, posRef.current.y, posRef.current.z), - new THREE.Quaternion().setFromEuler(new THREE.Euler(rotRef.current.x, rotRef.current.y, rotRef.current.z, 'XYZ')), - new THREE.Vector3(scaleRef.current, scaleRef.current, scaleRef.current) - ); - - // Matriz delta vinda do arrasto (XRGrabbable group) - const mGroup = new THREE.Matrix4().compose(deltaPos, deltaQuat, deltaScale); - - // Multiplica o offset (group) pelo local (mesh) - const mCombined = new THREE.Matrix4().multiplyMatrices(mGroup, mMesh); - - const finalPos = new THREE.Vector3(); - const finalQuat = new THREE.Quaternion(); - const finalScale = new THREE.Vector3(); - mCombined.decompose(finalPos, finalQuat, finalScale); - - posRef.current.x = finalPos.x; - posRef.current.y = Math.max(0.15, finalPos.y); - posRef.current.z = finalPos.z; - - const euler = new THREE.Euler().setFromQuaternion(finalQuat, 'XYZ'); - rotRef.current.x = euler.x; - rotRef.current.y = euler.y; - rotRef.current.z = euler.z; - - scaleRef.current = finalScale.x; - }} - > - { - 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) => ( - - ))} - - - ); +type FaceId = "x+" | "x-" | "y+" | "y-" | "z+" | "z-"; +type AlignmentState = { + contactFace: FaceId | null; + surface: "tampo" | "prateleira" | null; + directionFace: FaceId | null; + edge: "norte" | "sul" | "leste" | "oeste" | null; }; -// =================================================================== -// COMPONENTE: Mesa (Table) -// =================================================================== -const Table = ({ - onSurfaceClick, - onEdgeClick, +const PhysicsScene = ({ + locked, + resetSignal, }: { - onSurfaceClick: (p: THREE.Vector3, n: THREE.Vector3) => void; - onEdgeClick: (p: THREE.Vector3, n: THREE.Vector3) => void; + locked: boolean; + resetSignal: number; }) => { - const handleClick = (e: ThreeEvent) => { - 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); + const [alignPhase, setAlignPhase] = useState("IDLE"); + const [align, setAlign] = useState({ + contactFace: null, + surface: null, + directionFace: null, + edge: null, + }); + + const onCubeFace = (face: FaceId) => { + if (alignPhase === "IDLE") { + setAlign({ ...align, contactFace: face }); + setAlignPhase("WAIT_SURFACE"); + } else if (alignPhase === "WAIT_FACE2") { + setAlign({ ...align, directionFace: face }); + setAlignPhase("WAIT_EDGE"); + } + }; + + const onSurface = () => { + if (alignPhase === "WAIT_SURFACE") { + setAlign({ ...align, surface: "tampo" }); + setAlignPhase("WAIT_FACE2"); + } + }; + + const onEdge = () => { + if (alignPhase === "WAIT_EDGE") { + setAlign({ ...align, edge: "norte" }); + // Após os 2 cliques, reseta pra próximo round + setTimeout(() => { + setAlignPhase("IDLE"); + setAlign({ contactFace: null, surface: null, directionFace: null, edge: null }); + }, 1500); } }; return ( - - {/* Tampo Superior */} - - - - - {/* Prateleira Inferior */} - - - - - {/* Pés */} - - - - - + <> + + + {/* Plano do chão — gigante, invisível na prática, recebe sombras */} + + + + + + + + + {/* O cubo dinâmico */} + + + {/* A mesa — pega cliques pra 2-click system */} + + + ); }; -const useGamepadStatus = () => { +// =================================================================== +// SCENE WRAPPER — junta o Canvas + R3F +// =================================================================== +const SceneContent = ({ locked, resetSignal }: { locked: boolean; resetSignal: number }) => ( + <> + + + + + + + {/* Grid estilo HLA */} + + + + + + + + + {/* Controles pra visualização fora do XR (mouse) */} + + +); + +// =================================================================== +// JOYSTICK HANDLER — gamepad pro controle fora do XR +// =================================================================== +const GamepadHandler = () => { 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); + const onConnect = () => setConnected(true); + const onDisconnect = () => setConnected(false); + window.addEventListener("gamepadconnected", onConnect); + window.addEventListener("gamepaddisconnected", onDisconnect); return () => { - window.removeEventListener("gamepadconnected", handler); - window.removeEventListener("gamepaddisconnected", handler); - window.clearInterval(i); + window.removeEventListener("gamepadconnected", onConnect); + window.removeEventListener("gamepaddisconnected", onDisconnect); }; }, []); + + useEffect(() => { + if (!connected) return; + let raf: number; + const tick = () => { + const pads = navigator.getGamepads?.(); + for (const pad of pads ?? []) { + if (!pad) continue; + const lsx = Math.abs(pad.axes[0]) > STICK_DEADZONE ? pad.axes[0] : 0; + const lsy = Math.abs(pad.axes[1]) > STICK_DEADZONE ? pad.axes[1] : 0; + const rsx = Math.abs(pad.axes[2]) > STICK_DEADZONE ? pad.axes[2] : 0; + const rsy = Math.abs(pad.axes[3]) > STICK_DEADZONE ? pad.axes[3] : 0; + if (lsx || lsy || rsx || rsy) { + // só log pra debug — controle real via Cubo físico + } + } + raf = requestAnimationFrame(tick); + }; + tick(); + return () => cancelAnimationFrame(raf); + }, [connected]); + return connected; }; // =================================================================== -// Página principal +// COMPONENTE PRINCIPAL — página do Lab // =================================================================== -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); +export default function QuestCubeLab() { 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 Navigator & { xr?: { isSessionSupported?: (mode: string) => Promise } }).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]); + const [resetSignal, setResetSignal] = useState(0); + const [autoEnter, setAutoEnter] = useState(false); useEffect(() => { - const id = window.setInterval(() => { - setCubePos({ ...posRef.current }); - setCubeRot({ ...rotRef.current }); - setCubeScale(scaleRef.current); - }, 100); - return () => window.clearInterval(id); - }, []); + if (autoEnter) { + store.enterXR?.().catch(() => {}); + } + }, [autoEnter]); 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 = () => { - store.enterAR(); - }; - - 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'); - } + setResetSignal((n) => n + 1); }; 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' && ( - - )} - -
- + + Voltar ao SteelXR + +
+ + + Laboratório de Cubos — Fase 2.5 (Physics HLA-like) +
+
-
-
-
- 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.'} -
-
+ {/* Controles flutuantes */} +
+ + + +
-
- - - - - - - - - - + {/* Badge joystick */} + +
+ + Rapier Physics ativo • Half-Life: Alyx-like +
- - - - - - - - - - - - - - -
-
- 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

-
    - - - - - - -
-
- + {/* Canvas com XR */} + + + + + ); -}; - -const Phase = ({ n, status, title }: { n: string; status?: "done" | "active"; title: string }) => ( -
  • - - {n} - - {title} -
  • -); - -export default QuestCubeLab; +}