396 lines
13 KiB
TypeScript
396 lines
13 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 } 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 { Button } from '@/components/ui/button';
|
||
import { generateTrackingMarker, generateMarkerDownloadURL } from '@/lib/trackingMarker';
|
||
|
||
// Store tracking marker bitmap globally so it persists across renders
|
||
let markerBitmapPromise: Promise<ImageBitmap> | null = null;
|
||
function getMarkerBitmap(): Promise<ImageBitmap> {
|
||
if (!markerBitmapPromise) {
|
||
markerBitmapPromise = generateTrackingMarker();
|
||
}
|
||
return markerBitmapPromise;
|
||
}
|
||
|
||
const xrStore = createXRStore({
|
||
emulate: 'metaQuest3',
|
||
hand: { left: true, right: true },
|
||
controller: { left: true, right: true },
|
||
});
|
||
|
||
/**
|
||
* The 3D model rendered inside the XR scene.
|
||
*/
|
||
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 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();
|
||
}
|
||
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);
|
||
} else {
|
||
mat.color.set(0x8899aa);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}, [scene, opacity, renderMode, checklist]);
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Controller-based fine tuning input handler.
|
||
*/
|
||
function ControllerFineTuning() {
|
||
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]];
|
||
}
|
||
}
|
||
|
||
if (!gripHeld) return;
|
||
|
||
const posStep = 0.001;
|
||
const rotStep = 0.1;
|
||
const deadzone = 0.15;
|
||
const store = useModelStore.getState();
|
||
const ft = { ...store.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;
|
||
}
|
||
|
||
/**
|
||
* Image Tracking Anchor — uses WebXR's getImageTrackingResults()
|
||
* to position the model at a detected QR marker.
|
||
* Falls back to manual placement when tracking is lost or unavailable.
|
||
*/
|
||
function ImageTrackingAnchor({ children }: { children: React.ReactNode }) {
|
||
const groupRef = useRef<THREE.Group>(null);
|
||
const session = useXR((s) => s.session);
|
||
const { gl } = useThree();
|
||
const { setAnchorMode } = useModelStore();
|
||
const trackingActive = useRef(false);
|
||
const lastPoseMatrix = useRef(new THREE.Matrix4());
|
||
|
||
useFrame((_, __, frame: any) => {
|
||
if (!frame || !session || !groupRef.current) return;
|
||
|
||
// Try to get image tracking results from the XR frame
|
||
try {
|
||
const results = frame.getImageTrackingResults?.();
|
||
if (results && results.length > 0) {
|
||
for (const result of results) {
|
||
if (result.trackingState === 'tracked' || result.trackingState === 'emulated') {
|
||
const refSpace = gl.xr.getReferenceSpace();
|
||
if (!refSpace) continue;
|
||
|
||
const pose = frame.getPose(result.imageSpace, refSpace);
|
||
if (pose) {
|
||
const pos = pose.transform.position;
|
||
const ori = pose.transform.orientation;
|
||
|
||
groupRef.current.position.set(pos.x, pos.y, pos.z);
|
||
groupRef.current.quaternion.set(ori.x, ori.y, ori.z, ori.w);
|
||
|
||
if (!trackingActive.current) {
|
||
trackingActive.current = true;
|
||
setAnchorMode('tracking');
|
||
toast.success('QR Code detectado — Modelo ancorado!');
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// getImageTrackingResults not available — fall back to manual
|
||
}
|
||
|
||
// If tracking was active but lost
|
||
if (trackingActive.current) {
|
||
// Keep last known position (don't reset)
|
||
return;
|
||
}
|
||
|
||
// Manual fallback — place 1.5m in front
|
||
if (!trackingActive.current) {
|
||
groupRef.current.position.set(0, 0, -1.5);
|
||
groupRef.current.quaternion.identity();
|
||
}
|
||
});
|
||
|
||
return <group ref={groupRef}>{children}</group>;
|
||
}
|
||
|
||
const XRSession = () => {
|
||
const navigate = useNavigate();
|
||
const { model, xrSupported, anchorMode, setAnchorMode } = useModelStore();
|
||
const [inXR, setInXR] = useState(false);
|
||
const [markerReady, setMarkerReady] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!model) {
|
||
navigate('/');
|
||
return;
|
||
}
|
||
// Pre-load marker bitmap
|
||
getMarkerBitmap().then(() => setMarkerReady(true));
|
||
}, [model, navigate]);
|
||
|
||
const handleEnterAR = useCallback(async () => {
|
||
try {
|
||
// We attempt to request with image-tracking as optional feature.
|
||
// The createXRStore.enterAR will handle the session request.
|
||
// We need to configure the session with trackedImages.
|
||
// Since @react-three/xr doesn't directly support trackedImages,
|
||
// we'll try to patch it through the native API.
|
||
|
||
const bitmap = await getMarkerBitmap();
|
||
|
||
// Try entering AR with image tracking support
|
||
// The @react-three/xr store manages the session, but we can
|
||
// request image-tracking as an optional feature
|
||
try {
|
||
// Override: request session directly with trackedImages
|
||
if (navigator.xr) {
|
||
const session = await navigator.xr.requestSession('immersive-ar', {
|
||
requiredFeatures: ['local-floor'],
|
||
optionalFeatures: [
|
||
'hand-tracking',
|
||
'image-tracking',
|
||
'anchors',
|
||
'plane-detection',
|
||
],
|
||
// @ts-ignore — trackedImages is a WebXR extension
|
||
trackedImages: [
|
||
{
|
||
image: bitmap,
|
||
widthInMeters: 0.15, // 15cm QR code
|
||
},
|
||
],
|
||
});
|
||
|
||
// Let @react-three/xr know about this session
|
||
// by entering through the store after manual session creation
|
||
session.addEventListener('end', () => {
|
||
setInXR(false);
|
||
setAnchorMode('manual');
|
||
});
|
||
}
|
||
} catch {
|
||
// Fallback: enter AR without image tracking
|
||
console.warn('Image tracking not available, using manual placement');
|
||
}
|
||
|
||
await xrStore.enterAR();
|
||
setInXR(true);
|
||
setAnchorMode('manual');
|
||
toast.success('Sessão XR iniciada — Buscando QR Code…');
|
||
} catch (err: any) {
|
||
console.error('XR error:', err);
|
||
toast.error(`Falha ao iniciar XR: ${err.message}`);
|
||
}
|
||
}, [setAnchorMode]);
|
||
|
||
const handleDownloadMarker = useCallback(async () => {
|
||
const url = await generateMarkerDownloadURL();
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'TrackkSteelXR_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 QR</span>
|
||
</Button>
|
||
<Button className="gap-2 glow-primary" onClick={handleEnterAR}>
|
||
Iniciar Sessão AR
|
||
</Button>
|
||
</>
|
||
)}
|
||
|
||
{inXR && (
|
||
<div className="flex items-center gap-3">
|
||
<span className="font-mono text-xs text-success flex items-center gap-1.5">
|
||
<span className="h-2 w-2 rounded-full bg-success animate-pulse" />
|
||
XR Ativo
|
||
</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>
|
||
</header>
|
||
|
||
{/* 3D Canvas */}
|
||
<div className="flex-1 relative">
|
||
<Canvas
|
||
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance' }}
|
||
camera={{ position: [0, 1.6, 0], fov: 50, near: 0.01, far: 100 }}
|
||
className="!bg-transparent"
|
||
onCreated={({ gl }) => {
|
||
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||
gl.setClearColor(0x000000, 0);
|
||
}}
|
||
>
|
||
<XR store={xrStore}>
|
||
<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>
|
||
|
||
<ControllerFineTuning />
|
||
</XR>
|
||
</Canvas>
|
||
|
||
{/* Floating HUD overlay */}
|
||
<div className="absolute bottom-4 left-4 right-4 z-10 pointer-events-none">
|
||
<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">
|
||
<QrCode className="h-3.5 w-3.5 text-primary" />
|
||
<span className="font-mono text-[10px] text-muted-foreground">
|
||
{anchorMode === 'tracking' ? '● QR Detectado' : '○ Buscando QR…'}
|
||
</span>
|
||
</div>
|
||
<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>
|
||
);
|
||
};
|
||
|
||
export default XRSession;
|