• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

graphty-org / graphty-monorepo / 20673181586

03 Jan 2026 05:40AM UTC coverage: 77.93% (+0.01%) from 77.92%
20673181586

push

github

apowers313
chore: merge logs fix into master

13442 of 17824 branches covered (75.42%)

Branch coverage included in aggregate %.

73 of 133 new or added lines in 8 files covered. (54.89%)

6 existing lines in 3 files now uncovered.

41276 of 52390 relevant lines covered (78.79%)

145404.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

42.03
/graphty-element/src/cameras/XRInputHandler.ts
1
import {
1!
2
    Quaternion,
3
    Ray,
4
    type Scene,
5
    Vector3,
6
    type WebXRDefaultExperience,
7
    type WebXRInputSource,
8
} from "@babylonjs/core";
9

10
import { GraphtyLogger, type Logger } from "../logging";
1✔
11
import type { NodeDragHandler } from "../NodeBehavior";
12
import { applyDeadzone } from "./InputUtils";
1✔
13
import type { PivotController } from "./PivotController";
14

15
const logger: Logger = GraphtyLogger.getLogger(["graphty", "camera", "xr", "input"]);
1✔
16

17
// Re-export applyDeadzone for backwards compatibility
18
export { applyDeadzone } from "./InputUtils";
1✔
19

20
/**
21
 * Internal state for tracking hand position and pinch gesture.
22
 */
23
interface HandState {
24
    position: Vector3;
25
    rotation: Quaternion;
26
    isPinching: boolean;
27
    pinchStrength: number;
28
}
29

30
/**
31
 * Controller entry for gesture tracking.
32
 */
33
interface ControllerEntry {
34
    controller: WebXRInputSource;
35
    handedness: string;
36
}
37

38
/**
39
 * XRInputHandler processes XR controller and hand tracking input.
40
 *
41
 * It handles:
42
 * - Thumbstick input for rotation, zoom, and panning
43
 * - Two-hand gestures for zoom and rotation
44
 *
45
 * Input mapping (matches demo):
46
 * - Left stick X: Yaw (turn left/right)
47
 * - Left stick Y: Pitch (tilt up/down)
48
 * - Right stick X: Pan left/right
49
 * - Right stick Y: Zoom in/out
50
 *
51
 * Input is applied to a PivotController which manages scene transformation.
52
 */
53
export class XRInputHandler {
1✔
54
    private pivotController: PivotController;
55
    private xr: WebXRDefaultExperience;
56
    private enabled = false;
1✔
57

58
    // Controller state - track by uniqueId to handle controller switching
59
    private setupControllers = new Map<string, boolean>();
1✔
60
    private controllerCleanup = new Map<string, () => void>();
1✔
61

62
    // Gesture controllers - track controllers for trigger-based gestures
63
    private gestureControllers = new Map<string, ControllerEntry>();
1✔
64

65
    // Thumbstick values - updated every frame
66
    private leftStick = { x: 0, y: 0 };
1✔
67
    private rightStick = { x: 0, y: 0 };
1✔
68

69
    // Hand tracking state for two-hand gestures
70
    private leftHand: HandState | null = null;
1✔
71
    private rightHand: HandState | null = null;
1✔
72
    private previousDistance: number | null = null;
1✔
73
    private previousDirection: Vector3 | null = null;
1✔
74

75
    // Track previous pinch state for hysteresis
76
    private wasPinching: Record<string, boolean> = { left: false, right: false };
1✔
77

78
    // Hand tracking feature reference
79
    private handTrackingFeature: unknown = null;
1✔
80

81
    // Sensitivity settings (matching demo)
82
    private readonly DEADZONE = 0.15;
1✔
83
    private readonly YAW_SPEED = 0.04;
1✔
84
    private readonly PITCH_SPEED = 0.03;
1✔
85
    private readonly PAN_SPEED = 0.08;
1✔
86
    private readonly ZOOM_SPEED = 0.02;
1✔
87
    private readonly GESTURE_ZOOM_SENSITIVITY = 2.0;
1✔
88
    private readonly PINCH_START = 0.7;
1✔
89
    private readonly PINCH_END = 0.5;
1✔
90

91
    // Input switch delay tracking
92
    private lastControllerRemovedTime = 0;
1✔
93
    private readonly INPUT_SWITCH_DELAY_MS = 100;
1✔
94

95
    // Node drag state - for XR node interaction
96
    private isDraggingNode = false;
1✔
97
    private draggedNodeHandler: NodeDragHandler | null = null;
1✔
98
    private dragHand: "left" | "right" | null = null;
1✔
99
    private lastDragHandPosition: Vector3 | null = null; // Previous frame position for delta calculation
1✔
100

101
    // Velocity-based gain (PRISM technique from VR research)
102
    // Raw hand deltas are tiny (~0.3mm/frame at 72fps), need significant amplification
103
    // User feedback: 5× was too slow - "full arm only moves 3 units"
104
    // Target: full arm movement (0.6m) should move node ~15-20 units
105
    private readonly VELOCITY_SLOW_THRESHOLD = 0.005; // meters/second - below this = precision mode
1✔
106
    private readonly VELOCITY_FAST_THRESHOLD = 0.03; // meters/second - above this = speed mode
1✔
107
    private readonly AMP_SLOW = 5.0; // Precision zone: usable but controlled
1✔
108
    private readonly AMP_MEDIUM = 10.0; // Natural zone: comfortable movement
1✔
109
    private readonly AMP_FAST = 20.0; // Speed zone: fast repositioning
1✔
110
    private lastDragTime = 0; // For velocity calculation
1✔
111

112
    // Smoothing to reduce jitter from hand tracking noise
113
    private readonly SMOOTHING_FACTOR = 0.7; // 0 = no smoothing, 1 = max smoothing
1✔
114
    private smoothedDelta: Vector3 | null = null;
1✔
115

116
    // Drag threshold - must move this far from pick point before node actually moves
117
    // Set high enough that natural hand tremor during a "click" doesn't trigger drag
118
    // Testing showed 2cm was too low - clicks naturally move 4-6cm
119
    private readonly DRAG_THRESHOLD = 0.08; // 8cm - requires intentional movement to drag
1✔
120
    private dragStartHandPosition: Vector3 | null = null; // Position when drag started
1✔
121
    private dragThresholdExceeded = false; // True once user moves far enough to start actual drag
1✔
122

123
    // Debug
124
    private frameCount = 0;
1✔
125
    private scene: Scene; // Reference to scene for onBeforeRenderObservable and picking
126

127
    /**
128
     * Creates a new XRInputHandler instance.
129
     * @param pivotController - The pivot controller to manipulate based on input
130
     * @param xr - The WebXR experience instance
131
     */
132
    constructor(pivotController: PivotController, xr: WebXRDefaultExperience) {
1✔
133
        this.pivotController = pivotController;
32✔
134
        this.xr = xr;
32✔
135
        this.scene = xr.baseExperience.sessionManager.scene;
32✔
136
        // Debug: XRInputHandler created
137
    }
32✔
138

139
    /**
140
     * Enable input handling.
141
     * Subscribes to controller add/remove events.
142
     */
143
    enable(): void {
1✔
144
        if (this.enabled) {
20✔
145
            return;
1✔
146
        }
1✔
147

148
        this.enabled = true;
19✔
149
        // Debug: XRInputHandler enabled with PIVOT approach
150

151
        // Try to get hand tracking feature
152
        this.enableHandTracking();
19✔
153

154
        // Subscribe to controller events
155
        this.xr.input.onControllerAddedObservable.add((controller) => {
19✔
156
            const now = performance.now();
6✔
157
            const timeSinceRemoval = now - this.lastControllerRemovedTime;
6✔
158

159
            if (timeSinceRemoval < this.INPUT_SWITCH_DELAY_MS && this.lastControllerRemovedTime > 0) {
6!
160
                // Delay setup to prevent race conditions during controller switching
161
                const remainingDelay = this.INPUT_SWITCH_DELAY_MS - timeSinceRemoval;
×
162

163
                setTimeout(() => {
×
164
                    this.setupController(controller);
×
165
                    this.addGestureController(controller);
×
166
                }, remainingDelay);
×
167
            } else {
6✔
168
                this.setupController(controller);
6✔
169
                this.addGestureController(controller);
6✔
170
            }
6✔
171
        });
19✔
172

173
        this.xr.input.onControllerRemovedObservable.add((controller) => {
19✔
174
            this.cleanupController(controller);
2✔
175
            this.removeGestureController(controller);
2✔
176
        });
19✔
177

178
        // Process existing controllers
179
        this.xr.input.controllers.forEach((controller) => {
19✔
180
            this.setupController(controller);
×
181
            this.addGestureController(controller);
×
182
        });
19✔
183
    }
20✔
184

185
    /**
186
     * Enable hand tracking feature if available.
187
     */
188
    private enableHandTracking(): void {
1✔
189
        try {
19✔
190
            const { featuresManager } = this.xr.baseExperience;
19✔
191
            // Try to get already enabled feature first
192
            this.handTrackingFeature = featuresManager.getEnabledFeature("xr-hand-tracking");
19✔
193
        } catch {
19!
194
            this.handTrackingFeature = null;
×
195
        }
×
196
    }
19✔
197

198
    /**
199
     * Add controller to gesture tracking.
200
     * @param controller - The XR input source to add for gesture tracking
201
     */
202
    private addGestureController(controller: WebXRInputSource): void {
1✔
203
        const { uniqueId } = controller;
6✔
204
        const { handedness } = controller.inputSource;
6✔
205

206
        if (handedness === "left" || handedness === "right") {
6✔
207
            this.gestureControllers.set(uniqueId, {
6✔
208
                controller,
6✔
209
                handedness,
6✔
210
            });
6✔
211
        }
6✔
212
    }
6✔
213

214
    /**
215
     * Remove controller from gesture tracking.
216
     * @param controller - The XR input source to remove from gesture tracking
217
     */
218
    private removeGestureController(controller: WebXRInputSource): void {
1✔
219
        const { uniqueId } = controller;
2✔
220
        if (this.gestureControllers.has(uniqueId)) {
2✔
221
            this.gestureControllers.delete(uniqueId);
2✔
222
        }
2✔
223
    }
2✔
224

225
    /**
226
     * Disable input handling.
227
     * Clears controller and hand state.
228
     */
229
    disable(): void {
1✔
230
        if (!this.enabled) {
24✔
231
            return;
17✔
232
        }
17✔
233

234
        this.enabled = false;
7✔
235

236
        // Cleanup all controllers
237
        this.setupControllers.forEach((_value, uniqueId) => {
7✔
238
            const cleanup = this.controllerCleanup.get(uniqueId);
×
239
            if (cleanup) {
×
240
                cleanup();
×
241
            }
×
242
        });
7✔
243

244
        this.setupControllers.clear();
7✔
245
        this.controllerCleanup.clear();
7✔
246
        this.gestureControllers.clear();
7✔
247
        this.leftStick = { x: 0, y: 0 };
7✔
248
        this.rightStick = { x: 0, y: 0 };
7✔
249
        this.leftHand = null;
7✔
250
        this.rightHand = null;
7✔
251
        this.wasPinching = { left: false, right: false };
7✔
252
        this.handTrackingFeature = null;
7✔
253
        this.resetGestureState();
7✔
254
    }
24✔
255

256
    private setupController(controller: WebXRInputSource): void {
1✔
257
        const { handedness } = controller.inputSource;
6✔
258
        const { uniqueId } = controller;
6✔
259

260
        // Skip if already setup THIS specific controller
261
        if (this.setupControllers.has(uniqueId)) {
6!
262
            return;
×
263
        }
×
264

265
        // Check if this is a hand (detected as controller but no real controller capabilities)
266
        const { inputSource } = controller;
6✔
267
        const hasHandProfile = inputSource.profiles.some(
6✔
268
            (p) => p.includes("hand") || p.includes("generic-trigger-touchpad"),
6✔
269
        );
6✔
270
        if (hasHandProfile) {
6!
271
            return;
×
272
        }
×
273

274
        const setupThumbstick = (motionController: unknown): void => {
6✔
275
            if (this.setupControllers.has(uniqueId)) {
6!
276
                return;
×
277
            }
×
278

279
            const mc = motionController as {
6✔
280
                getComponentIds: () => string[];
281
                getComponent: (id: string) => {
282
                    axes: { x: number; y: number };
283
                    onAxisValueChangedObservable: {
284
                        add: (callback: (axes: { x: number; y: number }) => void) => unknown;
285
                        remove: (observer: unknown) => void;
286
                    };
287
                    _disposed?: boolean;
288
                } | null;
289
            };
290

291
            const thumbstick = mc.getComponent("xr-standard-thumbstick");
6✔
292
            if (!thumbstick) {
6!
293
                return;
×
294
            }
×
295

296
            this.setupControllers.set(uniqueId, true);
6✔
297

298
            const isLeftHand = handedness === "left";
6✔
299
            let isCleanedUp = false;
6✔
300

301
            // Axis change observer
302
            const axisCallback = (axes: { x: number; y: number }): void => {
6✔
303
                if (isCleanedUp) {
×
304
                    return;
×
305
                }
×
306

307
                const { x } = axes;
×
308
                const { y } = axes;
×
309

310
                if (isLeftHand) {
×
311
                    this.leftStick.x = x;
×
312
                    this.leftStick.y = y;
×
313
                } else {
×
314
                    this.rightStick.x = x;
×
315
                    this.rightStick.y = y;
×
316
                }
×
317
            };
×
318
            const axisObserver = thumbstick.onAxisValueChangedObservable.add(axisCallback);
6✔
319

320
            // Frame-by-frame polling as backup
321
            const scene = this.scene as {
6✔
322
                onBeforeRenderObservable: {
323
                    add: (callback: () => void) => unknown;
324
                    remove: (observer: unknown) => void;
325
                };
326
            };
327
            let pollObserverRef: unknown = null;
6✔
328

329
            const pollThumbstick = (): void => {
6✔
330
                if (isCleanedUp || !this.enabled) {
×
331
                    return;
×
332
                }
×
333

334
                if (!this.setupControllers.has(uniqueId)) {
×
335
                    // Controller was removed
336
                    if (pollObserverRef) {
×
337
                        scene.onBeforeRenderObservable.remove(pollObserverRef);
×
338
                        pollObserverRef = null;
×
339
                    }
×
340

341
                    return;
×
342
                }
×
343

344
                if (thumbstick._disposed) {
×
345
                    if (pollObserverRef) {
×
346
                        scene.onBeforeRenderObservable.remove(pollObserverRef);
×
347
                        pollObserverRef = null;
×
348
                    }
×
349

350
                    return;
×
351
                }
×
352

353
                const currentX = thumbstick.axes.x;
×
354
                const currentY = thumbstick.axes.y;
×
355
                if (isLeftHand) {
×
356
                    this.leftStick.x = currentX;
×
357
                    this.leftStick.y = currentY;
×
358
                } else {
×
359
                    this.rightStick.x = currentX;
×
360
                    this.rightStick.y = currentY;
×
361
                }
×
362
            };
×
363

364
            pollObserverRef = scene.onBeforeRenderObservable.add(pollThumbstick);
6✔
365

366
            // Store cleanup function
367
            this.controllerCleanup.set(uniqueId, () => {
6✔
368
                isCleanedUp = true;
2✔
369

370
                if (axisObserver) {
2!
371
                    try {
×
372
                        thumbstick.onAxisValueChangedObservable.remove(axisObserver);
×
373
                    } catch {
×
374
                        // Ignore errors during cleanup
375
                    }
×
376
                }
×
377

378
                if (pollObserverRef) {
2✔
379
                    try {
2✔
380
                        scene.onBeforeRenderObservable.remove(pollObserverRef);
2✔
381
                    } catch {
2!
382
                        // Ignore errors during cleanup
383
                    }
×
384
                }
2✔
385
            });
6✔
386
        };
6✔
387

388
        const mc = controller as unknown as {
6✔
389
            motionController?: unknown;
390
            onMotionControllerInitObservable: { add: (callback: (mc: unknown) => void) => void };
391
        };
392

393
        if (mc.motionController) {
6✔
394
            setupThumbstick(mc.motionController);
6✔
395
        } else {
6!
396
            mc.onMotionControllerInitObservable.add((motionController) => {
×
397
                setupThumbstick(motionController);
×
398
            });
×
399

400
            // Polling fallback
401
            let attempts = 0;
×
402
            const poll = setInterval(() => {
×
403
                attempts++;
×
404
                if (this.setupControllers.has(uniqueId) || attempts > 20) {
×
405
                    clearInterval(poll);
×
406
                    return;
×
407
                }
×
408

409
                if (mc.motionController) {
×
410
                    setupThumbstick(mc.motionController);
×
411
                    clearInterval(poll);
×
412
                }
×
413
            }, 100);
×
414
        }
×
415
    }
6✔
416

417
    private cleanupController(controller: WebXRInputSource): void {
1✔
418
        const { uniqueId } = controller;
2✔
419
        const { handedness } = controller.inputSource;
2✔
420

421
        const cleanup = this.controllerCleanup.get(uniqueId);
2✔
422
        if (cleanup) {
2✔
423
            cleanup();
2✔
424
            this.controllerCleanup.delete(uniqueId);
2✔
425
        }
2✔
426

427
        this.setupControllers.delete(uniqueId);
2✔
428

429
        // Clear stick values for the removed controller
430
        if (handedness === "left") {
2✔
431
            this.leftStick = { x: 0, y: 0 };
2✔
432
        } else if (handedness === "right") {
2!
433
            this.rightStick = { x: 0, y: 0 };
×
434
        }
×
435

436
        // Record removal time for delay mechanism
437
        this.lastControllerRemovedTime = performance.now();
2✔
438
    }
2✔
439

440
    /**
441
     * Process input each frame.
442
     * Call this from render loop.
443
     */
444
    update(): void {
1✔
445
        if (!this.enabled) {
9✔
446
            return;
2✔
447
        }
2✔
448

449
        this.frameCount++;
7✔
450

451
        // Always process thumbsticks (they don't conflict with node dragging)
452
        this.processThumbsticks();
7✔
453

454
        // Update hand states before processing interactions
455
        this.updateHandStates();
7✔
456

457
        // Process node interaction first (single-hand picking/dragging)
458
        // This must happen before gesture processing
459
        this.processNodeInteraction();
7✔
460

461
        // Only process gestures if NOT dragging a node
462
        // Two-hand gestures are for view manipulation, not while dragging nodes
463
        if (!this.isDraggingNode) {
7✔
464
            this.processHandGesturesInternal();
7✔
465
        }
7✔
466
    }
9✔
467

468
    /**
469
     * Process thumbstick input from controllers.
470
     * Left stick: X = yaw, Y = pitch
471
     * Right stick: X = pan, Y = zoom
472
     */
473
    private processThumbsticks(): void {
1✔
474
        // Get raw stick values
475
        const rawLeftX = this.leftStick.x;
7✔
476
        const rawLeftY = this.leftStick.y;
7✔
477
        const rawRightX = this.rightStick.x;
7✔
478
        const rawRightY = this.rightStick.y;
7✔
479

480
        // Apply deadzone with curve
481
        const leftX = applyDeadzone(rawLeftX, this.DEADZONE);
7✔
482
        const leftY = applyDeadzone(rawLeftY, this.DEADZONE);
7✔
483
        const rightX = applyDeadzone(rawRightX, this.DEADZONE);
7✔
484
        const rightY = applyDeadzone(rawRightY, this.DEADZONE);
7✔
485

486
        const hasInput = leftX !== 0 || leftY !== 0 || rightX !== 0 || rightY !== 0;
7✔
487
        if (!hasInput) {
7✔
488
            return;
7✔
489
        }
7!
490

491
        // LEFT STICK: Rotation (matching demo behavior)
492
        // X = yaw (push right = positive yaw = scene rotates right around you)
493
        // Y = pitch (push forward = negative pitch = look up)
494
        const yawDelta = leftX * this.YAW_SPEED;
×
495
        const pitchDelta = -leftY * this.PITCH_SPEED;
×
496

497
        if (Math.abs(yawDelta) > 0.0001 || Math.abs(pitchDelta) > 0.0001) {
7!
498
            this.pivotController.rotate(yawDelta, pitchDelta);
×
499
        }
×
500

501
        // RIGHT STICK: Zoom and Pan
502
        // Y = zoom (push forward = zoom in = scale up)
503
        if (Math.abs(rightY) > 0.0001) {
×
504
            const zoomFactor = 1.0 + rightY * this.ZOOM_SPEED;
×
505
            this.pivotController.zoom(zoomFactor);
×
506
        }
×
507

508
        // X = pan (push right = move focal point right)
509
        if (Math.abs(rightX) > 0.0001) {
×
510
            const panAmount = rightX * this.PAN_SPEED;
×
511
            this.pivotController.panViewRelative(panAmount, 0);
×
512
        }
×
513
    }
7✔
514

515
    /**
516
     * Process two-hand gesture input for zoom and rotation.
517
     * Requires both hands to be pinching.
518
     * Note: updateHandStates() is now called in update() before this method.
519
     */
520
    private processHandGesturesInternal(): void {
1✔
521
        // Need both hands pinching for gestures
522
        if (!this.leftHand?.isPinching || !this.rightHand?.isPinching) {
7!
523
            return;
7✔
524
        }
7!
525

526
        const leftPos = this.leftHand.position;
×
527
        const rightPos = this.rightHand.position;
×
528
        const currentDistance = Vector3.Distance(leftPos, rightPos);
×
529
        const direction = rightPos.subtract(leftPos);
×
530
        const currentDirection = direction.normalize();
×
531

532
        if (this.previousDistance === null || this.previousDirection === null) {
7!
533
            this.previousDistance = currentDistance;
×
534
            this.previousDirection = currentDirection.clone();
×
535
            return;
×
536
        }
×
537

538
        // Zoom from distance change
539
        const distanceDelta = currentDistance - this.previousDistance;
×
540
        const zoomFactor = 1.0 + distanceDelta * this.GESTURE_ZOOM_SENSITIVITY;
×
541
        // Invert: hands apart (positive delta) = zoom out = scale down
542
        this.pivotController.zoom(2.0 - Math.max(0.9, Math.min(1.1, zoomFactor)));
×
543

544
        // Rotation from direction change
545
        const rotationAxis = Vector3.Cross(this.previousDirection, currentDirection);
×
546
        const axisLength = rotationAxis.length();
×
547
        if (axisLength > 0.0001) {
×
548
            const dot = Vector3.Dot(this.previousDirection, currentDirection);
×
549
            const angle = Math.acos(Math.max(-1, Math.min(1, dot)));
×
550
            rotationAxis.scaleInPlace(1 / axisLength);
×
551
            // Negate for world-mode rotation
552
            this.pivotController.rotateAroundAxis(rotationAxis, -angle);
×
553
        }
×
554

555
        this.previousDistance = currentDistance;
×
556
        this.previousDirection = currentDirection.clone();
×
557
    }
7✔
558

559
    /**
560
     * Update hand states from controllers (trigger) or hand tracking.
561
     */
562
    private updateHandStates(): void {
1✔
563
        // Get hand state for both hands using priority: controller trigger > hand tracking
564
        this.leftHand = this.getHandState("left");
7✔
565
        this.rightHand = this.getHandState("right");
7✔
566
    }
7✔
567

568
    /**
569
     * Get hand state from controller trigger OR hand tracking.
570
     * Priority: Controller trigger first (squeeze triggers), then hand tracking.
571
     * @param handedness - Which hand to get state for ('left' or 'right')
572
     * @returns Hand state including position, rotation, and pinch info, or null if unavailable
573
     */
574
    private getHandState(handedness: "left" | "right"): HandState | null {
1✔
575
        // PATH 1: Try controller trigger first
576
        // Find controller by handedness
577
        let controllerEntry: ControllerEntry | null = null;
14✔
578
        for (const entry of this.gestureControllers.values()) {
14✔
579
            if (entry.handedness === handedness) {
4✔
580
                controllerEntry = entry;
2✔
581
                break;
2✔
582
            }
2✔
583
        }
4✔
584

585
        if (controllerEntry) {
14✔
586
            const { controller } = controllerEntry;
2✔
587
            const { grip } = controller;
2✔
588
            const mc = controller as unknown as {
2✔
589
                motionController?: { getComponent: (id: string) => { pressed?: boolean; value?: number } | null };
590
            };
591

592
            // Check if grip and motion controller are valid
593
            if (grip && mc.motionController) {
2!
594
                try {
×
595
                    const gripPos = grip.position;
×
596
                    // Verify position is valid (not disposed)
597
                    if (isFinite(gripPos.x) && isFinite(gripPos.y) && isFinite(gripPos.z)) {
×
598
                        const trigger = mc.motionController.getComponent("xr-standard-trigger");
×
599
                        if (trigger) {
×
600
                            // Use trigger.pressed OR value > 0.5 for pinching
601
                            const triggerValue = trigger.value ?? 0;
×
602
                            const isPinching = trigger.pressed === true || triggerValue > 0.5;
×
603

604
                            if (isPinching) {
×
605
                                // Log when trigger state changes
606
                                if (!this.wasPinching[handedness]) {
×
NEW
607
                                    logger.debug("Trigger press", { handedness, value: triggerValue });
×
608
                                }
×
609

610
                                this.wasPinching[handedness] = true;
×
611

612
                                return {
×
613
                                    position: gripPos.clone(),
×
614
                                    rotation: grip.rotationQuaternion?.clone() ?? Quaternion.Identity(),
×
615
                                    isPinching: true,
×
616
                                    pinchStrength: triggerValue,
×
617
                                };
×
618
                            } else if (this.wasPinching[handedness]) {
×
NEW
619
                                logger.debug("Trigger release", { handedness, value: triggerValue });
×
620
                                this.wasPinching[handedness] = false;
×
621
                                this.resetGestureState();
×
622
                            }
×
623
                        }
×
624
                    }
×
625
                } catch {
×
626
                    // Controller is likely disposed, fall through to hand tracking
627
                }
×
628
            }
×
629
        }
2✔
630

631
        // PATH 2: Try hand tracking
632
        if (this.handTrackingFeature) {
14!
633
            try {
×
634
                const htFeature = this.handTrackingFeature as {
×
635
                    getHandByHandedness?: (h: string) => {
636
                        getJointMesh: (joint: string) => { position: Vector3; rotationQuaternion?: Quaternion } | null;
637
                    } | null;
638
                };
639

640
                const hand = htFeature.getHandByHandedness?.(handedness);
×
641
                if (hand) {
×
642
                    const wrist = hand.getJointMesh("wrist");
×
643
                    const thumbTip = hand.getJointMesh("thumb-tip");
×
644
                    const indexTip = hand.getJointMesh("index-finger-tip");
×
645

646
                    if (wrist && thumbTip && indexTip) {
×
647
                        // Verify positions are valid (not NaN from disposed objects)
648
                        if (!isFinite(wrist.position.x) || !isFinite(thumbTip.position.x)) {
×
649
                            return null;
×
650
                        }
×
651

652
                        const pinchDist = Vector3.Distance(thumbTip.position, indexTip.position);
×
653
                        const PINCH_THRESHOLD = 0.04; // 4cm
×
654
                        const PINCH_RELEASE_THRESHOLD = 0.06; // 6cm (looser for release)
×
655

656
                        // Hysteresis: different thresholds for start vs stop
657
                        const wasP = this.wasPinching[handedness];
×
658
                        const isP = wasP
×
659
                            ? pinchDist < PINCH_RELEASE_THRESHOLD // Already pinching - use looser threshold
×
660
                            : pinchDist < PINCH_THRESHOLD; // Not pinching - use tighter threshold
×
661

662
                        if (isP !== wasP) {
×
NEW
663
                            logger.debug(isP ? "Pinch start" : "Pinch end", { handedness, distance: pinchDist });
×
664
                            if (!isP) {
×
665
                                this.resetGestureState();
×
666
                            }
×
667
                        }
×
668

669
                        this.wasPinching[handedness] = isP;
×
670

671
                        if (isP) {
×
672
                            return {
×
673
                                position: wrist.position.clone(),
×
674
                                rotation: wrist.rotationQuaternion?.clone() ?? Quaternion.Identity(),
×
675
                                isPinching: true,
×
676
                                pinchStrength: Math.max(0, 1 - pinchDist / PINCH_THRESHOLD),
×
677
                            };
×
678
                        }
×
679
                    }
×
680
                }
×
681
            } catch {
×
682
                // Hand tracking access error - ignore
683
            }
×
684
        }
×
685

686
        return null;
14✔
687
    }
14✔
688

689
    /**
690
     * Reset gesture tracking state.
691
     * Called when hands stop pinching or tracking is lost.
692
     */
693
    private resetGestureState(): void {
1✔
694
        this.previousDistance = null;
7✔
695
        this.previousDirection = null;
7✔
696
    }
7✔
697

698
    /**
699
     * Check if input handling is enabled.
700
     * @returns True if enabled, false otherwise
701
     */
702
    isEnabled(): boolean {
1✔
703
        return this.enabled;
9✔
704
    }
9✔
705

706
    /**
707
     * Get reference to pivot controller (for testing).
708
     * @returns The pivot controller instance
709
     */
710
    getPivotController(): PivotController {
1✔
711
        return this.pivotController;
1✔
712
    }
1✔
713

714
    // ========================================================================
715
    // XR NODE INTERACTION
716
    // Handles picking and dragging nodes using XR controllers
717
    // ========================================================================
718

719
    /**
720
     * Process single-hand node interaction.
721
     * Called when only one hand is pinching (not both).
722
     * If the controller is pointing at a node, start/continue dragging it.
723
     */
724
    private processNodeInteraction(): void {
1✔
725
        const leftPinching = this.leftHand?.isPinching ?? false;
7!
726
        const rightPinching = this.rightHand?.isPinching ?? false;
7!
727

728
        // If both hands are pinching, don't do node interaction (gestures take over)
729
        if (leftPinching && rightPinching) {
7!
730
            // If we were dragging a node, end the drag since user wants to gesture
731
            if (this.isDraggingNode) {
×
732
                this.endNodeDrag();
×
733
            }
×
734

735
            return;
×
736
        }
×
737

738
        // If we're currently dragging, update the drag
739
        if (this.isDraggingNode && this.draggedNodeHandler && this.dragHand) {
7!
740
            const handState = this.dragHand === "left" ? this.leftHand : this.rightHand;
×
741

742
            if (handState?.isPinching) {
×
743
                // Continue dragging - update position based on controller movement
744
                this.updateNodeDrag(handState.position);
×
745
            } else {
×
746
                // Hand released, end drag
747
                this.endNodeDrag();
×
748
            }
×
749

750
            return;
×
751
        }
×
752

753
        // Not currently dragging - check if we should start
754
        if (leftPinching && !rightPinching && this.leftHand) {
7!
755
            this.tryStartNodeDrag("left", this.leftHand.position);
×
756
        } else if (rightPinching && !leftPinching && this.rightHand) {
7!
757
            this.tryStartNodeDrag("right", this.rightHand.position);
×
758
        }
×
759
    }
7✔
760

761
    /**
762
     * Try to start dragging a node if the controller is pointing at one.
763
     * @param handedness - Which hand is attempting to drag ('left' or 'right')
764
     * @param handPosition - The world position of the hand
765
     */
766
    private tryStartNodeDrag(handedness: "left" | "right", handPosition: Vector3): void {
1✔
767
        // Find the controller for this hand
768
        let controller: WebXRInputSource | null = null;
×
769
        for (const entry of this.gestureControllers.values()) {
×
770
            if (entry.handedness === handedness) {
×
771
                ({ controller } = entry);
×
772
                break;
×
773
            }
×
774
        }
×
775

776
        if (!controller) {
×
777
            return;
×
778
        }
×
779

780
        // Get the controller's pointer ray using Babylon.js's proper method
781
        // This gives us the actual ray the WebXR system uses for interaction
782
        const ray = new Ray(Vector3.Zero(), Vector3.Forward(), 100);
×
783

784
        // Try to get the world pointer ray from the controller
785
        const controllerWithRay = controller as unknown as {
×
786
            getWorldPointerRayToRef?: (ray: Ray) => void;
787
        };
788

789
        if (controllerWithRay.getWorldPointerRayToRef) {
×
790
            controllerWithRay.getWorldPointerRayToRef(ray);
×
791
        } else {
×
792
            // Fallback: Use pointer node (always available) or grip node
793
            const pointerNode = controller.grip ?? controller.pointer;
×
794
            ray.origin = pointerNode.position.clone();
×
795
            ray.direction = pointerNode.forward.clone();
×
796
        }
×
797

798
        // Pick meshes in the scene
799
        const pickInfo = this.scene.pickWithRay(ray, (mesh) => {
×
800
            // Only pick node meshes (they have dragHandler in metadata or on the node)
801
            return mesh.isPickable && mesh.isEnabled();
×
802
        });
×
803

804
        if (!pickInfo?.hit || !pickInfo.pickedMesh) {
×
805
            return;
×
806
        }
×
807

808
        // Try to find the NodeDragHandler for this mesh
809
        const dragHandler = this.findDragHandlerForMesh(pickInfo.pickedMesh);
×
810
        if (!dragHandler) {
×
811
            return;
×
812
        }
×
813

814
        // Get the node's current position for debugging
815
        const node = dragHandler.getNode();
×
816
        const nodeMeshPosition = node.mesh.position;
×
817
        const { pickedPoint } = pickInfo;
×
818

NEW
819
        logger.debug("Drag start", {
×
NEW
820
            handedness,
×
NEW
821
            handPos: { x: handPosition.x, y: handPosition.y, z: handPosition.z },
×
NEW
822
            nodePos: { x: nodeMeshPosition.x, y: nodeMeshPosition.y, z: nodeMeshPosition.z },
×
NEW
823
            pickPt: pickedPoint ? { x: pickedPoint.x, y: pickedPoint.y, z: pickedPoint.z } : null,
×
UNCOV
824
        });
×
825

826
        this.isDraggingNode = true;
×
827
        this.draggedNodeHandler = dragHandler;
×
828
        this.dragHand = handedness;
×
829
        this.lastDragHandPosition = handPosition.clone(); // Store for delta calculation
×
830
        this.dragStartHandPosition = handPosition.clone(); // Store initial position for threshold check
×
831
        this.dragThresholdExceeded = false; // Reset threshold flag
×
832
        this.lastDragTime = performance.now(); // Initialize time for velocity calculation
×
833

834
        // DON'T call onDragStart yet - wait until threshold is exceeded
835
        // This prevents the node from "twitching" from hand tremor
836
    }
×
837

838
    /**
839
     * Update node drag position based on controller movement.
840
     *
841
     * Uses delta-based movement from the working demo:
842
     * 1. Calculate delta from previous frame (not drag start)
843
     * 2. Transform delta through pivot rotation
844
     * 3. Apply amplification
845
     * 4. Add to current node position
846
     *
847
     * This approach correctly handles pivot rotation changes during drag.
848
     * @param currentHandPosition - The current world position of the dragging hand
849
     */
850
    // Track drag update count for limiting debug logs
851
    private dragUpdateCount = 0;
1✔
852

853
    private updateNodeDrag(currentHandPosition: Vector3): void {
1✔
854
        if (!this.draggedNodeHandler || !this.lastDragHandPosition || !this.dragStartHandPosition) {
×
855
            return;
×
856
        }
×
857

858
        const now = performance.now();
×
859

860
        // Check if we've exceeded the drag threshold yet
861
        if (!this.dragThresholdExceeded) {
×
862
            const distanceFromStart = Vector3.Distance(currentHandPosition, this.dragStartHandPosition);
×
863

864
            if (distanceFromStart < this.DRAG_THRESHOLD) {
×
865
                // Still within threshold - don't move the node yet
866
                // Update lastDragHandPosition so we track movement for when threshold is exceeded
867
                this.lastDragHandPosition = currentHandPosition.clone();
×
868
                this.lastDragTime = now;
×
869
                return;
×
870
            }
×
871

872
            // Threshold exceeded! Now we're actually dragging
873
            this.dragThresholdExceeded = true;
×
NEW
874
            logger.debug("Drag threshold exceeded", { distance: distanceFromStart });
×
875

876
            // NOW call onDragStart since user has committed to dragging
877
            const node = this.draggedNodeHandler.getNode();
×
878
            this.draggedNodeHandler.onDragStart(node.mesh.position.clone());
×
879

880
            // Reset lastDragHandPosition to current so first delta is from threshold point
881
            this.lastDragHandPosition = currentHandPosition.clone();
×
882
            this.lastDragTime = now;
×
883
            return;
×
884
        }
×
885

886
        // Calculate delta from previous frame (not drag start)
887
        const xrDelta = currentHandPosition.subtract(this.lastDragHandPosition);
×
888

889
        // Skip if no significant movement
890
        const deltaLength = xrDelta.length();
×
891
        if (deltaLength < 0.0001) {
×
892
            return;
×
893
        }
×
894

895
        this.dragUpdateCount++;
×
896

897
        // Calculate velocity for PRISM-style adaptive gain
898
        const deltaTimeMs = now - this.lastDragTime;
×
899
        const deltaTimeSec = Math.max(deltaTimeMs / 1000, 0.001); // Minimum 1ms to avoid division by zero
×
900
        const velocity = deltaLength / deltaTimeSec; // meters per second
×
901

902
        // Apply velocity-based amplification (PRISM technique)
903
        const amplification = this.calculateVelocityBasedGain(velocity);
×
904

905
        // Transform delta through pivot rotation
906
        const sceneDelta = this.transformDeltaToSceneSpace(xrDelta);
×
907

908
        // Apply velocity-based amplification
909
        sceneDelta.scaleInPlace(amplification);
×
910

911
        // Apply smoothing to reduce jitter from hand tracking noise
912
        if (this.smoothedDelta === null) {
×
913
            this.smoothedDelta = sceneDelta.clone();
×
914
        } else {
×
915
            // Exponential moving average: smoothed = smoothed * factor + new * (1 - factor)
916
            this.smoothedDelta.scaleInPlace(this.SMOOTHING_FACTOR);
×
917
            this.smoothedDelta.addInPlace(sceneDelta.scale(1 - this.SMOOTHING_FACTOR));
×
918
        }
×
919

920
        // Use smoothed delta for position update
921
        const finalDelta = this.smoothedDelta.clone();
×
922

923
        // Get current node position and add delta
924
        const node = this.draggedNodeHandler.getNode();
×
925
        const currentNodePos = node.mesh.position.clone();
×
926
        const newPosition = currentNodePos.add(finalDelta);
×
927

928
        // Log only first 5 updates to catch initial movement issues
929
        if (this.dragUpdateCount <= 5) {
×
NEW
930
            logger.trace("Drag update", {
×
NEW
931
                updateCount: this.dragUpdateCount,
×
NEW
932
                delta: { x: xrDelta.x, y: xrDelta.y, z: xrDelta.z },
×
NEW
933
                velocity,
×
NEW
934
                amplification,
×
NEW
935
                newPos: { x: newPosition.x, y: newPosition.y, z: newPosition.z },
×
936
            });
×
937
        }
×
938

939
        // Update via drag handler (handles layout engine)
940
        this.draggedNodeHandler.setPositionDirect(newPosition);
×
941

942
        // Store for next frame
943
        this.lastDragHandPosition = currentHandPosition.clone();
×
944
        this.lastDragTime = now;
×
945
    }
×
946

947
    /**
948
     * Calculate velocity-based gain using PRISM technique.
949
     * Slow movements → low amplification (precision)
950
     * Fast movements → higher amplification (speed)
951
     *
952
     * This provides intuitive control: fine adjustments when moving slowly,
953
     * quick repositioning when moving fast.
954
     * @param velocity - The velocity of hand movement in world units per second.
955
     * @returns The amplification gain factor to apply to the movement.
956
     */
957
    private calculateVelocityBasedGain(velocity: number): number {
1✔
958
        if (velocity < this.VELOCITY_SLOW_THRESHOLD) {
×
959
            // Precision zone: reduce amplification for fine control
960
            return this.AMP_SLOW;
×
961
        }
×
962

963
        if (velocity > this.VELOCITY_FAST_THRESHOLD) {
×
964
            // Speed zone: increase amplification for faster repositioning
965
            return this.AMP_FAST;
×
966
        }
×
967

968
        // Transition zone: linear interpolation between slow and fast
969
        const t =
×
970
            (velocity - this.VELOCITY_SLOW_THRESHOLD) / (this.VELOCITY_FAST_THRESHOLD - this.VELOCITY_SLOW_THRESHOLD);
×
971
        return this.AMP_SLOW + t * (this.AMP_FAST - this.AMP_SLOW);
×
972
    }
×
973

974
    /**
975
     * Transform a movement delta from XR space to scene space.
976
     * This accounts for the pivot's rotation so movements feel natural.
977
     *
978
     * When the pivot rotates, the user's view rotates with it.
979
     * The user's "forward" direction in their view corresponds to a rotated direction in world space.
980
     * So we rotate the XR movement by the pivot's rotation to match the user's perspective.
981
     * @param delta - The movement delta in XR space
982
     * @returns The transformed delta in scene space
983
     */
984
    private transformDeltaToSceneSpace(delta: Vector3): Vector3 {
1✔
985
        const pivotRotation = this.pivotController.pivot.rotationQuaternion;
×
986
        if (!pivotRotation) {
×
987
            return delta.clone();
×
988
        }
×
989

990
        // Transform the delta by the pivot rotation
991
        const transformedDelta = delta.clone();
×
992
        transformedDelta.rotateByQuaternionToRef(pivotRotation, transformedDelta);
×
993
        return transformedDelta;
×
994
    }
×
995

996
    /**
997
     * End the current node drag.
998
     * If threshold was never exceeded, this was actually a selection (tap), not a drag.
999
     */
1000
    private endNodeDrag(): void {
1✔
1001
        if (this.draggedNodeHandler) {
×
1002
            if (this.dragThresholdExceeded) {
×
1003
                // User actually dragged - end the drag normally
NEW
1004
                logger.debug("Drag end", { updateCount: this.dragUpdateCount });
×
1005
                this.draggedNodeHandler.onDragEnd();
×
1006
            } else {
×
1007
                // User released before threshold - this is a SELECTION, not a drag
NEW
1008
                logger.debug("Select (threshold not exceeded)");
×
1009

1010
                // Select the node using the drag handler's select() method
1011
                this.draggedNodeHandler.select();
×
1012
            }
×
1013
        }
×
1014

1015
        this.isDraggingNode = false;
×
1016
        this.draggedNodeHandler = null;
×
1017
        this.dragHand = null;
×
1018
        this.lastDragHandPosition = null;
×
1019
        this.dragStartHandPosition = null;
×
1020
        this.dragThresholdExceeded = false;
×
1021
        this.dragUpdateCount = 0; // Reset for next drag
×
1022
        this.lastDragTime = 0; // Reset velocity tracking
×
1023
        this.smoothedDelta = null; // Reset smoothing for next drag
×
1024
    }
×
1025

1026
    /**
1027
     * Find the NodeDragHandler associated with a mesh.
1028
     * Nodes store their dragHandler reference in mesh.metadata.graphNode.
1029
     * @param mesh - The mesh to find a drag handler for
1030
     * @param mesh.name - The mesh name
1031
     * @param mesh.metadata - The mesh metadata containing node reference
1032
     * @returns The drag handler if found, null otherwise
1033
     */
1034
    private findDragHandlerForMesh(mesh: { name: string; metadata?: unknown }): NodeDragHandler | null {
1✔
1035
        // Check mesh.metadata.graphNode for node reference
1036
        // Node.ts sets: mesh.metadata = { graphNode: this, ... }
1037
        if (mesh.metadata && typeof mesh.metadata === "object") {
×
1038
            const metadata = mesh.metadata as { graphNode?: { dragHandler?: NodeDragHandler } };
×
1039
            if (metadata.graphNode?.dragHandler) {
×
1040
                return metadata.graphNode.dragHandler;
×
1041
            }
×
1042
        }
×
1043

1044
        return null;
×
1045
    }
×
1046

1047
    /**
1048
     * Check if currently dragging a node.
1049
     * Used to suppress gesture processing while dragging.
1050
     * @returns True if currently dragging a node, false otherwise
1051
     */
1052
    isDragging(): boolean {
1✔
1053
        return this.isDraggingNode;
×
1054
    }
×
1055
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc