import { useRef, ReactNode, useEffect } from 'react'; import { useFrame } from '@react-three/fiber'; import * as THREE from 'three'; import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useControllerGrab'; import { useModelStore } from '@/stores/useModelStore'; interface XRGrabbableProps { /** * Quando true (padrão), o grip duplo aplica ZOOM uniforme além da rotação. * Quando false, o grip duplo só ORBITA — a escala da peça é preservada. */ allowScale?: boolean; /** When true, the active model is locked — grab is ignored entirely */ lockedActive?: boolean; /** Called the first time the user grabs (e.g. to exit placement mode) */ onGrabStart?: () => void; children: ReactNode; } interface SingleGrabRecord { hand: 'left' | 'right'; /** Offset (world) = groupWorldPos - controllerWorldPos at grab start. */ offsetWorld: THREE.Vector3; } 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 _tmpMat2 = new THREE.Matrix4(); const _tmpVecL = new THREE.Vector3(); const _tmpVecR = new THREE.Vector3(); const _tmpQuat = new THREE.Quaternion(); /** * Touch Plus grip mapping (Meta Quest 3): * * • One-hand grip → **PAN ONLY** (translação). A rotação e a escala * da peça NÃO mudam — o grip simples só arrasta. * • Two-hand grip → ORBIT (eixo entre as mãos) + ZOOM uniforme * (distância entre os controles). O zoom está sempre ligado. * * Trigger não interfere aqui (reservado para seleção/medição). */ export function XRGrabbable({ allowScale = true, lockedActive = 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); // Reset scale to 1 (position/rotation kept) whenever resetScale() is called. const scaleResetNonce = useModelStore((s) => s.scaleResetNonce); useEffect(() => { const g = groupRef.current; if (!g) return; g.scale.set(1, 1, 1); dual.current = null; single.current = null; }, [scaleResetNonce]); useFrame(() => { const group = groupRef.current; if (!group) return; const snap: ControllerGrabSnapshot = grab.current; const L = snap.left; const R = snap.right; if (lockedActive) { if (single.current) single.current = null; if (dual.current) dual.current = null; if (haloRef.current) haloRef.current.visible = false; return; } 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 (orbit + zoom, sempre) ──────────────────── 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) { group.updateMatrixWorld(); dual.current = { startGroupWorld: group.matrixWorld.clone(), startMid: midNow.clone(), startAxis: axisNow.clone(), startDist: distNow, startScale: group.scale.x, }; single.current = null; if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); } console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom)'); } const d = dual.current; const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow); // ZOOM só quando allowScale=true. Caso contrário, mantém escala 1× (só orbita). const scaleRatio = allowScale ? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10) : 1; 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); const m = new THREE.Matrix4().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 (PAN ONLY) ──────────────────────────────── const activeHand: 'left' | 'right' | null = lActive ? 'left' : rActive ? 'right' : null; if (activeHand) { const slot = activeHand === 'left' ? L : R; const ctrlPos = new THREE.Vector3().setFromMatrixPosition(slot.gripWorld); if (!single.current || single.current.hand !== activeHand) { group.updateMatrixWorld(); const groupWorldPos = new THREE.Vector3().setFromMatrixPosition(group.matrixWorld); const offsetWorld = new THREE.Vector3().subVectors(groupWorldPos, ctrlPos); single.current = { hand: activeHand, offsetWorld }; if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); } console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — pan only`); } // Target world position = controller + initial offset const targetWorldPos = new THREE.Vector3().addVectors(ctrlPos, single.current.offsetWorld); // Convert to parent-local position (preserve current rotation/scale) if (group.parent) { group.parent.updateMatrixWorld(); const invParent = new THREE.Matrix4().copy(group.parent.matrixWorld).invert(); targetWorldPos.applyMatrix4(invParent); } group.position.copy(targetWorldPos); } else if (single.current) { single.current = null; } }); return ( {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); }