From 67d68596063c9c87353d33a93f791a96f1b7a7ff Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:11:41 +0000 Subject: [PATCH] Changes --- src/components/XRHud.tsx | 171 ++++++++++++++++++++++++ src/pages/XRSession.tsx | 277 +++++++++++++++++++++++++++++---------- 2 files changed, 376 insertions(+), 72 deletions(-) create mode 100644 src/components/XRHud.tsx diff --git a/src/components/XRHud.tsx b/src/components/XRHud.tsx new file mode 100644 index 0000000..83ea382 --- /dev/null +++ b/src/components/XRHud.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react'; +import { + Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera, + Move, RotateCw, RefreshCw, ChevronUp, ChevronDown, Grip, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Slider } from '@/components/ui/slider'; +import { useModelStore } from '@/stores/useModelStore'; +import { toast } from 'sonner'; + +const POSITION_STEP = 0.001; +const ROTATION_STEP = 0.1; + +interface XRHudProps { + /** Whether joystick moves model freely (without grip) */ + freeMove: boolean; + onToggleFreeMove: () => void; +} + +export function XRHud({ freeMove, onToggleFreeMove }: XRHudProps) { + const [expanded, setExpanded] = useState(false); + + const { + opacity, setOpacity, + renderMode, setRenderMode, + showGrid, setShowGrid, + measureMode, setMeasureMode, + measurements, clearMeasurements, measurePoints, + fineTuning, setFineTuning, resetFineTuning, + addScreenshot, + } = useModelStore(); + + const handleScreenshot = () => { + const canvas = document.querySelector('canvas'); + if (!canvas) return; + const dataUrl = canvas.toDataURL('image/png'); + addScreenshot(dataUrl); + toast.success('Screenshot capturado!'); + }; + + return ( +
+
+ {/* Expanded panel */} + {expanded && ( +
+ {/* Opacity */} +
+
+
+ + Opacidade +
+ {Math.round(opacity * 100)}% +
+ setOpacity(v / 100)} + /> +
+ + {/* Fine Tuning */} +
+
+
+ + Posição (mm) +
+ +
+
+ {(['posX', 'posY', 'posZ'] as const).map((axis) => ( +
+ {axis.slice(-1).toUpperCase()} + + + {(fineTuning[axis] * 1000).toFixed(1)} + + +
+ ))} +
+
+ + + + + {fineTuning.rotY.toFixed(1)} + + +
+
+
+ )} + + {/* Main toolbar */} +
+
+ {/* Expand/collapse */} + + + {/* Free move toggle */} + + + {/* Grid */} + + + {/* Render mode */} + + + {/* Measure */} + + + {measurements.length > 0 && ( + + )} + + {/* Screenshot */} + +
+
+
+
+ ); +} diff --git a/src/pages/XRSession.tsx b/src/pages/XRSession.tsx index 2c06754..3c66227 100644 --- a/src/pages/XRSession.tsx +++ b/src/pages/XRSession.tsx @@ -1,32 +1,29 @@ 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 { 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, Move, RotateCw, QrCode, Download, Crosshair } from 'lucide-react'; +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'; // --- Diagnóstico XR --- console.log('[XR] Inicializando store...'); -console.log('[XR] navigator.xr disponível:', !!navigator.xr); if (navigator.xr) { navigator.xr.isSessionSupported('immersive-ar').then((supported) => { console.log('[XR] immersive-ar suportado:', supported); - }).catch((err) => { - console.error('[XR] Erro ao checar suporte AR:', err); }); } -// --- XR Store at MODULE level (required by Quest 3 browser) --- const store = createXRStore({ hand: { left: true, right: true }, controller: { left: true, right: true }, }); -console.log('[XR] Store criado com sucesso'); // ─── XRModel ─────────────────────────────────────────── function XRModel({ url }: { url: string }) { @@ -35,6 +32,9 @@ function XRModel({ url }: { url: string }) { 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(() => { @@ -57,25 +57,55 @@ function XRModel({ url }: { url: string }) { } 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) { - 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); + if (renderMode === 'edges') { + mat.visible = false; } else { - mat.color.set(0x8899aa); + 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]); + }, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle]); const rotYRad = (fineTuning.rotY * Math.PI) / 180; @@ -95,7 +125,7 @@ function XRModel({ url }: { url: string }) { } // ─── ControllerFineTuning ────────────────────────────── -function ControllerFineTuning() { +function ControllerFineTuning({ freeMove }: { freeMove: boolean }) { const { setFineTuning } = useModelStore(); const session = useXR((s) => s.session); @@ -121,7 +151,8 @@ function ControllerFineTuning() { } } - if (!gripHeld) return; + // In freeMove mode, joystick always moves. Otherwise requires grip. + if (!freeMove && !gripHeld) return; const posStep = 0.001; const rotStep = 0.1; @@ -144,17 +175,146 @@ function ControllerFineTuning() { 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 ─────────────────────────────── 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}; } @@ -163,28 +323,23 @@ const XRSession = () => { const navigate = useNavigate(); const { model, anchorMode, setAnchorMode } = useModelStore(); const [inXR, setInXR] = useState(false); + const [freeMove, setFreeMove] = useState(true); // default ON for easier positioning useEffect(() => { - if (!model) { - navigate('/'); - } + if (!model) navigate('/'); }, [model, navigate]); - // Listen for XR session state changes useEffect(() => { - console.log('[XR] Subscribing to store state changes'); const unsubscribe = store.subscribe((state) => { - console.log('[XR] Store state changed — session:', !!state.session, 'mode:', state.mode); const session = state.session; if (session && !inXR) { - console.log('[XR] ✅ Sessão AR ativa! Features:', (session as any).enabledFeatures); + 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); - setAnchorMode('manual'); }); } }); @@ -195,7 +350,7 @@ const XRSession = () => { const url = await generateMarkerDownloadURL(); const a = document.createElement('a'); a.href = url; - a.download = 'TrackkSteelXR_Marker.png'; + a.download = 'TrackSteelXR_Marker.png'; a.click(); URL.revokeObjectURL(url); toast.success('Marcador baixado — Imprima em 15×15cm'); @@ -221,33 +376,21 @@ const XRSession = () => { <> - {/* CRITICAL: Direct call without async wrapper to preserve user gesture */} - )} - {inXR && ( -
- - - XR Ativo - - {anchorMode === 'tracking' ? ( - - - Tracking - - ) : ( - - - Manual - - )} -
+ + + XR Ativo + )} @@ -255,12 +398,14 @@ const XRSession = () => { {/* 3D Canvas */}
{ + onCreated={({ gl, invalidate }) => { gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); gl.setClearColor(0x000000, 0); + const loop = () => { invalidate(); requestAnimationFrame(loop); }; + loop(); }} > @@ -274,27 +419,15 @@ const XRSession = () => { - + + + + - {/* Floating HUD overlay */} -
-
-
- - - Grip + Joy Esq: X/Y - -
-
- - - Grip + Joy Dir: Z/Rot - -
-
-
+ {/* Floating HUD overlay with all controls */} + setFreeMove(!freeMove)} />
);