diff --git a/src/components/three/XRGrabbable.tsx b/src/components/three/XRGrabbable.tsx index 2c10a61..713db36 100644 --- a/src/components/three/XRGrabbable.tsx +++ b/src/components/three/XRGrabbable.tsx @@ -1,7 +1,8 @@ import { useRef, ReactNode } from 'react'; -import { useFrame } from '@react-three/fiber'; +import { useFrame, useThree } from '@react-three/fiber'; import * as THREE from 'three'; import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useControllerGrab'; +import { useModelStore } from '@/stores/useModelStore'; interface XRGrabbableProps { /** Allow uniform scaling via two-hand grab (distance between hands) */ @@ -42,18 +43,57 @@ const _tmpQuat = new THREE.Quaternion(); * • 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). + * Parent re-attach strategy: + * At grab start the group is attached to the Scene root (preserving + * world-transform). At grab end it is re-attached to its original parent + * (typically XRHitTestPlacement), unless `isLocked` is true — in which case + * it stays anchored in world space and the original parent can no longer + * influence its matrix. */ export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGrabbableProps) { const groupRef = useRef(null); const grab = useControllerGrab(); + const { scene } = useThree(); const single = useRef(null); const dual = useRef(null); + /** Parent the group belonged to before the active grab started. */ + const originalParent = useRef(null); const haloRef = useRef(null); + const lockHaloRef = useRef(null); const everGrabbed = useRef(false); + const beginGrab = (mode: 'single' | 'dual', hand: 'left' | 'right' | 'dual') => { + const group = groupRef.current; + if (!group) return; + // Reparent to scene root preserving world-transform. + if (group.parent && group.parent !== scene) { + originalParent.current = group.parent; + scene.attach(group); + console.log(`[XR][grab] attach→scene (mode=${mode} hand=${hand})`); + } else if (!originalParent.current) { + originalParent.current = group.parent ?? scene; + } + if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); } + }; + + const endGrab = () => { + const group = groupRef.current; + if (!group) return; + const locked = useModelStore.getState().isLocked; + if (locked) { + console.log('[XR][grab] release→scene (locked, staying anchored)'); + return; + } + const target = originalParent.current; + originalParent.current = null; + if (target && target !== group.parent) { + const name = target === scene ? 'scene' : (target.name || target.type); + target.attach(group); + console.log(`[XR][grab] release→${name}`); + } + }; + useFrame(() => { const group = groupRef.current; if (!group) return; @@ -63,14 +103,20 @@ export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGra const lActive = L.isGrabbing && L.hasPose; const rActive = R.isGrabbing && R.hasPose; + const isLocked = useModelStore.getState().isLocked; // ─── Halo intensity (visual feedback for analog grip) ──────── if (haloRef.current) { - const maxGrip = Math.max(L.gripValue, R.gripValue); + const maxGrip = isLocked ? 0 : 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; } + if (lockHaloRef.current) { + const mat = lockHaloRef.current.material as THREE.MeshBasicMaterial; + mat.opacity = 0.18; + lockHaloRef.current.visible = isLocked; + } // ─── Two-hand mode ─────────────────────────────────────────── if (lActive && rActive) { @@ -83,7 +129,9 @@ export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGra axisNow.divideScalar(distNow); if (!dual.current) { - // Capture + // Capture — reparent BEFORE snapshotting matrixWorld so subsequent + // applyWorldMatrixToLocal math is consistent. + beginGrab('dual', 'dual'); group.updateMatrixWorld(); dual.current = { startGroupWorld: group.matrixWorld.clone(), @@ -93,7 +141,6 @@ export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGra startScale: group.scale.x, }; single.current = null; // dual takes over - if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); } console.log('[XR][grab] ◆ TWO-HAND start'); } @@ -123,6 +170,7 @@ export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGra } else if (dual.current) { console.log('[XR][grab] ◆ TWO-HAND end'); dual.current = null; + if (!lActive && !rActive) endGrab(); } // ─── One-hand mode ─────────────────────────────────────────── @@ -130,18 +178,19 @@ export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGra if (activeHand) { const slot = activeHand === 'left' ? L : R; if (!single.current || single.current.hand !== activeHand) { + beginGrab('single', 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; + endGrab(); } }); @@ -152,6 +201,11 @@ export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGra + {/* Lock halo — golden, visible while isLocked is true */} + + + + {children} );