diff --git a/src/components/three/ControllerLocomotion.tsx b/src/components/three/ControllerLocomotion.tsx new file mode 100644 index 0000000..2a9c1cf --- /dev/null +++ b/src/components/three/ControllerLocomotion.tsx @@ -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 que envolve ; sua posição/rotação move o usuário. */ + rigRef: RefObject; +} + +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; +} diff --git a/src/components/three/XRGrabbable.tsx b/src/components/three/XRGrabbable.tsx index 6404ef8..0cb8774 100644 --- a/src/components/three/XRGrabbable.tsx +++ b/src/components/three/XRGrabbable.tsx @@ -5,7 +5,10 @@ import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useController import { useModelStore } from '@/stores/useModelStore'; 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; /** When true, the active model is locked — grab is ignored entirely */ lockedActive?: boolean; @@ -16,8 +19,8 @@ interface XRGrabbableProps { interface SingleGrabRecord { hand: 'left' | 'right'; - /** Offset = gripWorld^-1 * groupWorld at grab start */ - offset: THREE.Matrix4; + /** Offset (world) = groupWorldPos - controllerWorldPos at grab start. */ + offsetWorld: THREE.Vector3; } interface DualGrabRecord { @@ -33,22 +36,22 @@ interface DualGrabRecord { 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). + * Touch Plus grip mapping (Meta Quest 3): * - * 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). + * • 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 = false, lockedActive = false, onGrabStart, children }: XRGrabbableProps) { +export function XRGrabbable({ lockedActive = false, onGrabStart, children }: XRGrabbableProps) { const groupRef = useRef(null); const grab = useControllerGrab(); @@ -63,7 +66,6 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt const g = groupRef.current; if (!g) return; g.scale.set(1, 1, 1); - // Abort any in-progress two-hand scaling so it doesn't snap back. dual.current = null; single.current = null; }, [scaleResetNonce]); @@ -75,7 +77,6 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt const L = snap.left; const R = snap.right; - // Locked: ignore all grab input — release any in-progress grabs and bail. if (lockedActive) { if (single.current) single.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; } - // ─── Two-hand mode ─────────────────────────────────────────── + // ─── Two-hand mode (orbit + zoom, sempre) ──────────────────── if (lActive && rActive) { _tmpVecL.setFromMatrixPosition(L.gripWorld); _tmpVecR.setFromMatrixPosition(R.gripWorld); @@ -105,7 +106,6 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt axisNow.divideScalar(distNow); if (!dual.current) { - // Capture group.updateMatrixWorld(); dual.current = { startGroupWorld: group.matrixWorld.clone(), @@ -114,26 +114,21 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt startDist: distNow, startScale: group.scale.x, }; - single.current = null; // dual takes over + single.current = null; 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; - // 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; + // ZOOM sempre ativo no grip duplo + const scaleRatio = THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10); - // 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() + const m = new THREE.Matrix4().identity() .multiply(tBack) .multiply(rot) .multiply(scl) @@ -147,21 +142,31 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt dual.current = null; } - // ─── One-hand mode ─────────────────────────────────────────── + // ─── 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 offset = new THREE.Matrix4() - .copy(slot.gripWorld).invert() - .multiply(group.matrixWorld); - single.current = { hand: activeHand, offset }; + 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})`); + 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) { single.current = null; } @@ -169,7 +174,6 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt return ( - {/* Subtle halo proportional to analog grip — invisible by default */}