Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useRef } from 'react';
|
||||
import { useFrame, useThree } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
|
||||
export interface GrabState {
|
||||
/** Analog grip pressure (0..1) */
|
||||
gripValue: number;
|
||||
/** Analog trigger pressure (0..1) */
|
||||
triggerValue: number;
|
||||
/** True when grip pressure crosses grab threshold (with hysteresis) */
|
||||
isGrabbing: boolean;
|
||||
/** World-space matrix of the gripSpace (Touch Plus controller pose) */
|
||||
gripWorld: THREE.Matrix4;
|
||||
/** True when controller pose is valid this frame */
|
||||
hasPose: boolean;
|
||||
}
|
||||
|
||||
export interface ControllerGrabSnapshot {
|
||||
left: GrabState;
|
||||
right: GrabState;
|
||||
}
|
||||
|
||||
const GRAB_ON = 0.7;
|
||||
const GRAB_OFF = 0.3;
|
||||
|
||||
function makeState(): GrabState {
|
||||
return {
|
||||
gripValue: 0,
|
||||
triggerValue: 0,
|
||||
isGrabbing: false,
|
||||
gripWorld: new THREE.Matrix4(),
|
||||
hasPose: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls WebXR input sources every frame and writes Touch Plus controller
|
||||
* state (grip analog, trigger analog, grip world pose, isGrabbing with
|
||||
* hysteresis) into a stable ref object.
|
||||
*
|
||||
* The returned ref is mutated in place — callers should also call useFrame()
|
||||
* with a *later* registration to read it. Pass a higher `renderPriority` to
|
||||
* the consumer's useFrame to ensure ordering.
|
||||
*/
|
||||
export function useControllerGrab() {
|
||||
const ref = useRef<ControllerGrabSnapshot>({
|
||||
left: makeState(),
|
||||
right: makeState(),
|
||||
});
|
||||
const { gl } = useThree();
|
||||
|
||||
useFrame((_state, _delta, frame: XRFrame | undefined) => {
|
||||
if (!frame) return;
|
||||
const session = frame.session;
|
||||
const referenceSpace = gl.xr.getReferenceSpace();
|
||||
if (!session || !referenceSpace) return;
|
||||
|
||||
// Reset hasPose flags
|
||||
ref.current.left.hasPose = false;
|
||||
ref.current.right.hasPose = false;
|
||||
|
||||
for (const source of session.inputSources) {
|
||||
if (source.handedness !== 'left' && source.handedness !== 'right') continue;
|
||||
const slot = ref.current[source.handedness];
|
||||
const gp = source.gamepad;
|
||||
|
||||
if (gp) {
|
||||
const gripBtn = gp.buttons[2];
|
||||
const trigBtn = gp.buttons[1];
|
||||
const gripValue = gripBtn ? (gripBtn.value || (gripBtn.pressed ? 1 : 0)) : 0;
|
||||
const trigValue = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0;
|
||||
slot.gripValue = gripValue;
|
||||
slot.triggerValue = trigValue;
|
||||
// Hysteresis
|
||||
if (!slot.isGrabbing && gripValue > GRAB_ON) {
|
||||
slot.isGrabbing = true;
|
||||
console.log(`[XR][grab] ▶ ${source.handedness} START (v=${gripValue.toFixed(2)})`);
|
||||
} else if (slot.isGrabbing && gripValue < GRAB_OFF) {
|
||||
slot.isGrabbing = false;
|
||||
console.log(`[XR][grab] ◼ ${source.handedness} END`);
|
||||
}
|
||||
}
|
||||
|
||||
if (source.gripSpace) {
|
||||
const pose = frame.getPose(source.gripSpace, referenceSpace);
|
||||
if (pose) {
|
||||
slot.gripWorld.fromArray(pose.transform.matrix);
|
||||
slot.hasPose = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ interface FineTuning {
|
||||
rotX: number;
|
||||
rotY: number;
|
||||
rotZ: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
type AnchorMode = 'tracking' | 'manual' | 'fine-tuning';
|
||||
@@ -119,7 +120,7 @@ interface ModelStore {
|
||||
clearScreenshots: () => void;
|
||||
}
|
||||
|
||||
const defaultFineTuning: FineTuning = { posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0 };
|
||||
const defaultFineTuning: FineTuning = { posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 };
|
||||
|
||||
export const useModelStore = create<ModelStore>((set) => ({
|
||||
model: null,
|
||||
|
||||
Reference in New Issue
Block a user