iwer 2.1.1 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/build/iwer.js +1760 -5
  2. package/build/iwer.min.js +12 -12
  3. package/build/iwer.module.js +1751 -6
  4. package/build/iwer.module.min.js +12 -12
  5. package/lib/depth/XRDepthInformation.d.ts +38 -0
  6. package/lib/depth/XRDepthInformation.d.ts.map +1 -0
  7. package/lib/depth/XRDepthInformation.js +55 -0
  8. package/lib/depth/XRDepthInformation.js.map +1 -0
  9. package/lib/device/XRController.d.ts +20 -0
  10. package/lib/device/XRController.d.ts.map +1 -1
  11. package/lib/device/XRController.js +56 -0
  12. package/lib/device/XRController.js.map +1 -1
  13. package/lib/device/XRDevice.d.ts +45 -1
  14. package/lib/device/XRDevice.d.ts.map +1 -1
  15. package/lib/device/XRDevice.js +88 -0
  16. package/lib/device/XRDevice.js.map +1 -1
  17. package/lib/frameloop/XRFrame.d.ts +4 -0
  18. package/lib/frameloop/XRFrame.d.ts.map +1 -1
  19. package/lib/frameloop/XRFrame.js +12 -1
  20. package/lib/frameloop/XRFrame.js.map +1 -1
  21. package/lib/index.d.ts +5 -0
  22. package/lib/index.d.ts.map +1 -1
  23. package/lib/index.js +6 -0
  24. package/lib/index.js.map +1 -1
  25. package/lib/private.d.ts +1 -0
  26. package/lib/private.d.ts.map +1 -1
  27. package/lib/private.js +1 -0
  28. package/lib/private.js.map +1 -1
  29. package/lib/remote/RemoteControlInterface.d.ts +172 -0
  30. package/lib/remote/RemoteControlInterface.d.ts.map +1 -0
  31. package/lib/remote/RemoteControlInterface.js +1240 -0
  32. package/lib/remote/RemoteControlInterface.js.map +1 -0
  33. package/lib/remote/index.d.ts +9 -0
  34. package/lib/remote/index.d.ts.map +1 -0
  35. package/lib/remote/index.js +8 -0
  36. package/lib/remote/index.js.map +1 -0
  37. package/lib/remote/types.d.ts +348 -0
  38. package/lib/remote/types.d.ts.map +1 -0
  39. package/lib/remote/types.js +8 -0
  40. package/lib/remote/types.js.map +1 -0
  41. package/lib/session/XRSession.d.ts +7 -0
  42. package/lib/session/XRSession.d.ts.map +1 -1
  43. package/lib/session/XRSession.js +42 -0
  44. package/lib/session/XRSession.js.map +1 -1
  45. package/lib/types/state.d.ts +46 -0
  46. package/lib/types/state.d.ts.map +1 -0
  47. package/lib/types/state.js +8 -0
  48. package/lib/types/state.js.map +1 -0
  49. package/lib/utils/control-math.d.ts +64 -0
  50. package/lib/utils/control-math.d.ts.map +1 -0
  51. package/lib/utils/control-math.js +238 -0
  52. package/lib/utils/control-math.js.map +1 -0
  53. package/lib/version.d.ts +1 -1
  54. package/lib/version.js +1 -1
  55. package/package.json +10 -5
  56. package/lib/layers/XRWebGLBinding.d.ts +0 -92
  57. package/lib/layers/XRWebGLBinding.d.ts.map +0 -1
  58. package/lib/layers/XRWebGLBinding.js +0 -186
  59. package/lib/layers/XRWebGLBinding.js.map +0 -1
@@ -0,0 +1,1240 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+ import { vec3 } from 'gl-matrix';
8
+ import { P_SESSION, P_SPACE } from '../private.js';
9
+ import { vec3ToObj, quatToObj, quatToEuler, eulerToQuat, directionTo, lookRotation, lookRotationGimbal, waitForCondition, } from '../utils/control-math.js';
10
+ /**
11
+ * Check if an orientation input is euler angles (has any of pitch, yaw, or roll)
12
+ */
13
+ function isEulerRotation(orientation) {
14
+ return 'pitch' in orientation || 'yaw' in orientation || 'roll' in orientation;
15
+ }
16
+ /**
17
+ * Normalize an orientation input to a quaternion
18
+ */
19
+ function normalizeOrientation(orientation) {
20
+ if (isEulerRotation(orientation)) {
21
+ return eulerToQuat(orientation);
22
+ }
23
+ return orientation;
24
+ }
25
+ /**
26
+ * Linear interpolation for numbers
27
+ */
28
+ function lerp(a, b, t) {
29
+ return a + (b - a) * t;
30
+ }
31
+ /**
32
+ * Linear interpolation for Vec3
33
+ */
34
+ function lerpVec3(a, b, t) {
35
+ return {
36
+ x: lerp(a.x, b.x, t),
37
+ y: lerp(a.y, b.y, t),
38
+ z: lerp(a.z, b.z, t),
39
+ };
40
+ }
41
+ /**
42
+ * Spherical linear interpolation for quaternions
43
+ */
44
+ function slerpQuat(a, b, t) {
45
+ // Compute dot product
46
+ let dot = a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w;
47
+ // If dot is negative, negate one quaternion to take shorter path
48
+ let bx = b.x, by = b.y, bz = b.z, bw = b.w;
49
+ if (dot < 0) {
50
+ dot = -dot;
51
+ bx = -bx;
52
+ by = -by;
53
+ bz = -bz;
54
+ bw = -bw;
55
+ }
56
+ // If quaternions are very close, use linear interpolation
57
+ if (dot > 0.9995) {
58
+ const result = {
59
+ x: lerp(a.x, bx, t),
60
+ y: lerp(a.y, by, t),
61
+ z: lerp(a.z, bz, t),
62
+ w: lerp(a.w, bw, t),
63
+ };
64
+ // Normalize
65
+ const len = Math.sqrt(result.x * result.x +
66
+ result.y * result.y +
67
+ result.z * result.z +
68
+ result.w * result.w);
69
+ return {
70
+ x: result.x / len,
71
+ y: result.y / len,
72
+ z: result.z / len,
73
+ w: result.w / len,
74
+ };
75
+ }
76
+ // Standard slerp
77
+ const theta0 = Math.acos(dot);
78
+ const theta = theta0 * t;
79
+ const sinTheta = Math.sin(theta);
80
+ const sinTheta0 = Math.sin(theta0);
81
+ const s0 = Math.cos(theta) - (dot * sinTheta) / sinTheta0;
82
+ const s1 = sinTheta / sinTheta0;
83
+ return {
84
+ x: s0 * a.x + s1 * bx,
85
+ y: s0 * a.y + s1 * by,
86
+ z: s0 * a.z + s1 * bz,
87
+ w: s0 * a.w + s1 * bw,
88
+ };
89
+ }
90
+ /**
91
+ * Map of common device name aliases to canonical DeviceId values.
92
+ * Enables callers to use natural variants like "right", "left-controller",
93
+ * "controllers.right", etc. in addition to the canonical names.
94
+ */
95
+ const DEVICE_ID_ALIASES = {
96
+ right: 'controller-right',
97
+ left: 'controller-left',
98
+ 'right-controller': 'controller-right',
99
+ 'left-controller': 'controller-left',
100
+ 'controllers.right': 'controller-right',
101
+ 'controllers.left': 'controller-left',
102
+ rightController: 'controller-right',
103
+ leftController: 'controller-left',
104
+ 'right-hand': 'hand-right',
105
+ 'left-hand': 'hand-left',
106
+ 'hands.right': 'hand-right',
107
+ 'hands.left': 'hand-left',
108
+ rightHand: 'hand-right',
109
+ leftHand: 'hand-left',
110
+ };
111
+ /**
112
+ * Resolve a device identifier, accepting both canonical names and common aliases.
113
+ */
114
+ function resolveDeviceId(id) {
115
+ var _a;
116
+ return (_a = DEVICE_ID_ALIASES[id]) !== null && _a !== void 0 ? _a : id;
117
+ }
118
+ /**
119
+ * RemoteControlInterface provides frame-synchronized programmatic control of an XRDevice.
120
+ *
121
+ * This class implements a command queue that processes actions during each frame update,
122
+ * enabling smooth animations and coordinated control with DevUI.
123
+ *
124
+ * Key features:
125
+ * - Frame-synchronized execution: Commands are queued and processed during frame update
126
+ * - Duration-based actions: Smooth animations via lerp over multiple frames
127
+ * - Automatic capture/release: Captures device on first command, releases 30s after queue empties
128
+ * - Unified device identifiers: 'headset', 'controller-left', 'hand-right', etc.
129
+ *
130
+ * Usage:
131
+ * ```typescript
132
+ * import { XRDevice, metaQuest3 } from 'iwer';
133
+ *
134
+ * const device = new XRDevice(metaQuest3);
135
+ * device.installRuntime();
136
+ *
137
+ * // Get transform
138
+ * const result = await device.remote.dispatch('get_transform', { device: 'headset' });
139
+ *
140
+ * // Animate headset to new position over 1 second
141
+ * await device.remote.dispatch('animate_to', {
142
+ * device: 'headset',
143
+ * position: { x: 0, y: 1.6, z: -1 },
144
+ * duration: 1.0
145
+ * });
146
+ * ```
147
+ */
148
+ export class RemoteControlInterface {
149
+ constructor(device) {
150
+ this.commandQueue = [];
151
+ this._isCaptured = false;
152
+ this.releaseTimer = null;
153
+ this.actionIdCounter = 0;
154
+ /** Release timeout in milliseconds (default: 30000 = 30 seconds) */
155
+ this.RELEASE_TIMEOUT_MS = 30000;
156
+ this.device = device;
157
+ }
158
+ generateActionId() {
159
+ return `action_${++this.actionIdCounter}`;
160
+ }
161
+ // =============================================================================
162
+ // Public Properties
163
+ // =============================================================================
164
+ /**
165
+ * Whether the device is currently captured for programmatic control.
166
+ * When true, DevUI should go into passive mode (sync FROM device only).
167
+ */
168
+ get isCaptured() {
169
+ return this._isCaptured;
170
+ }
171
+ /**
172
+ * Number of pending actions in the queue
173
+ */
174
+ get queueLength() {
175
+ return this.commandQueue.length;
176
+ }
177
+ // =============================================================================
178
+ // Queue Management
179
+ // =============================================================================
180
+ /**
181
+ * Enqueue a discrete action for processing
182
+ */
183
+ enqueueDiscrete(method, params) {
184
+ return new Promise((resolve, reject) => {
185
+ const action = {
186
+ type: 'discrete',
187
+ id: this.generateActionId(),
188
+ method,
189
+ params,
190
+ resolve,
191
+ reject,
192
+ };
193
+ this.commandQueue.push(action);
194
+ });
195
+ }
196
+ /**
197
+ * Enqueue a duration action for processing
198
+ */
199
+ enqueueDuration(method, params, durationMs, startState, targetState) {
200
+ return new Promise((resolve, reject) => {
201
+ const action = {
202
+ type: 'duration',
203
+ id: this.generateActionId(),
204
+ method,
205
+ params,
206
+ durationMs,
207
+ elapsedMs: 0,
208
+ startState,
209
+ targetState,
210
+ resolve,
211
+ reject,
212
+ };
213
+ this.commandQueue.push(action);
214
+ });
215
+ }
216
+ /**
217
+ * Update method called each frame by XRDevice.
218
+ * Processes the command queue and handles duration-based animations.
219
+ *
220
+ * @param deltaTimeMs - Time since last frame in milliseconds
221
+ */
222
+ update(deltaTimeMs) {
223
+ if (this.commandQueue.length === 0) {
224
+ return;
225
+ }
226
+ // Always cancel pending release while queue is active
227
+ this.cancelReleaseTimer();
228
+ // Activate capture mode
229
+ if (!this._isCaptured) {
230
+ this._isCaptured = true;
231
+ this.device.controlMode = 'programmatic';
232
+ }
233
+ while (this.commandQueue.length > 0) {
234
+ const action = this.commandQueue[0];
235
+ if (action.type === 'discrete') {
236
+ // Execute discrete action immediately
237
+ try {
238
+ const result = this.executeDiscreteAction(action);
239
+ action.resolve(result);
240
+ }
241
+ catch (error) {
242
+ action.reject(error);
243
+ }
244
+ this.commandQueue.shift();
245
+ // Continue to next action
246
+ }
247
+ else {
248
+ // Duration action - lerp by delta time
249
+ action.elapsedMs += deltaTimeMs;
250
+ if (action.elapsedMs >= action.durationMs) {
251
+ // Complete - apply final state
252
+ try {
253
+ this.applyDurationFinalState(action);
254
+ action.resolve(this.getDurationResult(action));
255
+ }
256
+ catch (error) {
257
+ action.reject(error);
258
+ }
259
+ this.commandQueue.shift();
260
+ // Continue to next action
261
+ }
262
+ else {
263
+ // In progress - lerp
264
+ try {
265
+ const t = action.elapsedMs / action.durationMs;
266
+ this.applyDurationLerpState(action, t);
267
+ }
268
+ catch (error) {
269
+ action.reject(error);
270
+ this.commandQueue.shift();
271
+ continue;
272
+ }
273
+ // Stop processing - wait for next frame
274
+ break;
275
+ }
276
+ }
277
+ }
278
+ // Notify state change
279
+ this.device.notifyStateChange();
280
+ // Start release timer if queue is empty
281
+ if (this.commandQueue.length === 0) {
282
+ this.startReleaseTimer();
283
+ }
284
+ }
285
+ startReleaseTimer() {
286
+ this.cancelReleaseTimer();
287
+ this.releaseTimer = setTimeout(() => {
288
+ this._isCaptured = false;
289
+ this.device.controlMode = 'manual';
290
+ this.releaseTimer = null;
291
+ }, this.RELEASE_TIMEOUT_MS);
292
+ }
293
+ cancelReleaseTimer() {
294
+ if (this.releaseTimer !== null) {
295
+ clearTimeout(this.releaseTimer);
296
+ this.releaseTimer = null;
297
+ }
298
+ }
299
+ // =============================================================================
300
+ // Device Resolution
301
+ // =============================================================================
302
+ /**
303
+ * Get the transform (position, quaternion) for a device
304
+ */
305
+ getDeviceTransform(deviceId) {
306
+ switch (deviceId) {
307
+ case 'headset':
308
+ return {
309
+ position: vec3ToObj(this.device.position),
310
+ orientation: quatToObj(this.device.quaternion),
311
+ };
312
+ case 'controller-left': {
313
+ const controller = this.device.controllers.left;
314
+ if (!controller)
315
+ throw new Error('Left controller not available');
316
+ return {
317
+ position: vec3ToObj(controller.position),
318
+ orientation: quatToObj(controller.quaternion),
319
+ };
320
+ }
321
+ case 'controller-right': {
322
+ const controller = this.device.controllers.right;
323
+ if (!controller)
324
+ throw new Error('Right controller not available');
325
+ return {
326
+ position: vec3ToObj(controller.position),
327
+ orientation: quatToObj(controller.quaternion),
328
+ };
329
+ }
330
+ case 'hand-left': {
331
+ const hand = this.device.hands.left;
332
+ if (!hand)
333
+ throw new Error('Left hand not available');
334
+ return {
335
+ position: vec3ToObj(hand.position),
336
+ orientation: quatToObj(hand.quaternion),
337
+ };
338
+ }
339
+ case 'hand-right': {
340
+ const hand = this.device.hands.right;
341
+ if (!hand)
342
+ throw new Error('Right hand not available');
343
+ return {
344
+ position: vec3ToObj(hand.position),
345
+ orientation: quatToObj(hand.quaternion),
346
+ };
347
+ }
348
+ default:
349
+ throw new Error(`Unknown device: ${deviceId}`);
350
+ }
351
+ }
352
+ /**
353
+ * Set the transform for a device
354
+ */
355
+ setDeviceTransform(deviceId, position, orientation) {
356
+ switch (deviceId) {
357
+ case 'headset':
358
+ if (position) {
359
+ this.device.position.set(position.x, position.y, position.z);
360
+ }
361
+ if (orientation) {
362
+ this.device.quaternion.set(orientation.x, orientation.y, orientation.z, orientation.w);
363
+ }
364
+ break;
365
+ case 'controller-left': {
366
+ const controller = this.device.controllers.left;
367
+ if (!controller)
368
+ throw new Error('Left controller not available');
369
+ if (position) {
370
+ controller.position.set(position.x, position.y, position.z);
371
+ }
372
+ if (orientation) {
373
+ controller.quaternion.set(orientation.x, orientation.y, orientation.z, orientation.w);
374
+ }
375
+ break;
376
+ }
377
+ case 'controller-right': {
378
+ const controller = this.device.controllers.right;
379
+ if (!controller)
380
+ throw new Error('Right controller not available');
381
+ if (position) {
382
+ controller.position.set(position.x, position.y, position.z);
383
+ }
384
+ if (orientation) {
385
+ controller.quaternion.set(orientation.x, orientation.y, orientation.z, orientation.w);
386
+ }
387
+ break;
388
+ }
389
+ case 'hand-left': {
390
+ const hand = this.device.hands.left;
391
+ if (!hand)
392
+ throw new Error('Left hand not available');
393
+ if (position) {
394
+ hand.position.set(position.x, position.y, position.z);
395
+ }
396
+ if (orientation) {
397
+ hand.quaternion.set(orientation.x, orientation.y, orientation.z, orientation.w);
398
+ }
399
+ break;
400
+ }
401
+ case 'hand-right': {
402
+ const hand = this.device.hands.right;
403
+ if (!hand)
404
+ throw new Error('Right hand not available');
405
+ if (position) {
406
+ hand.position.set(position.x, position.y, position.z);
407
+ }
408
+ if (orientation) {
409
+ hand.quaternion.set(orientation.x, orientation.y, orientation.z, orientation.w);
410
+ }
411
+ break;
412
+ }
413
+ default:
414
+ throw new Error(`Unknown device: ${deviceId}`);
415
+ }
416
+ }
417
+ /**
418
+ * Transform a position from XR-origin-relative coordinates to GlobalSpace.
419
+ * The XR origin is defined by the first reference space requested by the app.
420
+ * This is necessary because device positions are in GlobalSpace, but positions
421
+ * from get_object_transform are relative to the XR origin.
422
+ */
423
+ transformXROriginToGlobal(position) {
424
+ var _a, _b;
425
+ const session = this.device.activeSession;
426
+ if (!session) {
427
+ return position;
428
+ }
429
+ const refSpaces = (_a = session[P_SESSION]) === null || _a === void 0 ? void 0 : _a.referenceSpaces;
430
+ if (!refSpaces || refSpaces.length === 0) {
431
+ return position;
432
+ }
433
+ // Use the first reference space (primary one requested by app)
434
+ const primaryRefSpace = refSpaces[0];
435
+ const offsetMatrix = (_b = primaryRefSpace[P_SPACE]) === null || _b === void 0 ? void 0 : _b.offsetMatrix;
436
+ if (!offsetMatrix) {
437
+ return position;
438
+ }
439
+ // Transform position from XR-origin space to GlobalSpace
440
+ const posVec = vec3.fromValues(position.x, position.y, position.z);
441
+ vec3.transformMat4(posVec, posVec, offsetMatrix);
442
+ return {
443
+ x: posVec[0],
444
+ y: posVec[1],
445
+ z: posVec[2],
446
+ };
447
+ }
448
+ /**
449
+ * Get the select value for an input device (trigger for controller, pinch for hand)
450
+ */
451
+ getDeviceSelectValue(deviceId) {
452
+ var _a, _b, _c, _d, _e, _f, _g, _h;
453
+ switch (deviceId) {
454
+ case 'controller-left':
455
+ return (_b = (_a = this.device.controllers.left) === null || _a === void 0 ? void 0 : _a.getButtonValue('trigger')) !== null && _b !== void 0 ? _b : 0;
456
+ case 'controller-right':
457
+ return (_d = (_c = this.device.controllers.right) === null || _c === void 0 ? void 0 : _c.getButtonValue('trigger')) !== null && _d !== void 0 ? _d : 0;
458
+ case 'hand-left':
459
+ return (_f = (_e = this.device.hands.left) === null || _e === void 0 ? void 0 : _e.pinchValue) !== null && _f !== void 0 ? _f : 0;
460
+ case 'hand-right':
461
+ return (_h = (_g = this.device.hands.right) === null || _g === void 0 ? void 0 : _g.pinchValue) !== null && _h !== void 0 ? _h : 0;
462
+ default:
463
+ throw new Error(`Unknown input device: ${deviceId}`);
464
+ }
465
+ }
466
+ /**
467
+ * Set the select value for an input device
468
+ */
469
+ setDeviceSelectValue(deviceId, value) {
470
+ var _a, _b, _c, _d;
471
+ switch (deviceId) {
472
+ case 'controller-left':
473
+ (_a = this.device.controllers.left) === null || _a === void 0 ? void 0 : _a.updateButtonValue('trigger', value);
474
+ break;
475
+ case 'controller-right':
476
+ (_b = this.device.controllers.right) === null || _b === void 0 ? void 0 : _b.updateButtonValue('trigger', value);
477
+ break;
478
+ case 'hand-left':
479
+ (_c = this.device.hands.left) === null || _c === void 0 ? void 0 : _c.updatePinchValue(value);
480
+ break;
481
+ case 'hand-right':
482
+ (_d = this.device.hands.right) === null || _d === void 0 ? void 0 : _d.updatePinchValue(value);
483
+ break;
484
+ default:
485
+ throw new Error(`Unknown input device: ${deviceId}`);
486
+ }
487
+ }
488
+ /**
489
+ * Set connected state for an input device
490
+ */
491
+ setDeviceConnected(deviceId, connected) {
492
+ switch (deviceId) {
493
+ case 'controller-left':
494
+ if (this.device.controllers.left) {
495
+ this.device.controllers.left.connected = connected;
496
+ }
497
+ break;
498
+ case 'controller-right':
499
+ if (this.device.controllers.right) {
500
+ this.device.controllers.right.connected = connected;
501
+ }
502
+ break;
503
+ case 'hand-left':
504
+ if (this.device.hands.left) {
505
+ this.device.hands.left.connected = connected;
506
+ }
507
+ break;
508
+ case 'hand-right':
509
+ if (this.device.hands.right) {
510
+ this.device.hands.right.connected = connected;
511
+ }
512
+ break;
513
+ default:
514
+ throw new Error(`Unknown input device: ${deviceId}`);
515
+ }
516
+ }
517
+ // =============================================================================
518
+ // Discrete Action Execution
519
+ // =============================================================================
520
+ executeDiscreteAction(action) {
521
+ const { method, params } = action;
522
+ switch (method) {
523
+ // Session tools
524
+ case 'get_session_status':
525
+ return this.executeGetSessionStatus();
526
+ case 'accept_session':
527
+ return this.executeAcceptSession();
528
+ case 'end_session':
529
+ return this.executeEndSession();
530
+ // Transform tools
531
+ case 'get_transform':
532
+ return this.executeGetTransform(params);
533
+ case 'set_transform':
534
+ return this.executeSetTransform(params);
535
+ case 'look_at':
536
+ return this.executeLookAt(params);
537
+ // Input tools
538
+ case 'set_input_mode':
539
+ return this.executeSetInputMode(params);
540
+ case 'set_connected':
541
+ return this.executeSetConnected(params);
542
+ case 'get_select_value':
543
+ return this.executeGetSelectValue(params);
544
+ case 'set_select_value':
545
+ return this.executeSetSelectValue(params);
546
+ // Gamepad tools
547
+ case 'get_gamepad_state':
548
+ return this.executeGetGamepadState(params);
549
+ case 'set_gamepad_state':
550
+ return this.executeSetGamepadState(params);
551
+ // State tools
552
+ case 'get_device_state':
553
+ return this.executeGetDeviceState();
554
+ case 'set_device_state':
555
+ return this.executeSetDeviceState(params);
556
+ case 'capture_canvas':
557
+ return this.executeCaptureCanvas(params);
558
+ // Internal select sequence actions
559
+ case '_select_press': {
560
+ const deviceId = params.device;
561
+ this.setDeviceSelectValue(deviceId, 1);
562
+ return undefined;
563
+ }
564
+ case '_select_release': {
565
+ const deviceId = params.device;
566
+ this.setDeviceSelectValue(deviceId, 0);
567
+ return undefined;
568
+ }
569
+ default:
570
+ throw new Error(`Unknown method: ${method}`);
571
+ }
572
+ }
573
+ // =============================================================================
574
+ // Session Tool Implementations
575
+ // =============================================================================
576
+ executeGetSessionStatus() {
577
+ const session = this.device.activeSession;
578
+ return {
579
+ deviceName: this.device.name,
580
+ isRuntimeInstalled: true,
581
+ sessionActive: !!session,
582
+ sessionOffered: this.device.sessionOffered,
583
+ sessionMode: session ? session.mode : null,
584
+ enabledFeatures: session
585
+ ? Array.from(session.enabledFeatures || [])
586
+ : [],
587
+ visibilityState: this.device.visibilityState,
588
+ };
589
+ }
590
+ executeAcceptSession() {
591
+ if (!this.device.sessionOffered) {
592
+ throw new Error('No session has been offered');
593
+ }
594
+ this.device.grantOfferedSession();
595
+ // Session activation is async - caller should use get_session_status to poll
596
+ return { success: true };
597
+ }
598
+ executeEndSession() {
599
+ const session = this.device.activeSession;
600
+ if (!session) {
601
+ throw new Error('No active session');
602
+ }
603
+ session.end();
604
+ return { success: true };
605
+ }
606
+ // =============================================================================
607
+ // Transform Tool Implementations
608
+ // =============================================================================
609
+ executeGetTransform(params) {
610
+ const { device: deviceId } = params;
611
+ const transform = this.getDeviceTransform(deviceId);
612
+ return {
613
+ device: deviceId,
614
+ position: transform.position,
615
+ orientation: transform.orientation,
616
+ euler: quatToEuler(transform.orientation),
617
+ };
618
+ }
619
+ executeSetTransform(params) {
620
+ const { device: deviceId, position, orientation } = params;
621
+ const targetOrientation = orientation
622
+ ? normalizeOrientation(orientation)
623
+ : undefined;
624
+ this.setDeviceTransform(deviceId, position, targetOrientation);
625
+ const newTransform = this.getDeviceTransform(deviceId);
626
+ return {
627
+ device: deviceId,
628
+ position: newTransform.position,
629
+ orientation: newTransform.orientation,
630
+ };
631
+ }
632
+ executeLookAt(params) {
633
+ const { device: deviceId, target, moveToDistance } = params;
634
+ const currentTransform = this.getDeviceTransform(deviceId);
635
+ // Transform target from XR-origin-relative to GlobalSpace
636
+ const targetInGlobal = this.transformXROriginToGlobal(target);
637
+ // Calculate direction to target
638
+ const direction = directionTo(currentTransform.position, targetInGlobal);
639
+ // Calculate look rotation
640
+ // Use gimbal rotation for headset (keeps it level, no roll)
641
+ // Use standard lookRotation for controllers/hands (can tilt freely)
642
+ const lookQuat = deviceId === 'headset'
643
+ ? lookRotationGimbal(direction)
644
+ : lookRotation(direction);
645
+ // Optionally move to a specific distance from target
646
+ let newPosition;
647
+ if (moveToDistance !== undefined) {
648
+ newPosition = {
649
+ x: targetInGlobal.x - direction.x * moveToDistance,
650
+ y: targetInGlobal.y - direction.y * moveToDistance,
651
+ z: targetInGlobal.z - direction.z * moveToDistance,
652
+ };
653
+ }
654
+ this.setDeviceTransform(deviceId, newPosition, lookQuat);
655
+ const newTransform = this.getDeviceTransform(deviceId);
656
+ return {
657
+ device: deviceId,
658
+ position: newTransform.position,
659
+ orientation: newTransform.orientation,
660
+ };
661
+ }
662
+ // =============================================================================
663
+ // Input Tool Implementations
664
+ // =============================================================================
665
+ executeSetInputMode(params) {
666
+ var _a, _b, _c, _d;
667
+ const { mode } = params;
668
+ this.device.primaryInputMode = mode;
669
+ const activeDevices = [];
670
+ if (mode === 'controller') {
671
+ if ((_a = this.device.controllers.left) === null || _a === void 0 ? void 0 : _a.connected) {
672
+ activeDevices.push('controller-left');
673
+ }
674
+ if ((_b = this.device.controllers.right) === null || _b === void 0 ? void 0 : _b.connected) {
675
+ activeDevices.push('controller-right');
676
+ }
677
+ }
678
+ else {
679
+ if ((_c = this.device.hands.left) === null || _c === void 0 ? void 0 : _c.connected) {
680
+ activeDevices.push('hand-left');
681
+ }
682
+ if ((_d = this.device.hands.right) === null || _d === void 0 ? void 0 : _d.connected) {
683
+ activeDevices.push('hand-right');
684
+ }
685
+ }
686
+ return { mode, activeDevices };
687
+ }
688
+ executeSetConnected(params) {
689
+ const { device: deviceId, connected } = params;
690
+ this.setDeviceConnected(deviceId, connected);
691
+ return { device: deviceId, connected };
692
+ }
693
+ executeGetSelectValue(params) {
694
+ const { device: deviceId } = params;
695
+ const value = this.getDeviceSelectValue(deviceId);
696
+ return { device: deviceId, value };
697
+ }
698
+ executeSetSelectValue(params) {
699
+ const { device: deviceId, value } = params;
700
+ this.setDeviceSelectValue(deviceId, value);
701
+ return { device: deviceId, value };
702
+ }
703
+ // =============================================================================
704
+ // Gamepad Tool Implementations
705
+ // =============================================================================
706
+ executeGetGamepadState(params) {
707
+ const { device: deviceId } = params;
708
+ const hand = deviceId === 'controller-left' ? 'left' : 'right';
709
+ const controller = this.device.controllers[hand];
710
+ if (!controller) {
711
+ throw new Error(`Controller ${hand} not available`);
712
+ }
713
+ // Button layout for Meta Quest Touch Plus controllers
714
+ // Use hand-conditional internal names for lookup
715
+ const buttonInternalNames = [
716
+ 'trigger',
717
+ 'squeeze',
718
+ 'thumbstick',
719
+ hand === 'left' ? 'x-button' : 'a-button',
720
+ hand === 'left' ? 'y-button' : 'b-button',
721
+ 'thumbrest',
722
+ ];
723
+ const buttons = buttonInternalNames.map((name, index) => ({
724
+ index,
725
+ name: name
726
+ .replace('x-button', 'x')
727
+ .replace('y-button', 'y')
728
+ .replace('a-button', 'a')
729
+ .replace('b-button', 'b'),
730
+ value: controller.getButtonValue(name),
731
+ touched: controller.getButtonTouched(name),
732
+ pressed: controller.getButtonValue(name) > 0.5,
733
+ }));
734
+ const axesData = controller.getAxes();
735
+ const axes = [
736
+ { index: 0, name: 'thumbstick-x', value: axesData.x },
737
+ { index: 1, name: 'thumbstick-y', value: axesData.y },
738
+ ];
739
+ return {
740
+ device: deviceId,
741
+ connected: controller.connected,
742
+ buttons,
743
+ axes,
744
+ };
745
+ }
746
+ executeSetGamepadState(params) {
747
+ const { device: deviceId, buttons, axes } = params;
748
+ const hand = deviceId === 'controller-left' ? 'left' : 'right';
749
+ const controller = this.device.controllers[hand];
750
+ if (!controller) {
751
+ throw new Error(`Controller ${hand} not available`);
752
+ }
753
+ let buttonsSet = 0;
754
+ let axesSet = 0;
755
+ // Button index to name mapping
756
+ const buttonIndexToName = [
757
+ 'trigger',
758
+ 'squeeze',
759
+ 'thumbstick',
760
+ hand === 'left' ? 'x-button' : 'a-button',
761
+ hand === 'left' ? 'y-button' : 'b-button',
762
+ 'thumbrest',
763
+ ];
764
+ if (buttons) {
765
+ for (const btn of buttons) {
766
+ const buttonName = buttonIndexToName[btn.index];
767
+ if (buttonName) {
768
+ // Use updateButtonValue for proper event triggering
769
+ controller.updateButtonValue(buttonName, btn.value);
770
+ if (btn.touched !== undefined) {
771
+ controller.updateButtonTouch(buttonName, btn.touched);
772
+ }
773
+ buttonsSet++;
774
+ }
775
+ }
776
+ }
777
+ if (axes) {
778
+ let xValue;
779
+ let yValue;
780
+ for (const axis of axes) {
781
+ if (axis.index === 0) {
782
+ xValue = axis.value;
783
+ axesSet++;
784
+ }
785
+ else if (axis.index === 1) {
786
+ yValue = axis.value;
787
+ axesSet++;
788
+ }
789
+ }
790
+ if (xValue !== undefined || yValue !== undefined) {
791
+ const currentAxes = controller.getAxes();
792
+ controller.updateAxes('thumbstick', xValue !== null && xValue !== void 0 ? xValue : currentAxes.x, yValue !== null && yValue !== void 0 ? yValue : currentAxes.y);
793
+ }
794
+ }
795
+ return { device: deviceId, buttonsSet, axesSet };
796
+ }
797
+ // =============================================================================
798
+ // State Tool Implementations
799
+ // =============================================================================
800
+ executeGetDeviceState() {
801
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z;
802
+ return {
803
+ headset: {
804
+ position: vec3ToObj(this.device.position),
805
+ orientation: quatToObj(this.device.quaternion),
806
+ },
807
+ inputMode: this.device.primaryInputMode,
808
+ controllers: {
809
+ left: {
810
+ connected: (_b = (_a = this.device.controllers.left) === null || _a === void 0 ? void 0 : _a.connected) !== null && _b !== void 0 ? _b : false,
811
+ position: vec3ToObj((_d = (_c = this.device.controllers.left) === null || _c === void 0 ? void 0 : _c.position) !== null && _d !== void 0 ? _d : { x: 0, y: 0, z: 0 }),
812
+ orientation: quatToObj((_f = (_e = this.device.controllers.left) === null || _e === void 0 ? void 0 : _e.quaternion) !== null && _f !== void 0 ? _f : {
813
+ x: 0,
814
+ y: 0,
815
+ z: 0,
816
+ w: 1,
817
+ }),
818
+ },
819
+ right: {
820
+ connected: (_h = (_g = this.device.controllers.right) === null || _g === void 0 ? void 0 : _g.connected) !== null && _h !== void 0 ? _h : false,
821
+ position: vec3ToObj((_k = (_j = this.device.controllers.right) === null || _j === void 0 ? void 0 : _j.position) !== null && _k !== void 0 ? _k : { x: 0, y: 0, z: 0 }),
822
+ orientation: quatToObj((_m = (_l = this.device.controllers.right) === null || _l === void 0 ? void 0 : _l.quaternion) !== null && _m !== void 0 ? _m : {
823
+ x: 0,
824
+ y: 0,
825
+ z: 0,
826
+ w: 1,
827
+ }),
828
+ },
829
+ },
830
+ hands: {
831
+ left: {
832
+ connected: (_p = (_o = this.device.hands.left) === null || _o === void 0 ? void 0 : _o.connected) !== null && _p !== void 0 ? _p : false,
833
+ position: vec3ToObj((_r = (_q = this.device.hands.left) === null || _q === void 0 ? void 0 : _q.position) !== null && _r !== void 0 ? _r : { x: 0, y: 0, z: 0 }),
834
+ orientation: quatToObj((_t = (_s = this.device.hands.left) === null || _s === void 0 ? void 0 : _s.quaternion) !== null && _t !== void 0 ? _t : { x: 0, y: 0, z: 0, w: 1 }),
835
+ },
836
+ right: {
837
+ connected: (_v = (_u = this.device.hands.right) === null || _u === void 0 ? void 0 : _u.connected) !== null && _v !== void 0 ? _v : false,
838
+ position: vec3ToObj((_x = (_w = this.device.hands.right) === null || _w === void 0 ? void 0 : _w.position) !== null && _x !== void 0 ? _x : { x: 0, y: 0, z: 0 }),
839
+ orientation: quatToObj((_z = (_y = this.device.hands.right) === null || _y === void 0 ? void 0 : _y.quaternion) !== null && _z !== void 0 ? _z : { x: 0, y: 0, z: 0, w: 1 }),
840
+ },
841
+ },
842
+ stereoEnabled: this.device.stereoEnabled,
843
+ fov: this.device.fovy * (180 / Math.PI), // Convert to degrees
844
+ };
845
+ }
846
+ executeSetDeviceState(params) {
847
+ const { state } = params;
848
+ if (!state) {
849
+ // Reset to initial state
850
+ this.device.position.set(0, 1.6, 0);
851
+ this.device.quaternion.set(0, 0, 0, 1);
852
+ this.device.primaryInputMode = 'controller';
853
+ this.device.stereoEnabled = false;
854
+ // Reset controllers and hands to default positions
855
+ if (this.device.controllers.left) {
856
+ this.device.controllers.left.position.set(-0.2, 1.4, -0.3);
857
+ this.device.controllers.left.quaternion.set(0, 0, 0, 1);
858
+ this.device.controllers.left.connected = true;
859
+ }
860
+ if (this.device.controllers.right) {
861
+ this.device.controllers.right.position.set(0.2, 1.4, -0.3);
862
+ this.device.controllers.right.quaternion.set(0, 0, 0, 1);
863
+ this.device.controllers.right.connected = true;
864
+ }
865
+ if (this.device.hands.left) {
866
+ this.device.hands.left.position.set(-0.15, 1.3, -0.4);
867
+ this.device.hands.left.quaternion.set(0, 0, 0, 1);
868
+ this.device.hands.left.connected = true;
869
+ }
870
+ if (this.device.hands.right) {
871
+ this.device.hands.right.position.set(0.15, 1.3, -0.4);
872
+ this.device.hands.right.quaternion.set(0, 0, 0, 1);
873
+ this.device.hands.right.connected = true;
874
+ }
875
+ }
876
+ else {
877
+ // Apply partial state
878
+ if (state.headset) {
879
+ if (state.headset.position) {
880
+ this.device.position.set(state.headset.position.x, state.headset.position.y, state.headset.position.z);
881
+ }
882
+ if (state.headset.orientation) {
883
+ this.device.quaternion.set(state.headset.orientation.x, state.headset.orientation.y, state.headset.orientation.z, state.headset.orientation.w);
884
+ }
885
+ }
886
+ if (state.inputMode !== undefined) {
887
+ this.device.primaryInputMode = state.inputMode;
888
+ }
889
+ if (state.stereoEnabled !== undefined) {
890
+ this.device.stereoEnabled = state.stereoEnabled;
891
+ }
892
+ if (state.fov !== undefined) {
893
+ this.device.fovy = state.fov * (Math.PI / 180); // Convert to radians
894
+ }
895
+ if (state.controllers) {
896
+ this.applyInputState('controller-left', state.controllers.left);
897
+ this.applyInputState('controller-right', state.controllers.right);
898
+ }
899
+ if (state.hands) {
900
+ this.applyInputState('hand-left', state.hands.left);
901
+ this.applyInputState('hand-right', state.hands.right);
902
+ }
903
+ }
904
+ return { state: this.executeGetDeviceState() };
905
+ }
906
+ applyInputState(deviceId, state) {
907
+ if (!state)
908
+ return;
909
+ if (state.connected !== undefined) {
910
+ this.setDeviceConnected(deviceId, state.connected);
911
+ }
912
+ if (state.position || state.orientation) {
913
+ this.setDeviceTransform(deviceId, state.position, state.orientation);
914
+ }
915
+ }
916
+ executeCaptureCanvas(params) {
917
+ const { maxWidth = 800, format = 'png', quality = 0.92 } = params;
918
+ // Get the app canvas - try device first, then fallback to DOM query
919
+ let canvas = this.device.appCanvas;
920
+ if (!canvas) {
921
+ // No active session - try to find the canvas in the DOM
922
+ // Before XR session, only the app's canvas is in the DOM
923
+ // (IWER's canvases are not added until session starts)
924
+ const canvases = document.querySelectorAll('canvas');
925
+ if (canvases.length === 1) {
926
+ canvas = canvases[0];
927
+ }
928
+ else if (canvases.length > 1) {
929
+ // Multiple canvases - try to find the most likely app canvas
930
+ // Prefer the largest visible canvas
931
+ let bestCanvas = null;
932
+ let bestArea = 0;
933
+ canvases.forEach((c) => {
934
+ const rect = c.getBoundingClientRect();
935
+ const area = rect.width * rect.height;
936
+ if (area > bestArea && rect.width > 0 && rect.height > 0) {
937
+ bestArea = area;
938
+ bestCanvas = c;
939
+ }
940
+ });
941
+ canvas = bestCanvas;
942
+ }
943
+ }
944
+ if (!canvas) {
945
+ throw new Error('No canvas available. Either start an XR session or ensure an app canvas is in the DOM.');
946
+ }
947
+ // Create a temporary canvas for scaling
948
+ const tempCanvas = document.createElement('canvas');
949
+ const ctx = tempCanvas.getContext('2d');
950
+ if (!ctx) {
951
+ throw new Error('Failed to create canvas context');
952
+ }
953
+ // Calculate scaled dimensions
954
+ const aspectRatio = canvas.height / canvas.width;
955
+ const targetWidth = Math.min(canvas.width, maxWidth);
956
+ const targetHeight = Math.round(targetWidth * aspectRatio);
957
+ tempCanvas.width = targetWidth;
958
+ tempCanvas.height = targetHeight;
959
+ // Draw scaled image
960
+ ctx.drawImage(canvas, 0, 0, targetWidth, targetHeight);
961
+ // Convert to base64
962
+ const mimeType = `image/${format}`;
963
+ const dataUrl = tempCanvas.toDataURL(mimeType, quality);
964
+ const imageData = dataUrl.split(',')[1]; // Remove data URL prefix
965
+ return {
966
+ imageData,
967
+ width: targetWidth,
968
+ height: targetHeight,
969
+ format,
970
+ timestamp: Date.now(),
971
+ };
972
+ }
973
+ // =============================================================================
974
+ // Duration Action Handling
975
+ // =============================================================================
976
+ applyDurationLerpState(action, t) {
977
+ const { startState, targetState, params } = action;
978
+ const deviceId = params.device;
979
+ let newPosition;
980
+ let newOrientation;
981
+ if (startState.position && targetState.position) {
982
+ newPosition = lerpVec3(startState.position, targetState.position, t);
983
+ }
984
+ if (startState.orientation && targetState.orientation) {
985
+ newOrientation = slerpQuat(startState.orientation, targetState.orientation, t);
986
+ }
987
+ this.setDeviceTransform(deviceId, newPosition, newOrientation);
988
+ }
989
+ applyDurationFinalState(action) {
990
+ const { targetState, params } = action;
991
+ const deviceId = params.device;
992
+ this.setDeviceTransform(deviceId, targetState.position, targetState.orientation);
993
+ }
994
+ getDurationResult(action) {
995
+ const { params, elapsedMs } = action;
996
+ const deviceId = params.device;
997
+ const transform = this.getDeviceTransform(deviceId);
998
+ return {
999
+ device: deviceId,
1000
+ position: transform.position,
1001
+ orientation: transform.orientation,
1002
+ actualDuration: elapsedMs / 1000,
1003
+ };
1004
+ }
1005
+ /**
1006
+ * Activate capture mode for programmatic control.
1007
+ * Called when active methods are executed.
1008
+ */
1009
+ activateCaptureMode() {
1010
+ if (!this._isCaptured) {
1011
+ this._isCaptured = true;
1012
+ this.cancelReleaseTimer();
1013
+ this.device.controlMode = 'programmatic';
1014
+ }
1015
+ // Reset the release timer
1016
+ this.startReleaseTimer();
1017
+ }
1018
+ /**
1019
+ * Dispatch a method call.
1020
+ *
1021
+ * Immediate methods (queries, session management) execute synchronously.
1022
+ * State-modifying methods require an active session and are queued for frame-synchronized execution.
1023
+ *
1024
+ * @param method - The method name (e.g., 'get_transform', 'animate_to')
1025
+ * @param params - The method parameters
1026
+ * @returns Promise that resolves with the method result
1027
+ */
1028
+ async dispatch(method, params = {}) {
1029
+ var _a;
1030
+ // Normalize device identifier aliases (e.g. "right" -> "controller-right")
1031
+ if (typeof params.device === 'string') {
1032
+ params.device = resolveDeviceId(params.device);
1033
+ }
1034
+ // Immediate methods execute synchronously without queue
1035
+ if (RemoteControlInterface.IMMEDIATE_METHODS.has(method)) {
1036
+ // Active immediate methods trigger capture mode
1037
+ if (RemoteControlInterface.ACTIVE_IMMEDIATE_METHODS.has(method)) {
1038
+ this.activateCaptureMode();
1039
+ }
1040
+ return this.executeImmediateMethod(method, params);
1041
+ }
1042
+ // Methods that modify state require an active session
1043
+ if (RemoteControlInterface.SESSION_REQUIRED_METHODS.has(method)) {
1044
+ if (!this.device.activeSession) {
1045
+ throw new Error(`Cannot execute '${method}': No active XR session. ` +
1046
+ `Use 'get_session_status' to check session state, and 'accept_session' to start a session.`);
1047
+ }
1048
+ }
1049
+ // Handle animate_to specially - it's a duration action
1050
+ if (method === 'animate_to') {
1051
+ const animateParams = params;
1052
+ const currentTransform = this.getDeviceTransform(animateParams.device);
1053
+ const durationMs = ((_a = animateParams.duration) !== null && _a !== void 0 ? _a : 0.5) * 1000;
1054
+ const targetOrientation = animateParams.orientation
1055
+ ? normalizeOrientation(animateParams.orientation)
1056
+ : undefined;
1057
+ // Transform target position from XR-origin-relative to GlobalSpace
1058
+ const targetPosition = animateParams.position
1059
+ ? this.transformXROriginToGlobal(animateParams.position)
1060
+ : undefined;
1061
+ return this.enqueueDuration(method, params, durationMs, {
1062
+ position: animateParams.position
1063
+ ? currentTransform.position
1064
+ : undefined,
1065
+ orientation: targetOrientation
1066
+ ? currentTransform.orientation
1067
+ : undefined,
1068
+ }, {
1069
+ position: targetPosition,
1070
+ orientation: targetOrientation,
1071
+ });
1072
+ }
1073
+ // Handle select specially - it's a discrete action that enqueues multiple sub-actions
1074
+ if (method === 'select') {
1075
+ const selectParams = params;
1076
+ return this.executeSelectSequence(selectParams);
1077
+ }
1078
+ // All other methods are discrete actions that go through the queue
1079
+ return this.enqueueDiscrete(method, params);
1080
+ }
1081
+ /**
1082
+ * Execute an immediate method synchronously (not queued).
1083
+ * Used for queries and session management that must work outside XR frames.
1084
+ */
1085
+ executeImmediateMethod(method, params) {
1086
+ switch (method) {
1087
+ case 'get_session_status':
1088
+ return this.executeGetSessionStatus();
1089
+ case 'accept_session':
1090
+ return this.executeAcceptSession();
1091
+ case 'end_session':
1092
+ return this.executeEndSession();
1093
+ case 'get_transform':
1094
+ return this.executeGetTransform(params);
1095
+ case 'get_select_value':
1096
+ return this.executeGetSelectValue(params);
1097
+ case 'get_gamepad_state':
1098
+ return this.executeGetGamepadState(params);
1099
+ case 'get_device_state':
1100
+ return this.executeGetDeviceState();
1101
+ case 'capture_canvas':
1102
+ return this.executeCaptureCanvas(params);
1103
+ default:
1104
+ throw new Error(`Unknown immediate method: ${method}`);
1105
+ }
1106
+ }
1107
+ /**
1108
+ * Execute select action - this directly enqueues the three sub-actions without awaiting
1109
+ * The caller's promise resolves when all sub-actions complete
1110
+ */
1111
+ executeSelectSequence(params) {
1112
+ const { device: deviceId, duration = 0.15 } = params;
1113
+ // Validate device upfront to prevent sub-actions from failing in the frame loop
1114
+ this.getDeviceSelectValue(deviceId);
1115
+ return new Promise((resolve, reject) => {
1116
+ // Track completion of all three actions
1117
+ let actionsCompleted = 0;
1118
+ const totalActions = 3;
1119
+ const checkComplete = () => {
1120
+ actionsCompleted++;
1121
+ if (actionsCompleted === totalActions) {
1122
+ resolve({
1123
+ device: deviceId,
1124
+ duration,
1125
+ });
1126
+ }
1127
+ };
1128
+ // Enqueue: set value to 1
1129
+ const action1 = {
1130
+ type: 'discrete',
1131
+ id: this.generateActionId(),
1132
+ method: '_select_press',
1133
+ params: { device: deviceId },
1134
+ resolve: checkComplete,
1135
+ reject,
1136
+ };
1137
+ // Enqueue: wait for duration
1138
+ const action2 = {
1139
+ type: 'duration',
1140
+ id: this.generateActionId(),
1141
+ method: '_select_wait',
1142
+ params: { device: deviceId },
1143
+ durationMs: duration * 1000,
1144
+ elapsedMs: 0,
1145
+ startState: {},
1146
+ targetState: {},
1147
+ resolve: checkComplete,
1148
+ reject,
1149
+ };
1150
+ // Enqueue: set value to 0
1151
+ const action3 = {
1152
+ type: 'discrete',
1153
+ id: this.generateActionId(),
1154
+ method: '_select_release',
1155
+ params: { device: deviceId },
1156
+ resolve: checkComplete,
1157
+ reject,
1158
+ };
1159
+ this.commandQueue.push(action1, action2, action3);
1160
+ });
1161
+ }
1162
+ /**
1163
+ * Accept an offered XR session (async wrapper for proper session activation)
1164
+ */
1165
+ async acceptSession() {
1166
+ if (!this.device.sessionOffered) {
1167
+ throw new Error('No session has been offered');
1168
+ }
1169
+ this.device.grantOfferedSession();
1170
+ // Wait for session to become active
1171
+ await waitForCondition(() => !!this.device.activeSession, 5000);
1172
+ // Just return success - caller can use get_session_status for details
1173
+ return { success: true };
1174
+ }
1175
+ /**
1176
+ * Force release capture mode (for testing/cleanup)
1177
+ */
1178
+ forceRelease() {
1179
+ this.cancelReleaseTimer();
1180
+ this._isCaptured = false;
1181
+ this.device.controlMode = 'manual';
1182
+ // Clear pending actions
1183
+ for (const action of this.commandQueue) {
1184
+ action.reject(new Error('Capture released'));
1185
+ }
1186
+ this.commandQueue = [];
1187
+ // Reset any stuck select/trigger values
1188
+ for (const hand of ['left', 'right']) {
1189
+ const controller = this.device.controllers[hand];
1190
+ if (controller) {
1191
+ controller.updateButtonValue('trigger', 0);
1192
+ controller.updateButtonValue('squeeze', 0);
1193
+ }
1194
+ }
1195
+ }
1196
+ }
1197
+ // =============================================================================
1198
+ // Public API - Dispatch
1199
+ // =============================================================================
1200
+ /**
1201
+ * Set of methods that execute immediately (synchronously) without going through the queue.
1202
+ * These are queries and session management commands that need to work outside of XR frames.
1203
+ */
1204
+ RemoteControlInterface.IMMEDIATE_METHODS = new Set([
1205
+ // Session management - must work before/after XR session
1206
+ 'get_session_status',
1207
+ 'accept_session',
1208
+ 'end_session',
1209
+ // Pure queries - just read current state
1210
+ 'get_transform',
1211
+ 'get_select_value',
1212
+ 'get_gamepad_state',
1213
+ 'get_device_state',
1214
+ // Canvas capture - reads current canvas state
1215
+ 'capture_canvas',
1216
+ ]);
1217
+ /**
1218
+ * Set of immediate methods that are "active" - they modify state and should trigger capture mode.
1219
+ * Passive methods (queries) should NOT trigger capture mode.
1220
+ */
1221
+ RemoteControlInterface.ACTIVE_IMMEDIATE_METHODS = new Set([
1222
+ 'accept_session',
1223
+ 'end_session',
1224
+ ]);
1225
+ /**
1226
+ * Set of methods that require an active XR session.
1227
+ * These are state-modifying methods that are processed during frame updates.
1228
+ */
1229
+ RemoteControlInterface.SESSION_REQUIRED_METHODS = new Set([
1230
+ 'set_transform',
1231
+ 'look_at',
1232
+ 'animate_to',
1233
+ 'set_input_mode',
1234
+ 'set_connected',
1235
+ 'set_select_value',
1236
+ 'select',
1237
+ 'set_gamepad_state',
1238
+ 'set_device_state',
1239
+ ]);
1240
+ //# sourceMappingURL=RemoteControlInterface.js.map