🚀 Auto-deploy: melhoria no snap e medição AR em 20/07/2026 20:08:44
This commit is contained in:
@@ -9,6 +9,7 @@ import XRSession from "./pages/XRSession";
|
|||||||
import Watch from "./pages/Watch";
|
import Watch from "./pages/Watch";
|
||||||
import MeetingRoom from "./pages/MeetingRoom";
|
import MeetingRoom from "./pages/MeetingRoom";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
|
import QuestCubeLab from "./lab/QuestCubeLab";
|
||||||
import "@/lib/remoteLogger";
|
import "@/lib/remoteLogger";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
@@ -26,6 +27,7 @@ const App = () => (
|
|||||||
<Route path="/watch/:code" element={<Watch />} />
|
<Route path="/watch/:code" element={<Watch />} />
|
||||||
<Route path="/meeting" element={<MeetingRoom />} />
|
<Route path="/meeting" element={<MeetingRoom />} />
|
||||||
<Route path="/meeting/:roomId" element={<MeetingRoom />} />
|
<Route path="/meeting/:roomId" element={<MeetingRoom />} />
|
||||||
|
<Route path="/lab" element={<QuestCubeLab />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Link
|
||||||
|
to="/lab"
|
||||||
|
className="mt-4 block rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 transition-all duration-200 hover:border-amber-400/60 hover:bg-amber-500/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-amber-500/15 text-amber-300">
|
||||||
|
<FlaskConical className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-amber-100">
|
||||||
|
Laboratório de Cubos
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-amber-500/40 px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider text-amber-300/80">
|
||||||
|
alpha
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-amber-200/60">
|
||||||
|
Sandbox experimental — Meta Quest 3 joystick + alinhamento 3D
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 text-amber-400/60" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LabButton;
|
||||||
@@ -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 <Grid>)
|
||||||
|
* - 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<number>;
|
||||||
|
lockedRef: React.MutableRefObject<boolean>;
|
||||||
|
gamepadRef: React.MutableRefObject<{ a: boolean }>;
|
||||||
|
}) => {
|
||||||
|
const meshRef = useRef<THREE.Mesh>(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 (
|
||||||
|
<mesh ref={meshRef} castShadow receiveShadow position={[0, 0.5, 0]}>
|
||||||
|
<boxGeometry args={[CUBE_SIZE, CUBE_SIZE, CUBE_SIZE]} />
|
||||||
|
{materials.map((m, i) => (
|
||||||
|
<primitive attach="material-0" object={m} key={i} />
|
||||||
|
))}
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// 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<boolean> } }).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<XRSession> } }).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 <XR> wrapper pra usar sessionMode.
|
||||||
|
// Workaround mínimo: setamos a session no Canvas via gl.xr.setSession.
|
||||||
|
// Como o setup <XR> 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 (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 text-slate-100">
|
||||||
|
<header className="border-b border-slate-800/80 bg-slate-950/80 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-6 py-4">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-2 text-sm text-slate-400 transition hover:text-slate-100"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" /> Voltar ao SteelXR
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Beaker className="h-5 w-5 text-amber-400" />
|
||||||
|
<h1 className="font-mono text-sm tracking-wider text-slate-300">
|
||||||
|
QUEST CUBE LAB <span className="text-amber-400/70">(alpha)</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`inline-flex h-2 w-2 rounded-full ${
|
||||||
|
joystickConnected ? "bg-emerald-400" : "bg-slate-600"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
{joystickConnected ? "Joystick OK" : "Sem joystick"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-6xl px-6 py-8">
|
||||||
|
<div className="mb-6 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-200">
|
||||||
|
<strong>Sandbox experimental — Fase 2.</strong> Cubo Three.js real (6 faces coloridas),
|
||||||
|
grid fino no chão, travar/destravar, joystick funcional.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar com Travar/Destravar + Reset + XR */}
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setLocked((v) => !v)}
|
||||||
|
className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition ${
|
||||||
|
locked
|
||||||
|
? "border-amber-500 bg-amber-500/15 text-amber-200"
|
||||||
|
: "border-slate-700 bg-slate-900 text-slate-300 hover:border-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{locked ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />}
|
||||||
|
{locked ? "Travado" : "Destravar"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-300 transition hover:border-slate-500"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
disabled={!xrSupported}
|
||||||
|
onClick={enterXR}
|
||||||
|
className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition ${
|
||||||
|
xrSupported
|
||||||
|
? "border-emerald-500/50 bg-emerald-500/10 text-emerald-200 hover:bg-emerald-500/20"
|
||||||
|
: "cursor-not-allowed border-slate-700 bg-slate-900 text-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Hand className="h-4 w-4" />
|
||||||
|
Entrar em AR (Passthrough)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Viewport 3D real */}
|
||||||
|
<section className="overflow-hidden rounded-xl border border-slate-800 bg-slate-950">
|
||||||
|
<div style={{ height: 460 }}>
|
||||||
|
<Canvas shadows>
|
||||||
|
<PerspectiveCamera makeDefault position={[3, 2.5, 4]} fov={50} />
|
||||||
|
<ambientLight intensity={0.4} />
|
||||||
|
<directionalLight
|
||||||
|
position={[5, 8, 5]}
|
||||||
|
intensity={1}
|
||||||
|
castShadow
|
||||||
|
shadow-mapSize-width={1024}
|
||||||
|
shadow-mapSize-height={1024}
|
||||||
|
/>
|
||||||
|
<CubeControlado
|
||||||
|
posRef={posRef}
|
||||||
|
rotRef={rotRef}
|
||||||
|
scaleRef={scaleRef}
|
||||||
|
lockedRef={lockedRef}
|
||||||
|
gamepadRef={gamepadRef}
|
||||||
|
/>
|
||||||
|
<Grid
|
||||||
|
args={[20, 20]}
|
||||||
|
cellSize={0.25}
|
||||||
|
cellThickness={0.5}
|
||||||
|
cellColor="#475569"
|
||||||
|
sectionSize={1}
|
||||||
|
sectionThickness={1}
|
||||||
|
sectionColor="#94a3b8"
|
||||||
|
fadeDistance={18}
|
||||||
|
fadeStrength={1}
|
||||||
|
infiniteGrid={false}
|
||||||
|
position={[0, 0, 0]}
|
||||||
|
/>
|
||||||
|
<mesh receiveShadow rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.001, 0]}>
|
||||||
|
<planeGeometry args={[20, 20]} />
|
||||||
|
<shadowMaterial transparent opacity={0.35} />
|
||||||
|
</mesh>
|
||||||
|
<OrbitControls makeDefault enableDamping />
|
||||||
|
<Environment preset="city" />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-slate-800 bg-slate-950/80 px-4 py-2">
|
||||||
|
<div className="font-mono text-[11px] text-slate-400">
|
||||||
|
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"}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[11px] uppercase tracking-wider text-slate-500">
|
||||||
|
{locked ? "modo: rotação livre" : "modo: livre"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 3 ControlCards */}
|
||||||
|
<section className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<ControlCard
|
||||||
|
icon={<Box className="h-4 w-4 text-cyan-300" />}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<ControlCard
|
||||||
|
icon={<Gamepad2 className="h-4 w-4 text-violet-300" />}
|
||||||
|
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 }))}
|
||||||
|
/>
|
||||||
|
<ControlCard
|
||||||
|
icon={<Maximize2Inline />}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-8 rounded-lg border border-slate-800 bg-slate-900/30 p-6">
|
||||||
|
<h2 className="mb-4 font-mono text-xs uppercase tracking-[0.2em] text-slate-400">
|
||||||
|
Próximas fases
|
||||||
|
</h2>
|
||||||
|
<ol className="space-y-2 text-sm text-slate-300">
|
||||||
|
<Phase n="1" status="done" title="Botão na Home + rota /lab" />
|
||||||
|
<Phase n="2" status="done" title="Cubo Three.js + Grid fino + Travar/Destravar + Joystick funcional" />
|
||||||
|
<Phase
|
||||||
|
n="2.5"
|
||||||
|
status="active"
|
||||||
|
title="Integração WebXR completa (@react-three/xr) — Passthrough real"
|
||||||
|
/>
|
||||||
|
<Phase n="3" title="Sistema 2-cliques (PAR CONTATO: face objeto + superfície alvo)" />
|
||||||
|
<Phase n="4" title="Sistema 2-cliques (PAR DIREÇÃO: face objeto + aresta lateral mesa)" />
|
||||||
|
<Phase n="5" title="Migração da lógica redonda pro SteelXR XR Session" />
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Maximize2Inline = () => (
|
||||||
|
<svg className="h-4 w-4 text-amber-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 21l-4.35-4.35" />
|
||||||
|
<path d="M11 5h6v6" />
|
||||||
|
<path d="M21 5L14 12" />
|
||||||
|
<path d="M5 21l7-7" />
|
||||||
|
<path d="M5 13V5h8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Phase = ({ n, status, title }: { n: string; status?: "done" | "active"; title: string }) => (
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full font-mono text-xs ${
|
||||||
|
status === "done"
|
||||||
|
? "bg-emerald-500/20 text-emerald-300"
|
||||||
|
: status === "active"
|
||||||
|
? "bg-amber-500/20 text-amber-300"
|
||||||
|
: "bg-slate-800 text-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-200">{title}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}) => (
|
||||||
|
<div className={`rounded-lg border border-slate-800 bg-slate-900/60 p-4 ${disabled ? "opacity-50" : ""}`}>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
{icon}
|
||||||
|
<span className="text-sm font-medium text-slate-200">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 font-mono text-xs text-slate-400">{value}</div>
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
<div />
|
||||||
|
<button
|
||||||
|
onClick={onUp}
|
||||||
|
disabled={disabled}
|
||||||
|
className="rounded border border-slate-700 bg-slate-950 py-1.5 text-xs hover:border-slate-500 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<div />
|
||||||
|
<button
|
||||||
|
onClick={onLeft}
|
||||||
|
disabled={disabled}
|
||||||
|
className="rounded border border-slate-700 bg-slate-950 py-1.5 text-xs hover:border-slate-500 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
◀
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDown}
|
||||||
|
disabled={disabled}
|
||||||
|
className="rounded border border-slate-700 bg-slate-950 py-1.5 text-xs hover:border-slate-500 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onRight}
|
||||||
|
disabled={disabled}
|
||||||
|
className="rounded border border-slate-700 bg-slate-950 py-1.5 text-xs hover:border-slate-500 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default QuestCubeLab;
|
||||||
@@ -12,6 +12,7 @@ import { validateModelFile } from '@/lib/validateModelFile';
|
|||||||
import { CloudLoader } from '@/components/CloudLoader';
|
import { CloudLoader } from '@/components/CloudLoader';
|
||||||
import { SceneModelList } from '@/components/SceneModelList';
|
import { SceneModelList } from '@/components/SceneModelList';
|
||||||
import { RecentFilesList } from '@/components/RecentFilesList';
|
import { RecentFilesList } from '@/components/RecentFilesList';
|
||||||
|
import LabButton from '@/lab/LabButton';
|
||||||
import { addRecentFile } from '@/lib/recentFiles';
|
import { addRecentFile } from '@/lib/recentFiles';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -369,6 +370,8 @@ const Index = () => {
|
|||||||
Reunião Virtual
|
Reunião Virtual
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<LabButton />
|
||||||
|
|
||||||
{/* XR Status */}
|
{/* XR Status */}
|
||||||
<div className="flex items-center justify-center gap-2 pt-2">
|
<div className="flex items-center justify-center gap-2 pt-2">
|
||||||
{xrSupported === null ?
|
{xrSupported === null ?
|
||||||
|
|||||||
Reference in New Issue
Block a user