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)}
+
+
+
+ ))}
+
+
+
+ Y°
+
+
+ {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 */}
-