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, useXR, XROrigin, useXRHitTest } from '@react-three/xr'; import { xrStore as store } from '@/stores/useXRStore'; import * as THREE from 'three'; import { useModelStore } from '@/stores/useModelStore'; import { toast } from 'sonner'; import { ArrowLeft, Download, QrCode, Crosshair, Home, Glasses, Loader2 } 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'; import { xrCalibration, pushXRModelFaceNormal, computeXRCalibrationQuaternion, subscribeXRCalibration, pushXRRealPoint, pushXRVirtualPoint, computeVirtRealTransform, activeModelGroupRef, } from '@/components/three/xrCalibrationBus'; // --- 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); }); } // `store` is now imported from `@/stores/useXRStore` // ─── XRModel ─────────────────────────────────────────── function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').SceneModel }) { const [rawScene, setRawScene] = useState(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(null); const calGroupRef = useRef(null); const localFrameRef = useRef(null); const topGroupRef = useRef(null); // Escuta mudanças de calibração para re-renderizar no AR const [, setCalTick] = useState(0); useEffect(() => subscribeXRCalibration(() => setCalTick(t => t + 1)), []); useEffect(() => { const g = localFrameRef.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, toneMapped: false, transparent: true, opacity: 0.95, }); 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; const calQuatArr = sceneModel.calibrationQuat; const isCalibratingThis = xrCalibration.modelId === sceneModel.id && xrCalibration.step !== 'idle' && xrCalibration.step !== 'done'; const calQuat = useMemo(() => { if (isCalibratingThis) return new THREE.Quaternion(); if (!calQuatArr) return new THREE.Quaternion(); return new THREE.Quaternion(calQuatArr[0], calQuatArr[1], calQuatArr[2], calQuatArr[3]); }, [calQuatArr, isCalibratingThis]); if (!sceneModel.visible) return null; if (!scene) return null; return ( { e.stopPropagation(); // Calibração do Grid em AR (clonando os mesmos princípios do modo viewer) const gridCalibMode = useModelStore.getState().gridCalibMode; if (gridCalibMode && e.point) { useModelStore.setState({ gridY: e.point.y - 0.001, gridAutoFollow: false, gridCalibMode: false, showGrid: true, }); return; } // Calibração XR da Peça: captura normal da face do modelo em espaço de mundo. if ( xrCalibration.modelId === sceneModel.id && xrCalibration.alignType === 'cube' && (xrCalibration.step === 'await-model-1' || xrCalibration.step === 'await-model-2' || xrCalibration.step === 'await-model-3') && e.face && e.object ) { const n = e.face.normal.clone(); const nm = new THREE.Matrix3().getNormalMatrix(e.object.matrixWorld); n.applyMatrix3(nm).normalize(); const wq = new THREE.Quaternion(); (calGroupRef.current ?? e.object).getWorldQuaternion(wq); pushXRModelFaceNormal(n, wq); // Aplica/atualiza calibração se tivermos >= 2 pares if (xrCalibration.pairs.length >= 2) { const q = computeXRCalibrationQuaternion(xrCalibration.pairs); if (q) { useModelStore.getState().setCalibration(sceneModel.id, [q.x, q.y, q.z, q.w]); } } return; } // Calibração Virt/Real da Peça: captura os pontos virtuais. if ( xrCalibration.modelId === sceneModel.id && xrCalibration.alignType === 'virt-real' && (xrCalibration.step === 'await-virtual-1' || xrCalibration.step === 'await-virtual-2' || xrCalibration.step === 'await-virtual-3') && localFrameRef.current ) { const localPoint = localFrameRef.current.worldToLocal(e.point.clone()); const localPivotPoint = localPoint.clone().sub(modelInfo.center); const stepNum = xrCalibration.step.endsWith('1') ? 1 : xrCalibration.step.endsWith('2') ? 2 : 3; if (stepNum === 3 && xrCalibration.realPoints.length >= 3 && topGroupRef.current) { const vPoints = [...xrCalibration.virtualPoints, localPivotPoint]; const realWorldPoints = xrCalibration.realPoints; const parentPoints = realWorldPoints.map(rw => topGroupRef.current!.worldToLocal(rw.clone()) ); const res = computeVirtRealTransform(vPoints, parentPoints); if (res) { const { quaternion, position } = res; useModelStore.getState().setCalibration(sceneModel.id, [quaternion.x, quaternion.y, quaternion.z, quaternion.w]); useModelStore.getState().setFineTuning({ posX: position.x, posY: position.y, posZ: position.z, rotX: 0, rotY: 0, rotZ: 0, }); pushXRVirtualPoint(localPivotPoint); toast.success("Peça virtual ajustada com sucesso na peça real!"); } else { toast.error("Erro ao alinhar: pontos são colineares!"); } } else { pushXRVirtualPoint(localPivotPoint); toast.success(`Ponto virtual ${stepNum} registrado!`); } return; } }} > {/* Translação */} {/* Rotação e escala em volta do centro geométrico */} {/* Rotação de calibração */} {/* Sistema local do modelo com pivot no centro */} ); } /** 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) => ( ))} ); } /** Component that renders a laser from the camera (headset) to the grid landing target. */ function XRGridLandingLaser({ reticlePos }: { reticlePos: THREE.Vector3 | null }) { const { camera } = useThree(); const geom = useMemo(() => new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3(0, 0, -1)]), []); const mat = useMemo(() => new THREE.LineBasicMaterial({ color: '#eab308', transparent: true, opacity: 0.6 }), []); const lineRef = useRef(null); useFrame(() => { if (lineRef.current && reticlePos) { const posAttr = geom.attributes.position as THREE.BufferAttribute; // Laser origin slightly below eyes posAttr.setXYZ(0, camera.position.x, camera.position.y - 0.05, camera.position.z); posAttr.setXYZ(1, reticlePos.x, reticlePos.y, reticlePos.z); posAttr.needsUpdate = true; geom.computeBoundingSphere(); lineRef.current.visible = true; } else if (lineRef.current) { lineRef.current.visible = false; } }); return ; } /** Handles physics surface hit-testing using WebXR for grid and active model landing. */ function XRGridLandingHandler() { const gridLandingMode = useModelStore((s) => s.gridLandingMode); const gridY = useModelStore((s) => s.gridY); const reticleRef = useRef(null); const [reticlePos, setReticlePos] = useState(null); // Continuous hit-test from viewer center useXRHitTest( gridLandingMode ? (results, getWorldMatrix) => { if (results.length === 0) { setReticlePos(null); return; } const matrixHelper = new THREE.Matrix4(); getWorldMatrix(matrixHelper, results[0]); const positionHelper = new THREE.Vector3().setFromMatrixPosition(matrixHelper); setReticlePos(positionHelper.clone()); } : undefined, 'viewer' ); // Trata a confirmação (clique/gatilho no óculos ou controle) const handleSelect = useCallback(() => { if (!gridLandingMode || !reticlePos) return; const targetY = reticlePos.y; const deltaY = targetY - gridY; // Desloca a peça ativa na mesma diferença (deltaY) para que ela "pouse junto" const state = useModelStore.getState(); const activeModel = state.models.find(m => m.id === state.activeModelId); if (activeModel) { const ft = activeModel.fineTuning; state.setFineTuning({ posY: ft.posY + deltaY, }); } // Atualiza o grid Y no store useModelStore.setState({ gridY: targetY, gridAutoFollow: false, gridLandingMode: false, showGrid: true, }); toast.success("Grid e peça posicionados na superfície real!"); }, [gridLandingMode, reticlePos, gridY]); // Atualiza posição da retícula a cada frame useFrame(() => { if (reticleRef.current) { if (reticlePos && gridLandingMode) { reticleRef.current.visible = true; reticleRef.current.position.copy(reticlePos); } else { reticleRef.current.visible = false; } } }); if (!gridLandingMode) return null; return ( <> {/* Retícula dourada premium */} {/* Plano invisível de click no fundo */} ); } /** Component that renders a laser from the camera (headset) to the virtual/real calibration target. */ function XRVirtRealCalibLaser({ reticlePos }: { reticlePos: THREE.Vector3 | null }) { const { camera } = useThree(); const geom = useMemo(() => new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3(0, 0, -1)]), []); const mat = useMemo(() => new THREE.LineBasicMaterial({ color: '#eab308', transparent: true, opacity: 0.8, depthTest: false }), []); const lineRef = useRef(null); useFrame(() => { if (lineRef.current && reticlePos) { const posAttr = geom.attributes.position as THREE.BufferAttribute; // Laser origin slightly below eyes posAttr.setXYZ(0, camera.position.x, camera.position.y - 0.05, camera.position.z); posAttr.setXYZ(1, reticlePos.x, reticlePos.y, reticlePos.z); posAttr.needsUpdate = true; geom.computeBoundingSphere(); lineRef.current.visible = true; } else if (lineRef.current) { lineRef.current.visible = false; } }); return ; } /** Handles physics surface hit-testing using WebXR for virtual-real piece alignment. */ function XRVirtRealCalibHandler() { const [, force] = useState(0); useEffect(() => subscribeXRCalibration(() => force(t => t + 1)), []); const isRealStep = xrCalibration.alignType === 'virt-real' && (xrCalibration.step === 'await-real-1' || xrCalibration.step === 'await-real-2' || xrCalibration.step === 'await-real-3'); const reticleRef = useRef(null); const [reticlePos, setReticlePos] = useState(null); // Continuous hit-test from viewer center useXRHitTest( isRealStep ? (results, getWorldMatrix) => { if (results.length === 0) { setReticlePos(null); return; } const matrixHelper = new THREE.Matrix4(); getWorldMatrix(matrixHelper, results[0]); const positionHelper = new THREE.Vector3().setFromMatrixPosition(matrixHelper); setReticlePos(positionHelper.clone()); } : undefined, 'viewer' ); const handleSelect = useCallback(() => { if (!isRealStep || !reticlePos) return; const stepNum = xrCalibration.step.endsWith('1') ? 1 : xrCalibration.step.endsWith('2') ? 2 : 3; pushXRRealPoint(reticlePos.clone()); toast.success(`Ponto real ${stepNum} registrado!`); }, [isRealStep, reticlePos]); // Update reticle position useFrame(() => { if (reticleRef.current) { if (reticlePos && isRealStep) { reticleRef.current.visible = true; reticleRef.current.position.copy(reticlePos); } else { reticleRef.current.visible = false; } } }); if (!isRealStep) return null; return ( <> {/* Golden reticle */} {/* Tap plane to capture select events */} ); } /** 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 ; } // ─── 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 trigBtn = gp.buttons[0]; const gripBtn = 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 ( ); } // 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 line = useMemo(() => { const geometry = new THREE.BufferGeometry().setFromPoints(points); const material = new THREE.LineBasicMaterial({ color: '#22c55e' }); return new THREE.Line(geometry, material); }, [points]); return ; } // 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 ( {!isHole && ( <> )} ); })} ); } // ─── 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 && ( )} {measurePoints.length === 1 && ( )} {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 ( {!isHole && ( <> )} ); })} ); } // ─── 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; } // ─── Desktop Aligner Fallback for Virt/Real Calibration ─ function XRVirtRealDesktopAligner() { const { gl, camera, scene } = useThree(); const [tick, setTick] = useState(0); useEffect(() => { return subscribeXRCalibration(() => setTick(t => t + 1)); }, []); useEffect(() => { const isAligning = xrCalibration.alignType === 'virt-real' && (xrCalibration.step === 'await-real-1' || xrCalibration.step === 'await-real-2' || xrCalibration.step === 'await-real-3'); if (!isAligning) return; if (gl.xr.isPresenting) return; const mouse = new THREE.Vector2(); const raycaster = new THREE.Raycaster(); const onClick = (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; raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(scene.children, true); const activeModelId = useModelStore.getState().activeModelId; const hit = intersects.find(i => { if (i.object.userData?.__edgeLine) return false; let cur: THREE.Object3D | null = i.object; while (cur) { if (cur.userData?.modelId === activeModelId) return false; cur = cur.parent; } return true; }); const point = hit ? hit.point.clone() : new THREE.Vector3(0, 0, 0); pushXRRealPoint(point); const stepNum = xrCalibration.step.endsWith('1') ? 1 : xrCalibration.step.endsWith('2') ? 2 : 3; toast.success(`Ponto real ${stepNum} registrado (Simulado)!`); }; gl.domElement.addEventListener('click', onClick); return () => gl.domElement.removeEventListener('click', onClick); }, [gl, camera, scene, tick]); return null; } // ─── XR Grid ─────────────────────────────────────────── function XRGrid() { const showGrid = useModelStore((s) => s.showGrid); const gridY = useModelStore((s) => s.gridY); const activeModelId = useModelStore((s) => s.activeModelId); const activeModel = useModelStore((s) => s.models.find(m => m.id === activeModelId)); const groupRef = useRef(null); useFrame(() => { if (!showGrid || !groupRef.current) return; let px = 0; let pz = 0; let ry = 0; if (activeModelGroupRef.current) { const g = activeModelGroupRef.current; const wp = new THREE.Vector3(); g.getWorldPosition(wp); px = wp.x; pz = wp.z; const wq = new THREE.Quaternion(); g.getWorldQuaternion(wq); const euler = new THREE.Euler().setFromQuaternion(wq, 'YXZ'); ry = euler.y; } else if (activeModel) { px = activeModel.fineTuning.posX; pz = activeModel.fineTuning.posZ; ry = (activeModel.fineTuning.rotY * Math.PI) / 180; } groupRef.current.position.set(px, gridY, pz); groupRef.current.rotation.set(0, ry, 0); }); if (!showGrid) return null; return ( ); } /** 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(null); const [liveViewers, setLiveViewers] = useState(0); // Rig que envolve — locomoção por joystick escreve aqui. const rigRef = useRef(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; // Indica que enterAR() foi chamado mas a sessão ainda não começou — previne auto-return prematuro const [isEnteringAR, setIsEnteringAR] = useState(false); const [hasClickedEnter, setHasClickedEnter] = useState(false); useEffect(() => { if (!model) navigate('/'); }, [model, navigate]); // Detecta quando a sessão XR de fato começa useEffect(() => { const unsubscribe = store.subscribe((state) => { const session = state.session; if (session && !inXR) { console.log('[XR] ✅ Sessão AR ativa!'); setInXR(true); setIsEnteringAR(false); setAnchorMode('manual'); toast.success('Sessão AR iniciada!'); session.addEventListener('end', () => { console.log('[XR] ❌ Sessão AR encerrada'); setInXR(false); setHasClickedEnter(false); navigate('/viewer'); }); } }); return unsubscribe; }, [inXR, setAnchorMode, navigate]); // Flag que indica que o usuário pediu para entrar no AR (enterAR chamado). // Usada para bloquear o auto-return durante o onboarding do WebXR. useEffect(() => { // Se inXR virou true mas isEnteringAR é true, a sessão começou — limpa o flag if (inXR && isEnteringAR) { setIsEnteringAR(false); } }, [inXR, isEnteringAR]); // Expõe setIsEnteringAR para o Viewer via window (permite marcar "entrando" antes de navegar) useEffect(() => { (window as unknown as { __setXrEntering?: (v: boolean) => void }).__setXrEntering = setIsEnteringAR; }, []); const handleDownloadMarker = useCallback(async () => { const url = await generateMarkerDownloadURL(); const a = document.createElement('a'); a.href = url; a.download = 'SteelXR_Marker.png'; a.click(); URL.revokeObjectURL(url); toast.success('Marcador baixado — Imprima em 15×15cm'); }, []); if (!model) return null; return (
{/* Header */}

Modo XR Imersivo

{inXR && ( XR Ativo )}
{/* 3D Canvas */}
{ gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); gl.setClearColor(0x000000, 0); }} > { (rigRef as React.MutableRefObject).current = el; useModelStore.getState().setXRRig(el); }}> {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 ? ( { if (placementMode) setPlacementMode(false); }} > ) : ( { setPlacementMode(false); toast.success('Modelo posicionado na superfície!'); }} > { if (placementMode) setPlacementMode(false); }} > )} {/* In-world HUD — visible inside passthrough where DOM overlays cannot reach */} 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 */} {/* DEVKIT: fake controllers visible in scene + keyboard driver */} {devkit && simXR && } {/* DEVKIT: orbit camera in SimXR so you can navigate around the model */} {simXR && ( )} ) : ( <> {/* Desktop preview (no headset): show all models with OrbitControls */} )} {/* Onboarding do AR */} {!effectiveInXR && (

Pronto para entrar no Modo AR?

Você está prestes a carregar o modelo em escala real 1:1 no seu ambiente físico.

{/* Informações da Peça */}
Modelo Ativo: {model.fileName}
Tamanho: {(model.fileSize / (1024 * 1024)).toFixed(2)} MB
{/* Dicas Rápidas */}

💡 Guia de Controles Rápidos:

  • Teletransporte: Thumbstick para frente. Solte para teleportar.
  • Escala Real (1:1): Aponte o feixe para a peça e teletransporte nela.
  • Mover Peça: Segure o Grip de qualquer controle para arrastar.
  • Girar/Zoom: Ambas as miras na peça, segure ambos os Grips e mova as mãos.
  • Menu do App: Botões A/B/X/Y abrem/fecham o menu flutuante.
{/* Ações */}
)} {/* Floating DOM HUD overlay — only visible OUTSIDE passthrough. Inside AR, the DOM is occluded by the headset compositor; the in-world XRHudInWorld replaces it. */}
setFreeMove(!freeMove)} placementMode={placementMode} onTogglePlacement={() => setPlacementMode(!placementMode)} snapToPlanes={snapToPlanes} onToggleSnap={() => setSnapToPlanes(!snapToPlanes)} allowScale={allowScale} onToggleAllowScale={() => setAllowScale(!allowScale)} />
{/* 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. */}
setLiveCode(h?.code ?? null)} onViewerCountChange={setLiveViewers} />
{/* DEVKIT: floating diagnostic panel + SimXR toggle */} {devkit && ( setSimXR((v) => !v)} /> )}
); }; export default XRSession;