Files
SteelXR/src/hooks/useControllerGrab.ts
T
gpt-engineer-app[bot] 9a353795f2 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-13 21:33:15 +00:00

119 lines
3.9 KiB
TypeScript

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<ControllerGrabSnapshot>({
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;
}