Files
SteelXR/src/pages/XRSession.tsx
T
gpt-engineer-app[bot] 966379a14a Changes
2026-02-27 19:59:15 +00:00

288 lines
9.9 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 at MODULE level (required by Quest 3 browser) ---
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 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 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;
}
// ─── ImageTrackingAnchor ───────────────────────────────
function ImageTrackingAnchor({ children }: { children: React.ReactNode }) {
const groupRef = useRef<THREE.Group>(null);
useFrame(() => {
if (!groupRef.current) return;
// Place model 1.5m in front of the user
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);
useEffect(() => {
if (!model) {
navigate('/');
}
}, [model, navigate]);
// Listen for XR session state changes
useEffect(() => {
const unsubscribe = store.subscribe((state) => {
const session = state.session;
if (session && !inXR) {
setInXR(true);
setAnchorMode('manual');
toast.success('Sessão AR iniciada!');
session.addEventListener('end', () => {
setInXR(false);
setAnchorMode('manual');
});
}
});
return unsubscribe;
}, [inXR, 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>
{/* CRITICAL: Direct call without async wrapper to preserve user gesture */}
<Button className="gap-2 glow-primary" onClick={() => store.enterAR()}>
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">
<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;