import { useRef } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; import * as THREE from 'three'; // DEVKIT: remove this import + the fake-input block in useFrame to strip devkit import { fakeInput, isFakeActive } from '@/devkit/fakeInputStore'; export interface GrabState { /** Analog grip pressure (0..1) */ gripValue: number; /** Analog trigger pressure (0..1) */ triggerValue: number; /** True when grip pressure crosses grab threshold (with hysteresis) */ isGrabbing: boolean; /** World-space matrix of the gripSpace (Touch Plus controller pose) */ gripWorld: THREE.Matrix4; /** True when controller pose is valid this frame */ hasPose: boolean; } export interface ControllerGrabSnapshot { left: GrabState; right: GrabState; } const GRAB_ON = 0.7; const GRAB_OFF = 0.3; function makeState(): GrabState { return { gripValue: 0, triggerValue: 0, isGrabbing: false, gripWorld: new THREE.Matrix4(), hasPose: false, }; } /** * Polls WebXR input sources every frame and writes Touch Plus controller * state (grip analog, trigger analog, grip world pose, isGrabbing with * hysteresis) into a stable ref object. * * The returned ref is mutated in place — callers should also call useFrame() * with a *later* registration to read it. Pass a higher `renderPriority` to * the consumer's useFrame to ensure ordering. */ export function useControllerGrab() { const ref = useRef({ left: makeState(), right: makeState(), }); const { gl } = useThree(); useFrame((_state, _delta, frame: XRFrame | undefined) => { // DEVKIT: fake-input branch — bypasses real WebXR when SimXR is active. if (isFakeActive()) { for (const hand of ['left', 'right'] as const) { const fake = fakeInput[hand]; const slot = ref.current[hand]; slot.gripValue = fake.gripValue; slot.triggerValue = fake.triggerValue; slot.hasPose = fake.hasPose; slot.gripWorld.copy(fake.gripWorld); if (!slot.isGrabbing && fake.gripValue > GRAB_ON) { slot.isGrabbing = true; console.log(`[XR][grab] ▶ ${hand} START (fake v=${fake.gripValue.toFixed(2)})`); } else if (slot.isGrabbing && fake.gripValue < GRAB_OFF) { slot.isGrabbing = false; console.log(`[XR][grab] ◼ ${hand} END (fake)`); } } return; } if (!frame) return; const session = frame.session; const referenceSpace = gl.xr.getReferenceSpace(); if (!session || !referenceSpace) return; // Reset hasPose flags ref.current.left.hasPose = false; ref.current.right.hasPose = false; for (const source of session.inputSources) { if (source.handedness !== 'left' && source.handedness !== 'right') continue; const slot = ref.current[source.handedness]; const gp = source.gamepad; if (gp) { // xr-standard mapping: [0]=trigger, [1]=squeeze/grip, [2]=touchpad, [3]=thumbstick const trigBtn = gp.buttons[0]; const gripBtn = gp.buttons[1]; const gripValue = gripBtn ? (gripBtn.value || (gripBtn.pressed ? 1 : 0)) : 0; const trigValue = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0; slot.gripValue = gripValue; slot.triggerValue = trigValue; // Hysteresis if (!slot.isGrabbing && gripValue > GRAB_ON) { slot.isGrabbing = true; console.log(`[XR][grab] ▶ ${source.handedness} START (v=${gripValue.toFixed(2)})`); } else if (slot.isGrabbing && gripValue < GRAB_OFF) { slot.isGrabbing = false; console.log(`[XR][grab] ◼ ${source.handedness} END`); } } if (source.gripSpace) { const pose = frame.getPose(source.gripSpace, referenceSpace); if (pose) { slot.gripWorld.fromArray(pose.transform.matrix); slot.hasPose = true; } } } }); return ref; }