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 { generateTrackingMarker, generateMarkerDownloadURL } from '@/lib/trackingMarker'; // Store tracking marker bitmap globally so it persists across renders let markerBitmapPromise: Promise | null = null; function getMarkerBitmap(): Promise { if (!markerBitmapPromise) { markerBitmapPromise = generateTrackingMarker(); } return markerBitmapPromise; } const xrStore = createXRStore({ emulate: 'metaQuest3', hand: { left: true, right: true }, controller: { left: true, right: true }, }); /** * The 3D model rendered inside the XR scene. */ 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 ( ); } /** * Controller-based fine tuning input handler. */ 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 store = useModelStore.getState(); const ft = { ...store.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; } /** * Image Tracking Anchor — uses WebXR's getImageTrackingResults() * to position the model at a detected QR marker. * Falls back to manual placement when tracking is lost or unavailable. */ function ImageTrackingAnchor({ children }: { children: React.ReactNode }) { const groupRef = useRef(null); const session = useXR((s) => s.session); const { gl } = useThree(); const { setAnchorMode } = useModelStore(); const trackingActive = useRef(false); const lastPoseMatrix = useRef(new THREE.Matrix4()); useFrame((_, __, frame: any) => { if (!frame || !session || !groupRef.current) return; // Try to get image tracking results from the XR frame try { const results = frame.getImageTrackingResults?.(); if (results && results.length > 0) { for (const result of results) { if (result.trackingState === 'tracked' || result.trackingState === 'emulated') { const refSpace = gl.xr.getReferenceSpace(); if (!refSpace) continue; const pose = frame.getPose(result.imageSpace, refSpace); if (pose) { const pos = pose.transform.position; const ori = pose.transform.orientation; groupRef.current.position.set(pos.x, pos.y, pos.z); groupRef.current.quaternion.set(ori.x, ori.y, ori.z, ori.w); if (!trackingActive.current) { trackingActive.current = true; setAnchorMode('tracking'); toast.success('QR Code detectado — Modelo ancorado!'); } return; } } } } } catch { // getImageTrackingResults not available — fall back to manual } // If tracking was active but lost if (trackingActive.current) { // Keep last known position (don't reset) return; } // Manual fallback — place 1.5m in front if (!trackingActive.current) { groupRef.current.position.set(0, 0, -1.5); groupRef.current.quaternion.identity(); } }); return {children}; } const XRSession = () => { const navigate = useNavigate(); const { model, xrSupported, anchorMode, setAnchorMode } = useModelStore(); const [inXR, setInXR] = useState(false); const [markerReady, setMarkerReady] = useState(false); useEffect(() => { if (!model) { navigate('/'); return; } // Pre-load marker bitmap getMarkerBitmap().then(() => setMarkerReady(true)); }, [model, navigate]); const handleEnterAR = useCallback(async () => { try { // We attempt to request with image-tracking as optional feature. // The createXRStore.enterAR will handle the session request. // We need to configure the session with trackedImages. // Since @react-three/xr doesn't directly support trackedImages, // we'll try to patch it through the native API. const bitmap = await getMarkerBitmap(); // Try entering AR with image tracking support // The @react-three/xr store manages the session, but we can // request image-tracking as an optional feature try { // Override: request session directly with trackedImages if (navigator.xr) { const session = await navigator.xr.requestSession('immersive-ar', { requiredFeatures: ['local-floor'], optionalFeatures: [ 'hand-tracking', 'image-tracking', 'anchors', 'plane-detection', ], // @ts-ignore — trackedImages is a WebXR extension trackedImages: [ { image: bitmap, widthInMeters: 0.15, // 15cm QR code }, ], }); // Let @react-three/xr know about this session // by entering through the store after manual session creation session.addEventListener('end', () => { setInXR(false); setAnchorMode('manual'); }); } } catch { // Fallback: enter AR without image tracking console.warn('Image tracking not available, using manual placement'); } await xrStore.enterAR(); setInXR(true); setAnchorMode('manual'); toast.success('Sessão XR iniciada — Buscando QR Code…'); } catch (err: any) { console.error('XR error:', err); toast.error(`Falha ao iniciar XR: ${err.message}`); } }, [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 && ( <> )} {inXR && (
XR Ativo {anchorMode === 'tracking' ? ( Tracking ) : ( Manual )}
)}
{/* 3D Canvas */}
{ gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); gl.setClearColor(0x000000, 0); }} > {/* Floating HUD overlay */}
{anchorMode === 'tracking' ? '● QR Detectado' : '○ Buscando QR…'}
Grip + Joy Esq: X/Y
Grip + Joy Dir: Z/Rot
); }; export default XRSession;