Render XR 3D with passthrough

Implement full WebXR rendering session including passthrough, anchoring, and controls:
- Added XRSession page enhancements using @react-three/xr
- Render model inside XR via XROrigin with passthrough
- Integrated fine-tuning controls and visual HUD
- Enabled AR entry flow and updated UI structure for immersive XR mode

X-Lovable-Edit-ID: edt-87e09073-2939-41e4-a956-51b5d9e2440f
This commit is contained in:
gpt-engineer-app[bot]
2026-02-27 01:07:02 +00:00
+229 -79
View File
@@ -1,65 +1,180 @@
import { useEffect, useCallback, useState } from 'react'; import { useEffect, useRef, useMemo, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom'; 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 { useModelStore } from '@/stores/useModelStore';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ArrowLeft, Move, RotateCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
const xrStore = createXRStore({
emulate: 'metaQuest3',
hand: { left: true, right: true },
controller: { left: true, right: true },
});
/** /**
* XR Immersive Session Page * The 3D model rendered inside the XR scene.
* Uses native WebXR API for passthrough AR on Quest 3. * Applies fine tuning offsets from the store.
* - Loads GLB model
* - Attempts image tracking for QR anchor
* - Falls back to manual placement
* - Fine tuning with grip + joysticks
*/ */
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]);
// Apply visual properties
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.
* Left joystick = X/Y position, Right joystick = Z position + Y rotation.
* Active only when any grip button is held.
*/
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;
// Check grip button (usually index 2)
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; // 1mm per frame at full deflection
const rotStep = 0.1; // 0.1° per frame at full deflection
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;
}
/**
* Manual placement: places model 1.5m in front of user at floor level.
*/
function ManualAnchor({ children }: { children: React.ReactNode }) {
return (
<group position={[0, 0, -1.5]}>
{children}
</group>
);
}
const XRSession = () => { const XRSession = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { model, xrSupported } = useModelStore(); const { model, xrSupported, anchorMode } = useModelStore();
const [sessionActive, setSessionActive] = useState(false); const [inXR, setInXR] = useState(false);
const [statusMessage, setStatusMessage] = useState('Preparando sessão XR…');
useEffect(() => { useEffect(() => {
if (!model) { if (!model) {
navigate('/'); navigate('/');
return; return;
} }
if (!xrSupported) { }, [model, navigate]);
toast.error('WebXR não disponível');
navigate('/viewer');
return;
}
}, [model, xrSupported, navigate]);
const startXRSession = useCallback(async () => {
if (!navigator.xr) return;
const handleEnterAR = useCallback(async () => {
try { try {
setStatusMessage('Iniciando sessão imersiva…'); await xrStore.enterAR();
setInXR(true);
// Check for image tracking support toast.success('Sessão XR iniciada — Passthrough ativo');
const features: string[] = ['local-floor', 'hand-tracking'];
const optionalFeatures: string[] = ['image-tracking', 'anchors', 'plane-detection'];
const session = await navigator.xr.requestSession('immersive-ar', {
requiredFeatures: features,
optionalFeatures: optionalFeatures,
domOverlay: { root: document.getElementById('xr-overlay')! },
});
setSessionActive(true);
setStatusMessage('Sessão XR ativa — Modo Passthrough');
session.addEventListener('end', () => {
setSessionActive(false);
setStatusMessage('Sessão XR encerrada');
toast.info('Sessão XR encerrada');
});
// The actual 3D rendering in XR would require a full WebXR render loop
// with Three.js. For now we show the overlay UI.
toast.success('Sessão XR iniciada com passthrough!');
} catch (err: any) { } catch (err: any) {
console.error('XR Session error:', err); console.error('XR error:', err);
setStatusMessage('Erro ao iniciar XR');
toast.error(`Falha ao iniciar XR: ${err.message}`); toast.error(`Falha ao iniciar XR: ${err.message}`);
} }
}, []); }, []);
@@ -67,48 +182,83 @@ const XRSession = () => {
if (!model) return null; if (!model) return null;
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background p-6"> <div className="flex h-screen flex-col bg-background">
<div id="xr-overlay" className="fixed inset-0 z-50 pointer-events-none" /> {/* Header */}
<header className="flex items-center justify-between border-b bg-card px-4 py-3 z-10">
<div className="w-full max-w-sm space-y-6 text-center"> <div className="flex items-center gap-3">
<h1 className="font-mono text-xl font-bold text-foreground"> <Button variant="ghost" size="icon" onClick={() => navigate('/viewer')}>
Modo <span className="text-primary">XR Imersivo</span> <ArrowLeft className="h-5 w-5" />
</h1> </Button>
<h1 className="font-mono text-sm font-semibold text-foreground">
<p className="font-mono text-sm text-muted-foreground">{statusMessage}</p> Modo <span className="text-primary">XR Imersivo</span>
</h1>
<div className="rounded-lg border bg-card p-4 text-left">
<p className="font-mono text-xs text-muted-foreground mb-2">Modelo:</p>
<p className="font-mono text-sm text-foreground truncate">{model.fileName}</p>
</div> </div>
{!sessionActive && ( {!inXR && (
<button <Button className="gap-2 glow-primary" onClick={handleEnterAR}>
onClick={startXRSession} Iniciar Sessão AR
className="w-full rounded-lg bg-primary px-6 py-4 font-mono text-sm font-bold text-primary-foreground transition-all hover:opacity-90 glow-primary" </Button>
>
Iniciar Sessão XR
</button>
)} )}
{sessionActive && ( {inXR && (
<div className="space-y-3"> <div className="flex items-center gap-2">
<div className="rounded-lg border border-success/30 bg-success/10 p-3"> <span className="font-mono text-xs text-success flex items-center gap-1.5">
<p className="font-mono text-xs text-success"> Sessão XR Ativa</p> <span className="h-2 w-2 rounded-full bg-success animate-pulse" />
</div> XR Ativo
<p className="font-mono text-xs text-muted-foreground"> </span>
Coloque o Quest 3 para visualizar o modelo em passthrough.
Use Grip + Joysticks para ajuste fino.
</p>
</div> </div>
)} )}
</header>
<button {/* 3D Canvas — fills remaining space */}
onClick={() => navigate('/viewer')} <div className="flex-1 relative">
className="font-mono text-xs text-muted-foreground underline hover:text-foreground transition-colors" <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));
// Transparent background for passthrough
gl.setClearColor(0x000000, 0);
}}
> >
Voltar ao Visualizador <XR store={xrStore}>
</button> <ambientLight intensity={0.8} />
<directionalLight position={[3, 5, 3]} intensity={1.2} />
<directionalLight position={[-3, 3, -3]} intensity={0.4} />
<XROrigin />
<ManualAnchor>
<XRModel url={model.url} />
</ManualAnchor>
<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 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 className="flex items-center gap-2">
<span className="font-mono text-[10px] text-muted-foreground">
Modo: {anchorMode === 'tracking' ? 'Image Tracking' : 'Manual'}
</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
); );