Files
SteelXR2/src/pages/XRSession.tsx
T
gpt-engineer-app[bot] 2a06a440f6 Changes
2026-02-27 19:52:58 +00:00

347 lines
12 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 { generateMarkerDownloadURL } from '@/lib/trackingMarker';
// --- XR Store singleton ---
let xrStoreInstance: ReturnType<typeof createXRStore> | null = null;
function getOrCreateXRStore() {
if (xrStoreInstance) return xrStoreInstance;
// Only emulate when on localhost AND no native XR support
const shouldEmulate =
typeof window !== 'undefined' &&
window.location.hostname === 'localhost' &&
!navigator.xr;
xrStoreInstance = createXRStore({
emulate: shouldEmulate ? 'metaQuest3' : false,
hand: { left: true, right: true },
controller: { left: true, right: true },
});
return xrStoreInstance;
}
// ─── 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 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>
);
}
// ─── ControllerFineTuning ──────────────────────────────
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;
}
// ─── ImageTrackingAnchor ───────────────────────────────
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);
useFrame((_, __, frame: any) => {
if (!frame || !session || !groupRef.current) return;
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
}
if (trackingActive.current) return;
// Manual fallback — place 1.5m in front
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 [store] = useState(() => getOrCreateXRStore());
useEffect(() => {
if (!model) {
navigate('/');
}
}, [model, navigate]);
const handleEnterAR = useCallback(async () => {
try {
const session = await store.enterAR();
if (session) {
session.addEventListener('end', () => {
setInXR(false);
setAnchorMode('manual');
});
setInXR(true);
setAnchorMode('manual');
toast.success('Sessão XR iniciada!');
}
} catch (err: any) {
console.error('XR error:', err);
toast.error(`Falha ao iniciar XR: ${err.message}`);
}
}, [store, 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={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>
<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;