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

iTowns / itowns / 17397347436

02 Sep 2025 08:08AM UTC coverage: 86.933% (+0.006%) from 86.927%
17397347436

push

github

gchoqueux
doc(bundle): add doc to use the bundles ESM and UMD

2791 of 3733 branches covered (74.77%)

Branch coverage included in aggregate %.

25996 of 29381 relevant lines covered (88.48%)

1103.59 hits per line

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

83.3
/packages/Main/src/Controls/VRControls.js
1
import * as THREE from 'three';
1✔
2
import { Coordinates } from '@itowns/geographic';
1✔
3
import DEMUtils from 'Utils/DEMUtils';
1✔
4
// eslint-disable-next-line import/extensions, import/no-unresolved
1✔
5
import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js';
1✔
6

1✔
7
/**
1✔
8
 * @property {Array} controllers - WebXR controllers list
1✔
9
 * */
1✔
10
class VRControls {
1✔
11
    static MIN_DELTA_ALTITUDE = 1.8;
1✔
12
    static MAX_NUMBER_CONTROLLERS = 2;  // For now, we are fully supporting a maximum of 2 controllers.
1✔
13
    /**
1✔
14
     * Requires a contextXR variable.
1✔
15
     * @param {*} _view itowns view object
1✔
16
     * @param {*} _groupXR XR 3D object group
1✔
17
     */
1✔
18
    constructor(_view, _groupXR = {}) {
1!
19
    // Store instance references.
1✔
20
        this.view = _view;
1✔
21
        this.groupXR = _groupXR;
1✔
22
        this.webXRManager = _view.mainLoop.gfxEngine.renderer.xr;
1✔
23

1✔
24
        this.rightButtonPressed = false;
1✔
25
        this.controllers = [];
1✔
26
        this.initControllers();
1✔
27
    }
1✔
28

1✔
29
    // Static factory method:
1✔
30
    static init(view, vrHeadSet) {
1✔
31
        return new VRControls(view, vrHeadSet);
×
32
    }
×
33

1✔
34

1✔
35
    initControllers() {
1✔
36
        //  Add a light for the controllers
1✔
37
        this.groupXR.add(new THREE.HemisphereLight(0xa5a5a5, 0x898989, 3));
1✔
38

1✔
39
        const controllerModelFactory = new XRControllerModelFactory();
1✔
40

1✔
41
        for (let i = 0; i < VRControls.MAX_NUMBER_CONTROLLERS; i++) {
1✔
42
            const controller = this.webXRManager.getController(i);
2✔
43

2✔
44

2✔
45
            controller.addEventListener('connected', (event) => {
2✔
46
                controller.name = event.data.handedness;    // Left or right
2✔
47
                controller.userData.handedness = event.data.handedness;
2✔
48
                controller.gamepad = event.data.gamepad;
2✔
49
                this.groupXR.add(controller);
2✔
50

2✔
51
                const gripController = this.webXRManager.getControllerGrip(i);
2✔
52
                gripController.name = `${controller.name}GripController`;
2✔
53
                gripController.userData.handedness = event.data.handedness;
2✔
54
                this.bindGripController(controllerModelFactory, gripController, this.groupXR);
2✔
55
                this.controllers.push(controller);
2✔
56
                this.groupXR.add(gripController);
2✔
57

2✔
58

2✔
59
                // Event listeners
2✔
60
                this.setupEventListeners(controller);
2✔
61
            });
2✔
62

2✔
63
            controller.addEventListener('disconnected', function removeCtrl() {
2✔
64
                this.remove(this.children[0]);
×
65
            });
2✔
66
        }
2✔
67
    }
2✔
68

1✔
69
    bindGripController(controllerModelFactory, gripController, vrHeadSet) {
1✔
70
        gripController.add(controllerModelFactory.createControllerModel(gripController));
2✔
71
        vrHeadSet.add(gripController);
2✔
72
    }
2✔
73

1✔
74

1✔
75

1✔
76

1✔
77
    // Register event listeners for controllers.
1✔
78
    setupEventListeners(controller) {
1✔
79
        controller.addEventListener('itowns-xr-axes-changed', e => this.onAxisChanged(e));
2✔
80
        controller.addEventListener('itowns-xr-axes-stop', e => this.onAxisStop(e));
2✔
81
        controller.addEventListener('itowns-xr-button-pressed', e => this.onButtonPressed(e));
2✔
82
        controller.addEventListener('itowns-xr-button-released', e => this.onButtonReleased(e));
2✔
83

2✔
84
        controller.addEventListener('selectstart', e => this.onSelectStart(e));
2✔
85
        controller.addEventListener('selectend', e => this.onSelectEnd(e));
2✔
86
    }
2✔
87

1✔
88

1✔
89
    /*
1✔
90
Listening {XRInputSource} and emit changes for convenience user binding,
1✔
91
There is NO JOYSTICK Events so we need to check it ourselves
1✔
92
Adding a few internal states for reactivity
1✔
93
- controller.isStickActive {boolean} true when a controller stick is not on initial state.
1✔
94
*/
1✔
95

1✔
96
    listenGamepad() {
1✔
97
        for (const controller of this.controllers) {
3✔
98
            if (!controller.gamepad) {
5!
99
                return;
×
100
            }
×
101
            // gamepad.axes = [0, 0, x, y];
5✔
102

5✔
103
            const gamepad = controller.gamepad;
5✔
104
            const activeValue = gamepad.axes.some(value => value !== 0);
5✔
105

5✔
106
            // Handle stick activity state
5✔
107
            if (controller.isStickActive && !activeValue && controller.gamepad.endGamePadtrackEmit) {
5✔
108
                controller.dispatchEvent({
1✔
109
                    type: 'itowns-xr-axes-stop',
1✔
110
                    message: { controller },
1✔
111
                });
1✔
112
                controller.isStickActive = false;
1✔
113
                return;
1✔
114
            } else if (!controller.isStickActive && activeValue) {
5✔
115
                controller.gamepad.endGamePadtrackEmit = false;
1✔
116
                controller.isStickActive = true;
1✔
117
            } else if (controller.isStickActive && !activeValue) {
4!
118
                controller.gamepad.endGamePadtrackEmit = true;
×
119
            }
×
120

4✔
121
            if (activeValue) {
5✔
122
                controller.dispatchEvent({
1✔
123
                    type: 'itowns-xr-axes-changed',
1✔
124
                    message: { controller },
1✔
125
                });
1✔
126
            }
1✔
127

4✔
128
            for (const [index, button] of gamepad.buttons.entries()) {
5✔
129
                if (button.pressed) {
2✔
130
                    // 0 - trigger
1✔
131
                    // 1 - grip
1✔
132
                    // 3 - stick pressed
1✔
133
                    // 4 - bottom button
1✔
134
                    // 5 - upper button
1✔
135
                    controller.dispatchEvent({
1✔
136
                        type: 'itowns-xr-button-pressed',
1✔
137
                        message: {
1✔
138
                            controller,
1✔
139
                            buttonIndex: index,
1✔
140
                            button,
1✔
141
                        },
1✔
142
                    });
1✔
143
                    controller.lastButtonItem = button;
1✔
144
                } else if (controller.lastButtonItem && controller.lastButtonItem === button) {
1✔
145
                    controller.dispatchEvent({
1✔
146
                        type: 'itowns-xr-button-released',
1✔
147
                        message: {
1✔
148
                            controller,
1✔
149
                            buttonIndex: index,
1✔
150
                            button,
1✔
151
                        },
1✔
152
                    });
1✔
153
                    controller.lastButtonItem = undefined;
1✔
154
                }
1✔
155

2✔
156
                if (button.touched) {
2!
157
                    // triggered really often
×
158
                }
2✔
159
            }
2✔
160
        }
4✔
161
    }
2✔
162

1✔
163

1✔
164
    // Clamp a translation to ground and then apply the transformation.
1✔
165
    clampAndApplyTransformationToXR(trans, offsetRotation) {
1✔
166
        const transClamped = this.clampToGround(trans);
2✔
167
        this.applyTransformationToXR(transClamped, offsetRotation);
2✔
168
    }
2✔
169

1✔
170
    // Apply a translation and rotation to the XR group.
1✔
171
    applyTransformationToXR(trans, offsetRotation) {
1✔
172
        this.groupXR.position.copy(trans);
5✔
173
        this.groupXR.quaternion.copy(offsetRotation);
5✔
174
        this.groupXR.updateMatrixWorld(true);
5✔
175
    }
5✔
176

1✔
177
    /**
1✔
178
   * Clamp the given translation vector so that the camera remains at or above ground level.
1✔
179
   * @param {THREE.Vector3} trans - The translation vector.
1✔
180
   * @returns {THREE.Vector3} The clamped coordinates as a Vector3.
1✔
181
   */
1✔
182
    clampToGround(trans) {
1✔
183
        const transCoordinate = new Coordinates(
3✔
184
            this.view.referenceCrs,
3✔
185
            trans.x,
3✔
186
            trans.y,
3✔
187
            trans.z,
3✔
188
        );
3✔
189

3✔
190
        const terrainElevation = DEMUtils.getElevationValueAt(
3✔
191
            this.view.tileLayer,
3✔
192
            transCoordinate,
3✔
193
            DEMUtils.PRECISE_READ_Z,
3✔
194
        ) || 0;
3!
195

3✔
196
        if (this.view.controls.getCameraCoordinate) {
3✔
197
            const coordsProjected = transCoordinate.as(this.view.controls.getCameraCoordinate().crs);
2✔
198
            if (coordsProjected.altitude - terrainElevation - VRControls.MIN_DELTA_ALTITUDE <= 0) {
2✔
199
                coordsProjected.altitude = terrainElevation + VRControls.MIN_DELTA_ALTITUDE;
2✔
200
            }
2✔
201
            return coordsProjected.as(this.view.referenceCrs).toVector3();
2✔
202
        } else {
3✔
203
            return trans;
1✔
204
        }
1✔
205
    }
3✔
206

1✔
207
    // Calculate a speed factor based on the camera's altitude.
1✔
208
    getSpeedFactor() {
1✔
209
        const altitude = this.view.controls.getCameraCoordinate ? this.view.controls.getCameraCoordinate().altitude : 1;
4!
210
        return Math.min(Math.max(altitude / 50, 2), 2000); // TODO: Adjust if needed -> add as a config ?
4✔
211
    }
4✔
212

1✔
213
    // Calculate a yaw rotation quaternion based on an axis value from the joystick.
1✔
214
    getRotationYaw(axisValue) {
1✔
215
        // Clone the current XR group's orientation.
3✔
216
        const baseOrientation = this.groupXR.quaternion.clone().normalize();
3✔
217
        let deltaRotation = 0;
3✔
218

3✔
219
        if (axisValue) {
3✔
220
            deltaRotation = -Math.PI * axisValue / 140; // Adjust sensitivity as needed.
2✔
221
        }
2✔
222
        // Get the "up" direction from the camera coordinate. // TODO should we handle other than globe ?
3✔
223
        const upAxis = this.groupXR.position.clone().normalize();
3✔
224
        // Create a quaternion representing a yaw rotation about the up axis.
3✔
225
        const yawQuaternion = new THREE.Quaternion()
3✔
226
            .setFromAxisAngle(upAxis, deltaRotation)
3✔
227
            .normalize();
3✔
228
        // Apply the yaw rotation.
3✔
229
        baseOrientation.premultiply(yawQuaternion);
3✔
230
        return baseOrientation;
3✔
231
    }
3✔
232

1✔
233
    // Calculate a pitch rotation quaternion based on an axis value from the joystick.
1✔
234
    getRotationPitch(axisValue) {
1✔
235
    // Clone the current XR group's orientation.
2✔
236
        const baseOrientation = this.groupXR.quaternion.clone().normalize();
2✔
237
        let deltaRotation = 0;
2✔
238

2✔
239
        if (axisValue) {
2✔
240
            deltaRotation = -Math.PI * axisValue / 140; // Adjust sensitivity as needed.
2✔
241
        }
2✔
242

2✔
243
        // Compute the right axis from the current orientation.
2✔
244
        // (Assuming (1, 0, 0) is the right direction in local space.)
2✔
245
        const rightAxis = new THREE.Vector3(1, 0, 0)
2✔
246
            .applyQuaternion(baseOrientation)
2✔
247
            .normalize();
2✔
248

2✔
249
        // Create a quaternion representing a pitch rotation about the right axis.
2✔
250
        const pitchQuaternion = new THREE.Quaternion()
2✔
251
            .setFromAxisAngle(rightAxis, deltaRotation)
2✔
252
            .normalize();
2✔
253

2✔
254
        // Apply the pitch rotation.
2✔
255
        baseOrientation.premultiply(pitchQuaternion);
2✔
256
        return baseOrientation;
2✔
257
    }
2✔
258

1✔
259
    // Compute a translation vector for vertical adjustment.
1✔
260
    getTranslationElevation(axisValue, speedFactor) {
1✔
261
        const speed = axisValue * speedFactor;
1✔
262
        const direction = this.view.camera3D.position.clone().normalize();
1✔
263

1✔
264
        direction.multiplyScalar(-speed);
1✔
265
        return direction;
1✔
266
    }
1✔
267

1✔
268
    // Handles camera flying based on controller input.
1✔
269
    cameraOnFly(ctrl) {
1✔
270
        let directionX = new THREE.Vector3();
1✔
271
        let directionZ = new THREE.Vector3();
1✔
272
        const speedFactor = this.getSpeedFactor();
1✔
273
        if (ctrl.gamepad.axes[3] !== 0) {
1✔
274
            const speed = ctrl.gamepad.axes[3] * speedFactor;
1✔
275
            directionZ = new THREE.Vector3(0, 0, 1)
1✔
276
                .applyQuaternion(this.view.camera3D.quaternion.clone().normalize())
1✔
277
                .multiplyScalar(speed);
1✔
278
        }
1✔
279
        if (ctrl.gamepad.axes[2] !== 0) {
1✔
280
            const speed = ctrl.gamepad.axes[2] * speedFactor;
1✔
281
            directionX = new THREE.Vector3(1, 0, 0)
1✔
282
                .applyQuaternion(this.view.camera3D.quaternion.clone().normalize())
1✔
283
                .multiplyScalar(speed);
1✔
284
        }
1✔
285
        const offsetRotation = this.groupXR.quaternion.clone();
1✔
286
        const trans = this.groupXR.position.clone().add(directionX.add(directionZ));
1✔
287
        // this.applyTransformationToXR(trans, offsetRotation);
1✔
288
        this.clampAndApplyTransformationToXR(trans, offsetRotation);
1✔
289
    }
1✔
290

1✔
291
    /* =======================
1✔
292
     Event Handler Methods
1✔
293
     ======================= */
1✔
294

1✔
295
    // Right select ends.
1✔
296
    /* c8 ignore next 3 */
1✔
297
    onSelectRightEnd() {
1✔
298
    // Uncomment and implement teleportation if needed:
1✔
299
    }
1✔
300

1✔
301
    // Right select starts.
1✔
302
    /* c8 ignore next 3 */
1✔
303
    onSelectRightStart() {
1✔
304
    // No operation needed yet.
1✔
305
    }
1✔
306

1✔
307
    // Left select starts.
1✔
308
    /* c8 ignore next 3 */
1✔
309
    onSelectLeftStart() {
1✔
310
    // No operation needed yet.
1✔
311
    }
1✔
312

1✔
313
    // Left select ends.
1✔
314
    /* c8 ignore next 3 */
1✔
315
    onSelectLeftEnd() {
1✔
316
        // No operation needed yet.
1✔
317
    }
1✔
318
    onSelectStart(data) {
1✔
319
        const ctrl = data.target;
×
320

×
321
        if (ctrl.userData.handedness === 'left') {
×
322
            this.onSelectLeftStart(ctrl);
×
323
        } else if (ctrl.userData.handedness === 'right') {
×
324
            this.onSelectRightStart(ctrl);
×
325
        }
×
326
    }
×
327
    onSelectEnd(data) {
1✔
328
        const ctrl = data.target;
2✔
329

2✔
330
        if (ctrl.userData.handedness === 'left') {
2✔
331
            this.onSelectRightEnd(ctrl);
1✔
332
        } else if (ctrl.userData.handedness === 'right') {
1✔
333
            this.onSelectLeftEnd(ctrl);
1✔
334
        }
1✔
335
    }
2✔
336
    onButtonPressed(data) {
1✔
337
        const ctrl = data.target;
×
338
        if (ctrl.userData.handedness === 'left') {
×
339
            this.onLeftButtonPressed(data);
×
340
        } else if (ctrl.userData.handedness === 'right') {
×
341
            this.onRightButtonPressed(data);
×
342
        }
×
343
    }
×
344

1✔
345
    // Right button pressed.
1✔
346
    onRightButtonPressed(data) {
1✔
347
        const ctrl = data.target;
×
348
        if (data.message.buttonIndex === 1) {
×
349
            // Activate vertical adjustment.
×
350
            if (ctrl.gamepad.axes[3] === 0) {
×
351
                return;
×
352
            }
×
353
            this.rightButtonPressed = true;
×
354
        }
×
355
    }
×
356

1✔
357
    // Left button pressed.
1✔
358
    /* c8 ignore next 3 */
1✔
359
    onLeftButtonPressed() {
1✔
360
    // No operation defined.
1✔
361
    }
1✔
362

1✔
363
    // Axis changed.
1✔
364
    onAxisChanged(data) {
1✔
365
        const ctrl = data.target;
×
366
        if (ctrl.gamepad.axes[2] === 0 && ctrl.gamepad.axes[3] === 0) {
×
367
            return;
×
368
        }
×
369
        if (ctrl.userData.handedness === 'left') {
×
370
            this.onLeftAxisChanged(ctrl);
×
371
        } else if (ctrl.userData.handedness === 'right') {
×
372
            this.onRightAxisChanged(ctrl);
×
373
        }
×
374
    }
×
375

1✔
376
    // Right axis changed.
1✔
377
    onRightAxisChanged(ctrl) {
1✔
378
        if (ctrl.userData.handedness !== 'right') {
×
379
            return;
×
380
        }
×
381
        //  Check if GRIP is pressed
×
382
        if (this.rightButtonPressed) {
×
383
            const offsetRotation = this.groupXR.quaternion.clone();
×
384
            const speedFactor = this.getSpeedFactor();
×
385
            const deltaTransl = this.getTranslationElevation(ctrl.gamepad.axes[3], speedFactor);
×
386
            const trans = this.groupXR.position.clone().add(deltaTransl);
×
387
            this.clampAndApplyTransformationToXR(trans, offsetRotation);
×
388
        } else {
×
389
            this.cameraOnFly(ctrl);
×
390
        }
×
391
    }
×
392

1✔
393
    // Left axis changed.
1✔
394
    onLeftAxisChanged(ctrl) {
1✔
395
        if (ctrl.userData.handedness !== 'left') {
2!
396
            return;
×
397
        }
×
398

2✔
399
        const trans = this.groupXR.position.clone();
2✔
400
        let offsetRotation;
2✔
401

2✔
402
        //  Only apply rotation on 1 axis at the time
2✔
403
        if (Math.abs(ctrl.gamepad.axes[2]) > Math.abs(ctrl.gamepad.axes[3])) {
2✔
404
            offsetRotation = this.getRotationYaw(ctrl.gamepad.axes[2]);
1✔
405
        } else {
1✔
406
            offsetRotation = this.getRotationPitch(ctrl.gamepad.axes[3]);
1✔
407
        }
1✔
408

2✔
409
        this.applyTransformationToXR(trans, offsetRotation);
2✔
410
    }
2✔
411

1✔
412
    // Right axis stops.
1✔
413
    onAxisStop(data) {
1✔
414
        const ctrl = data.target;
×
415

×
416
        if (ctrl.userData.handedness === 'left') {
×
417
            this.onLeftAxisStop(ctrl);
×
418
        } else if (ctrl.userData.handedness === 'right') {
×
419
            this.onRightAxisStop(ctrl);
×
420
        }
×
421
    }
×
422

1✔
423
    // Right axis stops.
1✔
424
    /* c8 ignore next 3 */
1✔
425
    onRightAxisStop() {
1✔
426
        // No operation defined.
1✔
427
    }
1✔
428

1✔
429
    // Left axis stops.
1✔
430
    /* c8 ignore next 3 */
1✔
431
    onLeftAxisStop() {
1✔
432
        // No operation defined.
1✔
433
    }
1✔
434

1✔
435
    // Button released.
1✔
436
    onButtonReleased(data) {
1✔
437
        const ctrl = data.target;
×
438

×
439
        if (ctrl.userData.handedness === 'left') {
×
440
            this.onLeftButtonReleased(ctrl);
×
441
        } else if (ctrl.userData.handedness === 'right') {
×
442
            this.onRightButtonReleased(ctrl);
×
443
        }
×
444
    }
×
445
    // Right button released.
1✔
446
    onRightButtonReleased() {
1✔
447
        this.rightButtonPressed = false;
×
448
    }
×
449

1✔
450
    // Left button released.
1✔
451
    /* c8 ignore next 3 */
1✔
452
    onLeftButtonReleased() {
1✔
453
    // No operation defined.
1✔
454
    }
1✔
455
}
1✔
456

1✔
457
export default VRControls;
1✔
458

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