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:
+229
-79
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user