🚀 Auto-deploy: melhoria no snap e medição AR em 20/07/2026 21:36:42

This commit is contained in:
2026-07-20 21:36:42 +00:00
parent 1f83a82c92
commit 7801c779d1
+217 -164
View File
@@ -14,37 +14,32 @@ import {
Lock, Lock,
Unlock, Unlock,
RotateCcw, RotateCcw,
Box,
Gamepad2,
Hand, Hand,
} from "lucide-react"; } from "lucide-react";
/** /**
* ============================================================ * ============================================================
* QUEST CUBE LAB — Fase 2 * QUEST CUBE LAB — Fase 3 e 4
* - Cubo 3D real (Three.js via @react-three/fiber) * - Cubo 3D real + Mesa de dois níveis
* - Grid fino no plano y=0 (Drei <Grid>) * - Sistema de alinhamento por clique duplo:
* - Toggle Travar/Destravar (upgrades decididos pelo Marcos) * 1. Seleciona face do cubo
* - Joystick funcional (gamepad API) — analógico esq translação, dir rotação, * 2. Seleciona superfície da mesa (tampo ou prateleira) -> cubo deita na superfície
* triggers LB/RB escala, A reseta cubo * 3. Seleciona outra face do cubo (ortogonal à primeira)
* - OrbitControls pra teste em PC (amigo pode testar com mouse antes de VR) * 4. Seleciona aresta (lateral/frente) da mesa -> cubo rotaciona e alinha face
* - WebXR entry button: suportado se browser for MetaQuest Browser
* ============================================================ * ============================================================
*/ */
// Limiares analógicos (deadzone do joystick)
const STICK_DEADZONE = 0.12; const STICK_DEADZONE = 0.12;
const TRIGGER_DEADZONE = 0.05; const TRIGGER_DEADZONE = 0.05;
const MOVE_SPEED = 1.5; // m/s lógico const MOVE_SPEED = 1.5;
const ROT_SPEED = 1.2; // rad/s const ROT_SPEED = 1.2;
const SCALE_SPEED = 0.6; const SCALE_SPEED = 0.6;
// Dimensão inicial do cubo (1m x 1m x 1m)
const CUBE_SIZE = 1; const CUBE_SIZE = 1;
type AlignPhase = 'IDLE' | 'WAIT_SURFACE' | 'WAIT_FACE2' | 'WAIT_EDGE';
// =================================================================== // ===================================================================
// COMPONENTE: CuboControlado // COMPONENTE: CuboControlado
// Recebe refs compartilhadas pro estado, atualiza a cada frame.
// =================================================================== // ===================================================================
const CubeControlado = ({ const CubeControlado = ({
posRef, posRef,
@@ -52,73 +47,64 @@ const CubeControlado = ({
scaleRef, scaleRef,
lockedRef, lockedRef,
gamepadRef, gamepadRef,
alignPhase,
setAlignPhase,
face1Ref,
face2Ref,
}: { }: {
posRef: React.MutableRefObject<{ x: number; y: number; z: number }>; posRef: React.MutableRefObject<{ x: number; y: number; z: number }>;
rotRef: React.MutableRefObject<{ x: number; y: number; z: number }>; rotRef: React.MutableRefObject<{ x: number; y: number; z: number }>;
scaleRef: React.MutableRefObject<number>; scaleRef: React.MutableRefObject<number>;
lockedRef: React.MutableRefObject<boolean>; lockedRef: React.MutableRefObject<boolean>;
gamepadRef: React.MutableRefObject<{ a: boolean }>; gamepadRef: React.MutableRefObject<{ a: boolean }>;
alignPhase: AlignPhase;
setAlignPhase: (p: AlignPhase) => void;
face1Ref: React.MutableRefObject<THREE.Vector3>;
face2Ref: React.MutableRefObject<THREE.Vector3>;
}) => { }) => {
const meshRef = useRef<THREE.Mesh>(null); const meshRef = useRef<THREE.Mesh>(null);
// Material do cubo com 6 faces coloridas (Pra identificar cada face no AR)
const materials = [ const materials = [
new THREE.MeshStandardMaterial({ color: "#ef4444" }), // +x - vermelho new THREE.MeshStandardMaterial({ color: "#ef4444" }), // +x
new THREE.MeshStandardMaterial({ color: "#22c55e" }), // -x - verde new THREE.MeshStandardMaterial({ color: "#22c55e" }), // -x
new THREE.MeshStandardMaterial({ color: "#3b82f6" }), // +y - azul new THREE.MeshStandardMaterial({ color: "#3b82f6" }), // +y
new THREE.MeshStandardMaterial({ color: "#facc15" }), // -y - amarelo new THREE.MeshStandardMaterial({ color: "#facc15" }), // -y
new THREE.MeshStandardMaterial({ color: "#a855f7" }), // +z - roxo new THREE.MeshStandardMaterial({ color: "#a855f7" }), // +z
new THREE.MeshStandardMaterial({ color: "#06b6d4" }), // -z - ciano new THREE.MeshStandardMaterial({ color: "#06b6d4" }), // -z
]; ];
useFrame((state, delta) => { useFrame((state, delta) => {
if (!meshRef.current) return; 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 gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
const gp = gamepads[0]; const gp = gamepads[0];
if (gp && !lockedRef.current) { if (gp && !lockedRef.current && alignPhase === 'IDLE') {
// Stick esquerdo -> translação XZ (FRente/tras, esquerda/direita) const lx = gp.axes[0] ?? 0;
const lx = gp.axes[0] ?? 0; // esquerda(+)/direita(-) const ly = gp.axes[1] ?? 0;
const ly = gp.axes[1] ?? 0; // frente(-)/tras(+)
// Stick direito -> rotação yaw (Y) e pitch (X)
const rx = gp.axes[2] ?? 0; const rx = gp.axes[2] ?? 0;
const ry = gp.axes[3] ?? 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 lt = gp.buttons[6]?.value ?? 0;
const rt = gp.buttons[7]?.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; const aBtn = gp.buttons[0]?.pressed;
// Deadzone + escala
const ax = Math.abs(lx) > STICK_DEADZONE ? lx : 0; const ax = Math.abs(lx) > STICK_DEADZONE ? lx : 0;
const ay = Math.abs(ly) > STICK_DEADZONE ? ly : 0; const ay = Math.abs(ly) > STICK_DEADZONE ? ly : 0;
const arx = Math.abs(rx) > STICK_DEADZONE ? rx : 0; const arx = Math.abs(rx) > STICK_DEADZONE ? rx : 0;
const ary = Math.abs(ry) > STICK_DEADZONE ? ry : 0; const ary = Math.abs(ry) > STICK_DEADZONE ? ry : 0;
// Aplica translação
posRef.current.x -= ax * MOVE_SPEED * delta; posRef.current.x -= ax * MOVE_SPEED * delta;
posRef.current.z -= ay * 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 posRef.current.y = Math.max(0.5, posRef.current.y);
// Aplica rotação
rotRef.current.x = (rotRef.current.x + ary * ROT_SPEED * delta) % (Math.PI * 2); 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); rotRef.current.y = (rotRef.current.y - arx * ROT_SPEED * delta) % (Math.PI * 2);
// Triggers -> escala (LT encolhe, RT cresce)
const scaleDelta = rt - lt; const scaleDelta = rt - lt;
if (Math.abs(scaleDelta) > TRIGGER_DEADZONE) { if (Math.abs(scaleDelta) > TRIGGER_DEADZONE) {
scaleRef.current = Math.max( scaleRef.current = Math.max(0.2, Math.min(3, scaleRef.current + scaleDelta * SCALE_SPEED * delta));
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) { if (aBtn && !gamepadRef.current.a) {
posRef.current = { x: 0, y: 0.5, z: 0 }; posRef.current = { x: 0, y: 0.5, z: 0 };
rotRef.current = { x: 0, y: 0, z: 0 }; rotRef.current = { x: 0, y: 0, z: 0 };
@@ -126,7 +112,6 @@ const CubeControlado = ({
} }
gamepadRef.current.a = aBtn; gamepadRef.current.a = aBtn;
} else if (gp && lockedRef.current) { } else if (gp && lockedRef.current) {
// Se travado, só rotação por joystick (yaw/pitch)
const rx = gp.axes[2] ?? 0; const rx = gp.axes[2] ?? 0;
const ry = gp.axes[3] ?? 0; const ry = gp.axes[3] ?? 0;
const arx = Math.abs(rx) > STICK_DEADZONE ? rx : 0; const arx = Math.abs(rx) > STICK_DEADZONE ? rx : 0;
@@ -135,25 +120,88 @@ const CubeControlado = ({
rotRef.current.y = (rotRef.current.y - arx * 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.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.rotation.set(rotRef.current.x, rotRef.current.y, rotRef.current.z);
meshRef.current.scale.setScalar(scaleRef.current); meshRef.current.scale.setScalar(scaleRef.current);
}); });
return ( return (
<mesh ref={meshRef} castShadow receiveShadow position={[0, 0.5, 0]}> <mesh
ref={meshRef}
castShadow
receiveShadow
position={[0, 0.5, 0]}
onClick={(e) => {
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'; }}
>
<boxGeometry args={[CUBE_SIZE, CUBE_SIZE, CUBE_SIZE]} /> <boxGeometry args={[CUBE_SIZE, CUBE_SIZE, CUBE_SIZE]} />
{materials.map((m, i) => ( {materials.map((m, i) => (
<primitive attach="material-0" object={m} key={i} /> <primitive attach={`material-${i}`} object={m} key={i} />
))} ))}
</mesh> </mesh>
); );
}; };
// =================================================================== // ===================================================================
// Detector de gamepad (sinaliza status pra UI) // 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 (
<group position={[0, 0, -2]}>
{/* Tampo Superior */}
<mesh position={[0, 1, 0]} onClick={handleClick} castShadow receiveShadow>
<boxGeometry args={[2, 0.1, 1]} />
<meshStandardMaterial color="#8B4513" />
</mesh>
{/* Prateleira Inferior */}
<mesh position={[0, 0.5, 0]} onClick={handleClick} castShadow receiveShadow>
<boxGeometry args={[2, 0.1, 1]} />
<meshStandardMaterial color="#A0522D" />
</mesh>
{/* Pés */}
<mesh position={[-0.9, 0.5, 0.4]} castShadow receiveShadow><boxGeometry args={[0.1, 1, 0.1]} /><meshStandardMaterial color="#5c3a21" /></mesh>
<mesh position={[0.9, 0.5, 0.4]} castShadow receiveShadow><boxGeometry args={[0.1, 1, 0.1]} /><meshStandardMaterial color="#5c3a21" /></mesh>
<mesh position={[-0.9, 0.5, -0.4]} castShadow receiveShadow><boxGeometry args={[0.1, 1, 0.1]} /><meshStandardMaterial color="#5c3a21" /></mesh>
<mesh position={[0.9, 0.5, -0.4]} castShadow receiveShadow><boxGeometry args={[0.1, 1, 0.1]} /><meshStandardMaterial color="#5c3a21" /></mesh>
</group>
);
};
const useGamepadStatus = () => { const useGamepadStatus = () => {
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
useEffect(() => { useEffect(() => {
@@ -164,7 +212,7 @@ const useGamepadStatus = () => {
window.addEventListener("gamepadconnected", handler); window.addEventListener("gamepadconnected", handler);
window.addEventListener("gamepaddisconnected", handler); window.addEventListener("gamepaddisconnected", handler);
handler(); handler();
const i = window.setInterval(handler, 1000); // poll, porque alguns headsets só conectam após entrar em XR const i = window.setInterval(handler, 1000);
return () => { return () => {
window.removeEventListener("gamepadconnected", handler); window.removeEventListener("gamepadconnected", handler);
window.removeEventListener("gamepaddisconnected", handler); window.removeEventListener("gamepaddisconnected", handler);
@@ -175,44 +223,38 @@ const useGamepadStatus = () => {
}; };
// =================================================================== // ===================================================================
// Página principal QuestCubeLab // Página principal
// =================================================================== // ===================================================================
const QuestCubeLab = () => { const QuestCubeLab = () => {
const joystickConnected = useGamepadStatus(); const joystickConnected = useGamepadStatus();
// Estado editável via UI (ControlCards e sliders)
const [cubePos, setCubePos] = useState({ x: 0, y: 0.5, z: 0 }); const [cubePos, setCubePos] = useState({ x: 0, y: 0.5, z: 0 });
const [cubeRot, setCubeRot] = useState({ x: 0, y: 0, z: 0 }); const [cubeRot, setCubeRot] = useState({ x: 0, y: 0, z: 0 });
const [cubeScale, setCubeScale] = useState(1); const [cubeScale, setCubeScale] = useState(1);
const [locked, setLocked] = useState(false); const [locked, setLocked] = useState(false);
const [alignPhase, setAlignPhase] = useState<AlignPhase>('IDLE');
const face1Ref = useRef(new THREE.Vector3());
const face2Ref = useRef(new THREE.Vector3());
const surfaceNormalRef = useRef(new THREE.Vector3());
const [xrSupported] = useState(() => const [xrSupported] = useState(() =>
typeof navigator !== "undefined" && typeof navigator !== "undefined" &&
"xr" in navigator && "xr" in navigator &&
typeof (navigator as unknown as { xr?: { isSessionSupported?: (m: string) => Promise<boolean> } }).xr?.isSessionSupported === "function" typeof (navigator as any).xr?.isSessionSupported === "function"
); );
// Refs pra o gamepad loop no useFrame ler/escrever sem re-render
const posRef = useRef(cubePos); const posRef = useRef(cubePos);
const rotRef = useRef(cubeRot); const rotRef = useRef(cubeRot);
const scaleRef = useRef(cubeScale); const scaleRef = useRef(cubeScale);
const lockedRef = useRef(locked); const lockedRef = useRef(locked);
const gamepadRef = useRef({ a: false }); const gamepadRef = useRef({ a: false });
// Sincroniza refs com state (escrita do state -> refs; leitura do gamepad -> state) useEffect(() => { posRef.current = cubePos; }, [cubePos]);
useEffect(() => { useEffect(() => { rotRef.current = cubeRot; }, [cubeRot]);
posRef.current = cubePos; useEffect(() => { scaleRef.current = cubeScale; }, [cubeScale]);
}, [cubePos]); useEffect(() => { lockedRef.current = locked; }, [locked]);
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(() => { useEffect(() => {
const id = window.setInterval(() => { const id = window.setInterval(() => {
setCubePos({ ...posRef.current }); setCubePos({ ...posRef.current });
@@ -229,33 +271,72 @@ const QuestCubeLab = () => {
setCubePos({ x: 0, y: 0.5, z: 0 }); setCubePos({ x: 0, y: 0.5, z: 0 });
setCubeRot({ x: 0, y: 0, z: 0 }); setCubeRot({ x: 0, y: 0, z: 0 });
setCubeScale(1); setCubeScale(1);
setAlignPhase('IDLE');
}; };
// Helper pra entrar em XR (sessão imersiva-ar com Passthrough do Quest 3)
const enterXR = async () => { const enterXR = async () => {
try { try {
const xr = (navigator as unknown as { xr: { requestSession: (m: string) => Promise<XRSession> } }).xr; const xr = (navigator as any).xr;
const session = await xr.requestSession("immersive-ar"); 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; void session;
alert("XR session requisitada. (Para integração completa com R3F, falta instalar @react-three/xr — Fase 2.5)."); alert("XR session requisitada.");
} catch (e) { } catch (e) {
alert("Falha ao entrar em XR: " + (e as Error).message); 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 ( return (
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 text-slate-100"> <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"> <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"> <div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-6 py-4">
<Link <Link to="/" className="flex items-center gap-2 text-sm text-slate-400 transition hover:text-slate-100">
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 <ArrowLeft className="h-4 w-4" /> Voltar ao SteelXR
</Link> </Link>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -265,95 +346,96 @@ const QuestCubeLab = () => {
</h1> </h1>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span className={`inline-flex h-2 w-2 rounded-full ${joystickConnected ? "bg-emerald-400" : "bg-slate-600"}`} />
className={`inline-flex h-2 w-2 rounded-full ${ <span className="text-xs text-slate-400">{joystickConnected ? "Joystick OK" : "Sem joystick"}</span>
joystickConnected ? "bg-emerald-400" : "bg-slate-600"
}`}
/>
<span className="text-xs text-slate-400">
{joystickConnected ? "Joystick OK" : "Sem joystick"}
</span>
</div> </div>
</div> </div>
</header> </header>
<main className="mx-auto max-w-6xl px-6 py-8"> <main className="mx-auto max-w-6xl px-6 py-8 relative">
<div className="mb-6 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-200"> <div className="mb-6 flex gap-4">
<strong>Sandbox experimental Fase 2.</strong> Cubo Three.js real (6 faces coloridas), <div className="flex-1 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-200">
grid fino no chão, travar/destravar, joystick funcional. <strong>Fase 3/4 Sistema de Alinhamento:</strong>
<ul className="mt-2 list-disc pl-5">
<li>1. Clique numa face do cubo; depois, numa superfície da mesa (Tampo ou Prateleira).</li>
<li>2. Clique noutra face ortogonal do cubo; depois, numa borda/lateral da mesa.</li>
</ul>
</div>
</div> </div>
{/* Toolbar com Travar/Destravar + Reset + XR */}
<div className="mb-4 flex flex-wrap items-center gap-2"> <div className="mb-4 flex flex-wrap items-center gap-2">
<button <button
onClick={() => setLocked((v) => !v)} onClick={() => setLocked((v) => !v)}
className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition ${ className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition ${
locked locked ? "border-amber-500 bg-amber-500/15 text-amber-200" : "border-slate-700 bg-slate-900 text-slate-300 hover:border-slate-500"
? "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 ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />}
{locked ? "Travado" : "Destravar"} {locked ? "Travado" : "Destravar"}
</button> </button>
<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">
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" /> <RotateCcw className="h-4 w-4" />
Reset Reset
</button> </button>
{alignPhase !== 'IDLE' && (
<button onClick={() => setAlignPhase('IDLE')} className="flex items-center gap-2 rounded-md border border-red-500/50 bg-red-900/30 px-3 py-2 text-sm text-red-300 transition hover:border-red-500">
Cancelar Alinhamento
</button>
)}
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<button <button
disabled={!xrSupported} disabled={!xrSupported}
onClick={enterXR} onClick={enterXR}
className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition ${ className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition ${
xrSupported 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"
? "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" /> <Hand className="h-4 w-4" />
Entrar em AR (Passthrough) Entrar em AR
</button> </button>
</div> </div>
</div> </div>
{/* Viewport 3D real */} <section className="relative overflow-hidden rounded-xl border border-slate-800 bg-slate-950">
<section className="overflow-hidden rounded-xl border border-slate-800 bg-slate-950"> <div className="absolute top-4 left-4 z-10 rounded bg-slate-900/90 p-4 text-sm border border-slate-700/50 backdrop-blur pointer-events-none max-w-sm">
<div style={{ height: 460 }}> <div className="font-bold text-amber-300 mb-1 flex items-center gap-2">
Status Alinhamento
</div>
<div className="text-slate-300">
{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.'}
</div>
</div>
<div style={{ height: 600 }}>
<Canvas shadows> <Canvas shadows>
<PerspectiveCamera makeDefault position={[3, 2.5, 4]} fov={50} /> <PerspectiveCamera makeDefault position={[3, 4, 6]} fov={50} />
<ambientLight intensity={0.4} /> <ambientLight intensity={0.5} />
<directionalLight <directionalLight position={[5, 8, 5]} intensity={1} castShadow shadow-mapSize-width={1024} shadow-mapSize-height={1024} />
position={[5, 8, 5]}
intensity={1}
castShadow
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
/>
<CubeControlado <CubeControlado
posRef={posRef} posRef={posRef}
rotRef={rotRef} rotRef={rotRef}
scaleRef={scaleRef} scaleRef={scaleRef}
lockedRef={lockedRef} lockedRef={lockedRef}
gamepadRef={gamepadRef} gamepadRef={gamepadRef}
alignPhase={alignPhase}
setAlignPhase={setAlignPhase}
face1Ref={face1Ref}
face2Ref={face2Ref}
/> />
<Grid
args={[20, 20]} <Table
cellSize={0.25} onSurfaceClick={handleSurfaceClick}
cellThickness={0.5} onEdgeClick={handleEdgeClick}
cellColor="#475569"
sectionSize={1}
sectionThickness={1}
sectionColor="#94a3b8"
fadeDistance={18}
fadeStrength={1}
infiniteGrid={false}
position={[0, 0, 0]}
/> />
<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]}> <mesh receiveShadow rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.001, 0]}>
<planeGeometry args={[20, 20]} /> <planeGeometry args={[20, 20]} />
<shadowMaterial transparent opacity={0.35} /> <shadowMaterial transparent opacity={0.35} />
@@ -365,33 +447,20 @@ const QuestCubeLab = () => {
<div className="flex items-center justify-between border-t border-slate-800 bg-slate-950/80 px-4 py-2"> <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"> <div className="font-mono text-[11px] text-slate-400">
pos({cubePos.x.toFixed(2)}, {cubePos.y.toFixed(2)}, {cubePos.z.toFixed(2)}) · rot( 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
{((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>
</div> </div>
</section> </section>
<section className="mt-8 rounded-lg border border-slate-800 bg-slate-900/30 p-6"> <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"> <h2 className="mb-4 font-mono text-xs uppercase tracking-[0.2em] text-slate-400">Próximas fases</h2>
Próximas fases
</h2>
<ol className="space-y-2 text-sm text-slate-300"> <ol className="space-y-2 text-sm text-slate-300">
<Phase n="1" status="done" title="Botão na Home + rota /lab" /> <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" status="done" title="Cubo Three.js + Grid fino + Travar/Destravar + Joystick funcional" />
<Phase <Phase n="3" status="done" title="Sistema de Apoio (face objeto + superfície alvo) - Tampo/Prateleira" />
n="2.5" <Phase n="4" status="done" title="Sistema Direcional (face objeto + aresta alvo)" />
status="active" <Phase n="5" title="Integração WebXR completa (@react-three/xr) — Passthrough real" />
title="Integração WebXR completa (@react-three/xr) — Passthrough real" <Phase n="6" title="Migração da lógica redonda pro SteelXR XR Session principal" />
/>
<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> </ol>
</section> </section>
</main> </main>
@@ -399,25 +468,11 @@ const QuestCubeLab = () => {
); );
}; };
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 }) => ( const Phase = ({ n, status, title }: { n: string; status?: "done" | "active"; title: string }) => (
<li className="flex items-start gap-3"> <li className="flex items-start gap-3">
<span <span
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full font-mono text-xs ${ className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full font-mono text-xs ${
status === "done" status === "done" ? "bg-emerald-500/20 text-emerald-300" : status === "active" ? "bg-amber-500/20 text-amber-300" : "bg-slate-800 text-slate-500"
? "bg-emerald-500/20 text-emerald-300"
: status === "active"
? "bg-amber-500/20 text-amber-300"
: "bg-slate-800 text-slate-500"
}`} }`}
> >
{n} {n}
@@ -426,6 +481,4 @@ const Phase = ({ n, status, title }: { n: string; status?: "done" | "active"; ti
</li> </li>
); );
export default QuestCubeLab; export default QuestCubeLab;