import { useEffect, useRef, useMemo, useCallback, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Canvas, useFrame, useThree } from '@react-three/fiber'; import { useGLTF, Grid, Html, Line } 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, Download, QrCode, Crosshair } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { generateMarkerDownloadURL } from '@/lib/trackingMarker'; import { XRHud } from '@/components/XRHud'; import { findNearestVertex } from '@/components/three/SmartMeasure'; import { XRHitTestPlacement } from '@/components/three/XRHitTestPlacement'; // --- Diagnóstico XR --- console.log('[XR] Inicializando store...'); if (navigator.xr) { navigator.xr.isSessionSupported('immersive-ar').then((supported) => { console.log('[XR] immersive-ar suportado:', supported); }); } 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 wireframeColor = useModelStore((s) => s.wireframeColor); const wireframeThickness = useModelStore((s) => s.wireframeThickness); const edgeThresholdAngle = useModelStore((s) => s.edgeThresholdAngle); 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(); } // Clean up previous edge lines const toRemove: THREE.Object3D[] = []; child.children.forEach(c => { if (c.userData.__edgeLine) toRemove.push(c); }); toRemove.forEach(c => { if (c instanceof THREE.LineSegments) { c.geometry.dispose(); (c.material as THREE.Material).dispose(); } child.remove(c); }); const materials = Array.isArray(child.material) ? child.material : [child.material]; materials.forEach((mat: THREE.Material) => { if (mat instanceof THREE.MeshStandardMaterial) { if (renderMode === 'edges') { mat.visible = false; } else { mat.visible = true; mat.transparent = opacity < 1; mat.opacity = opacity; mat.wireframe = renderMode === 'wireframe'; if (renderMode === 'wireframe') { mat.wireframeLinewidth = wireframeThickness; mat.color.set(wireframeColor); } else 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); } } mat.needsUpdate = true; } }); if (renderMode === 'edges' && child.geometry) { const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle); const lineMat = new THREE.LineBasicMaterial({ color: wireframeColor, linewidth: wireframeThickness }); const lineSegments = new THREE.LineSegments(edgesGeo, lineMat); lineSegments.userData.__edgeLine = true; child.add(lineSegments); } } }); }, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle]); const rotYRad = (fineTuning.rotY * Math.PI) / 180; return ( ); } // ─── ControllerFineTuning ────────────────────────────── function ControllerFineTuning({ freeMove }: { freeMove: boolean }) { 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]]; } } // In freeMove mode, joystick always moves. Otherwise requires grip. if (!freeMove && !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; } // ─── Measurement overlay (reused from ModelViewer) ───── function XRMeasurementOverlay() { const measurements = useModelStore((s) => s.measurements); const measurePoints = useModelStore((s) => s.measurePoints); const snapPoint = useModelStore((s) => s.snapPoint); const measureMode = useModelStore((s) => s.measureMode); return ( <> {measureMode && snapPoint && ( )} {measurePoints.length === 1 && ( )} {measurements.map((m) => { const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z]; const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z]; const mid: [number, number, number] = [(a[0]+b[0])/2, (a[1]+b[1])/2, (a[2]+b[2])/2]; return (
{m.distanceMM.toFixed(1)} mm
); })} ); } // ─── Snap handler for XR raycasting ──────────────────── function XRSnapHandler() { const { camera, scene, gl } = useThree(); const measureMode = useModelStore((s) => s.measureMode); const setSnapPoint = useModelStore((s) => s.setSnapPoint); const addMeasurePoint = useModelStore((s) => s.addMeasurePoint); const snapPoint = useModelStore((s) => s.snapPoint); const raycaster = useMemo(() => new THREE.Raycaster(), []); const mouse = useMemo(() => new THREE.Vector2(), []); useEffect(() => { if (!measureMode) return; const onMove = (e: MouseEvent) => { const rect = gl.domElement.getBoundingClientRect(); mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; }; gl.domElement.addEventListener('pointermove', onMove); return () => gl.domElement.removeEventListener('pointermove', onMove); }, [gl, mouse, measureMode]); useFrame(() => { if (!measureMode) { setSnapPoint(null); return; } raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(scene.children, true); const hit = intersects.find(i => { const obj = i.object; if (obj instanceof THREE.GridHelper) return false; if (obj instanceof THREE.Mesh && (obj.geometry instanceof THREE.SphereGeometry || obj.geometry instanceof THREE.RingGeometry)) return false; if (obj.userData.__edgeLine) return false; return obj instanceof THREE.Mesh; }); if (hit && hit.object instanceof THREE.Mesh) { const canvas = gl.domElement; const snap = findNearestVertex(hit.object, hit.point, camera, { width: canvas.clientWidth, height: canvas.clientHeight }, 10); setSnapPoint(snap ? { x: snap.x, y: snap.y, z: snap.z } : null); } else { setSnapPoint(null); } }); // Click to measure useEffect(() => { if (!measureMode) return; const onClick = (e: MouseEvent) => { if (snapPoint) { addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z }); return; } const rect = gl.domElement.getBoundingClientRect(); mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(scene.children, true); const hit = intersects.find(i => i.object instanceof THREE.Mesh && !i.object.userData.__edgeLine); if (hit) addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z }); }; gl.domElement.addEventListener('click', onClick); return () => gl.domElement.removeEventListener('click', onClick); }, [measureMode, snapPoint, gl, camera, scene, raycaster, mouse, addMeasurePoint]); useEffect(() => { gl.domElement.style.cursor = measureMode ? 'crosshair' : 'grab'; return () => { gl.domElement.style.cursor = 'grab'; }; }, [measureMode, gl]); return null; } // ─── XR Grid ─────────────────────────────────────────── function XRGrid() { const showGrid = useModelStore((s) => s.showGrid); if (!showGrid) return null; return ( ); } // ImageTrackingAnchor removed — replaced by XRHitTestPlacement // ─── XRSession Page ──────────────────────────────────── const XRSession = () => { const navigate = useNavigate(); const { model, anchorMode, setAnchorMode } = useModelStore(); const [inXR, setInXR] = useState(false); const [freeMove, setFreeMove] = useState(true); const [placementMode, setPlacementMode] = useState(true); // start in placement mode const [snapToPlanes, setSnapToPlanes] = useState(true); useEffect(() => { if (!model) navigate('/'); }, [model, navigate]); useEffect(() => { const unsubscribe = store.subscribe((state) => { const session = state.session; if (session && !inXR) { console.log('[XR] ✅ Sessão AR ativa!'); setInXR(true); setAnchorMode('manual'); toast.success('Sessão AR iniciada!'); session.addEventListener('end', () => { console.log('[XR] ❌ Sessão AR encerrada'); setInXR(false); }); } }); return unsubscribe; }, [inXR, setAnchorMode]); const handleDownloadMarker = useCallback(async () => { const url = await generateMarkerDownloadURL(); const a = document.createElement('a'); a.href = url; a.download = 'TrackSteelXR_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 )}
{/* 3D Canvas */}
{ gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); gl.setClearColor(0x000000, 0); const loop = () => { invalidate(); requestAnimationFrame(loop); }; loop(); }} > { setPlacementMode(false); toast.success('Modelo posicionado na superfície!'); }} > {/* Floating HUD overlay with all controls */} setFreeMove(!freeMove)} placementMode={placementMode} onTogglePlacement={() => setPlacementMode(!placementMode)} snapToPlanes={snapToPlanes} onToggleSnap={() => setSnapToPlanes(!snapToPlanes)} />
); }; export default XRSession;