diff --git a/src/App.tsx b/src/App.tsx
index 0c0ca1b..b2cd79b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -9,6 +9,7 @@ import XRSession from "./pages/XRSession";
import Watch from "./pages/Watch";
import MeetingRoom from "./pages/MeetingRoom";
import NotFound from "./pages/NotFound";
+import QuestCubeLab from "./lab/QuestCubeLab";
import "@/lib/remoteLogger";
const queryClient = new QueryClient();
@@ -26,6 +27,7 @@ const App = () => (
} />
} />
} />
+ } />
} />
diff --git a/src/lab/LabButton.tsx b/src/lab/LabButton.tsx
new file mode 100644
index 0000000..592f9c9
--- /dev/null
+++ b/src/lab/LabButton.tsx
@@ -0,0 +1,42 @@
+import { Link } from "react-router-dom";
+import { FlaskConical, ArrowRight } from "lucide-react";
+
+/**
+ * LabButton — Botão que aparece na Home do SteelXR.
+ *
+ * ⚠️ Importante: este componente vive em /lab/.
+ * Se a pasta /lab/ for deletada, o botão some.
+ * (E o import em Index.tsx vira linha morta que Vite descarta via tree-shaking.)
+ */
+const LabButton = () => {
+ return (
+
+
+
+
+
+
+
+
+
+ Laboratório de Cubos
+
+
+ alpha
+
+
+
+ Sandbox experimental — Meta Quest 3 joystick + alinhamento 3D
+
+
+
+
+
+
+ );
+};
+
+export default LabButton;
diff --git a/src/lab/QuestCubeLab.tsx b/src/lab/QuestCubeLab.tsx
new file mode 100644
index 0000000..7b85134
--- /dev/null
+++ b/src/lab/QuestCubeLab.tsx
@@ -0,0 +1,520 @@
+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,
+ 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
+ * ============================================================
+ */
+
+// 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 SCALE_SPEED = 0.6;
+
+// Dimensão inicial do cubo (1m x 1m x 1m)
+const CUBE_SIZE = 1;
+
+// ===================================================================
+// COMPONENTE: CuboControlado
+// Recebe refs compartilhadas pro estado, atualiza a cada frame.
+// ===================================================================
+const CubeControlado = ({
+ posRef,
+ rotRef,
+ scaleRef,
+ lockedRef,
+ gamepadRef,
+}: {
+ 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 }>;
+}) => {
+ 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
+ ];
+
+ 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)
+ 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
+
+ // 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)
+ );
+ }
+
+ // 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 };
+ scaleRef.current = 1;
+ }
+ 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;
+ 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);
+ }
+
+ // 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 (
+
+
+ {materials.map((m, i) => (
+
+ ))}
+
+ );
+};
+
+// ===================================================================
+// Detector de gamepad (sinaliza status pra UI)
+// ===================================================================
+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); // poll, porque alguns headsets só conectam após entrar em XR
+ return () => {
+ window.removeEventListener("gamepadconnected", handler);
+ window.removeEventListener("gamepaddisconnected", handler);
+ window.clearInterval(i);
+ };
+ }, []);
+ return connected;
+};
+
+// ===================================================================
+// Página principal QuestCubeLab
+// ===================================================================
+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 [xrSupported] = useState(() =>
+ typeof navigator !== "undefined" &&
+ "xr" in navigator &&
+ typeof (navigator as unknown as { xr?: { isSessionSupported?: (m: string) => Promise } }).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]);
+
+ // Loop de polling separado pra sincronizar state <- refs (porque useFrame altera refs)
+ 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);
+ };
+
+ // 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 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).");
+ } catch (e) {
+ alert("Falha ao entrar em XR: " + (e as Error).message);
+ }
+ };
+
+ return (
+
+
+
+
+
+ Sandbox experimental — Fase 2. Cubo Three.js real (6 faces coloridas),
+ grid fino no chão, travar/destravar, joystick funcional.
+
+
+ {/* Toolbar com Travar/Destravar + Reset + XR */}
+
+
+
+
+
+
+
+
+
+
+ {/* Viewport 3D real */}
+
+
+
+
+
+
+
+ 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"}
+
+
+
+
+ {/* 3 ControlCards */}
+
+ }
+ title="Translação (stick esq)"
+ value={`X: ${cubePos.x.toFixed(2)} · Z: ${cubePos.z.toFixed(2)}`}
+ onUp={() => setCubePos((p) => ({ ...p, z: p.z - 0.1 }))}
+ onDown={() => setCubePos((p) => ({ ...p, z: p.z + 0.1 }))}
+ onLeft={() => setCubePos((p) => ({ ...p, x: p.x - 0.1 }))}
+ onRight={() => setCubePos((p) => ({ ...p, x: p.x + 0.1 }))}
+ disabled={locked}
+ />
+ }
+ title="Rotação (stick dir)"
+ value={`Yaw: ${((cubeRot.y * 180) / Math.PI).toFixed(0)}°`}
+ onUp={() => setCubeRot((r) => ({ ...r, x: r.x + 0.1 }))}
+ onDown={() => setCubeRot((r) => ({ ...r, x: r.x - 0.1 }))}
+ onLeft={() => setCubeRot((r) => ({ ...r, y: r.y - 0.1 }))}
+ onRight={() => setCubeRot((r) => ({ ...r, y: r.y + 0.1 }))}
+ />
+ }
+ title="Escala (LT/RT)"
+ value={`${cubeScale.toFixed(2)}x`}
+ onUp={() => setCubeScale((s) => Math.min(3, +(s + 0.1).toFixed(2)))}
+ onDown={() => setCubeScale((s) => Math.max(0.2, +(s - 0.1).toFixed(2)))}
+ onLeft={() => setCubeScale((s) => Math.max(0.2, +(s - 0.1).toFixed(2)))}
+ onRight={() => setCubeScale((s) => Math.min(3, +(s + 0.1).toFixed(2)))}
+ disabled={locked}
+ />
+
+
+
+
+ Próximas fases
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const Maximize2Inline = () => (
+
+);
+
+const Phase = ({ n, status, title }: { n: string; status?: "done" | "active"; title: string }) => (
+
+
+ {n}
+
+ {title}
+
+);
+
+const ControlCard = ({
+ icon,
+ title,
+ value,
+ onUp,
+ onDown,
+ onLeft,
+ onRight,
+ disabled,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ value: string;
+ onUp: () => void;
+ onDown: () => void;
+ onLeft: () => void;
+ onRight: () => void;
+ disabled?: boolean;
+}) => (
+
+
+ {icon}
+ {title}
+
+
{value}
+
+
+
+
+
+
+
+
+
+);
+
+export default QuestCubeLab;
diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx
index 5dd701a..4809cd6 100644
--- a/src/pages/Index.tsx
+++ b/src/pages/Index.tsx
@@ -12,6 +12,7 @@ import { validateModelFile } from '@/lib/validateModelFile';
import { CloudLoader } from '@/components/CloudLoader';
import { SceneModelList } from '@/components/SceneModelList';
import { RecentFilesList } from '@/components/RecentFilesList';
+import LabButton from '@/lab/LabButton';
import { addRecentFile } from '@/lib/recentFiles';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
@@ -369,6 +370,8 @@ const Index = () => {
Reunião Virtual
+
+
{/* XR Status */}
{xrSupported === null ?