Files
SteelXR2/src/components/three/XRGrabbable.tsx
T
gpt-engineer-app[bot] 073a3f8dc8 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:38:18 +00:00

199 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<THREE.Group>(null);
const grab = useControllerGrab();
const single = useRef<SingleGrabRecord | null>(null);
const dual = useRef<DualGrabRecord | null>(null);
const haloRef = useRef<THREE.Mesh>(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 (
<group ref={groupRef}>
<mesh ref={haloRef} visible={false}>
<sphereGeometry args={[0.5, 16, 12]} />
<meshBasicMaterial color="#22c55e" transparent opacity={0} depthWrite={false} />
</mesh>
{children}
</group>
);
}
/** 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);
}