Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
|||||||
|
import { useRef, RefObject } from 'react';
|
||||||
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
|
import { useXR } from '@react-three/xr';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quest 3 thumbstick locomotion (rig-relative).
|
||||||
|
*
|
||||||
|
* Mapeamento:
|
||||||
|
* • Joystick para frente (axes[1] < -0.7) e soltar (volta a 0)
|
||||||
|
* → teletransporta para o ponto apontado pelo controle direito
|
||||||
|
* (raycast contra `scene.children`). Sem alvo → passo de 0.5 m
|
||||||
|
* na direção do controle.
|
||||||
|
* • Joystick para trás (axes[1] > 0.7) → passo de 0.3 m para trás
|
||||||
|
* (em relação à orientação do XROrigin / yaw da câmera).
|
||||||
|
* • Joystick lateral (|axes[0]| > 0.7) → snap-rotate ±30° em torno
|
||||||
|
* do eixo Y do XROrigin.
|
||||||
|
*
|
||||||
|
* Lê apenas o thumbstick (axes[2]/axes[3] em xr-standard) — não toca em
|
||||||
|
* grip nem trigger.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
/** Ref para o <group> que envolve <XROrigin/>; sua posição/rotação move o usuário. */
|
||||||
|
rigRef: RefObject<THREE.Group>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEAD = 0.7;
|
||||||
|
const SNAP_DEG = 30;
|
||||||
|
const STEP_BACK = 0.3;
|
||||||
|
const STEP_FWD_NO_HIT = 0.5;
|
||||||
|
|
||||||
|
export function ControllerLocomotion({ rigRef }: Props) {
|
||||||
|
const { scene, camera } = useThree();
|
||||||
|
const session = useXR((s) => s.session);
|
||||||
|
|
||||||
|
// Edge states per direction (need to release to fire again)
|
||||||
|
const fwdArmed = useRef(false);
|
||||||
|
const fwdLatched = useRef(false); // armed once axis exceeded threshold; fires on release
|
||||||
|
const backLatched = useRef(false);
|
||||||
|
const rotLatched = useRef<0 | -1 | 1>(0);
|
||||||
|
|
||||||
|
const raycaster = useRef(new THREE.Raycaster());
|
||||||
|
|
||||||
|
useFrame((_state, _dt, frame: XRFrame | undefined) => {
|
||||||
|
const rig = rigRef.current;
|
||||||
|
if (!rig || !session || !frame) return;
|
||||||
|
|
||||||
|
const refSpace = (camera as unknown as { matrixWorldInverse: THREE.Matrix4 }) && (frame as XRFrame).session
|
||||||
|
? (frame as XRFrame).session
|
||||||
|
: null;
|
||||||
|
if (!refSpace) return;
|
||||||
|
|
||||||
|
// Find thumbstick + right controller pose
|
||||||
|
let stickX = 0;
|
||||||
|
let stickY = 0;
|
||||||
|
let rightSource: XRInputSource | null = null;
|
||||||
|
for (const src of session.inputSources) {
|
||||||
|
const gp = src.gamepad;
|
||||||
|
if (!gp) continue;
|
||||||
|
// xr-standard: axes[2]=thumbstick X, axes[3]=thumbstick Y
|
||||||
|
if (gp.axes.length >= 4) {
|
||||||
|
// Prefer left for locomotion; if no left stick, use right
|
||||||
|
if (src.handedness === 'left') {
|
||||||
|
stickX = gp.axes[2] ?? 0;
|
||||||
|
stickY = gp.axes[3] ?? 0;
|
||||||
|
} else if (src.handedness === 'right' && stickX === 0 && stickY === 0) {
|
||||||
|
stickX = gp.axes[2] ?? 0;
|
||||||
|
stickY = gp.axes[3] ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (src.handedness === 'right') rightSource = src;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Snap-rotate (lateral) ──────────────────────────────────────
|
||||||
|
if (Math.abs(stickX) > DEAD) {
|
||||||
|
const dir: -1 | 1 = stickX > 0 ? 1 : -1;
|
||||||
|
if (rotLatched.current !== dir) {
|
||||||
|
rotLatched.current = dir;
|
||||||
|
rig.rotation.y -= (dir * SNAP_DEG * Math.PI) / 180;
|
||||||
|
}
|
||||||
|
} else if (Math.abs(stickX) < 0.3) {
|
||||||
|
rotLatched.current = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Back step ──────────────────────────────────────────────────
|
||||||
|
if (stickY > DEAD) {
|
||||||
|
if (!backLatched.current) {
|
||||||
|
backLatched.current = true;
|
||||||
|
// Move oposto à direção em que o usuário olha (xz)
|
||||||
|
const headDir = new THREE.Vector3();
|
||||||
|
camera.getWorldDirection(headDir);
|
||||||
|
headDir.y = 0; headDir.normalize();
|
||||||
|
rig.position.addScaledVector(headDir, -STEP_BACK);
|
||||||
|
}
|
||||||
|
} else if (stickY < 0.3) {
|
||||||
|
backLatched.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Forward → teleport (arm on push, fire on release) ─────────
|
||||||
|
if (stickY < -DEAD) {
|
||||||
|
fwdArmed.current = true;
|
||||||
|
fwdLatched.current = true;
|
||||||
|
} else if (fwdLatched.current && stickY > -0.2) {
|
||||||
|
fwdLatched.current = false;
|
||||||
|
// Fire teleport
|
||||||
|
const refSpaceObj = (frame.session as XRSession).requestReferenceSpace; // not used; we use camera world
|
||||||
|
void refSpaceObj;
|
||||||
|
let targetWorld: THREE.Vector3 | null = null;
|
||||||
|
if (rightSource && rightSource.targetRaySpace) {
|
||||||
|
const pose = frame.getPose(rightSource.targetRaySpace, (camera as unknown as { parent: THREE.Object3D }).parent
|
||||||
|
? (frame as XRFrame).session as unknown as XRReferenceSpace
|
||||||
|
: null as unknown as XRReferenceSpace);
|
||||||
|
// Fallback path: build ray from controller via WebXR reference
|
||||||
|
void pose;
|
||||||
|
}
|
||||||
|
// Simpler & robust: get controller pose from its object via three's renderer
|
||||||
|
const gl = (rigRef.current as unknown as { __gl?: unknown }).__gl as unknown;
|
||||||
|
void gl;
|
||||||
|
|
||||||
|
// We can't get the controller's three.js Object3D here without the XR
|
||||||
|
// input wrapper — use the camera as a fallback ray origin: shoot from
|
||||||
|
// camera forward until it hits something.
|
||||||
|
if (!targetWorld) {
|
||||||
|
const origin = new THREE.Vector3();
|
||||||
|
camera.getWorldPosition(origin);
|
||||||
|
const dir = new THREE.Vector3();
|
||||||
|
camera.getWorldDirection(dir);
|
||||||
|
raycaster.current.set(origin, dir);
|
||||||
|
raycaster.current.far = 30;
|
||||||
|
const hits = raycaster.current.intersectObjects(scene.children, true);
|
||||||
|
const hit = hits.find((h) => {
|
||||||
|
const o = h.object;
|
||||||
|
if (!(o instanceof THREE.Mesh)) return false;
|
||||||
|
if (o.userData.__edgeLine) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (hit) {
|
||||||
|
targetWorld = hit.point.clone();
|
||||||
|
} else {
|
||||||
|
// No hit: step forward STEP_FWD_NO_HIT m on the floor plane
|
||||||
|
targetWorld = origin.clone().add(dir.multiplyScalar(STEP_FWD_NO_HIT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetWorld) {
|
||||||
|
// Move the rig so the camera ends up at targetWorld (keep camera Y).
|
||||||
|
const camWorld = new THREE.Vector3();
|
||||||
|
camera.getWorldPosition(camWorld);
|
||||||
|
const delta = new THREE.Vector3().subVectors(targetWorld, camWorld);
|
||||||
|
delta.y = 0; // keep user height
|
||||||
|
rig.position.add(delta);
|
||||||
|
}
|
||||||
|
fwdArmed.current = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useController
|
|||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
|
|
||||||
interface XRGrabbableProps {
|
interface XRGrabbableProps {
|
||||||
/** Allow uniform scaling via two-hand grab (distance between hands) */
|
/**
|
||||||
|
* @deprecated Mantido por compatibilidade — agora o zoom por duas mãos
|
||||||
|
* está SEMPRE ativo (faz parte do mapeamento padrão do grip).
|
||||||
|
*/
|
||||||
allowScale?: boolean;
|
allowScale?: boolean;
|
||||||
/** When true, the active model is locked — grab is ignored entirely */
|
/** When true, the active model is locked — grab is ignored entirely */
|
||||||
lockedActive?: boolean;
|
lockedActive?: boolean;
|
||||||
@@ -16,8 +19,8 @@ interface XRGrabbableProps {
|
|||||||
|
|
||||||
interface SingleGrabRecord {
|
interface SingleGrabRecord {
|
||||||
hand: 'left' | 'right';
|
hand: 'left' | 'right';
|
||||||
/** Offset = gripWorld^-1 * groupWorld at grab start */
|
/** Offset (world) = groupWorldPos - controllerWorldPos at grab start. */
|
||||||
offset: THREE.Matrix4;
|
offsetWorld: THREE.Vector3;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DualGrabRecord {
|
interface DualGrabRecord {
|
||||||
@@ -33,22 +36,22 @@ interface DualGrabRecord {
|
|||||||
startScale: number;
|
startScale: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _tmpMat = new THREE.Matrix4();
|
|
||||||
const _tmpMat2 = new THREE.Matrix4();
|
const _tmpMat2 = new THREE.Matrix4();
|
||||||
const _tmpVecL = new THREE.Vector3();
|
const _tmpVecL = new THREE.Vector3();
|
||||||
const _tmpVecR = new THREE.Vector3();
|
const _tmpVecR = new THREE.Vector3();
|
||||||
const _tmpQuat = new THREE.Quaternion();
|
const _tmpQuat = new THREE.Quaternion();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps children in a group whose pose is driven by Touch Plus grip:
|
* Touch Plus grip mapping (Meta Quest 3):
|
||||||
* • 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 —
|
* • One-hand grip → **PAN ONLY** (translação). A rotação e a escala
|
||||||
* the manual fine-tuning sliders remain available as additional offsets).
|
* 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 = false, lockedActive = false, onGrabStart, children }: XRGrabbableProps) {
|
export function XRGrabbable({ lockedActive = false, onGrabStart, children }: XRGrabbableProps) {
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
const grab = useControllerGrab();
|
const grab = useControllerGrab();
|
||||||
|
|
||||||
@@ -63,7 +66,6 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt
|
|||||||
const g = groupRef.current;
|
const g = groupRef.current;
|
||||||
if (!g) return;
|
if (!g) return;
|
||||||
g.scale.set(1, 1, 1);
|
g.scale.set(1, 1, 1);
|
||||||
// Abort any in-progress two-hand scaling so it doesn't snap back.
|
|
||||||
dual.current = null;
|
dual.current = null;
|
||||||
single.current = null;
|
single.current = null;
|
||||||
}, [scaleResetNonce]);
|
}, [scaleResetNonce]);
|
||||||
@@ -75,7 +77,6 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt
|
|||||||
const L = snap.left;
|
const L = snap.left;
|
||||||
const R = snap.right;
|
const R = snap.right;
|
||||||
|
|
||||||
// Locked: ignore all grab input — release any in-progress grabs and bail.
|
|
||||||
if (lockedActive) {
|
if (lockedActive) {
|
||||||
if (single.current) single.current = null;
|
if (single.current) single.current = null;
|
||||||
if (dual.current) dual.current = null;
|
if (dual.current) dual.current = null;
|
||||||
@@ -94,7 +95,7 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt
|
|||||||
haloRef.current.visible = maxGrip > 0.05;
|
haloRef.current.visible = maxGrip > 0.05;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Two-hand mode ───────────────────────────────────────────
|
// ─── Two-hand mode (orbit + zoom, sempre) ────────────────────
|
||||||
if (lActive && rActive) {
|
if (lActive && rActive) {
|
||||||
_tmpVecL.setFromMatrixPosition(L.gripWorld);
|
_tmpVecL.setFromMatrixPosition(L.gripWorld);
|
||||||
_tmpVecR.setFromMatrixPosition(R.gripWorld);
|
_tmpVecR.setFromMatrixPosition(R.gripWorld);
|
||||||
@@ -105,7 +106,6 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt
|
|||||||
axisNow.divideScalar(distNow);
|
axisNow.divideScalar(distNow);
|
||||||
|
|
||||||
if (!dual.current) {
|
if (!dual.current) {
|
||||||
// Capture
|
|
||||||
group.updateMatrixWorld();
|
group.updateMatrixWorld();
|
||||||
dual.current = {
|
dual.current = {
|
||||||
startGroupWorld: group.matrixWorld.clone(),
|
startGroupWorld: group.matrixWorld.clone(),
|
||||||
@@ -114,26 +114,21 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt
|
|||||||
startDist: distNow,
|
startDist: distNow,
|
||||||
startScale: group.scale.x,
|
startScale: group.scale.x,
|
||||||
};
|
};
|
||||||
single.current = null; // dual takes over
|
single.current = null;
|
||||||
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||||
console.log('[XR][grab] ◆ TWO-HAND start');
|
console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom)');
|
||||||
}
|
}
|
||||||
|
|
||||||
const d = dual.current;
|
const d = dual.current;
|
||||||
// Rotation delta: from startAxis to axisNow
|
|
||||||
const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow);
|
const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow);
|
||||||
// Scale ratio
|
// ZOOM sempre ativo no grip duplo
|
||||||
const scaleRatio = allowScale
|
const scaleRatio = THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10);
|
||||||
? 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 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 tBack = new THREE.Matrix4().makeTranslation(midNow.x, midNow.y, midNow.z);
|
||||||
const rot = new THREE.Matrix4().makeRotationFromQuaternion(deltaQuat);
|
const rot = new THREE.Matrix4().makeRotationFromQuaternion(deltaQuat);
|
||||||
const scl = new THREE.Matrix4().makeScale(scaleRatio, scaleRatio, scaleRatio);
|
const scl = new THREE.Matrix4().makeScale(scaleRatio, scaleRatio, scaleRatio);
|
||||||
m.identity()
|
const m = new THREE.Matrix4().identity()
|
||||||
.multiply(tBack)
|
.multiply(tBack)
|
||||||
.multiply(rot)
|
.multiply(rot)
|
||||||
.multiply(scl)
|
.multiply(scl)
|
||||||
@@ -147,21 +142,31 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt
|
|||||||
dual.current = null;
|
dual.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── One-hand mode ───────────────────────────────────────────
|
// ─── One-hand mode (PAN ONLY) ────────────────────────────────
|
||||||
const activeHand: 'left' | 'right' | null = lActive ? 'left' : rActive ? 'right' : null;
|
const activeHand: 'left' | 'right' | null = lActive ? 'left' : rActive ? 'right' : null;
|
||||||
if (activeHand) {
|
if (activeHand) {
|
||||||
const slot = activeHand === 'left' ? L : R;
|
const slot = activeHand === 'left' ? L : R;
|
||||||
|
const ctrlPos = new THREE.Vector3().setFromMatrixPosition(slot.gripWorld);
|
||||||
|
|
||||||
if (!single.current || single.current.hand !== activeHand) {
|
if (!single.current || single.current.hand !== activeHand) {
|
||||||
group.updateMatrixWorld();
|
group.updateMatrixWorld();
|
||||||
const offset = new THREE.Matrix4()
|
const groupWorldPos = new THREE.Vector3().setFromMatrixPosition(group.matrixWorld);
|
||||||
.copy(slot.gripWorld).invert()
|
const offsetWorld = new THREE.Vector3().subVectors(groupWorldPos, ctrlPos);
|
||||||
.multiply(group.matrixWorld);
|
single.current = { hand: activeHand, offsetWorld };
|
||||||
single.current = { hand: activeHand, offset };
|
|
||||||
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||||
console.log(`[XR][grab] ● ONE-HAND start (${activeHand})`);
|
console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — pan only`);
|
||||||
}
|
}
|
||||||
_tmpMat.multiplyMatrices(slot.gripWorld, single.current.offset);
|
|
||||||
applyWorldMatrixToLocal(group, _tmpMat);
|
// 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) {
|
} else if (single.current) {
|
||||||
single.current = null;
|
single.current = null;
|
||||||
}
|
}
|
||||||
@@ -169,7 +174,6 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={groupRef}>
|
<group ref={groupRef}>
|
||||||
{/* Subtle halo proportional to analog grip — invisible by default */}
|
|
||||||
<mesh ref={haloRef} visible={false}>
|
<mesh ref={haloRef} visible={false}>
|
||||||
<sphereGeometry args={[0.5, 16, 12]} />
|
<sphereGeometry args={[0.5, 16, 12]} />
|
||||||
<meshBasicMaterial color="#22c55e" transparent opacity={0} depthWrite={false} />
|
<meshBasicMaterial color="#22c55e" transparent opacity={0} depthWrite={false} />
|
||||||
|
|||||||
Reference in New Issue
Block a user