import { useEffect, useRef, useMemo, useCallback, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Canvas, useFrame, useThree } from '@react-three/fiber'; import { useGLTF } from '@react-three/drei'; import { XR, createXRStore, useXR, XROrigin } from '@react-three/xr'; import * as THREE from 'three'; import { useModelStore } from '@/stores/useModelStore'; import { toast } from 'sonner'; import { ArrowLeft, Move, RotateCw, QrCode, Download, Crosshair } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { generateMarkerDownloadURL } from '@/lib/trackingMarker'; // --- XR Store at MODULE level (required by Quest 3 browser) --- const store = createXRStore({ hand: { left: true, right: true }, controller: { left: true, right: true }, }); // ─── XRModel ─────────────────────────────────────────── function XRModel({ url }: { url: string }) { const { scene } = useGLTF(url); const ref = useRef(null); const fineTuning = useModelStore((s) => s.fineTuning); const opacity = useModelStore((s) => s.opacity); const renderMode = useModelStore((s) => s.renderMode); const checklist = useModelStore((s) => s.checklist); const modelInfo = useMemo(() => { const box = new THREE.Box3().setFromObject(scene); const size = new THREE.Vector3(); const center = new THREE.Vector3(); box.getSize(size); box.getCenter(center); return { size, center }; }, [scene]); useEffect(() => { const hasRejected = checklist.some(i => i.status === 'rejected'); const allApproved = checklist.every(i => i.status === 'approved'); scene.traverse((child) => { if (child instanceof THREE.Mesh) { if (Array.isArray(child.material)) { child.material = child.material.map(m => m.clone()); } else { child.material = child.material.clone(); } const materials = Array.isArray(child.material) ? child.material : [child.material]; materials.forEach((mat: THREE.Material) => { if (mat instanceof THREE.MeshStandardMaterial) { mat.transparent = opacity < 1; mat.opacity = opacity; mat.wireframe = renderMode === 'wireframe'; mat.needsUpdate = true; if (hasRejected) { mat.color.setHSL(0, 0.7, 0.5); } else if (allApproved) { mat.color.setHSL(0.38, 0.7, 0.45); } else { mat.color.set(0x8899aa); } } }); } }); }, [scene, opacity, renderMode, checklist]); const rotYRad = (fineTuning.rotY * Math.PI) / 180; return ( ); } // ─── ControllerFineTuning ────────────────────────────── function ControllerFineTuning() { const { setFineTuning } = useModelStore(); const session = useXR((s) => s.session); useFrame(() => { if (!session) return; const inputSources = session.inputSources; if (!inputSources) return; let leftAxes: number[] | null = null; let rightAxes: number[] | null = null; let gripHeld = false; for (const source of inputSources) { const gp = source.gamepad; if (!gp) continue; const gripButton = gp.buttons[2]; if (gripButton && gripButton.pressed) gripHeld = true; if (source.handedness === 'left' && gp.axes.length >= 4) { leftAxes = [gp.axes[2], gp.axes[3]]; } if (source.handedness === 'right' && gp.axes.length >= 4) { rightAxes = [gp.axes[2], gp.axes[3]]; } } if (!gripHeld) return; const posStep = 0.001; const rotStep = 0.1; const deadzone = 0.15; const modelStore = useModelStore.getState(); const ft = { ...modelStore.fineTuning }; if (leftAxes) { if (Math.abs(leftAxes[0]) > deadzone) ft.posX += leftAxes[0] * posStep; if (Math.abs(leftAxes[1]) > deadzone) ft.posY -= leftAxes[1] * posStep; } if (rightAxes) { if (Math.abs(rightAxes[1]) > deadzone) ft.posZ += rightAxes[1] * posStep; if (Math.abs(rightAxes[0]) > deadzone) ft.rotY += rightAxes[0] * rotStep; } setFineTuning(ft); }); return null; } // ─── ImageTrackingAnchor ─────────────────────────────── function ImageTrackingAnchor({ children }: { children: React.ReactNode }) { const groupRef = useRef(null); useFrame(() => { if (!groupRef.current) return; // Place model 1.5m in front of the user groupRef.current.position.set(0, 0, -1.5); groupRef.current.quaternion.identity(); }); return {children}; } // ─── XRSession Page ──────────────────────────────────── const XRSession = () => { const navigate = useNavigate(); const { model, anchorMode, setAnchorMode } = useModelStore(); const [inXR, setInXR] = useState(false); useEffect(() => { if (!model) { navigate('/'); } }, [model, navigate]); // Listen for XR session state changes useEffect(() => { const unsubscribe = store.subscribe((state) => { const session = state.session; if (session && !inXR) { setInXR(true); setAnchorMode('manual'); toast.success('Sessão AR iniciada!'); session.addEventListener('end', () => { setInXR(false); setAnchorMode('manual'); }); } }); return unsubscribe; }, [inXR, setAnchorMode]); const handleDownloadMarker = useCallback(async () => { const url = await generateMarkerDownloadURL(); const a = document.createElement('a'); a.href = url; a.download = 'TrackkSteelXR_Marker.png'; a.click(); URL.revokeObjectURL(url); toast.success('Marcador baixado — Imprima em 15×15cm'); }, []); if (!model) return null; return (
{/* Header */}

Modo XR Imersivo

{!inXR && ( <> {/* CRITICAL: Direct call without async wrapper to preserve user gesture */} )} {inXR && (
XR Ativo {anchorMode === 'tracking' ? ( Tracking ) : ( Manual )}
)}
{/* 3D Canvas */}
{ gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); gl.setClearColor(0x000000, 0); }} > {/* Floating HUD overlay */}
Grip + Joy Esq: X/Y
Grip + Joy Dir: Z/Rot
); }; export default XRSession;