diff --git a/src/components/three/XRControllerMeasure.tsx b/src/components/three/XRControllerMeasure.tsx new file mode 100644 index 0000000..1702e0d --- /dev/null +++ b/src/components/three/XRControllerMeasure.tsx @@ -0,0 +1,84 @@ +import { useRef } from 'react'; +import { useFrame, useThree } from '@react-three/fiber'; +import * as THREE from 'three'; +import { useModelStore } from '@/stores/useModelStore'; + +const TRIG_ON = 0.7; +const TRIG_OFF = 0.3; +const MAX_RAY = 10; // meters + +/** + * Right-controller trigger driven measurement for AR. + * + * When `measureMode` is on and the user pulls the right trigger, casts a ray + * forward from the controller (target-ray space). The first mesh hit becomes + * a measurement point. Two pulls = one measurement (handled by the store). + * + * Uses hysteresis so a single trigger pull adds exactly one point. + */ +export function XRControllerMeasure() { + const { scene, gl } = useThree(); + const triggered = useRef(false); + const raycaster = useRef(new THREE.Raycaster()); + const tmpOrigin = useRef(new THREE.Vector3()); + const tmpDir = useRef(new THREE.Vector3()); + const tmpQuat = useRef(new THREE.Quaternion()); + + useFrame((_state, _dt, frame: XRFrame | undefined) => { + const measureMode = useModelStore.getState().measureMode; + if (!measureMode || !frame) return; + const session = frame.session; + const refSpace = gl.xr.getReferenceSpace(); + if (!session || !refSpace) return; + + // Find right controller + let right: XRInputSource | null = null; + for (const src of session.inputSources) { + if (src.handedness === 'right') { right = src; break; } + } + if (!right) return; + + const gp = right.gamepad; + const trigBtn = gp?.buttons?.[0]; + const trigVal = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0; + + // Rising edge with hysteresis + if (!triggered.current && trigVal > TRIG_ON) { + triggered.current = true; + + 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); + // Controller aims along -Z in WebXR target-ray convention + tmpDir.current.set(0, 0, -1).applyQuaternion(tmpQuat.current).normalize(); + + raycaster.current.set(tmpOrigin.current, tmpDir.current); + raycaster.current.far = MAX_RAY; + + 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 (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) { + useModelStore.getState().addMeasurePoint({ + x: hit.point.x, y: hit.point.y, z: hit.point.z, + }); + } + } else if (triggered.current && trigVal < TRIG_OFF) { + triggered.current = false; + } + }); + + return null; +}