073a3f8dc8
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
199 lines
7.4 KiB
TypeScript
199 lines
7.4 KiB
TypeScript
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);
|
||
}
|