import { useRef, useState } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; import * as THREE from 'three'; import { useModelStore } from '@/stores/useModelStore'; import { sendRemoteLog } from '@/lib/remoteLogger'; import { resolveSnap, detectCircularEdgeAtPoint, findNearestEdgeSegment, resolveSnap3D, detectCircularEdgeAtPoint3D, findNearestEdgeSegment3D } from './SmartMeasure'; import { xrCalibration, pushXRRealPoint } from './xrCalibrationBus'; import { toast } from 'sonner'; const TRIG_ON = 0.7; const TRIG_OFF = 0.3; const BTN_ON = 0.6; const MAX_RAY = 10; // meters function isPickableModelMesh(o: THREE.Object3D): boolean { let cur: THREE.Object3D | null = o; while (cur) { if (cur.userData?.modelId) return true; cur = cur.parent; } return false; } function getModelIdFromObject(o: THREE.Object3D): string | undefined { let cur: THREE.Object3D | null = o; while (cur) { if (cur.userData?.modelId) return cur.userData.modelId as string; cur = cur.parent; } return undefined; } /** * Right-controller trigger driven measurement for AR. * * Mapping (right hand): * - Trigger → add measurement point (with snap) * - Button A (gp.buttons[4]) → undo last point/measurement * - Button B (gp.buttons[5]) → clear all measurements * Left hand: * - Trigger → toggle vertex snap ON/OFF * * Renders a thin laser from the right controller to the snap candidate * (green if snapped to vertex/edge, amber if surface). */ export function XRControllerMeasure() { const { scene, gl } = useThree(); const trigState = useRef(false); const aState = useRef(false); const bState = useRef(false); const lTrigState = useRef(false); const [snapEnabled, setSnapEnabled] = useState(true); const raycaster = useRef(new THREE.Raycaster()); const tmpOrigin = useRef(new THREE.Vector3()); const tmpDir = useRef(new THREE.Vector3()); const tmpQuat = useRef(new THREE.Quaternion()); // Visual laser refs const laserRef = useRef(null); const tipRef = useRef(null); const laserGeom = useRef( new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3(0, 0, -1)]) ); const laserMat = useRef(new THREE.LineBasicMaterial({ color: '#22c55e', transparent: true, opacity: 0.7, depthTest: false })); const tipColor = useRef(new THREE.Color('#22c55e')); const lockedSnap = useRef<{ point: THREE.Vector3; kind: 'vertex' | 'edge' | 'hole'; modelId?: string; lastSeen: number } | null>(null); // Dwell detection for hover-based smart measurement (1 s) const dwellPos = useRef(new THREE.Vector3(Infinity, Infinity, Infinity)); const dwellStart = useRef(0); const dwellFired = useRef(false); // Throttling: raycast + snap analysis are heavy on dense IFC models and // running them every frame at 72fps can stall the Quest right after // toggling "Medir" from the Ferramentas tab. We cap heavy work to ~24Hz. // Trigger/A/B/L-trigger polling continues every frame for responsiveness. const hitTestSourceRef = useRef(null); const hitTestRequestedRef = useRef(false); useFrame((_state, _dt, frame: XRFrame | undefined) => { const measureMode = useModelStore.getState().measureMode; const selectionMode = useModelStore.getState().selectionMode; // Alinhamento Virt/Real ativo const isAligning = xrCalibration.alignType === 'virt-real' && (xrCalibration.step === 'await-real-1' || xrCalibration.step === 'await-virtual-1' || xrCalibration.step === 'await-real-2' || xrCalibration.step === 'await-virtual-2' || xrCalibration.step === 'await-real-3' || xrCalibration.step === 'await-virtual-3'); if (laserRef.current) laserRef.current.visible = false; if (tipRef.current) tipRef.current.visible = false; if ((!measureMode && !selectionMode && !isAligning) || !frame) { if (hitTestRequestedRef.current) { hitTestSourceRef.current = null; hitTestRequestedRef.current = false; } return; } const session = frame.session; const refSpace = gl.xr.getReferenceSpace(); if (!session || !refSpace) return; let right: XRInputSource | null = null; let left: XRInputSource | null = null; for (const src of session.inputSources) { if (src.handedness === 'right') right = src; else if (src.handedness === 'left') left = src; } // ── Left trigger: toggle snap ───────────────────────────────────── if (left) { const v = left.gamepad?.buttons?.[0]?.value ?? (left.gamepad?.buttons?.[0]?.pressed ? 1 : 0); if (!lTrigState.current && v > TRIG_ON) { lTrigState.current = true; setSnapEnabled((s) => !s); } else if (lTrigState.current && v < TRIG_OFF) { lTrigState.current = false; } } if (!right) return; const gp = right.gamepad; // ── Compute right-controller ray ────────────────────────────────── const raySpace = right.targetRaySpace ?? right.gripSpace; if (!raySpace) return; const pose = frame.getPose(raySpace, refSpace); if (!pose) return; const m = new THREE.Matrix4().fromArray(pose.transform.matrix); tmpOrigin.current.setFromMatrixPosition(m); tmpQuat.current.setFromRotationMatrix(m); tmpDir.current.set(0, 0, -1).applyQuaternion(tmpQuat.current).normalize(); raycaster.current.set(tmpOrigin.current, tmpDir.current); raycaster.current.far = MAX_RAY; // Hit test do mundo real para alinhamento const isRealStep = xrCalibration.alignType === 'virt-real' && (xrCalibration.step === 'await-real-1' || xrCalibration.step === 'await-real-2' || xrCalibration.step === 'await-real-3'); const isVirtualStep = xrCalibration.alignType === 'virt-real' && (xrCalibration.step === 'await-virtual-1' || xrCalibration.step === 'await-virtual-2' || xrCalibration.step === 'await-virtual-3'); if (session && right && !hitTestRequestedRef.current && isAligning) { hitTestRequestedRef.current = true; session.requestHitTestSource({ space: right.targetRaySpace }) .then((source) => { hitTestSourceRef.current = source; }) .catch((err) => { console.error('[XR] Failed to request controller hit test source:', err); hitTestRequestedRef.current = false; }); } let realHitPoint: THREE.Vector3 | null = null; if (isRealStep) { if (hitTestSourceRef.current && frame) { const results = frame.getHitTestResults(hitTestSourceRef.current); if (results.length > 0) { const poseResult = results[0].getPose(refSpace); if (poseResult) { realHitPoint = new THREE.Vector3().setFromMatrixPosition( new THREE.Matrix4().fromArray(poseResult.transform.matrix) ); } } } if (!realHitPoint) { // Fallback: 1.5 metros na direção do controle realHitPoint = tmpOrigin.current.clone().add(tmpDir.current.clone().multiplyScalar(1.5)); } } let snappedPoint: THREE.Vector3 | null = null; let snapKind: 'vertex' | 'edge' | 'surface' | 'hole' = 'surface'; let hoverDetected: { kind: 'hole' | 'edge'; value: number; position: THREE.Vector3; modelId?: string; endpoints?: { a: THREE.Vector3; b: THREE.Vector3 } } | null = null; let hitModelId: string | undefined; const nowT = performance.now(); const hits = raycaster.current.intersectObjects(scene.children, true); const hit = hits.find((h) => { const o = h.object; if (!(o instanceof THREE.Mesh)) return false; if (!isPickableModelMesh(o)) return false; if (o.userData.__edgeLine) return false; if (o.geometry instanceof THREE.SphereGeometry) return false; if (o.geometry instanceof THREE.RingGeometry) return false; if (o.geometry instanceof THREE.PlaneGeometry) return false; return true; }); if (hit && hit.object instanceof THREE.Mesh) { hitModelId = getModelIdFromObject(hit.object); const dist = tmpOrigin.current.distanceTo(hit.point); const vertexThreshold = Math.max(0.08, dist * 0.12); const edgeThreshold = Math.max(0.10, dist * 0.14); // Snap to existing registered hole centers first const existing = useModelStore.getState().measurements; let bestHoleCenter: THREE.Vector3 | null = null; let bestHoleDist = Infinity; for (const m of existing) { if (m.kind !== 'hole') continue; const c = new THREE.Vector3(m.pointA.x, m.pointA.y, m.pointA.z); const d = hit.point.distanceTo(c); if (d < 0.05 && d < bestHoleDist) { bestHoleDist = d; bestHoleCenter = c; } } if (snapEnabled && bestHoleCenter) { snappedPoint = bestHoleCenter; snapKind = 'hole'; } else if (snapEnabled) { const circle = detectCircularEdgeAtPoint3D(hit.object, hit.point, Math.max(0.08, dist * 0.12)); if (circle) { snappedPoint = circle.center; snapKind = 'hole'; hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center, modelId: hitModelId }; } else { const snap = resolveSnap3D(hit.object, hit.point, vertexThreshold, edgeThreshold); snappedPoint = snap.point; snapKind = snap.type; if (snap.type === 'edge') { const seg = findNearestEdgeSegment3D(hit.object, hit.point, edgeThreshold); if (seg) { const lenMM = seg.a.distanceTo(seg.b) * 1000; hoverDetected = { kind: 'edge', value: lenMM, position: seg.midpoint, modelId: hitModelId, endpoints: { a: seg.a, b: seg.b } }; } } } } else { snappedPoint = hit.point.clone(); snapKind = 'surface'; } } const strongSnapKind = snapKind === 'vertex' || snapKind === 'edge' || snapKind === 'hole' ? snapKind : null; if (snappedPoint && strongSnapKind) { const locked = lockedSnap.current; const sameTarget = locked && locked.kind === strongSnapKind && locked.modelId === hitModelId; if (sameTarget && locked.point.distanceTo(snappedPoint) < (snapKind === 'edge' ? 0.08 : 0.12)) { if (snapKind === 'edge') locked.point.lerp(snappedPoint, 0.25); snappedPoint = locked.point.clone(); locked.lastSeen = nowT; } else { lockedSnap.current = { point: snappedPoint.clone(), kind: strongSnapKind, modelId: hitModelId, lastSeen: nowT }; } } else if (snappedPoint && lockedSnap.current && lockedSnap.current.point.distanceTo(snappedPoint) < 0.12 && nowT - lockedSnap.current.lastSeen < 600) { snapKind = lockedSnap.current.kind; snappedPoint = lockedSnap.current.point.clone(); lockedSnap.current.lastSeen = nowT; } else if (!snappedPoint || (lockedSnap.current && nowT - lockedSnap.current.lastSeen > 600)) { lockedSnap.current = null; } // ── Dwell detection (1 s) to auto-register hovered hole/edge ───── const measureModeNow = useModelStore.getState().measureMode; if (snappedPoint && hoverDetected) { const dist = dwellPos.current.distanceTo(snappedPoint); if (dist > 0.005) { dwellPos.current.copy(snappedPoint); dwellStart.current = nowT; dwellFired.current = false; } else if (!dwellFired.current && nowT - dwellStart.current > 1000) { dwellFired.current = true; if (measureModeNow) { useModelStore.getState().registerHoverMeasurement({ type: hoverDetected.kind, value: hoverDetected.value, position: [hoverDetected.position.x, hoverDetected.position.y, hoverDetected.position.z], endpoints: hoverDetected.endpoints && { a: { x: hoverDetected.endpoints.a.x, y: hoverDetected.endpoints.a.y, z: hoverDetected.endpoints.a.z }, b: { x: hoverDetected.endpoints.b.x, y: hoverDetected.endpoints.b.y, z: hoverDetected.endpoints.b.z }, }, modelId: hoverDetected.modelId, }); } } } else { dwellFired.current = false; dwellStart.current = nowT; } // ── Update laser visual ─────────────────────────────────────────── if (laserRef.current && tipRef.current) { if (isVirtualStep) { laserRef.current.visible = false; tipRef.current.visible = false; } else { const end = isRealStep ? realHitPoint : (snappedPoint ?? tmpOrigin.current.clone().add(tmpDir.current.clone().multiplyScalar(MAX_RAY))); const positions = laserGeom.current.attributes.position as THREE.BufferAttribute; positions.setXYZ(0, tmpOrigin.current.x, tmpOrigin.current.y, tmpOrigin.current.z); positions.setXYZ(1, end.x, end.y, end.z); positions.needsUpdate = true; laserGeom.current.computeBoundingSphere(); const color = isAligning ? '#eab308' // dourado para alinhamento Virt/Real : (selectionMode ? '#a855f7' : (snapKind === 'hole' ? '#f59e0b' : snapKind === 'vertex' ? '#22c55e' : snapKind === 'edge' ? '#3b82f6' : '#eab308')); tipColor.current.set(color); (laserRef.current.material as THREE.LineBasicMaterial).color.set(color); ((tipRef.current.material as THREE.MeshBasicMaterial)).color.copy(tipColor.current); laserRef.current.visible = true; const tipPos = isRealStep ? realHitPoint : snappedPoint; if (tipPos) { tipRef.current.visible = true; tipRef.current.position.copy(tipPos); const dist = tmpOrigin.current.distanceTo(tipPos); const strongSnap = isAligning || snapKind === 'vertex' || snapKind === 'edge' || snapKind === 'hole'; const factor = strongSnap ? 0.030 : 0.012; const s = Math.max(strongSnap ? 0.010 : 0.004, dist * factor); tipRef.current.scale.setScalar(s); } else { tipRef.current.visible = false; } } } // ── Right trigger: add point (measure) OR toggle selection ──────── const trigVal = gp?.buttons?.[0]?.value ?? (gp?.buttons?.[0]?.pressed ? 1 : 0); if (!trigState.current && trigVal > TRIG_ON) { trigState.current = true; const st = useModelStore.getState(); if (isRealStep) { if (realHitPoint) { pushXRRealPoint(realHitPoint); const stepNum = xrCalibration.step.endsWith('1') ? 1 : xrCalibration.step.endsWith('2') ? 2 : 3; toast.success(`Ponto real ${stepNum} registrado!`); } return; } if (isAligning) { // Ignora o trigger físico para medições quando no modo virtual pois o clique do modelo é capturado pelo R3F onClick. return; } if (st.selectionMode) { // Fresh raycast on the press itself (not every frame) for selection const triggerHits = raycaster.current.intersectObjects(scene.children, true); const triggerHit = triggerHits.find((h) => { const o = h.object; if (!(o instanceof THREE.Mesh)) return false; if (!isPickableModelMesh(o)) return false; if (o.userData.__edgeLine) return false; if (o.geometry instanceof THREE.SphereGeometry) return false; if (o.geometry instanceof THREE.RingGeometry) return false; if (o.geometry instanceof THREE.PlaneGeometry) return false; return true; }); if (triggerHit && triggerHit.object instanceof THREE.Mesh) { let cur: THREE.Object3D | null = triggerHit.object; let element: THREE.Object3D | null = null; let modelId: string | null = null; while (cur) { if (!element && cur.userData?.ifcElement) element = cur; if (!modelId && cur.userData?.modelId) modelId = cur.userData.modelId as string; cur = cur.parent; } if (!element) element = triggerHit.object; if (!modelId) modelId = st.activeModelId; if (modelId && element) { const id = element.userData?.ifcId ?? element.name ?? element.uuid; st.toggleElementSelection(`${modelId}:${id}`); } } } else if (snappedPoint) { sendRemoteLog('info', 'Trigger físico pressionado no AR com snapPoint ativo', snappedPoint); st.addMeasurePoint({ x: snappedPoint.x, y: snappedPoint.y, z: snappedPoint.z, modelId: hitModelId, }); } } else if (trigState.current && trigVal < TRIG_OFF) { trigState.current = false; } // ── Button A (undo) ────────────────────────────────────────────── const aBtn = gp?.buttons?.[4]; const aVal = aBtn ? (aBtn.value || (aBtn.pressed ? 1 : 0)) : 0; if (!aState.current && aVal > BTN_ON) { aState.current = true; useModelStore.getState().undoLastMeasurement(); } else if (aState.current && aVal < TRIG_OFF) { aState.current = false; } // ── Button B (clear) ───────────────────────────────────────────── const bBtn = gp?.buttons?.[5]; const bVal = bBtn ? (bBtn.value || (bBtn.pressed ? 1 : 0)) : 0; if (!bState.current && bVal > BTN_ON) { bState.current = true; useModelStore.getState().clearMeasurements(); } else if (bState.current && bVal < TRIG_OFF) { bState.current = false; } }); // Construct the laser Line object once so we can attach via const laserObject = useRef(); if (!laserObject.current) { laserObject.current = new THREE.Line(laserGeom.current, laserMat.current); laserObject.current.frustumCulled = false; laserObject.current.renderOrder = 999; (laserRef as React.MutableRefObject).current = laserObject.current; } return ( <> ); }