@realitycollective/webxr-interactions 0.1.1-preview.2 → 0.1.1-preview.3

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.
package/dist/runtime.js CHANGED
@@ -13,14 +13,14 @@
13
13
  * unregister, enable and disable; behaviours disabled by capability
14
14
  * negotiation announce themselves with a `behaviourDisabled` event.
15
15
  */
16
- import { SELECT_PRESS_THRESHOLD, SELECT_RELEASE_THRESHOLD, unmetRequirements, } from "@realitycollective/webxr-input";
16
+ import { SELECT_PRESS_THRESHOLD, SELECT_RELEASE_THRESHOLD, rayPoseFromRay, resolveEyeGazeOptions, unmetRequirements, } from "@realitycollective/webxr-input";
17
17
  import { createBehaviour, } from "./behaviours/factory.js";
18
18
  import { GrabBehaviour } from "./behaviours/grab.js";
19
19
  import { Emitter } from "./events.js";
20
- import { DwellState, resolveDwellConfig, DWELL_DEFAULTS } from "./gaze.js";
21
- import { vApplyQuat } from "./math.js";
20
+ import { DwellState, GazeConsensus, resolveDwellConfig, DWELL_DEFAULTS } from "./gaze.js";
21
+ import { quatConjugate, quatMultiply, vApplyQuat, vLength, vNormalize, vSub } from "./math.js";
22
+ import { pickActivePointer, pointerVisualsFor, resolveNearPointerOptions, TouchPointerState, } from "./near-pointer.js";
22
23
  import { VelocityTracker } from "./velocity-tracker.js";
23
- const DEFAULT_POKE_RADIUS = 0.05;
24
24
  /** Interactor id used when gaze is synthesized from the head pose. */
25
25
  export const HEAD_GAZE_INTERACTOR_ID = "head-gaze";
26
26
  export class InteractionRuntime {
@@ -32,14 +32,27 @@ export class InteractionRuntime {
32
32
  events = new Emitter();
33
33
  feedbackEmitter = new Emitter();
34
34
  afterSample = new Emitter();
35
+ visualsEmitter = new Emitter();
36
+ nearPointer;
37
+ lastVisuals = [];
35
38
  velocityTracker;
36
39
  lastSources = new Map();
37
40
  capabilities;
38
41
  unsubscribeCaps;
39
42
  disposed = false;
43
+ eyeGaze;
44
+ gazeConsensus;
45
+ /** The eye-gaze source's target this frame: cone, consensus and suppression applied. */
46
+ eyeGazeTarget = null;
47
+ /** Where the eye-gaze ray last aimed at each target, for the pinching hand to aim at. */
48
+ gazePoints = new Map();
49
+ gazeHold = null;
40
50
  constructor(options) {
41
51
  this.provider = options.provider;
42
52
  this.hitTester = options.hitTester ?? null;
53
+ this.nearPointer = resolveNearPointerOptions(options.nearPointer);
54
+ this.eyeGaze = resolveEyeGazeOptions(options.eyeGaze);
55
+ this.gazeConsensus = new GazeConsensus(this.eyeGaze.dwellWindowSeconds);
43
56
  const d = options.dwellDefaults;
44
57
  this.dwellDefaults = {
45
58
  holdSeconds: d?.holdSeconds ?? DWELL_DEFAULTS.holdSeconds,
@@ -72,7 +85,7 @@ export class InteractionRuntime {
72
85
  })),
73
86
  transform: ports.transform,
74
87
  enabled: def.enabled !== false,
75
- pokeRadius: def.pokeRadius ?? DEFAULT_POKE_RADIUS,
88
+ pokeRadius: def.pokeRadius ?? this.nearPointer.touchHoverEnter,
76
89
  gazeRequired: def.gaze?.required === true,
77
90
  dwellConfig: resolveDwellConfig(def.gaze?.dwell, this.dwellDefaults),
78
91
  dwell: new DwellState(),
@@ -177,12 +190,41 @@ export class InteractionRuntime {
177
190
  getProvider() {
178
191
  return this.provider;
179
192
  }
193
+ /** The near-pointer distances in force (the defaults, or the options given). */
194
+ getNearPointerOptions() {
195
+ return this.nearPointer;
196
+ }
197
+ /**
198
+ * The pointer that owns a source this frame: `"touch"`, `"grab"`, `"ray"`
199
+ * or `"gaze"`, or null when no pointer has a candidate (or the source is
200
+ * unknown). Decided by the core rule in `near-pointer.ts`, on every
201
+ * platform; a provider hint names the target but never the pointer.
202
+ */
203
+ getActivePointer(sourceId) {
204
+ return this.sourceStates.get(sourceId)?.active ?? null;
205
+ }
206
+ /**
207
+ * What to draw for every source this frame: the ray only while the ray is
208
+ * the active pointer or nothing is, the cursor at the active pointer's
209
+ * candidate. Published once per `update`, after the sources; a binding
210
+ * applies it to its ray and cursor visuals, and a native binding hands it
211
+ * to the host as `applyPointerVisuals`. The list and its entries are fresh
212
+ * each frame and never written to again.
213
+ */
214
+ onPointerVisuals(listener) {
215
+ return this.visualsEmitter.subscribe(listener);
216
+ }
217
+ /** The visuals last published for a source, or undefined before its first frame. */
218
+ getPointerVisuals(sourceId) {
219
+ return this.lastVisuals.find((visuals) => visuals.sourceId === sourceId);
220
+ }
180
221
  // -- frame ------------------------------------------------------------------
181
222
  update(dt) {
182
223
  if (this.disposed)
183
224
  return;
184
225
  const sampled = this.provider.sample();
185
- const sources = this.velocityTracker ? this.velocityTracker.update(sampled, dt) : sampled;
226
+ const tracked = this.velocityTracker ? this.velocityTracker.update(sampled, dt) : sampled;
227
+ const sources = this.followHand(tracked);
186
228
  const hints = this.provider.sampleHints?.() ?? [];
187
229
  this.lastSources = new Map(sources.map((source) => [source.id, source]));
188
230
  this.afterSample.emit(sources);
@@ -194,10 +236,31 @@ export class InteractionRuntime {
194
236
  else
195
237
  hintBySource.set(hint.sourceId, [hint]);
196
238
  }
239
+ // With eye gaze live, the hands and controllers go first: whether one of
240
+ // them has a near pointer active this frame decides what gaze may target
241
+ // (IWSDK reads the hand pointers' state before it drives the gaze pointer).
242
+ const eyeGazeLive = this.capabilities.eyeGaze;
243
+ const ordered = eyeGazeLive
244
+ ? [...sources].sort((a, b) => Number(a.kind === "gaze") - Number(b.kind === "gaze"))
245
+ : sources;
246
+ this.eyeGazeTarget = null;
247
+ let sawGaze = false;
197
248
  const seen = new Set();
198
- for (const source of sources) {
249
+ const visuals = [];
250
+ for (const source of ordered) {
199
251
  seen.add(source.id);
252
+ if (eyeGazeLive && source.kind === "gaze") {
253
+ sawGaze = true;
254
+ this.resolveEyeGaze(source, dt);
255
+ }
200
256
  this.updateSource(source, hintBySource.get(source.id) ?? []);
257
+ const state = this.sourceState(source.id);
258
+ visuals.push(pointerVisualsFor(source.id, source.ray !== undefined, state.active, state.candidate));
259
+ }
260
+ if (!sawGaze) {
261
+ // No eye-gaze source this frame (none, or far targeting handed back).
262
+ this.gazeHold = null;
263
+ this.gazeConsensus.reset();
201
264
  }
202
265
  // Sources that vanished mid-hold release everything they held.
203
266
  for (const [id, state] of this.sourceStates) {
@@ -208,6 +271,8 @@ export class InteractionRuntime {
208
271
  }
209
272
  this.updateGaze(sources, dt);
210
273
  this.tickBehaviours(sources, dt);
274
+ this.lastVisuals = visuals;
275
+ this.visualsEmitter.emit(visuals);
211
276
  }
212
277
  dispose() {
213
278
  if (this.disposed)
@@ -231,12 +296,129 @@ export class InteractionRuntime {
231
296
  this.sourceStates.clear();
232
297
  this.lastSources.clear();
233
298
  this.velocityTracker?.reset();
299
+ this.gazeHold = null;
300
+ this.gazePoints.clear();
301
+ this.gazeConsensus.reset();
302
+ this.lastVisuals = [];
303
+ }
304
+ // -- eye gaze -----------------------------------------------------------------
305
+ /**
306
+ * The targeting half of the eye-gaze rule (`@realitycollective/webxr-input`
307
+ * `eye-gaze.ts` states the whole of it): the cone's best candidate this
308
+ * frame, then the dwell consensus over the window, and nothing at all while
309
+ * a hand's near pointer is hovering or selecting (IWSDK
310
+ * `suppressWhenDirectPointerActive`). A frame with no valid gaze ray clears
311
+ * the record, as IWSDK resets its conecaster, unless a selection is held.
312
+ */
313
+ resolveEyeGaze(source, dt) {
314
+ const ray = source.ray;
315
+ if (!ray) {
316
+ if (!this.gazeHold)
317
+ this.gazeConsensus.reset();
318
+ this.eyeGazeTarget = null;
319
+ return;
320
+ }
321
+ if (this.eyeGaze.suppressWhenDirectPointerActive && this.directPointerActive()) {
322
+ this.gazeConsensus.skip(dt);
323
+ this.eyeGazeTarget = null;
324
+ return;
325
+ }
326
+ const halfAngle = (this.eyeGaze.coneAngleDegrees * Math.PI) / 180;
327
+ const maxLength = this.eyeGaze.maxRayLength;
328
+ let best = null;
329
+ if (this.hitTester) {
330
+ best = this.hitTester.hitCone
331
+ ? this.hitTester.hitCone(ray, halfAngle, maxLength)
332
+ : this.hitTester.hitRay(ray);
333
+ if (best && (best.distance > maxLength || !this.targetable(best.interactableId)))
334
+ best = null;
335
+ }
336
+ if (best)
337
+ this.gazePoints.set(best.interactableId, best.point);
338
+ this.eyeGazeTarget = this.gazeConsensus.update(best?.interactableId ?? null, dt, (id) => this.targetable(id));
339
+ }
340
+ /** A hand's near pointer is hovering or selecting: touch or grab owns the hand, or a grab is held. */
341
+ directPointerActive() {
342
+ for (const [id, state] of this.sourceStates) {
343
+ const source = this.lastSources.get(id);
344
+ if (!source || (source.handedness !== "left" && source.handedness !== "right"))
345
+ continue;
346
+ if (state.grabbed)
347
+ return true;
348
+ if ((state.active === "touch" || state.active === "grab") && (state.hoverTarget !== null || state.pressTarget !== null))
349
+ return true;
350
+ }
351
+ return false;
352
+ }
353
+ /**
354
+ * Once a gaze selection is held, the pinching hand drives the pointer
355
+ * (IWSDK `pointerTransformFollowsHand`): the frame the hold starts, a ray
356
+ * from the hand's ray space is aimed at the gaze hit and its orientation
357
+ * relative to the hand recorded; every later frame the gaze snapshot's ray
358
+ * is that hand's ray space turned by the same amount. When the hand could
359
+ * not reach the hit, or the option is off, the ray stays where the
360
+ * selection was made. Consumers of `onSample` and `getSource`, and every
361
+ * behaviour, see the followed ray.
362
+ */
363
+ followHand(sources) {
364
+ const hold = this.gazeHold;
365
+ if (!hold || !this.capabilities.eyeGaze)
366
+ return sources;
367
+ const index = sources.findIndex((source) => source.id === hold.sourceId);
368
+ if (index < 0)
369
+ return sources;
370
+ const source = sources[index];
371
+ let ray = hold.ray;
372
+ if (hold.relative && source.selectorPose) {
373
+ const quaternion = quatMultiply(source.selectorPose.quaternion, hold.relative);
374
+ ray = { origin: [...source.selectorPose.position], direction: vApplyQuat([0, 0, -1], quaternion) };
375
+ }
376
+ const out = [...sources];
377
+ out[index] = { ...source, ray };
378
+ return out;
379
+ }
380
+ /** Start, keep or end the gaze hold from the gaze source's press and grab state this frame. */
381
+ trackGazeHold(source, state, target) {
382
+ if (!state.pressed && !state.grabbed) {
383
+ this.gazeHold = null;
384
+ return;
385
+ }
386
+ if (this.gazeHold?.sourceId === source.id)
387
+ return;
388
+ if (!source.ray)
389
+ return;
390
+ let relative = null;
391
+ const point = target ? this.gazePoints.get(target) : undefined;
392
+ const selector = source.selectorPose;
393
+ if (this.eyeGaze.pointerTransformFollowsHand && selector && point) {
394
+ const aim = vSub(point, selector.position);
395
+ if (vLength(aim) > 1e-6) {
396
+ const up = vApplyQuat([0, 1, 0], selector.quaternion);
397
+ const aimed = rayPoseFromRay({ origin: selector.position, direction: vNormalize(aim) }, up);
398
+ const aimedRay = { origin: aimed.position, direction: vApplyQuat([0, 0, -1], aimed.quaternion) };
399
+ // IWSDK follows the hand only when the ray from the hand reaches the target.
400
+ const reaches = this.hitTester ? this.hitTester.hitRay(aimedRay) !== null : true;
401
+ if (reaches)
402
+ relative = quatMultiply(quatConjugate(selector.quaternion), aimed.quaternion);
403
+ }
404
+ }
405
+ this.gazeHold = { sourceId: source.id, ray: source.ray, relative };
234
406
  }
235
407
  // -- internals ---------------------------------------------------------------
236
408
  sourceState(id) {
237
409
  let state = this.sourceStates.get(id);
238
410
  if (!state) {
239
- state = { hoverTarget: null, pressed: false, pressTarget: null, grabbed: false, grabTarget: null };
411
+ state = {
412
+ hoverTarget: null,
413
+ resolvedBy: null,
414
+ pressed: false,
415
+ pressTarget: null,
416
+ grabbed: false,
417
+ grabTarget: null,
418
+ active: null,
419
+ candidate: null,
420
+ touch: new TouchPointerState(),
421
+ };
240
422
  this.sourceStates.set(id, state);
241
423
  }
242
424
  return state;
@@ -260,46 +442,105 @@ export class InteractionRuntime {
260
442
  info.angularVelocity = source.angularVelocity;
261
443
  return info;
262
444
  }
263
- /** Resolve this source's target: hints beat poke beats ray. */
264
- resolveTarget(source, hints) {
445
+ /**
446
+ * Resolve this source's target and its active pointer. The pointer is the
447
+ * core rule (`near-pointer.ts`): touch, then grab, then ray, the first with
448
+ * a candidate, locked while it presses or grabs. The target is a provider
449
+ * hint when there is one (provider power: IWSDK's mesh-accurate tags), else
450
+ * the active pointer's candidate. An eye-gaze source targets through the
451
+ * cone and consensus instead.
452
+ */
453
+ resolveTarget(source, hints, state) {
454
+ state.resolvedBy = null;
265
455
  const hint = hints.find((h) => h.state === "grab") ??
266
456
  hints.find((h) => h.state === "press") ??
267
457
  hints.find((h) => h.state === "hover");
268
- if (hint && this.targetable(hint.targetId))
269
- return hint.targetId;
270
- if (source.indexTip && this.hitTester) {
271
- // One proximity query at the largest radius any interactable asked for,
272
- // then the nearest hit must be inside ITS OWN radius. A per-interactable
273
- // query would be a hit test per registration per source per frame; this
274
- // keeps it to one. The trade: a farther interactable with a bigger radius
275
- // is not found behind a nearer one with a smaller radius, because the
276
- // tester returns only the nearest.
277
- const poke = this.hitTester.hitProximity(source.indexTip, this.maxPokeRadius());
278
- if (poke && this.targetable(poke.interactableId)) {
279
- const registered = this.interactables.get(poke.interactableId);
280
- if (poke.distance <= registered.pokeRadius)
281
- return poke.interactableId;
458
+ if (this.capabilities.eyeGaze && source.kind === "gaze") {
459
+ state.active = "gaze";
460
+ state.touch.reset();
461
+ const gazeTarget = this.eyeGazeTarget;
462
+ const point = gazeTarget ? this.gazePoints.get(gazeTarget) : undefined;
463
+ state.candidate = gazeTarget && point ? { targetId: gazeTarget, point, distance: 0 } : null;
464
+ if (hint && this.targetable(hint.targetId)) {
465
+ state.resolvedBy = "hint";
466
+ return hint.targetId;
282
467
  }
468
+ if (gazeTarget !== null)
469
+ state.resolvedBy = "gaze";
470
+ return gazeTarget;
283
471
  }
284
- if (source.ray && this.hitTester) {
285
- const hit = this.hitTester.hitRay(source.ray);
286
- if (hit && this.targetable(hit.interactableId))
287
- return hit.interactableId;
472
+ const touch = this.touchCandidate(source, state);
473
+ const grab = this.grabCandidate(source);
474
+ const ray = this.rayCandidate(source);
475
+ const selecting = state.pressTarget !== null || state.grabTarget !== null;
476
+ const active = pickActivePointer({ touch: touch !== null, grab: grab !== null, ray: ray !== null }, state.active, selecting);
477
+ state.active = active;
478
+ state.candidate = active === "touch" ? touch : active === "grab" ? grab : active === "ray" ? ray : null;
479
+ if (hint && this.targetable(hint.targetId)) {
480
+ state.resolvedBy = "hint";
481
+ return hint.targetId;
288
482
  }
289
- return null;
483
+ if (!state.candidate || !active)
484
+ return null;
485
+ state.resolvedBy = active === "touch" ? "poke" : active;
486
+ return state.candidate.targetId;
487
+ }
488
+ /**
489
+ * The touch pointer's candidate this frame, advancing the source's touch
490
+ * machine: the nearest target within the widest touch band, hovering by
491
+ * that target's own enter and exit distances. One proximity query at the
492
+ * largest radius any interactable asked for, then the nearest hit must be
493
+ * inside ITS OWN band; the trade is that a farther target with a wider
494
+ * band is not found behind a nearer one with a narrower band.
495
+ */
496
+ touchCandidate(source, state) {
497
+ const { touchHoverEnter, touchHoverExit, touchDown } = this.nearPointer;
498
+ const hysteresis = touchHoverExit - touchHoverEnter;
499
+ if (!source.indexTip || !this.hitTester) {
500
+ state.touch.update(null, touchHoverEnter, touchHoverExit, touchDown);
501
+ return null;
502
+ }
503
+ const hit = this.hitTester.hitProximity(source.indexTip, this.touchQueryRadius());
504
+ const registered = hit ? this.interactables.get(hit.interactableId) : undefined;
505
+ if (!hit || !registered || !registered.enabled) {
506
+ state.touch.update(null, touchHoverEnter, touchHoverExit, touchDown);
507
+ return null;
508
+ }
509
+ const enter = registered.pokeRadius;
510
+ const update = state.touch.update(hit.distance, enter, enter + hysteresis, touchDown);
511
+ return update.hovering ? { targetId: hit.interactableId, point: hit.point, distance: hit.distance } : null;
512
+ }
513
+ /** The grab pointer's candidate: the nearest target whose surface the grip sphere reaches. */
514
+ grabCandidate(source) {
515
+ if (!source.gripPose || !this.hitTester)
516
+ return null;
517
+ const hit = this.hitTester.hitProximity(source.gripPose.position, this.nearPointer.grabRadius);
518
+ if (!hit || !this.targetable(hit.interactableId))
519
+ return null;
520
+ return { targetId: hit.interactableId, point: hit.point, distance: hit.distance };
521
+ }
522
+ /** The ray pointer's candidate: the nearest target along the ray. */
523
+ rayCandidate(source) {
524
+ if (!source.ray || !this.hitTester)
525
+ return null;
526
+ const hit = this.hitTester.hitRay(source.ray);
527
+ if (!hit || !this.targetable(hit.interactableId))
528
+ return null;
529
+ return { targetId: hit.interactableId, point: hit.point, distance: hit.distance };
290
530
  }
291
531
  targetable(id) {
292
532
  const registered = this.interactables.get(id);
293
533
  return registered !== undefined && registered.enabled;
294
534
  }
295
- /** The largest poke radius among enabled interactables; the default when none is registered. */
296
- maxPokeRadius() {
535
+ /** The widest touch exit band among enabled interactables; the default exit distance when none is registered. */
536
+ touchQueryRadius() {
537
+ const hysteresis = this.nearPointer.touchHoverExit - this.nearPointer.touchHoverEnter;
297
538
  let radius = 0;
298
539
  for (const registered of this.interactables.values()) {
299
540
  if (registered.enabled && registered.pokeRadius > radius)
300
541
  radius = registered.pokeRadius;
301
542
  }
302
- return radius > 0 ? radius : DEFAULT_POKE_RADIUS;
543
+ return radius > 0 ? radius + hysteresis : this.nearPointer.touchHoverExit;
303
544
  }
304
545
  flags(registered) {
305
546
  let pressable = false;
@@ -316,7 +557,7 @@ export class InteractionRuntime {
316
557
  }
317
558
  updateSource(source, hints) {
318
559
  const state = this.sourceState(source.id);
319
- const target = this.resolveTarget(source, hints);
560
+ const target = this.resolveTarget(source, hints, state);
320
561
  // Hover transitions.
321
562
  if (state.hoverTarget !== target) {
322
563
  if (state.hoverTarget)
@@ -335,8 +576,11 @@ export class InteractionRuntime {
335
576
  const grabSignal = Math.max(source.squeeze, selectAsGrab ? source.select : 0);
336
577
  const hintPress = hints.some((h) => h.state === "press");
337
578
  const hintGrab = hints.some((h) => h.state === "grab") || source.nativeGrabbing === true;
338
- // Press with hysteresis (hints override).
339
- const pressActive = hintPress || (state.pressed
579
+ // The touch press: the fingertip at or under the press distance while
580
+ // touch owns the source (`near-pointer.ts`), with no select needed.
581
+ const touchPress = state.active === "touch" && state.touch.isPressed;
582
+ // Press with hysteresis (hints and the touch press override).
583
+ const pressActive = hintPress || touchPress || (state.pressed
340
584
  ? pressSignal > SELECT_RELEASE_THRESHOLD
341
585
  : pressSignal >= SELECT_PRESS_THRESHOLD);
342
586
  if (pressActive && !state.pressed) {
@@ -384,6 +628,8 @@ export class InteractionRuntime {
384
628
  state.grabTarget = null;
385
629
  }
386
630
  }
631
+ if (this.capabilities.eyeGaze && source.kind === "gaze")
632
+ this.trackGazeHold(source, state, target);
387
633
  }
388
634
  gazeAllows(registered) {
389
635
  return !registered.gazeRequired || registered.gazeHovered;
@@ -452,6 +698,9 @@ export class InteractionRuntime {
452
698
  this.endGrab(state.grabTarget, sourceId, info);
453
699
  if (state.hoverTarget)
454
700
  this.setHover(state.hoverTarget, sourceId, false);
701
+ state.touch.reset();
702
+ state.active = null;
703
+ state.candidate = null;
455
704
  }
456
705
  releaseAllHolds(id) {
457
706
  for (const [sourceId, state] of this.sourceStates) {
@@ -475,7 +724,16 @@ export class InteractionRuntime {
475
724
  let gazeRay = null;
476
725
  let gazeId = HEAD_GAZE_INTERACTOR_ID;
477
726
  const gazeSource = sources.find((s) => s.kind === "gaze");
478
- if (gazeSource?.ray) {
727
+ // Eye gaze, when live, is the gaze for gating and dwell too, with the
728
+ // target the cone and consensus chose (nothing while a near pointer is
729
+ // active, or while the pose is invalid). Otherwise a head-gaze source, or
730
+ // the head pose, targets by plain ray as before.
731
+ const eyeGaze = this.capabilities.eyeGaze && gazeSource !== undefined;
732
+ if (eyeGaze) {
733
+ gazeRay = gazeSource.ray ?? null;
734
+ gazeId = gazeSource.id;
735
+ }
736
+ else if (gazeSource?.ray) {
479
737
  gazeRay = gazeSource.ray;
480
738
  gazeId = gazeSource.id;
481
739
  }
@@ -486,7 +744,11 @@ export class InteractionRuntime {
486
744
  direction: vApplyQuat([0, 0, -1], head.quaternion),
487
745
  };
488
746
  }
489
- const gazeTarget = gazeRay && this.hitTester ? this.hitTester.hitRay(gazeRay)?.interactableId ?? null : null;
747
+ const gazeTarget = eyeGaze
748
+ ? this.eyeGazeTarget
749
+ : gazeRay && this.hitTester
750
+ ? (this.hitTester.hitRay(gazeRay)?.interactableId ?? null)
751
+ : null;
490
752
  for (const [id, registered] of this.interactables) {
491
753
  const hovered = registered.enabled && gazeTarget === id;
492
754
  if (hovered !== registered.gazeHovered) {