Files
SteelXR2/src/pages/XRSession.tsx
T

876 lines
33 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 { ShareButton } from '@/components/ShareButton';
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';
import { XRControllerMeasure } from '@/components/three/XRControllerMeasure';
import { ControllerLocomotion } from '@/components/three/ControllerLocomotion';
// 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';
import { VisibilityApplier } from '@/components/three/ModelViewer';
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
import { parseIFCtoThree } from '@/lib/convertIFC';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
// --- 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);
});
}
// Lê flags de feature WebXR do localStorage (default: todas ON).
// O usuário liga/desliga pelo HUD AR (aba WebXR) para diagnosticar qual
// feature ativa o grid de segurança do Quest. Mudanças exigem sair e re-entrar do AR.
function loadXRFeatures() {
const defaults = {
handTracking: true, planeDetection: true, hitTest: true, domOverlay: true,
anchors: true, meshDetection: true, depthSensing: true, layers: true,
bodyTracking: true, lightEstimation: true,
};
try {
const raw = localStorage.getItem('xrFeatures');
if (raw) return { ...defaults, ...JSON.parse(raw) };
} catch {}
return defaults;
}
const _xrf = loadXRFeatures();
console.log('[XR] Features:', _xrf);
const store = createXRStore({
hand: { left: true, right: true },
controller: { left: true, right: true },
handTracking: _xrf.handTracking,
planeDetection: _xrf.planeDetection,
hitTest: _xrf.hitTest,
domOverlay: _xrf.domOverlay,
anchors: _xrf.anchors,
meshDetection: _xrf.meshDetection,
depthSensing: _xrf.depthSensing,
layers: _xrf.layers,
bodyTracking: _xrf.bodyTracking,
});
// ─── XRModel ───────────────────────────────────────────
function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').SceneModel }) {
const [rawScene, setRawScene] = useState<THREE.Object3D | null>(null);
const scene = useMemo(() => rawScene ? rawScene.clone(true) : null, [rawScene]);
useEffect(() => {
let active = true;
const isIfc = sceneModel.fileName.toLowerCase().endsWith('.ifc');
if (isIfc) {
fetch(sceneModel.url)
.then((res) => res.arrayBuffer())
.then((buf) => parseIFCtoThree(buf))
.then((threeScene) => {
if (active) setRawScene(threeScene);
})
.catch((err) => console.error('[XRModel] IFC parsing error', err));
} else {
const loader = new GLTFLoader();
loader.load(
sceneModel.url,
(gltf) => {
if (active) setRawScene(gltf.scene);
},
undefined,
(err) => console.error('[XRModel] GLTF loading error', err)
);
}
return () => {
active = false;
};
}, [sceneModel.url, sceneModel.fileName]);
const ref = useRef<THREE.Group>(null);
useEffect(() => {
const g = ref.current;
if (!g) return;
registerModelLocalGroup(sceneModel.id, g);
return () => unregisterModelLocalGroup(sceneModel.id, g);
}, [sceneModel.id]);
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 measureMode = useModelStore((s) => s.measureMode);
const modelInfo = useMemo(() => {
if (!scene) return { size: new THREE.Vector3(), center: new THREE.Vector3() };
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(() => {
if (!scene) return;
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' && !measureMode) {
mat.visible = false;
} else {
mat.visible = true;
const targetOpacity = measureMode ? 0.25 : opacity;
mat.transparent = targetOpacity < 1;
mat.opacity = targetOpacity;
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' || measureMode) && child.geometry) {
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
const lineMat = new THREE.LineBasicMaterial({
color: measureMode ? '#00f3ff' : wireframeColor,
linewidth: measureMode ? 2 : wireframeThickness,
});
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
lineSegments.userData.__edgeLine = true;
child.add(lineSegments);
}
}
});
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color, measureMode]);
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;
if (!scene) return null;
return (
<group scale={[renderFactor, renderFactor, renderFactor]}>
<group
ref={ref}
userData={{ modelId: sceneModel.id }}
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} />
<XRLocalModelMeasurements modelId={sceneModel.id} />
</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);
const isActiveLocked = useModelStore((s) => {
const a = s.models.find(m => m.id === s.activeModelId);
return !!a?.locked;
});
useFrame(() => {
if (!session || isActiveLocked) 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;
}
// Componente de renderização de texto compatível e seguro para WebXR
function XRMeasurementText({ text, color }: { text: string; color: string }) {
const texture = useMemo(() => {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 64;
const ctx = canvas.getContext('2d');
if (ctx) {
// Limpa e desenha fundo semi-transparente
ctx.fillStyle = 'rgba(11, 18, 32, 0.85)';
ctx.beginPath();
// Desenha retângulo arredondado manualmente
const x = 0, y = 0, width = 256, height = 64, radius = 12;
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
ctx.fill();
// Desenha borda sutil
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.stroke();
// Texto
ctx.fillStyle = color;
ctx.font = 'bold 24px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, 128, 32);
}
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}, [text, color]);
return (
<mesh renderOrder={999}>
<planeGeometry args={[0.08, 0.02]} />
<meshBasicMaterial
map={texture}
transparent
depthTest={false}
side={THREE.DoubleSide}
/>
</mesh>
);
}
// Componente de linha nativa seguro para WebXR
export function XRSafeLine({ a, b }: { a: [number, number, number]; b: [number, number, number] }) {
const points = useMemo(() => [
new THREE.Vector3(a[0], a[1], a[2]),
new THREE.Vector3(b[0], b[1], b[2])
], [a, b]);
const geometry = useMemo(() => {
return new THREE.BufferGeometry().setFromPoints(points);
}, [points]);
return (
<line geometry={geometry}>
<lineBasicMaterial color="#22c55e" linewidth={2} />
</line>
);
}
// Componente para renderizar medições que pertencem a um modelo específico
export function XRLocalModelMeasurements({ modelId }: { modelId: string }) {
const measurements = useModelStore((s) => s.measurements);
const modelMeasurements = useMemo(() => {
return measurements.filter(m => m.modelId === modelId);
}, [measurements, modelId]);
return (
<>
{modelMeasurements.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];
const isHole = m.kind === 'hole';
const color = isHole ? '#f59e0b' : '#3b82f6';
const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`;
return (
<group key={m.id}>
{!isHole && (
<>
<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>
<XRSafeLine a={a} b={b} />
</>
)}
<group position={mid}>
<XRMeasurementText text={text} color={color} />
</group>
</group>
);
})}
</>
);
}
// ─── 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);
const globalMeasurements = useMemo(() => {
return measurements.filter(m => !m.modelId);
}, [measurements]);
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>
)}
{globalMeasurements.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];
const isHole = m.kind === 'hole';
const color = isHole ? '#f59e0b' : '#3b82f6';
const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`;
return (
<group key={m.id}>
{!isHole && (
<>
<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>
<XRSafeLine a={a} b={b} />
</>
)}
<group position={mid}>
<XRMeasurementText text={text} color={color} />
</group>
</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(() => {
// Em AR imersivo, a medição é feita pelo XRControllerMeasure (gatilho do controle).
// Evita raycast pesado por frame que travava o Quest ao ativar "Medir".
if (gl.xr.isPresenting) { setSnapPoint(null); return; }
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;
if (gl.xr.isPresenting) return; // Ignora cliques simulados do DOM quando o WebXR está ativo
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);
const gridY = useModelStore((s) => s.gridY);
if (!showGrid) return null;
return (
<Grid
position={[0, gridY, 0]}
infiniteGrid
cellSize={0.01}
sectionSize={0.1}
cellThickness={0.5}
sectionThickness={1}
cellColor="#334155"
sectionColor="#475569"
fadeDistance={5}
fadeStrength={1}
/>
);
}
/** Auto-follow grid to model bottom when enabled. */
function XRGridAutoFollower() {
const models = useModelStore((s) => s.models);
const { scene } = useThree();
useEffect(() => {
if (!useModelStore.getState().gridAutoFollow) return;
const id = setTimeout(() => {
if (!useModelStore.getState().gridAutoFollow) return;
const box = new THREE.Box3();
let has = false;
scene.traverse((obj) => {
if (obj instanceof THREE.Mesh && obj.geometry) {
if (obj.geometry instanceof THREE.SphereGeometry) return;
if (obj.geometry instanceof THREE.RingGeometry) return;
if (obj.userData.__edgeLine) return;
obj.updateWorldMatrix(true, false);
const b = new THREE.Box3().setFromObject(obj);
if (isFinite(b.min.y)) {
if (!has) { box.copy(b); has = true; }
else box.union(b);
}
}
});
if (has) useModelStore.setState({ gridY: box.min.y - 0.005 });
}, 150);
return () => clearTimeout(id);
}, [models, scene]);
return null;
}
// ImageTrackingAnchor removed — replaced by XRHitTestPlacement
// ─── XRSession Page ────────────────────────────────────
const XRSession = () => {
const navigate = useNavigate();
const { model, anchorMode, setAnchorMode } = useModelStore();
const isActiveLocked = useModelStore((s) => {
const a = s.models.find(m => m.id === s.activeModelId);
return !!a?.locked;
});
const [inXR, setInXR] = useState(false);
const [freeMove, setFreeMove] = useState(true);
const [placementMode, setPlacementMode] = useState(true); // start in placement mode
const [snapToPlanes, setSnapToPlanes] = useState(true);
// Zoom por grip duplo — toggle real, padrão ligado.
const [allowScale, setAllowScale] = useState(true);
const [liveCode, setLiveCode] = useState<string | null>(null);
const [liveViewers, setLiveViewers] = useState(0);
// Rig que envolve <XROrigin/> — locomoção por joystick escreve aqui.
const rigRef = useRef<THREE.Group>(null);
// 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 }) => {
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} />
<group ref={rigRef}>
<XROrigin />
</group>
{effectiveInXR ? (
<>
{/* In XR (or SimXR): all models share the same placement origin so
switching active doesn't make others appear to "disappear".
Only the ACTIVE model receives grab transforms. */}
{simXR ? (
<group position={[0, 1.0, -1.2]}>
<XRBackgroundModels />
<XRGrabbable
allowScale={allowScale}
lockedActive={isActiveLocked}
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
>
<XRActiveModel />
</XRGrabbable>
</group>
) : (
<XRHitTestPlacement
placementMode={placementMode}
snapToPlanes={snapToPlanes}
onPlace={() => {
setPlacementMode(false);
toast.success('Modelo posicionado na superfície!');
}}
>
<XRBackgroundModels />
<XRGrabbable
allowScale={allowScale}
lockedActive={isActiveLocked}
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
>
<XRActiveModel />
</XRGrabbable>
</XRHitTestPlacement>
)}
<XRMeasurementOverlay />
<XRControllerMeasure />
<ControllerLocomotion rigRef={rigRef} />
<VisibilityApplier />
{/* In-world HUD — visible inside passthrough where DOM overlays cannot reach */}
<XRHudInWorld
freeMove={freeMove}
onToggleFreeMove={() => setFreeMove(!freeMove)}
placementMode={placementMode}
onTogglePlacement={() => setPlacementMode(!placementMode)}
snapToPlanes={snapToPlanes}
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
allowScale={allowScale}
onToggleAllowScale={() => setAllowScale(!allowScale)}
liveCode={liveCode}
liveViewers={liveViewers}
onStartLive={() => (window as unknown as { __trackSteelStartLive?: () => void }).__trackSteelStartLive?.()}
onStopLive={() => (window as unknown as { __trackSteelStopLive?: () => void }).__trackSteelStopLive?.()}
/>
{/* Opaque mirror canvas used as the WebRTC source while in AR */}
<XRBroadcastMirror enabled />
{/* 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 />
<XRGridAutoFollower />
</XR>
</Canvas>
{/* Floating DOM HUD overlay — only visible OUTSIDE passthrough.
Inside AR, the DOM is occluded by the headset compositor; the
in-world XRHudInWorld replaces it. */}
<div style={{ display: inXR ? 'none' : 'contents' }}>
<XRHud
freeMove={freeMove}
onToggleFreeMove={() => setFreeMove(!freeMove)}
placementMode={placementMode}
onTogglePlacement={() => setPlacementMode(!placementMode)}
snapToPlanes={snapToPlanes}
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
allowScale={allowScale}
onToggleAllowScale={() => setAllowScale(!allowScale)}
/>
</div>
{/* Hidden ShareButton instance — registers window.__trackSteelStartLive
so the in-world Share tab can trigger broadcasts. Also keeps live
state in sync with the in-XR HUD. */}
<div style={{ display: 'none' }}>
<ShareButton
onHandleChange={(h) => setLiveCode(h?.code ?? null)}
onViewerCountChange={setLiveViewers}
/>
</div>
{/* DEVKIT: floating diagnostic panel + SimXR toggle */}
{devkit && (
<DevPanel simXR={simXR} onToggleSimXR={() => setSimXR((v) => !v)} />
)}
</div>
</div>
);
};
export default XRSession;