Files
SteelXR/src/pages/XRSession.tsx
T
gpt-engineer-app[bot] e65f5866be Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-14 18:05:54 +00:00

630 lines
24 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, Grid, Html, Line, OrbitControls, Text } 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, Download, QrCode, Crosshair, Home } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { generateMarkerDownloadURL } from '@/lib/trackingMarker';
import { XRHud } from '@/components/XRHud';
import { XRHudInWorld } from '@/components/three/XRHudInWorld';
import { XRBroadcastMirror } from '@/components/three/XRBroadcastMirror';
import { findNearestVertex } from '@/components/three/SmartMeasure';
import { XRHitTestPlacement } from '@/components/three/XRHitTestPlacement';
import { XRGrabbable } from '@/components/three/XRGrabbable';
// DEVKIT: remove these imports + all `// DEVKIT:` blocks below to strip devkit
import { useDevKit } from '@/devkit/useDevKit';
import { DevPanel } from '@/devkit/DevPanel';
import { FakeControllers } from '@/devkit/FakeControllers';
// --- Diagnóstico XR ---
console.log('[XR] Inicializando store...');
if (navigator.xr) {
navigator.xr.isSessionSupported('immersive-ar').then((supported) => {
console.log('[XR] immersive-ar suportado:', supported);
});
}
const store = createXRStore({
hand: { left: true, right: true },
controller: { left: true, right: true },
});
// ─── XRModel ───────────────────────────────────────────
function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').SceneModel }) {
const { scene: rawScene } = useGLTF(sceneModel.url);
const scene = useMemo(() => rawScene.clone(true), [rawScene]);
const ref = useRef<THREE.Group>(null);
const fineTuning = sceneModel.fineTuning;
const opacity = useModelStore((s) => s.opacity);
const renderMode = useModelStore((s) => s.renderMode);
const wireframeColor = useModelStore((s) => s.wireframeColor);
const wireframeThickness = useModelStore((s) => s.wireframeThickness);
const edgeThresholdAngle = useModelStore((s) => s.edgeThresholdAngle);
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 toRemove: THREE.Object3D[] = [];
child.children.forEach(c => {
if (c.userData.__edgeLine) toRemove.push(c);
});
toRemove.forEach(c => {
if (c instanceof THREE.LineSegments) {
c.geometry.dispose();
(c.material as THREE.Material).dispose();
}
child.remove(c);
});
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach((mat: THREE.Material) => {
if (mat instanceof THREE.MeshStandardMaterial) {
if (renderMode === 'edges') {
mat.visible = false;
} else {
mat.visible = true;
mat.transparent = opacity < 1;
mat.opacity = opacity;
mat.wireframe = renderMode === 'wireframe';
if (renderMode === 'wireframe') {
mat.wireframeLinewidth = wireframeThickness;
mat.color.set(wireframeColor);
} else 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(sceneModel.color);
}
}
mat.needsUpdate = true;
}
});
if (renderMode === 'edges' && child.geometry) {
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
const lineMat = new THREE.LineBasicMaterial({ color: wireframeColor, linewidth: wireframeThickness });
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
lineSegments.userData.__edgeLine = true;
child.add(lineSegments);
}
}
});
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color]);
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
const rotZRad = (fineTuning.rotZ * Math.PI) / 180;
const s = fineTuning.scale ?? 1;
const scaleRatio = useModelStore((st) => st.scaleRatio);
const renderFactor = scaleRatio?.factor ?? 1;
if (!sceneModel.visible) return null;
return (
<group scale={[renderFactor, renderFactor, renderFactor]}>
<group
ref={ref}
position={[
-modelInfo.center.x + fineTuning.posX,
-modelInfo.center.y + fineTuning.posY,
-modelInfo.center.z + fineTuning.posZ,
]}
rotation={[rotXRad, rotYRad, rotZRad]}
scale={[s, s, s]}
>
<primitive object={scene} />
</group>
</group>
);
}
/** Renders all NON-active models as static background (placement + grab apply only to active). */
function XRBackgroundModels() {
const models = useModelStore((s) => s.models);
const activeId = useModelStore((s) => s.activeModelId);
return (
<>
{models.filter(m => m.id !== activeId).map((m) => (
<XRModel key={m.id} sceneModel={m} />
))}
</>
);
}
/** Renders the currently-active model (the one wrapped by grab/placement). */
function XRActiveModel() {
const models = useModelStore((s) => s.models);
const activeId = useModelStore((s) => s.activeModelId);
const active = models.find(m => m.id === activeId);
if (!active) return null;
return <XRModel sceneModel={active} />;
}
// ─── ControllerFineTuning ──────────────────────────────
// Joystick for slow numeric adjustments. Disabled while any grip is held
// (grab takes priority). Trigger pressure modulates speed: light = fine,
// strong = fast. Standard VR mapping: left=lateral/forward, right=rotY/height.
function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
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 leftTrig = 0;
let rightTrig = 0;
let gripHeld = false;
let anyGripHeld = false;
for (const source of inputSources) {
const gp = source.gamepad;
if (!gp) continue;
const gripBtn = gp.buttons[2];
const trigBtn = gp.buttons[1];
const gripVal = gripBtn ? (gripBtn.value || (gripBtn.pressed ? 1 : 0)) : 0;
if (gripBtn?.pressed) gripHeld = true;
if (gripVal > 0.3) anyGripHeld = true; // matches grab hysteresis OFF
const trigVal = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0;
if (source.handedness === 'left' && gp.axes.length >= 4) {
leftAxes = [gp.axes[2], gp.axes[3]];
leftTrig = trigVal;
}
if (source.handedness === 'right' && gp.axes.length >= 4) {
rightAxes = [gp.axes[2], gp.axes[3]];
rightTrig = trigVal;
}
}
// Grab takes priority — joystick is disabled while grabbing
if (anyGripHeld) return;
// In freeMove mode, joystick always moves. Otherwise requires grip — but
// grip is reserved for grab now, so freeMove=false essentially disables.
if (!freeMove && !gripHeld) return;
const baseStep = 0.001;
const baseRot = 0.1;
const deadzone = 0.15;
// Trigger modulation: 0 → 0.3×, 1 → 3×
const speedL = 0.3 + leftTrig * 2.7;
const speedR = 0.3 + rightTrig * 2.7;
// Quadratic acceleration on thumbstick value
const curve = (v: number) => Math.sign(v) * v * v;
const modelStore = useModelStore.getState();
const ft = { ...modelStore.fineTuning };
if (leftAxes) {
// Left: X (horizontal) / Z forward-back (vertical, up = forward = -Z)
if (Math.abs(leftAxes[0]) > deadzone) ft.posX += curve(leftAxes[0]) * baseStep * speedL;
if (Math.abs(leftAxes[1]) > deadzone) ft.posZ += curve(leftAxes[1]) * baseStep * speedL;
}
if (rightAxes) {
// Right: rotY (horizontal) / Y height (vertical, up = up)
if (Math.abs(rightAxes[0]) > deadzone) ft.rotY += curve(rightAxes[0]) * baseRot * speedR;
if (Math.abs(rightAxes[1]) > deadzone) ft.posY -= curve(rightAxes[1]) * baseStep * speedR;
}
setFineTuning(ft);
});
return null;
}
// ─── Measurement overlay (reused from ModelViewer) ─────
function XRMeasurementOverlay() {
const measurements = useModelStore((s) => s.measurements);
const measurePoints = useModelStore((s) => s.measurePoints);
const snapPoint = useModelStore((s) => s.snapPoint);
const measureMode = useModelStore((s) => s.measureMode);
return (
<>
{measureMode && snapPoint && (
<mesh position={[snapPoint.x, snapPoint.y, snapPoint.z]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.003, 0.005, 24]} />
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} />
</mesh>
)}
{measurePoints.length === 1 && (
<mesh position={[measurePoints[0].x, measurePoints[0].y, measurePoints[0].z]}>
<sphereGeometry args={[0.003, 16, 16]} />
<meshBasicMaterial color="#e8a838" />
</mesh>
)}
{measurements.map((m) => {
const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z];
const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z];
const mid: [number, number, number] = [(a[0]+b[0])/2, (a[1]+b[1])/2, (a[2]+b[2])/2];
return (
<group key={m.id}>
<mesh position={a}><sphereGeometry args={[0.003, 16, 16]} /><meshBasicMaterial color="#22c55e" /></mesh>
<mesh position={b}><sphereGeometry args={[0.003, 16, 16]} /><meshBasicMaterial color="#22c55e" /></mesh>
<Line points={[a, b]} color="#22c55e" lineWidth={2} />
<Html position={mid} center distanceFactor={1} style={{ pointerEvents: 'none' }}>
<div className="rounded bg-card/95 border border-primary/50 px-2 py-0.5 shadow-lg backdrop-blur-sm">
<span className="font-mono text-[11px] font-bold text-primary whitespace-nowrap">
{m.distanceMM.toFixed(1)} mm
</span>
</div>
</Html>
</group>
);
})}
</>
);
}
// ─── Snap handler for XR raycasting ────────────────────
function XRSnapHandler() {
const { camera, scene, gl } = useThree();
const measureMode = useModelStore((s) => s.measureMode);
const setSnapPoint = useModelStore((s) => s.setSnapPoint);
const addMeasurePoint = useModelStore((s) => s.addMeasurePoint);
const snapPoint = useModelStore((s) => s.snapPoint);
const raycaster = useMemo(() => new THREE.Raycaster(), []);
const mouse = useMemo(() => new THREE.Vector2(), []);
useEffect(() => {
if (!measureMode) return;
const onMove = (e: MouseEvent) => {
const rect = gl.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
};
gl.domElement.addEventListener('pointermove', onMove);
return () => gl.domElement.removeEventListener('pointermove', onMove);
}, [gl, mouse, measureMode]);
useFrame(() => {
if (!measureMode) { setSnapPoint(null); return; }
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
const hit = intersects.find(i => {
const obj = i.object;
if (obj instanceof THREE.GridHelper) return false;
if (obj instanceof THREE.Mesh && (obj.geometry instanceof THREE.SphereGeometry || obj.geometry instanceof THREE.RingGeometry)) return false;
if (obj.userData.__edgeLine) return false;
return obj instanceof THREE.Mesh;
});
if (hit && hit.object instanceof THREE.Mesh) {
const canvas = gl.domElement;
const snap = findNearestVertex(hit.object, hit.point, camera, { width: canvas.clientWidth, height: canvas.clientHeight }, 10);
setSnapPoint(snap ? { x: snap.x, y: snap.y, z: snap.z } : null);
} else {
setSnapPoint(null);
}
});
// Click to measure
useEffect(() => {
if (!measureMode) return;
const onClick = (e: MouseEvent) => {
if (snapPoint) {
addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z });
return;
}
const rect = gl.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
const hit = intersects.find(i => i.object instanceof THREE.Mesh && !i.object.userData.__edgeLine);
if (hit) addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
};
gl.domElement.addEventListener('click', onClick);
return () => gl.domElement.removeEventListener('click', onClick);
}, [measureMode, snapPoint, gl, camera, scene, raycaster, mouse, addMeasurePoint]);
useEffect(() => {
gl.domElement.style.cursor = measureMode ? 'crosshair' : 'grab';
return () => { gl.domElement.style.cursor = 'grab'; };
}, [measureMode, gl]);
return null;
}
// ─── XR Grid ───────────────────────────────────────────
function XRGrid() {
const showGrid = useModelStore((s) => s.showGrid);
if (!showGrid) return null;
return (
<Grid
position={[0, -0.09, 0]}
infiniteGrid
cellSize={0.01}
sectionSize={0.1}
cellThickness={0.5}
sectionThickness={1}
cellColor="#334155"
sectionColor="#475569"
fadeDistance={5}
fadeStrength={1}
/>
);
}
// ImageTrackingAnchor removed — replaced by XRHitTestPlacement
// ─── XR Debug HUD (floats in front of viewer) ──────────
// Shows live grip/trigger values and isGrabbing state per hand.
// Essential for diagnosing grab issues without external monitoring.
function XRDebugHud() {
const session = useXR((s) => s.session);
const groupRef = useRef<THREE.Group>(null);
const { camera } = useThree();
const [text, setText] = useState('XR aguardando inputs...');
useFrame(() => {
if (!groupRef.current) return;
// Anchor the HUD to a fixed offset in front of the camera
const offset = new THREE.Vector3(-0.25, -0.18, -0.6).applyQuaternion(camera.quaternion);
groupRef.current.position.copy(camera.position).add(offset);
groupRef.current.quaternion.copy(camera.quaternion);
if (!session) return;
const sources = Array.from(session.inputSources);
const lines: string[] = [`inputs: ${sources.length}`];
for (const src of sources) {
const gp = src.gamepad;
const grip = gp?.buttons[2];
const trig = gp?.buttons[1];
const gv = grip ? (grip.value || (grip.pressed ? 1 : 0)) : 0;
const tv = trig ? (trig.value || (trig.pressed ? 1 : 0)) : 0;
const grabbing = gv > 0.7 ? 'GRAB' : gv > 0.3 ? '...' : ' ';
lines.push(`${src.handedness.padEnd(5)} grip:${gv.toFixed(2)} trg:${tv.toFixed(2)} ${grabbing}`);
}
setText(lines.join('\n'));
});
return (
<group ref={groupRef}>
<mesh position={[0, 0, -0.001]}>
<planeGeometry args={[0.32, 0.10]} />
<meshBasicMaterial color="#000000" transparent opacity={0.55} depthWrite={false} />
</mesh>
<Text
fontSize={0.012}
color="#22c55e"
anchorX="left"
anchorY="top"
position={[-0.155, 0.045, 0]}
maxWidth={0.30}
lineHeight={1.25}
>
{text}
</Text>
</group>
);
}
// ─── XRSession Page ────────────────────────────────────
const XRSession = () => {
const navigate = useNavigate();
const { model, anchorMode, setAnchorMode } = useModelStore();
const [inXR, setInXR] = useState(false);
const [freeMove, setFreeMove] = useState(true);
const [placementMode, setPlacementMode] = useState(true); // start in placement mode
const [snapToPlanes, setSnapToPlanes] = useState(true);
const [allowScale, setAllowScale] = useState(false);
// DEVKIT: simXR forces in-XR rendering on desktop without a real WebXR session
const devkit = useDevKit();
const [simXR, setSimXR] = useState(false);
const effectiveInXR = inXR || simXR;
useEffect(() => {
if (!model) navigate('/');
}, [model, navigate]);
useEffect(() => {
const unsubscribe = store.subscribe((state) => {
const session = state.session;
if (session && !inXR) {
console.log('[XR] ✅ Sessão AR ativa!');
setInXR(true);
setAnchorMode('manual');
toast.success('Sessão AR iniciada!');
session.addEventListener('end', () => {
console.log('[XR] ❌ Sessão AR encerrada');
setInXR(false);
});
}
});
return unsubscribe;
}, [inXR, setAnchorMode]);
const handleDownloadMarker = useCallback(async () => {
const url = await generateMarkerDownloadURL();
const a = document.createElement('a');
a.href = url;
a.download = 'TrackSteelXR_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-2">
<Button variant="ghost" size="icon" onClick={() => navigate('/viewer')} title="Voltar ao Viewer">
<ArrowLeft className="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" onClick={() => navigate('/')} title="Tela inicial">
<Home 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</span>
</Button>
<Button className="gap-2 glow-primary" onClick={() => {
console.log('[XR] Botão AR clicado');
store.enterAR().catch((e) => console.error('[XR] enterAR rejeitado:', e));
}}>
Iniciar Sessão AR
</Button>
</>
)}
{inXR && (
<span className="font-mono text-xs text-primary flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
XR Ativo
</span>
)}
</div>
</header>
{/* 3D Canvas */}
<div className="flex-1 relative">
<Canvas
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }}
camera={{ position: [1.5, 1.2, 1.5], fov: 50, near: 0.01, far: 100 }}
className="!bg-transparent"
onCreated={({ gl, invalidate }) => {
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
gl.setClearColor(0x000000, 0);
const loop = () => { invalidate(); requestAnimationFrame(loop); };
loop();
}}
>
<XR store={store}>
<ambientLight intensity={0.8} />
<directionalLight position={[3, 5, 3]} intensity={1.2} />
<directionalLight position={[-3, 3, -3]} intensity={0.4} />
<XROrigin />
{effectiveInXR ? (
<>
{/* In XR (or SimXR): grab wrapper → ACTIVE model. Other models render as static background. */}
<XRBackgroundModels />
{simXR ? (
<group position={[0, 1.0, -1.2]}>
<XRGrabbable
allowScale={allowScale}
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
>
<XRActiveModel />
</XRGrabbable>
</group>
) : (
<XRHitTestPlacement
placementMode={placementMode}
snapToPlanes={snapToPlanes}
onPlace={() => {
setPlacementMode(false);
toast.success('Modelo posicionado na superfície!');
}}
>
<XRGrabbable
allowScale={allowScale}
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
>
<XRActiveModel />
</XRGrabbable>
</XRHitTestPlacement>
)}
<XRMeasurementOverlay />
<ControllerFineTuning freeMove={freeMove} />
<XRDebugHud />
{/* DEVKIT: fake controllers visible in scene + keyboard driver */}
{devkit && simXR && <FakeControllers />}
{/* DEVKIT: orbit camera in SimXR so you can navigate around the model */}
{simXR && (
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.1}
minDistance={0.05}
maxDistance={50}
target={[0, 1.0, -1.2]}
/>
)}
</>
) : (
<>
{/* Desktop preview (no headset): show all models with OrbitControls */}
<group position={[0, 0, 0]}>
<XRBackgroundModels />
<XRActiveModel />
</group>
<XRMeasurementOverlay />
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.1}
minDistance={0.05}
maxDistance={50}
target={[0, 0, 0]}
/>
</>
)}
<XRSnapHandler />
<XRGrid />
</XR>
</Canvas>
{/* Floating HUD overlay with all controls */}
<XRHud
freeMove={freeMove}
onToggleFreeMove={() => setFreeMove(!freeMove)}
placementMode={placementMode}
onTogglePlacement={() => setPlacementMode(!placementMode)}
snapToPlanes={snapToPlanes}
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
allowScale={allowScale}
onToggleAllowScale={() => setAllowScale(!allowScale)}
/>
{/* DEVKIT: floating diagnostic panel + SimXR toggle */}
{devkit && (
<DevPanel simXR={simXR} onToggleSimXR={() => setSimXR((v) => !v)} />
)}
</div>
</div>
);
};
export default XRSession;