Files
SteelXR/src/components/three/XRControllerMeasure.tsx
T

446 lines
18 KiB
TypeScript

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<THREE.Line>(null);
const tipRef = useRef<THREE.Mesh>(null);
const laserGeom = useRef<THREE.BufferGeometry>(
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<any>(null);
const hitTestRequestedRef = useRef<boolean>(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 <primitive>
const laserObject = useRef<THREE.Line>();
if (!laserObject.current) {
laserObject.current = new THREE.Line(laserGeom.current, laserMat.current);
laserObject.current.frustumCulled = false;
laserObject.current.renderOrder = 999;
(laserRef as React.MutableRefObject<THREE.Line | null>).current = laserObject.current;
}
return (
<>
<primitive object={laserObject.current} />
<mesh ref={tipRef} frustumCulled={false} renderOrder={999}>
<sphereGeometry args={[1, 12, 12]} />
<meshBasicMaterial color="#22c55e" depthTest={false} transparent opacity={0.9} />
</mesh>
</>
);
}