From 5b14a1d971279d77bdfcd62fe36f49bcf0a67acd Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Wed, 29 Apr 2026 11:13:11 +0200 Subject: [PATCH] fix: decouple hand tracking from crosshair focus --- src/components/three/GrabbableObject.tsx | 19 ++++++++++++- src/components/three/InteractableObject.tsx | 21 +++++++++++++-- src/components/ui/HandTrackingProvider.tsx | 7 ++--- src/managers/InteractionManager.ts | 30 +++++++++++++++++++++ src/types/interaction.ts | 2 ++ 5 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/components/three/GrabbableObject.tsx b/src/components/three/GrabbableObject.tsx index 9d6d4e3..e0e5610 100644 --- a/src/components/three/GrabbableObject.tsx +++ b/src/components/three/GrabbableObject.tsx @@ -23,6 +23,11 @@ import { import { INTERACTION_RADIUS } from "@/data/interaction/interactionConfig"; import { useDebugFolder } from "@/hooks/debug/useDebugFolder"; import { useHandTrackingSnapshot } from "@/hooks/useHandTrackingSnapshot"; +import { InteractionManager } from "@/managers/InteractionManager"; +import type { + HandTrackingHand, + HandTrackingLandmark, +} from "@/types/handTracking"; import type { ColliderShape, Vector3Tuple } from "@/types/three"; interface GrabbableObjectProps { @@ -51,6 +56,14 @@ const _cameraPos = new THREE.Vector3(); const _objectPos = new THREE.Vector3(); const _handRaycaster = new THREE.Raycaster(); +function getHandAnchorPoint(hand: HandTrackingHand): HandTrackingLandmark { + return hand.landmarks.reduce( + (lowestPoint, landmark) => + landmark.y > lowestPoint.y ? landmark : lowestPoint, + { x: hand.x, y: hand.y, z: hand.z }, + ); +} + export function GrabbableObject({ position, children, @@ -107,7 +120,9 @@ export function GrabbableObject({ _currentPos.set(t.x, t.y, t.z); if (fistHand) { - _handNdc.set((1 - fistHand.x) * 2 - 1, -fistHand.y * 2 + 1, 0.5); + const handAnchor = getHandAnchorPoint(fistHand); + + _handNdc.set((1 - handAnchor.x) * 2 - 1, -handAnchor.y * 2 + 1, 0.5); _handNdc.unproject(camera); camera.getWorldPosition(_cameraPos); _handDirection.subVectors(_handNdc, _cameraPos).normalize(); @@ -127,10 +142,12 @@ export function GrabbableObject({ handHoldDistance.current = isHandHolding.current ? hits[0].distance : null; + InteractionManager.getInstance().setHandHolding(isHandHolding.current); } } else { isHandHolding.current = false; handHoldDistance.current = null; + InteractionManager.getInstance().setHandHolding(false); } if (!isHolding.current && !isHandHolding.current) return; diff --git a/src/components/three/InteractableObject.tsx b/src/components/three/InteractableObject.tsx index bcca255..4385d01 100644 --- a/src/components/three/InteractableObject.tsx +++ b/src/components/three/InteractableObject.tsx @@ -74,6 +74,7 @@ export function InteractableObject( useEffect(() => { const currentHandle = handle.current; + const manager = InteractionManager.getInstance(); if (currentHandle.kind === kind) { currentHandle.label = label; @@ -87,6 +88,8 @@ export function InteractableObject( return; } + manager.setNearby(currentHandle, false); + if (kind === "grab") { if (!onRelease) return; handle.current = { kind, label, onPress, onRelease }; @@ -94,12 +97,23 @@ export function InteractableObject( handle.current = { kind, label, onPress }; } - const manager = InteractionManager.getInstance(); if (manager.getState().focused === currentHandle) { manager.setFocused(handle.current); } }, [kind, label, onPress, onRelease]); + useEffect(() => { + const currentHandle = handle.current; + + return () => { + const manager = InteractionManager.getInstance(); + manager.setNearby(currentHandle, false); + if (manager.getState().focused === currentHandle) { + manager.setFocused(null); + } + }; + }, []); + const setupInteractionDebugFolder = useCallback((folder: GUI) => { folder .add({ radius: INTERACTION_RADIUS }, "radius") @@ -128,8 +142,11 @@ export function InteractableObject( camera.getWorldPosition(_cameraPos); const dist = _cameraPos.distanceTo(_objectPos); + const isNearby = dist <= INTERACTION_RADIUS; - if (dist > INTERACTION_RADIUS) { + manager.setNearby(handle.current, isNearby); + + if (!isNearby) { if (manager.getState().focused === handle.current) { manager.setFocused(null); } diff --git a/src/components/ui/HandTrackingProvider.tsx b/src/components/ui/HandTrackingProvider.tsx index 25495ae..36e9a36 100644 --- a/src/components/ui/HandTrackingProvider.tsx +++ b/src/components/ui/HandTrackingProvider.tsx @@ -14,10 +14,11 @@ export function HandTrackingProvider({ children: ReactNode; }): React.JSX.Element { const sceneMode = useSceneMode(); - const { focused, holding } = useInteraction(); - const isInInteractionZone = focused !== null || holding; + const { nearby, holding, handHolding } = useInteraction(); const enabled = - isDebugEnabled() && sceneMode === "physics" && isInInteractionZone; + isDebugEnabled() && + sceneMode === "physics" && + (nearby || holding || handHolding); const snapshot = useRemoteHandTracking({ enabled }); return ( diff --git a/src/managers/InteractionManager.ts b/src/managers/InteractionManager.ts index 6dcdd45..83697f0 100644 --- a/src/managers/InteractionManager.ts +++ b/src/managers/InteractionManager.ts @@ -8,11 +8,15 @@ export class InteractionManager { private static _instance: InteractionManager | null = null; private _focused: InteractableHandle | null = null; + private readonly _nearbyHandles = new Set(); private _holding = false; + private _handHolding = false; private _holdingHandle: GrabInteractableHandle | null = null; private _snapshot: InteractionSnapshot = { focused: null, + nearby: false, holding: false, + handHolding: false, }; private readonly _listeners = new Set<() => void>(); @@ -38,6 +42,26 @@ export class InteractionManager { this._emit(); } + setNearby(handle: InteractableHandle, nearby: boolean): void { + const hadHandle = this._nearbyHandles.has(handle); + if (nearby === hadHandle) return; + + if (nearby) { + this._nearbyHandles.add(handle); + } else { + this._nearbyHandles.delete(handle); + } + + this._emit(); + } + + setHandHolding(holding: boolean): void { + if (this._handHolding === holding) return; + + this._handHolding = holding; + this._emit(); + } + pressInteract(): void { if (!this._focused) return; @@ -73,11 +97,15 @@ export class InteractionManager { destroy(): void { this._focused = null; + this._nearbyHandles.clear(); this._holding = false; + this._handHolding = false; this._holdingHandle = null; this._snapshot = { focused: null, + nearby: false, holding: false, + handHolding: false, }; this._listeners.clear(); InteractionManager._instance = null; @@ -86,7 +114,9 @@ export class InteractionManager { private _emit(): void { this._snapshot = { focused: this._focused, + nearby: this._nearbyHandles.size > 0, holding: this._holding, + handHolding: this._handHolding, }; this._listeners.forEach((cb) => cb()); } diff --git a/src/types/interaction.ts b/src/types/interaction.ts index 5e65bf6..6652cde 100644 --- a/src/types/interaction.ts +++ b/src/types/interaction.ts @@ -19,5 +19,7 @@ export type InteractableHandle = export interface InteractionSnapshot { focused: InteractableHandle | null; + nearby: boolean; holding: boolean; + handHolding: boolean; }