437 lines
16 KiB
TypeScript
437 lines
16 KiB
TypeScript
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';
|
||
|
||
// --- 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<THREE.Group>(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 (
|
||
<group
|
||
ref={ref}
|
||
position={[
|
||
-modelInfo.center.x + fineTuning.posX,
|
||
-modelInfo.center.y + fineTuning.posY,
|
||
-modelInfo.center.z + fineTuning.posZ,
|
||
]}
|
||
rotation={[0, rotYRad, 0]}
|
||
>
|
||
<primitive object={scene} />
|
||
</group>
|
||
);
|
||
}
|
||
|
||
// ─── 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 && (
|
||
<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 ───────────────────────────────
|
||
function ImageTrackingAnchor({ children }: { children: React.ReactNode }) {
|
||
const groupRef = useRef<THREE.Group>(null);
|
||
useFrame(() => {
|
||
if (!groupRef.current) return;
|
||
groupRef.current.position.set(0, 0, -1.5);
|
||
groupRef.current.quaternion.identity();
|
||
});
|
||
return <group ref={groupRef}>{children}</group>;
|
||
}
|
||
|
||
// ─── XRSession Page ────────────────────────────────────
|
||
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('/');
|
||
}, [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 (
|
||
<div className="flex h-screen flex-col bg-background">
|
||
{/* Header */}
|
||
<header className="flex items-center justify-between border-b bg-card px-4 py-3 z-10">
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="ghost" size="icon" onClick={() => navigate('/viewer')}>
|
||
<ArrowLeft className="h-5 w-5" />
|
||
</Button>
|
||
<h1 className="font-mono text-sm font-semibold text-foreground">
|
||
Modo <span className="text-primary">XR Imersivo</span>
|
||
</h1>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{!inXR && (
|
||
<>
|
||
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleDownloadMarker}>
|
||
<Download className="h-3.5 w-3.5" />
|
||
<span className="font-mono text-xs">Marcador</span>
|
||
</Button>
|
||
<Button className="gap-2 glow-primary" onClick={() => {
|
||
console.log('[XR] Botão AR clicado');
|
||
store.enterAR().catch((e) => console.error('[XR] enterAR rejeitado:', e));
|
||
}}>
|
||
Iniciar Sessão AR
|
||
</Button>
|
||
</>
|
||
)}
|
||
{inXR && (
|
||
<span className="font-mono text-xs text-primary flex items-center gap-1.5">
|
||
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
|
||
XR Ativo
|
||
</span>
|
||
)}
|
||
</div>
|
||
</header>
|
||
|
||
{/* 3D Canvas */}
|
||
<div className="flex-1 relative">
|
||
<Canvas
|
||
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }}
|
||
camera={{ position: [0, 1.6, 0], fov: 50, near: 0.01, far: 100 }}
|
||
className="!bg-transparent"
|
||
onCreated={({ gl, invalidate }) => {
|
||
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||
gl.setClearColor(0x000000, 0);
|
||
const loop = () => { invalidate(); requestAnimationFrame(loop); };
|
||
loop();
|
||
}}
|
||
>
|
||
<XR store={store}>
|
||
<ambientLight intensity={0.8} />
|
||
<directionalLight position={[3, 5, 3]} intensity={1.2} />
|
||
<directionalLight position={[-3, 3, -3]} intensity={0.4} />
|
||
|
||
<XROrigin />
|
||
|
||
<ImageTrackingAnchor>
|
||
<XRModel url={model.url} />
|
||
</ImageTrackingAnchor>
|
||
|
||
<XRMeasurementOverlay />
|
||
<XRSnapHandler />
|
||
<XRGrid />
|
||
<ControllerFineTuning freeMove={freeMove} />
|
||
</XR>
|
||
</Canvas>
|
||
|
||
{/* Floating HUD overlay with all controls */}
|
||
<XRHud freeMove={freeMove} onToggleFreeMove={() => setFreeMove(!freeMove)} />
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default XRSession;
|