diff --git a/src/components/three/XRGrabbable.tsx b/src/components/three/XRGrabbable.tsx new file mode 100644 index 0000000..2c10a61 --- /dev/null +++ b/src/components/three/XRGrabbable.tsx @@ -0,0 +1,170 @@ +import { useRef, ReactNode } from 'react'; +import { useFrame } from '@react-three/fiber'; +import * as THREE from 'three'; +import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useControllerGrab'; + +interface XRGrabbableProps { + /** Allow uniform scaling via two-hand grab (distance between hands) */ + allowScale?: boolean; + /** Called the first time the user grabs (e.g. to exit placement mode) */ + onGrabStart?: () => void; + children: ReactNode; +} + +interface SingleGrabRecord { + hand: 'left' | 'right'; + /** Offset = gripWorld^-1 * groupWorld at grab start */ + offset: THREE.Matrix4; +} + +interface DualGrabRecord { + /** group^world at grab start of second hand */ + startGroupWorld: THREE.Matrix4; + /** midpoint between hands at start (world) */ + startMid: THREE.Vector3; + /** vector from left to right at start (world, normalized) */ + startAxis: THREE.Vector3; + /** distance between hands at start */ + startDist: number; + /** scale at start */ + startScale: number; +} + +const _tmpMat = new THREE.Matrix4(); +const _tmpMat2 = new THREE.Matrix4(); +const _tmpVecL = new THREE.Vector3(); +const _tmpVecR = new THREE.Vector3(); +const _tmpQuat = new THREE.Quaternion(); + +/** + * Wraps children in a group whose pose is driven by Touch Plus grip: + * • One-hand grab: model becomes an extension of that hand (6DOF). + * • Two-hand grab: midpoint = pivot, axis between hands = rotation, + * distance ratio = uniform scale (if allowScale). + * + * On release the group keeps its last pose (we don't write to the store — + * the manual fine-tuning sliders remain available as additional offsets). + */ +export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGrabbableProps) { + const groupRef = useRef(null); + const grab = useControllerGrab(); + + const single = useRef(null); + const dual = useRef(null); + const haloRef = useRef(null); + const everGrabbed = useRef(false); + + useFrame(() => { + const group = groupRef.current; + if (!group) return; + const snap: ControllerGrabSnapshot = grab.current; + const L = snap.left; + const R = snap.right; + + const lActive = L.isGrabbing && L.hasPose; + const rActive = R.isGrabbing && R.hasPose; + + // ─── Halo intensity (visual feedback for analog grip) ──────── + if (haloRef.current) { + const maxGrip = Math.max(L.gripValue, R.gripValue); + const mat = haloRef.current.material as THREE.MeshBasicMaterial; + mat.opacity = maxGrip * 0.25; + haloRef.current.visible = maxGrip > 0.05; + } + + // ─── Two-hand mode ─────────────────────────────────────────── + if (lActive && rActive) { + _tmpVecL.setFromMatrixPosition(L.gripWorld); + _tmpVecR.setFromMatrixPosition(R.gripWorld); + const midNow = new THREE.Vector3().addVectors(_tmpVecL, _tmpVecR).multiplyScalar(0.5); + const axisNow = new THREE.Vector3().subVectors(_tmpVecR, _tmpVecL); + const distNow = axisNow.length(); + if (distNow < 1e-4) return; + axisNow.divideScalar(distNow); + + if (!dual.current) { + // Capture + group.updateMatrixWorld(); + dual.current = { + startGroupWorld: group.matrixWorld.clone(), + startMid: midNow.clone(), + startAxis: axisNow.clone(), + startDist: distNow, + startScale: group.scale.x, + }; + single.current = null; // dual takes over + if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); } + console.log('[XR][grab] ◆ TWO-HAND start'); + } + + const d = dual.current; + // Rotation delta: from startAxis to axisNow + const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow); + // Scale ratio + const scaleRatio = allowScale + ? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10) + : 1; + + // Build target world: T(midNow) * R(delta) * S(ratio) * T(-startMid) * startGroupWorld + const m = new THREE.Matrix4(); + const tToOrigin = new THREE.Matrix4().makeTranslation(-d.startMid.x, -d.startMid.y, -d.startMid.z); + const tBack = new THREE.Matrix4().makeTranslation(midNow.x, midNow.y, midNow.z); + const rot = new THREE.Matrix4().makeRotationFromQuaternion(deltaQuat); + const scl = new THREE.Matrix4().makeScale(scaleRatio, scaleRatio, scaleRatio); + m.identity() + .multiply(tBack) + .multiply(rot) + .multiply(scl) + .multiply(tToOrigin) + .multiply(d.startGroupWorld); + + applyWorldMatrixToLocal(group, m); + return; + } else if (dual.current) { + console.log('[XR][grab] ◆ TWO-HAND end'); + dual.current = null; + } + + // ─── One-hand mode ─────────────────────────────────────────── + const activeHand: 'left' | 'right' | null = lActive ? 'left' : rActive ? 'right' : null; + if (activeHand) { + const slot = activeHand === 'left' ? L : R; + if (!single.current || single.current.hand !== activeHand) { + group.updateMatrixWorld(); + const offset = new THREE.Matrix4() + .copy(slot.gripWorld).invert() + .multiply(group.matrixWorld); + single.current = { hand: activeHand, offset }; + if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); } + console.log(`[XR][grab] ● ONE-HAND start (${activeHand})`); + } + _tmpMat.multiplyMatrices(slot.gripWorld, single.current.offset); + applyWorldMatrixToLocal(group, _tmpMat); + } else if (single.current) { + single.current = null; + } + }); + + return ( + + {/* Subtle halo proportional to analog grip — invisible by default */} + + + + + {children} + + ); +} + +/** Convert a desired world matrix to the group's local matrix (given its parent). */ +function applyWorldMatrixToLocal(group: THREE.Group, worldMatrix: THREE.Matrix4) { + if (group.parent) { + group.parent.updateMatrixWorld(); + _tmpMat2.copy(group.parent.matrixWorld).invert().multiply(worldMatrix); + } else { + _tmpMat2.copy(worldMatrix); + } + _tmpMat2.decompose(group.position, _tmpQuat, group.scale); + group.quaternion.copy(_tmpQuat); +} diff --git a/src/hooks/useControllerGrab.ts b/src/hooks/useControllerGrab.ts new file mode 100644 index 0000000..63c8c62 --- /dev/null +++ b/src/hooks/useControllerGrab.ts @@ -0,0 +1,95 @@ +import { useRef } from 'react'; +import { useFrame, useThree } from '@react-three/fiber'; +import * as THREE from 'three'; + +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) => { + 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) { + const gripBtn = gp.buttons[2]; + const trigBtn = 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; +} diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts index 617639e..2a4b883 100644 --- a/src/stores/useModelStore.ts +++ b/src/stores/useModelStore.ts @@ -13,6 +13,7 @@ interface FineTuning { rotX: number; rotY: number; rotZ: number; + scale: number; } type AnchorMode = 'tracking' | 'manual' | 'fine-tuning'; @@ -119,7 +120,7 @@ interface ModelStore { clearScreenshots: () => void; } -const defaultFineTuning: FineTuning = { posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0 }; +const defaultFineTuning: FineTuning = { posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 }; export const useModelStore = create((set) => ({ model: null,