Files
SteelXR2/src/pages/XRSession.tsx
T
gpt-engineer-app[bot] eb44d8352d Changes
2026-02-27 01:10:09 +00:00

396 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;