Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-09 13:38:22 +00:00
parent 1f40184f01
commit eba810b22e
3 changed files with 267 additions and 1 deletions
+170
View File
@@ -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<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);
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 (
<group ref={groupRef}>
{/* Subtle halo proportional to analog grip — invisible by default */}
<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);
}