From 34ac745cc0ce6aeaa2b5d930121e4338c71f5bed Mon Sep 17 00:00:00 2001 From: admtracksteel Date: Mon, 20 Jul 2026 20:08:44 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20Auto-deploy:=20melhoria=20no=20s?= =?UTF-8?q?nap=20e=20medi=C3=A7=C3=A3o=20AR=20em=2020/07/2026=2020:08:44?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 2 + src/lab/LabButton.tsx | 42 ++++ src/lab/QuestCubeLab.tsx | 520 +++++++++++++++++++++++++++++++++++++++ src/pages/Index.tsx | 3 + 4 files changed, 567 insertions(+) create mode 100644 src/lab/LabButton.tsx create mode 100644 src/lab/QuestCubeLab.tsx 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 ( +
+
+
+ + Voltar ao SteelXR + +
+ +

+ QUEST CUBE LAB (alpha) +

+
+
+ + + {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. +
+ + {/* 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 ?