Changes
This commit is contained in:
@@ -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 (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 z-20 pointer-events-none p-3">
|
||||||
|
<div className="pointer-events-auto mx-auto max-w-lg space-y-2">
|
||||||
|
{/* Expanded panel */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="rounded-xl border bg-card/90 backdrop-blur-md p-3 shadow-2xl space-y-3 animate-in slide-in-from-bottom-2 duration-200">
|
||||||
|
{/* Opacity */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Eye className="h-3 w-3 text-primary" />
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">Opacidade</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[10px] text-foreground">{Math.round(opacity * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[opacity * 100]}
|
||||||
|
min={0} max={100} step={5}
|
||||||
|
onValueChange={([v]) => setOpacity(v / 100)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fine Tuning */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Move className="h-3 w-3 text-primary" />
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">Posição (mm)</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="h-5 text-[9px] px-1.5 gap-0.5" onClick={resetFineTuning}>
|
||||||
|
<RefreshCw className="h-2.5 w-2.5" /> Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{(['posX', 'posY', 'posZ'] as const).map((axis) => (
|
||||||
|
<div key={axis} className="flex items-center gap-1 rounded bg-muted/50 px-1.5 py-1">
|
||||||
|
<span className="font-mono text-[9px] text-muted-foreground w-4">{axis.slice(-1).toUpperCase()}</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-5 w-5 text-[10px]"
|
||||||
|
onClick={() => setFineTuning({ [axis]: fineTuning[axis] - POSITION_STEP })}>−</Button>
|
||||||
|
<span className="font-mono text-[9px] text-foreground flex-1 text-center">
|
||||||
|
{(fineTuning[axis] * 1000).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-5 w-5 text-[10px]"
|
||||||
|
onClick={() => setFineTuning({ [axis]: fineTuning[axis] + POSITION_STEP })}>+</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 rounded bg-muted/50 px-1.5 py-1 max-w-[33%]">
|
||||||
|
<RotateCw className="h-2.5 w-2.5 text-primary" />
|
||||||
|
<span className="font-mono text-[9px] text-muted-foreground w-4">Y°</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-5 w-5 text-[10px]"
|
||||||
|
onClick={() => setFineTuning({ rotY: fineTuning.rotY - ROTATION_STEP })}>−</Button>
|
||||||
|
<span className="font-mono text-[9px] text-foreground flex-1 text-center">
|
||||||
|
{fineTuning.rotY.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-5 w-5 text-[10px]"
|
||||||
|
onClick={() => setFineTuning({ rotY: fineTuning.rotY + ROTATION_STEP })}>+</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main toolbar */}
|
||||||
|
<div className="flex items-center justify-between rounded-xl border bg-card/90 backdrop-blur-md p-2 shadow-2xl">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{/* Expand/collapse */}
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setExpanded(!expanded)}>
|
||||||
|
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Free move toggle */}
|
||||||
|
<Button
|
||||||
|
variant={freeMove ? 'default' : 'outline'}
|
||||||
|
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
||||||
|
onClick={onToggleFreeMove}
|
||||||
|
>
|
||||||
|
<Grip className="h-3.5 w-3.5" />
|
||||||
|
{freeMove ? 'Mover ON' : 'Mover'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<Button
|
||||||
|
variant={showGrid ? 'default' : 'outline'}
|
||||||
|
size="icon" className="h-8 w-8"
|
||||||
|
onClick={() => setShowGrid(!showGrid)}
|
||||||
|
title="Grid"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Render mode */}
|
||||||
|
<Button
|
||||||
|
variant={renderMode !== 'solid' ? 'default' : 'outline'}
|
||||||
|
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
||||||
|
onClick={() => {
|
||||||
|
const next = renderMode === 'solid' ? 'wireframe' : renderMode === 'wireframe' ? 'edges' : 'solid';
|
||||||
|
setRenderMode(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
|
||||||
|
{renderMode === 'solid' ? 'Sólido' : renderMode === 'wireframe' ? 'Wire' : 'Bordas'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Measure */}
|
||||||
|
<Button
|
||||||
|
variant={measureMode ? 'default' : 'outline'}
|
||||||
|
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
||||||
|
onClick={() => setMeasureMode(!measureMode)}
|
||||||
|
>
|
||||||
|
<Ruler className="h-3.5 w-3.5" />
|
||||||
|
{measureMode
|
||||||
|
? measurePoints.length === 0 ? 'Pt A…' : 'Pt B…'
|
||||||
|
: 'Medir'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{measurements.length > 0 && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={clearMeasurements} title="Limpar medições">
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Screenshot */}
|
||||||
|
<Button variant="outline" size="icon" className="h-8 w-8" onClick={handleScreenshot} title="Screenshot">
|
||||||
|
<Camera className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+205
-72
@@ -1,32 +1,29 @@
|
|||||||
import { useEffect, useRef, useMemo, useCallback, useState } from 'react';
|
import { useEffect, useRef, useMemo, useCallback, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
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 { XR, createXRStore, useXR, XROrigin } from '@react-three/xr';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
import { toast } from 'sonner';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { generateMarkerDownloadURL } from '@/lib/trackingMarker';
|
import { generateMarkerDownloadURL } from '@/lib/trackingMarker';
|
||||||
|
import { XRHud } from '@/components/XRHud';
|
||||||
|
import { findNearestVertex } from '@/components/three/SmartMeasure';
|
||||||
|
|
||||||
// --- Diagnóstico XR ---
|
// --- Diagnóstico XR ---
|
||||||
console.log('[XR] Inicializando store...');
|
console.log('[XR] Inicializando store...');
|
||||||
console.log('[XR] navigator.xr disponível:', !!navigator.xr);
|
|
||||||
if (navigator.xr) {
|
if (navigator.xr) {
|
||||||
navigator.xr.isSessionSupported('immersive-ar').then((supported) => {
|
navigator.xr.isSessionSupported('immersive-ar').then((supported) => {
|
||||||
console.log('[XR] immersive-ar suportado:', 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({
|
const store = createXRStore({
|
||||||
hand: { left: true, right: true },
|
hand: { left: true, right: true },
|
||||||
controller: { left: true, right: true },
|
controller: { left: true, right: true },
|
||||||
});
|
});
|
||||||
console.log('[XR] Store criado com sucesso');
|
|
||||||
|
|
||||||
// ─── XRModel ───────────────────────────────────────────
|
// ─── XRModel ───────────────────────────────────────────
|
||||||
function XRModel({ url }: { url: string }) {
|
function XRModel({ url }: { url: string }) {
|
||||||
@@ -35,6 +32,9 @@ function XRModel({ url }: { url: string }) {
|
|||||||
const fineTuning = useModelStore((s) => s.fineTuning);
|
const fineTuning = useModelStore((s) => s.fineTuning);
|
||||||
const opacity = useModelStore((s) => s.opacity);
|
const opacity = useModelStore((s) => s.opacity);
|
||||||
const renderMode = useModelStore((s) => s.renderMode);
|
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 checklist = useModelStore((s) => s.checklist);
|
||||||
|
|
||||||
const modelInfo = useMemo(() => {
|
const modelInfo = useMemo(() => {
|
||||||
@@ -57,25 +57,55 @@ function XRModel({ url }: { url: string }) {
|
|||||||
} else {
|
} else {
|
||||||
child.material = child.material.clone();
|
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];
|
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||||
materials.forEach((mat: THREE.Material) => {
|
materials.forEach((mat: THREE.Material) => {
|
||||||
if (mat instanceof THREE.MeshStandardMaterial) {
|
if (mat instanceof THREE.MeshStandardMaterial) {
|
||||||
mat.transparent = opacity < 1;
|
if (renderMode === 'edges') {
|
||||||
mat.opacity = opacity;
|
mat.visible = false;
|
||||||
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 {
|
} 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;
|
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
|
||||||
|
|
||||||
@@ -95,7 +125,7 @@ function XRModel({ url }: { url: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── ControllerFineTuning ──────────────────────────────
|
// ─── ControllerFineTuning ──────────────────────────────
|
||||||
function ControllerFineTuning() {
|
function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
|
||||||
const { setFineTuning } = useModelStore();
|
const { setFineTuning } = useModelStore();
|
||||||
const session = useXR((s) => s.session);
|
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 posStep = 0.001;
|
||||||
const rotStep = 0.1;
|
const rotStep = 0.1;
|
||||||
@@ -144,17 +175,146 @@ function ControllerFineTuning() {
|
|||||||
return null;
|
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 && (
|
||||||
|
<mesh position={[snapPoint.x, snapPoint.y, snapPoint.z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<ringGeometry args={[0.003, 0.005, 24]} />
|
||||||
|
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
{measurePoints.length === 1 && (
|
||||||
|
<mesh position={[measurePoints[0].x, measurePoints[0].y, measurePoints[0].z]}>
|
||||||
|
<sphereGeometry args={[0.003, 16, 16]} />
|
||||||
|
<meshBasicMaterial color="#e8a838" />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
{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 (
|
||||||
|
<group key={m.id}>
|
||||||
|
<mesh position={a}><sphereGeometry args={[0.003, 16, 16]} /><meshBasicMaterial color="#22c55e" /></mesh>
|
||||||
|
<mesh position={b}><sphereGeometry args={[0.003, 16, 16]} /><meshBasicMaterial color="#22c55e" /></mesh>
|
||||||
|
<Line points={[a, b]} color="#22c55e" lineWidth={2} />
|
||||||
|
<Html position={mid} center distanceFactor={1} style={{ pointerEvents: 'none' }}>
|
||||||
|
<div className="rounded bg-card/95 border border-primary/50 px-2 py-0.5 shadow-lg backdrop-blur-sm">
|
||||||
|
<span className="font-mono text-[11px] font-bold text-primary whitespace-nowrap">
|
||||||
|
{m.distanceMM.toFixed(1)} mm
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<Grid
|
||||||
|
infiniteGrid
|
||||||
|
cellSize={0.01}
|
||||||
|
sectionSize={0.1}
|
||||||
|
cellThickness={0.5}
|
||||||
|
sectionThickness={1}
|
||||||
|
cellColor="#334155"
|
||||||
|
sectionColor="#475569"
|
||||||
|
fadeDistance={5}
|
||||||
|
fadeStrength={1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── ImageTrackingAnchor ───────────────────────────────
|
// ─── ImageTrackingAnchor ───────────────────────────────
|
||||||
function ImageTrackingAnchor({ children }: { children: React.ReactNode }) {
|
function ImageTrackingAnchor({ children }: { children: React.ReactNode }) {
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
if (!groupRef.current) return;
|
if (!groupRef.current) return;
|
||||||
// Place model 1.5m in front of the user
|
|
||||||
groupRef.current.position.set(0, 0, -1.5);
|
groupRef.current.position.set(0, 0, -1.5);
|
||||||
groupRef.current.quaternion.identity();
|
groupRef.current.quaternion.identity();
|
||||||
});
|
});
|
||||||
|
|
||||||
return <group ref={groupRef}>{children}</group>;
|
return <group ref={groupRef}>{children}</group>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,28 +323,23 @@ const XRSession = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { model, anchorMode, setAnchorMode } = useModelStore();
|
const { model, anchorMode, setAnchorMode } = useModelStore();
|
||||||
const [inXR, setInXR] = useState(false);
|
const [inXR, setInXR] = useState(false);
|
||||||
|
const [freeMove, setFreeMove] = useState(true); // default ON for easier positioning
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!model) {
|
if (!model) navigate('/');
|
||||||
navigate('/');
|
|
||||||
}
|
|
||||||
}, [model, navigate]);
|
}, [model, navigate]);
|
||||||
|
|
||||||
// Listen for XR session state changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[XR] Subscribing to store state changes');
|
|
||||||
const unsubscribe = store.subscribe((state) => {
|
const unsubscribe = store.subscribe((state) => {
|
||||||
console.log('[XR] Store state changed — session:', !!state.session, 'mode:', state.mode);
|
|
||||||
const session = state.session;
|
const session = state.session;
|
||||||
if (session && !inXR) {
|
if (session && !inXR) {
|
||||||
console.log('[XR] ✅ Sessão AR ativa! Features:', (session as any).enabledFeatures);
|
console.log('[XR] ✅ Sessão AR ativa!');
|
||||||
setInXR(true);
|
setInXR(true);
|
||||||
setAnchorMode('manual');
|
setAnchorMode('manual');
|
||||||
toast.success('Sessão AR iniciada!');
|
toast.success('Sessão AR iniciada!');
|
||||||
session.addEventListener('end', () => {
|
session.addEventListener('end', () => {
|
||||||
console.log('[XR] ❌ Sessão AR encerrada');
|
console.log('[XR] ❌ Sessão AR encerrada');
|
||||||
setInXR(false);
|
setInXR(false);
|
||||||
setAnchorMode('manual');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -195,7 +350,7 @@ const XRSession = () => {
|
|||||||
const url = await generateMarkerDownloadURL();
|
const url = await generateMarkerDownloadURL();
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = 'TrackkSteelXR_Marker.png';
|
a.download = 'TrackSteelXR_Marker.png';
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
toast.success('Marcador baixado — Imprima em 15×15cm');
|
toast.success('Marcador baixado — Imprima em 15×15cm');
|
||||||
@@ -221,33 +376,21 @@ const XRSession = () => {
|
|||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleDownloadMarker}>
|
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleDownloadMarker}>
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
<span className="font-mono text-xs">Marcador QR</span>
|
<span className="font-mono text-xs">Marcador</span>
|
||||||
</Button>
|
</Button>
|
||||||
{/* CRITICAL: Direct call without async wrapper to preserve user gesture */}
|
<Button className="gap-2 glow-primary" onClick={() => {
|
||||||
<Button className="gap-2 glow-primary" onClick={() => { console.log('[XR] Botão AR clicado — chamando store.enterAR()'); store.enterAR().then((s) => console.log('[XR] enterAR resolveu:', s)).catch((e) => console.error('[XR] enterAR rejeitado:', e)); }}>
|
console.log('[XR] Botão AR clicado');
|
||||||
|
store.enterAR().catch((e) => console.error('[XR] enterAR rejeitado:', e));
|
||||||
|
}}>
|
||||||
Iniciar Sessão AR
|
Iniciar Sessão AR
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{inXR && (
|
{inXR && (
|
||||||
<div className="flex items-center gap-3">
|
<span className="font-mono text-xs text-primary flex items-center gap-1.5">
|
||||||
<span className="font-mono text-xs text-success flex items-center gap-1.5">
|
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
|
||||||
<span className="h-2 w-2 rounded-full bg-success animate-pulse" />
|
XR Ativo
|
||||||
XR Ativo
|
</span>
|
||||||
</span>
|
|
||||||
{anchorMode === 'tracking' ? (
|
|
||||||
<span className="font-mono text-[10px] text-primary flex items-center gap-1">
|
|
||||||
<QrCode className="h-3 w-3" />
|
|
||||||
Tracking
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="font-mono text-[10px] text-muted-foreground flex items-center gap-1">
|
|
||||||
<Crosshair className="h-3 w-3" />
|
|
||||||
Manual
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -255,12 +398,14 @@ const XRSession = () => {
|
|||||||
{/* 3D Canvas */}
|
{/* 3D Canvas */}
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Canvas
|
<Canvas
|
||||||
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance' }}
|
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }}
|
||||||
camera={{ position: [0, 1.6, 0], fov: 50, near: 0.01, far: 100 }}
|
camera={{ position: [0, 1.6, 0], fov: 50, near: 0.01, far: 100 }}
|
||||||
className="!bg-transparent"
|
className="!bg-transparent"
|
||||||
onCreated={({ gl }) => {
|
onCreated={({ gl, invalidate }) => {
|
||||||
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||||
gl.setClearColor(0x000000, 0);
|
gl.setClearColor(0x000000, 0);
|
||||||
|
const loop = () => { invalidate(); requestAnimationFrame(loop); };
|
||||||
|
loop();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<XR store={store}>
|
<XR store={store}>
|
||||||
@@ -274,27 +419,15 @@ const XRSession = () => {
|
|||||||
<XRModel url={model.url} />
|
<XRModel url={model.url} />
|
||||||
</ImageTrackingAnchor>
|
</ImageTrackingAnchor>
|
||||||
|
|
||||||
<ControllerFineTuning />
|
<XRMeasurementOverlay />
|
||||||
|
<XRSnapHandler />
|
||||||
|
<XRGrid />
|
||||||
|
<ControllerFineTuning freeMove={freeMove} />
|
||||||
</XR>
|
</XR>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|
||||||
{/* Floating HUD overlay */}
|
{/* Floating HUD overlay with all controls */}
|
||||||
<div className="absolute bottom-4 left-4 right-4 z-10 pointer-events-none">
|
<XRHud freeMove={freeMove} onToggleFreeMove={() => setFreeMove(!freeMove)} />
|
||||||
<div className="pointer-events-auto inline-flex flex-wrap gap-3 rounded-lg border bg-card/90 backdrop-blur-sm p-3 shadow-lg">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Move className="h-3.5 w-3.5 text-primary" />
|
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">
|
|
||||||
Grip + Joy Esq: X/Y
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<RotateCw className="h-3.5 w-3.5 text-primary" />
|
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">
|
|
||||||
Grip + Joy Dir: Z/Rot
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user