@realitycollective/webxr-interactions 0.1.1-preview.2 → 0.1.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 (54) hide show
  1. package/CHANGELOG.md +29 -2
  2. package/README.md +17 -2
  3. package/dist/behaviours/behaviour.d.ts +17 -1
  4. package/dist/behaviours/behaviour.js +12 -2
  5. package/dist/behaviours/behaviour.js.map +1 -1
  6. package/dist/behaviours/grab.d.ts +1 -0
  7. package/dist/behaviours/grab.js +10 -4
  8. package/dist/behaviours/grab.js.map +1 -1
  9. package/dist/contract-cases.js +84 -0
  10. package/dist/contract-cases.js.map +1 -1
  11. package/dist/descriptor.d.ts +7 -1
  12. package/dist/descriptor.js.map +1 -1
  13. package/dist/gaze.d.ts +50 -0
  14. package/dist/gaze.js +162 -0
  15. package/dist/gaze.js.map +1 -1
  16. package/dist/index.d.ts +9 -0
  17. package/dist/index.js +9 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/math.d.ts +8 -0
  20. package/dist/math.js +17 -0
  21. package/dist/math.js.map +1 -1
  22. package/dist/memory-physics.d.ts +41 -0
  23. package/dist/memory-physics.js +168 -0
  24. package/dist/memory-physics.js.map +1 -0
  25. package/dist/memory-ports.d.ts +55 -0
  26. package/dist/memory-ports.js +128 -0
  27. package/dist/memory-ports.js.map +1 -0
  28. package/dist/near-pointer-contract-cases.d.ts +16 -0
  29. package/dist/near-pointer-contract-cases.js +283 -0
  30. package/dist/near-pointer-contract-cases.js.map +1 -0
  31. package/dist/near-pointer.d.ts +88 -0
  32. package/dist/near-pointer.js +114 -0
  33. package/dist/near-pointer.js.map +1 -0
  34. package/dist/physics-contract-cases.d.ts +11 -0
  35. package/dist/physics-contract-cases.js +229 -0
  36. package/dist/physics-contract-cases.js.map +1 -0
  37. package/dist/physics.d.ts +148 -0
  38. package/dist/physics.js +35 -0
  39. package/dist/physics.js.map +1 -0
  40. package/dist/ports.d.ts +15 -0
  41. package/dist/ports.js.map +1 -1
  42. package/dist/release-velocity.d.ts +58 -0
  43. package/dist/release-velocity.js +89 -0
  44. package/dist/release-velocity.js.map +1 -0
  45. package/dist/runtime.d.ts +129 -4
  46. package/dist/runtime.js +362 -40
  47. package/dist/runtime.js.map +1 -1
  48. package/dist/trace-synthetic.d.ts +12 -0
  49. package/dist/trace-synthetic.js +194 -0
  50. package/dist/trace-synthetic.js.map +1 -0
  51. package/dist/trace.d.ts +215 -0
  52. package/dist/trace.js +350 -0
  53. package/dist/trace.js.map +1 -0
  54. package/package.json +8 -4
package/dist/runtime.js CHANGED
@@ -13,16 +13,18 @@
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 { PointerArbiter, PointerDisplay, SELECT_PRESS_THRESHOLD, SELECT_RELEASE_THRESHOLD, pointerVisualsFor, 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 { 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
+ /** The target set this runtime registers with its arbiter. */
27
+ export const INTERACTIONS_POINTER_SET = "interactions";
26
28
  export class InteractionRuntime {
27
29
  provider;
28
30
  hitTester;
@@ -32,14 +34,36 @@ export class InteractionRuntime {
32
34
  events = new Emitter();
33
35
  feedbackEmitter = new Emitter();
34
36
  afterSample = new Emitter();
37
+ visualsEmitter = new Emitter();
38
+ drawingEmitter = new Emitter();
39
+ nearPointer;
40
+ pointers;
41
+ targetSet;
42
+ pointerDisplay;
43
+ lastVisuals = [];
44
+ lastDrawings = [];
35
45
  velocityTracker;
36
46
  lastSources = new Map();
37
47
  capabilities;
38
48
  unsubscribeCaps;
39
49
  disposed = false;
50
+ eyeGaze;
51
+ gazeConsensus;
52
+ /** The eye-gaze source's target this frame: cone, consensus and suppression applied. */
53
+ eyeGazeTarget = null;
54
+ /** Where the eye-gaze ray last aimed at each target, for the pinching hand to aim at. */
55
+ gazePoints = new Map();
56
+ gazeHold = null;
40
57
  constructor(options) {
41
58
  this.provider = options.provider;
42
59
  this.hitTester = options.hitTester ?? null;
60
+ this.nearPointer = resolveNearPointerOptions(options.nearPointer);
61
+ this.pointers = options.pointers ?? new PointerArbiter();
62
+ this.targetSet = this.pointers.registerSet(INTERACTIONS_POINTER_SET, "object");
63
+ this.pointerDisplay =
64
+ options.pointerDisplay instanceof PointerDisplay ? options.pointerDisplay : new PointerDisplay(options.pointerDisplay);
65
+ this.eyeGaze = resolveEyeGazeOptions(options.eyeGaze);
66
+ this.gazeConsensus = new GazeConsensus(this.eyeGaze.dwellWindowSeconds);
43
67
  const d = options.dwellDefaults;
44
68
  this.dwellDefaults = {
45
69
  holdSeconds: d?.holdSeconds ?? DWELL_DEFAULTS.holdSeconds,
@@ -72,7 +96,7 @@ export class InteractionRuntime {
72
96
  })),
73
97
  transform: ports.transform,
74
98
  enabled: def.enabled !== false,
75
- pokeRadius: def.pokeRadius ?? DEFAULT_POKE_RADIUS,
99
+ pokeRadius: def.pokeRadius ?? this.nearPointer.touchHoverEnter,
76
100
  gazeRequired: def.gaze?.required === true,
77
101
  dwellConfig: resolveDwellConfig(def.gaze?.dwell, this.dwellDefaults),
78
102
  dwell: new DwellState(),
@@ -177,12 +201,67 @@ export class InteractionRuntime {
177
201
  getProvider() {
178
202
  return this.provider;
179
203
  }
204
+ /** The near-pointer distances in force (the defaults, or the options given). */
205
+ getNearPointerOptions() {
206
+ return this.nearPointer;
207
+ }
208
+ /**
209
+ * The pointer that owns a source this frame: `"touch"`, `"grab"`, `"ray"`
210
+ * or `"gaze"`, or null when no pointer has a candidate (or the source is
211
+ * unknown). Decided by the core rule in `near-pointer.ts`, on every
212
+ * platform; a provider hint names the target but never the pointer.
213
+ */
214
+ getActivePointer(sourceId) {
215
+ return this.sourceStates.get(sourceId)?.active ?? null;
216
+ }
217
+ /**
218
+ * What to draw for every source this frame: the ray only while the ray is
219
+ * the active pointer or nothing is, the cursor at the active pointer's
220
+ * candidate. Published once per `update`, after the sources; a binding
221
+ * applies it to its ray and cursor visuals, and a native binding hands it
222
+ * to the host as `applyPointerVisuals`. The list and its entries are fresh
223
+ * each frame and never written to again.
224
+ */
225
+ onPointerVisuals(listener) {
226
+ return this.visualsEmitter.subscribe(listener);
227
+ }
228
+ /** The visuals last published for a source, or undefined before its first frame. */
229
+ getPointerVisuals(sourceId) {
230
+ return this.lastVisuals.find((visuals) => visuals.sourceId === sourceId);
231
+ }
232
+ /**
233
+ * What to draw for every source this frame under the app's pointer display
234
+ * settings: the visuals above resolved through `pointerDrawing`. Published
235
+ * right after `onPointerVisuals`. A binding that draws its own ray and
236
+ * cursor applies exactly this; a native binding hands it to the host.
237
+ */
238
+ onPointerDrawing(listener) {
239
+ return this.drawingEmitter.subscribe(listener);
240
+ }
241
+ /** The drawing last published for a source, or undefined before its first frame. */
242
+ getPointerDrawing(sourceId) {
243
+ return this.lastDrawings.find((drawing) => drawing.sourceId === sourceId);
244
+ }
245
+ /** The arbiter deciding which pointer owns each source, shared with the UI Extensions hosts when the app passed one. */
246
+ getPointerArbiter() {
247
+ return this.pointers;
248
+ }
249
+ /** The app's pointer display settings; `set` changes them at run time. */
250
+ getPointerDisplay() {
251
+ return this.pointerDisplay;
252
+ }
253
+ /** Whether a source is pressing or grabbing this frame (the selection that recolours its pointer). */
254
+ isSelecting(sourceId) {
255
+ const state = this.sourceStates.get(sourceId);
256
+ return state !== undefined && (state.pressTarget !== null || state.grabTarget !== null);
257
+ }
180
258
  // -- frame ------------------------------------------------------------------
181
259
  update(dt) {
182
260
  if (this.disposed)
183
261
  return;
184
262
  const sampled = this.provider.sample();
185
- const sources = this.velocityTracker ? this.velocityTracker.update(sampled, dt) : sampled;
263
+ const tracked = this.velocityTracker ? this.velocityTracker.update(sampled, dt) : sampled;
264
+ const sources = this.followHand(tracked);
186
265
  const hints = this.provider.sampleHints?.() ?? [];
187
266
  this.lastSources = new Map(sources.map((source) => [source.id, source]));
188
267
  this.afterSample.emit(sources);
@@ -194,22 +273,50 @@ export class InteractionRuntime {
194
273
  else
195
274
  hintBySource.set(hint.sourceId, [hint]);
196
275
  }
276
+ // With eye gaze live, the hands and controllers go first: whether one of
277
+ // them has a near pointer active this frame decides what gaze may target
278
+ // (IWSDK reads the hand pointers' state before it drives the gaze pointer).
279
+ const eyeGazeLive = this.capabilities.eyeGaze;
280
+ const ordered = eyeGazeLive
281
+ ? [...sources].sort((a, b) => Number(a.kind === "gaze") - Number(b.kind === "gaze"))
282
+ : sources;
283
+ this.eyeGazeTarget = null;
284
+ let sawGaze = false;
197
285
  const seen = new Set();
198
- for (const source of sources) {
286
+ const visuals = [];
287
+ for (const source of ordered) {
199
288
  seen.add(source.id);
289
+ if (eyeGazeLive && source.kind === "gaze") {
290
+ sawGaze = true;
291
+ this.resolveEyeGaze(source, dt);
292
+ }
200
293
  this.updateSource(source, hintBySource.get(source.id) ?? []);
294
+ const state = this.sourceState(source.id);
295
+ visuals.push(pointerVisualsFor(source.id, source.ray !== undefined, state.active, state.candidate));
296
+ }
297
+ if (!sawGaze) {
298
+ // No eye-gaze source this frame (none, or far targeting handed back).
299
+ this.gazeHold = null;
300
+ this.gazeConsensus.reset();
201
301
  }
202
302
  // Sources that vanished mid-hold release everything they held.
203
303
  for (const [id, state] of this.sourceStates) {
204
304
  if (!seen.has(id)) {
205
305
  this.forceRelease(id, state);
206
306
  this.sourceStates.delete(id);
307
+ this.pointers.forget(id);
207
308
  }
208
309
  }
209
310
  this.updateGaze(sources, dt);
210
311
  this.tickBehaviours(sources, dt);
312
+ this.lastVisuals = visuals;
313
+ this.visualsEmitter.emit(visuals);
314
+ const drawings = visuals.map((entry) => this.pointerDisplay.drawing(entry, this.isSelecting(entry.sourceId)));
315
+ this.lastDrawings = drawings;
316
+ this.drawingEmitter.emit(drawings);
211
317
  }
212
318
  dispose() {
319
+ this.targetSet.dispose();
213
320
  if (this.disposed)
214
321
  return;
215
322
  this.disposed = true;
@@ -231,12 +338,129 @@ export class InteractionRuntime {
231
338
  this.sourceStates.clear();
232
339
  this.lastSources.clear();
233
340
  this.velocityTracker?.reset();
341
+ this.gazeHold = null;
342
+ this.gazePoints.clear();
343
+ this.gazeConsensus.reset();
344
+ this.lastVisuals = [];
345
+ }
346
+ // -- eye gaze -----------------------------------------------------------------
347
+ /**
348
+ * The targeting half of the eye-gaze rule (`@realitycollective/webxr-input`
349
+ * `eye-gaze.ts` states the whole of it): the cone's best candidate this
350
+ * frame, then the dwell consensus over the window, and nothing at all while
351
+ * a hand's near pointer is hovering or selecting (IWSDK
352
+ * `suppressWhenDirectPointerActive`). A frame with no valid gaze ray clears
353
+ * the record, as IWSDK resets its conecaster, unless a selection is held.
354
+ */
355
+ resolveEyeGaze(source, dt) {
356
+ const ray = source.ray;
357
+ if (!ray) {
358
+ if (!this.gazeHold)
359
+ this.gazeConsensus.reset();
360
+ this.eyeGazeTarget = null;
361
+ return;
362
+ }
363
+ if (this.eyeGaze.suppressWhenDirectPointerActive && this.directPointerActive()) {
364
+ this.gazeConsensus.skip(dt);
365
+ this.eyeGazeTarget = null;
366
+ return;
367
+ }
368
+ const halfAngle = (this.eyeGaze.coneAngleDegrees * Math.PI) / 180;
369
+ const maxLength = this.eyeGaze.maxRayLength;
370
+ let best = null;
371
+ if (this.hitTester) {
372
+ best = this.hitTester.hitCone
373
+ ? this.hitTester.hitCone(ray, halfAngle, maxLength)
374
+ : this.hitTester.hitRay(ray);
375
+ if (best && (best.distance > maxLength || !this.targetable(best.interactableId)))
376
+ best = null;
377
+ }
378
+ if (best)
379
+ this.gazePoints.set(best.interactableId, best.point);
380
+ this.eyeGazeTarget = this.gazeConsensus.update(best?.interactableId ?? null, dt, (id) => this.targetable(id));
381
+ }
382
+ /** A hand's near pointer is hovering or selecting: touch or grab owns the hand, or a grab is held. */
383
+ directPointerActive() {
384
+ for (const [id, state] of this.sourceStates) {
385
+ const source = this.lastSources.get(id);
386
+ if (!source || (source.handedness !== "left" && source.handedness !== "right"))
387
+ continue;
388
+ if (state.grabbed)
389
+ return true;
390
+ if ((state.active === "touch" || state.active === "grab") && (state.hoverTarget !== null || state.pressTarget !== null))
391
+ return true;
392
+ }
393
+ return false;
394
+ }
395
+ /**
396
+ * Once a gaze selection is held, the pinching hand drives the pointer
397
+ * (IWSDK `pointerTransformFollowsHand`): the frame the hold starts, a ray
398
+ * from the hand's ray space is aimed at the gaze hit and its orientation
399
+ * relative to the hand recorded; every later frame the gaze snapshot's ray
400
+ * is that hand's ray space turned by the same amount. When the hand could
401
+ * not reach the hit, or the option is off, the ray stays where the
402
+ * selection was made. Consumers of `onSample` and `getSource`, and every
403
+ * behaviour, see the followed ray.
404
+ */
405
+ followHand(sources) {
406
+ const hold = this.gazeHold;
407
+ if (!hold || !this.capabilities.eyeGaze)
408
+ return sources;
409
+ const index = sources.findIndex((source) => source.id === hold.sourceId);
410
+ if (index < 0)
411
+ return sources;
412
+ const source = sources[index];
413
+ let ray = hold.ray;
414
+ if (hold.relative && source.selectorPose) {
415
+ const quaternion = quatMultiply(source.selectorPose.quaternion, hold.relative);
416
+ ray = { origin: [...source.selectorPose.position], direction: vApplyQuat([0, 0, -1], quaternion) };
417
+ }
418
+ const out = [...sources];
419
+ out[index] = { ...source, ray };
420
+ return out;
421
+ }
422
+ /** Start, keep or end the gaze hold from the gaze source's press and grab state this frame. */
423
+ trackGazeHold(source, state, target) {
424
+ if (!state.pressed && !state.grabbed) {
425
+ this.gazeHold = null;
426
+ return;
427
+ }
428
+ if (this.gazeHold?.sourceId === source.id)
429
+ return;
430
+ if (!source.ray)
431
+ return;
432
+ let relative = null;
433
+ const point = target ? this.gazePoints.get(target) : undefined;
434
+ const selector = source.selectorPose;
435
+ if (this.eyeGaze.pointerTransformFollowsHand && selector && point) {
436
+ const aim = vSub(point, selector.position);
437
+ if (vLength(aim) > 1e-6) {
438
+ const up = vApplyQuat([0, 1, 0], selector.quaternion);
439
+ const aimed = rayPoseFromRay({ origin: selector.position, direction: vNormalize(aim) }, up);
440
+ const aimedRay = { origin: aimed.position, direction: vApplyQuat([0, 0, -1], aimed.quaternion) };
441
+ // IWSDK follows the hand only when the ray from the hand reaches the target.
442
+ const reaches = this.hitTester ? this.hitTester.hitRay(aimedRay) !== null : true;
443
+ if (reaches)
444
+ relative = quatMultiply(quatConjugate(selector.quaternion), aimed.quaternion);
445
+ }
446
+ }
447
+ this.gazeHold = { sourceId: source.id, ray: source.ray, relative };
234
448
  }
235
449
  // -- internals ---------------------------------------------------------------
236
450
  sourceState(id) {
237
451
  let state = this.sourceStates.get(id);
238
452
  if (!state) {
239
- state = { hoverTarget: null, pressed: false, pressTarget: null, grabbed: false, grabTarget: null };
453
+ state = {
454
+ hoverTarget: null,
455
+ resolvedBy: null,
456
+ pressed: false,
457
+ pressTarget: null,
458
+ grabbed: false,
459
+ grabTarget: null,
460
+ active: null,
461
+ candidate: null,
462
+ touch: new TouchPointerState(),
463
+ };
240
464
  this.sourceStates.set(id, state);
241
465
  }
242
466
  return state;
@@ -260,46 +484,117 @@ export class InteractionRuntime {
260
484
  info.angularVelocity = source.angularVelocity;
261
485
  return info;
262
486
  }
263
- /** Resolve this source's target: hints beat poke beats ray. */
264
- resolveTarget(source, hints) {
487
+ /**
488
+ * Resolve this source's target and its active pointer. The pointer is the
489
+ * core rule (`near-pointer.ts`): touch, then grab, then ray, the first with
490
+ * a candidate, locked while it presses or grabs. The target is a provider
491
+ * hint when there is one (provider power: IWSDK's mesh-accurate tags), else
492
+ * the active pointer's candidate. An eye-gaze source targets through the
493
+ * cone and consensus instead.
494
+ */
495
+ resolveTarget(source, hints, state) {
496
+ state.resolvedBy = null;
265
497
  const hint = hints.find((h) => h.state === "grab") ??
266
498
  hints.find((h) => h.state === "press") ??
267
499
  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;
500
+ if (this.capabilities.eyeGaze && source.kind === "gaze") {
501
+ state.active = "gaze";
502
+ state.touch.reset();
503
+ const gazeTarget = this.eyeGazeTarget;
504
+ const point = gazeTarget ? this.gazePoints.get(gazeTarget) : undefined;
505
+ state.candidate =
506
+ gazeTarget && point ? { targetId: gazeTarget, point, distance: 0, set: this.targetSet.id, targetKind: "object" } : null;
507
+ this.pointers.setDecision({ sourceId: source.id, active: "gaze", candidate: state.candidate });
508
+ if (hint && this.targetable(hint.targetId)) {
509
+ state.resolvedBy = "hint";
510
+ return hint.targetId;
282
511
  }
512
+ if (gazeTarget !== null)
513
+ state.resolvedBy = "gaze";
514
+ return gazeTarget;
283
515
  }
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;
516
+ // This runtime's own candidates go to the arbiter, which decides across
517
+ // every target set (a UI panel may be nearer than any interactable). A
518
+ // hand or controller is also reachable by its side, so a UI host that
519
+ // knows only the side offers to the same source.
520
+ if (source.handedness === "left" || source.handedness === "right")
521
+ this.pointers.alias(source.handedness, source.id);
522
+ this.targetSet.offer(source.id, "touch", this.touchCandidate(source, state));
523
+ this.targetSet.offer(source.id, "grab", this.grabCandidate(source));
524
+ this.targetSet.offer(source.id, "ray", this.rayCandidate(source));
525
+ const decision = this.pointers.resolve(source.id);
526
+ state.active = decision.active;
527
+ state.candidate = decision.candidate;
528
+ if (hint && this.targetable(hint.targetId)) {
529
+ state.resolvedBy = "hint";
530
+ return hint.targetId;
288
531
  }
289
- return null;
532
+ const active = decision.active;
533
+ if (!decision.candidate || !active)
534
+ return null;
535
+ // Another set's target (a panel) owns the pointer: no interactable is
536
+ // targeted, and the visuals still say where the cursor sits.
537
+ if (decision.candidate.set !== this.targetSet.id)
538
+ return null;
539
+ state.resolvedBy = active === "touch" ? "poke" : active;
540
+ return decision.candidate.targetId;
541
+ }
542
+ /**
543
+ * The touch pointer's candidate this frame, advancing the source's touch
544
+ * machine: the nearest target within the widest touch band, hovering by
545
+ * that target's own enter and exit distances. One proximity query at the
546
+ * largest radius any interactable asked for, then the nearest hit must be
547
+ * inside ITS OWN band; the trade is that a farther target with a wider
548
+ * band is not found behind a nearer one with a narrower band.
549
+ */
550
+ touchCandidate(source, state) {
551
+ const { touchHoverEnter, touchHoverExit, touchDown } = this.nearPointer;
552
+ const hysteresis = touchHoverExit - touchHoverEnter;
553
+ if (!source.indexTip || !this.hitTester) {
554
+ state.touch.update(null, touchHoverEnter, touchHoverExit, touchDown);
555
+ return null;
556
+ }
557
+ const hit = this.hitTester.hitProximity(source.indexTip, this.touchQueryRadius());
558
+ const registered = hit ? this.interactables.get(hit.interactableId) : undefined;
559
+ if (!hit || !registered || !registered.enabled) {
560
+ state.touch.update(null, touchHoverEnter, touchHoverExit, touchDown);
561
+ return null;
562
+ }
563
+ const enter = registered.pokeRadius;
564
+ const update = state.touch.update(hit.distance, enter, enter + hysteresis, touchDown);
565
+ return update.hovering ? { targetId: hit.interactableId, point: hit.point, distance: hit.distance } : null;
566
+ }
567
+ /** The grab pointer's candidate: the nearest target whose surface the grip sphere reaches. */
568
+ grabCandidate(source) {
569
+ if (!source.gripPose || !this.hitTester)
570
+ return null;
571
+ const hit = this.hitTester.hitProximity(source.gripPose.position, this.nearPointer.grabRadius);
572
+ if (!hit || !this.targetable(hit.interactableId))
573
+ return null;
574
+ return { targetId: hit.interactableId, point: hit.point, distance: hit.distance };
575
+ }
576
+ /** The ray pointer's candidate: the nearest target along the ray. */
577
+ rayCandidate(source) {
578
+ if (!source.ray || !this.hitTester)
579
+ return null;
580
+ const hit = this.hitTester.hitRay(source.ray);
581
+ if (!hit || !this.targetable(hit.interactableId))
582
+ return null;
583
+ return { targetId: hit.interactableId, point: hit.point, distance: hit.distance };
290
584
  }
291
585
  targetable(id) {
292
586
  const registered = this.interactables.get(id);
293
587
  return registered !== undefined && registered.enabled;
294
588
  }
295
- /** The largest poke radius among enabled interactables; the default when none is registered. */
296
- maxPokeRadius() {
589
+ /** The widest touch exit band among enabled interactables; the default exit distance when none is registered. */
590
+ touchQueryRadius() {
591
+ const hysteresis = this.nearPointer.touchHoverExit - this.nearPointer.touchHoverEnter;
297
592
  let radius = 0;
298
593
  for (const registered of this.interactables.values()) {
299
594
  if (registered.enabled && registered.pokeRadius > radius)
300
595
  radius = registered.pokeRadius;
301
596
  }
302
- return radius > 0 ? radius : DEFAULT_POKE_RADIUS;
597
+ return radius > 0 ? radius + hysteresis : this.nearPointer.touchHoverExit;
303
598
  }
304
599
  flags(registered) {
305
600
  let pressable = false;
@@ -316,7 +611,7 @@ export class InteractionRuntime {
316
611
  }
317
612
  updateSource(source, hints) {
318
613
  const state = this.sourceState(source.id);
319
- const target = this.resolveTarget(source, hints);
614
+ const target = this.resolveTarget(source, hints, state);
320
615
  // Hover transitions.
321
616
  if (state.hoverTarget !== target) {
322
617
  if (state.hoverTarget)
@@ -335,8 +630,11 @@ export class InteractionRuntime {
335
630
  const grabSignal = Math.max(source.squeeze, selectAsGrab ? source.select : 0);
336
631
  const hintPress = hints.some((h) => h.state === "press");
337
632
  const hintGrab = hints.some((h) => h.state === "grab") || source.nativeGrabbing === true;
338
- // Press with hysteresis (hints override).
339
- const pressActive = hintPress || (state.pressed
633
+ // The touch press: the fingertip at or under the press distance while
634
+ // touch owns the source (`near-pointer.ts`), with no select needed.
635
+ const touchPress = state.active === "touch" && state.touch.isPressed;
636
+ // Press with hysteresis (hints and the touch press override).
637
+ const pressActive = hintPress || touchPress || (state.pressed
340
638
  ? pressSignal > SELECT_RELEASE_THRESHOLD
341
639
  : pressSignal >= SELECT_PRESS_THRESHOLD);
342
640
  if (pressActive && !state.pressed) {
@@ -384,6 +682,14 @@ export class InteractionRuntime {
384
682
  state.grabTarget = null;
385
683
  }
386
684
  }
685
+ // The selection lock: the pointer that owns this source keeps it while
686
+ // it presses or grabs, whatever appears nearer meanwhile.
687
+ const selecting = state.pressTarget !== null || state.grabTarget !== null;
688
+ for (const kind of ["touch", "grab", "ray"]) {
689
+ this.targetSet.setSelecting(source.id, kind, selecting && state.active === kind);
690
+ }
691
+ if (this.capabilities.eyeGaze && source.kind === "gaze")
692
+ this.trackGazeHold(source, state, target);
387
693
  }
388
694
  gazeAllows(registered) {
389
695
  return !registered.gazeRequired || registered.gazeHovered;
@@ -445,23 +751,26 @@ export class InteractionRuntime {
445
751
  }
446
752
  }
447
753
  forceRelease(sourceId, state) {
448
- const info = { id: sourceId, kind: "other", select: 0, squeeze: 0 };
754
+ const info = { id: sourceId, kind: "other", select: 0, squeeze: 0, synthesized: true };
449
755
  if (state.pressTarget)
450
756
  this.routePressEnd(state.pressTarget, sourceId, info);
451
757
  if (state.grabTarget)
452
758
  this.endGrab(state.grabTarget, sourceId, info);
453
759
  if (state.hoverTarget)
454
760
  this.setHover(state.hoverTarget, sourceId, false);
761
+ state.touch.reset();
762
+ state.active = null;
763
+ state.candidate = null;
455
764
  }
456
765
  releaseAllHolds(id) {
457
766
  for (const [sourceId, state] of this.sourceStates) {
458
767
  if (state.pressTarget === id) {
459
- this.routePressEnd(id, sourceId, { id: sourceId, kind: "other", select: 0, squeeze: 0 });
768
+ this.routePressEnd(id, sourceId, { id: sourceId, kind: "other", select: 0, squeeze: 0, synthesized: true });
460
769
  state.pressTarget = null;
461
770
  state.pressed = false;
462
771
  }
463
772
  if (state.grabTarget === id) {
464
- this.endGrab(id, sourceId, { id: sourceId, kind: "other", select: 0, squeeze: 0 });
773
+ this.endGrab(id, sourceId, { id: sourceId, kind: "other", select: 0, squeeze: 0, synthesized: true });
465
774
  state.grabTarget = null;
466
775
  state.grabbed = false;
467
776
  }
@@ -475,7 +784,16 @@ export class InteractionRuntime {
475
784
  let gazeRay = null;
476
785
  let gazeId = HEAD_GAZE_INTERACTOR_ID;
477
786
  const gazeSource = sources.find((s) => s.kind === "gaze");
478
- if (gazeSource?.ray) {
787
+ // Eye gaze, when live, is the gaze for gating and dwell too, with the
788
+ // target the cone and consensus chose (nothing while a near pointer is
789
+ // active, or while the pose is invalid). Otherwise a head-gaze source, or
790
+ // the head pose, targets by plain ray as before.
791
+ const eyeGaze = this.capabilities.eyeGaze && gazeSource !== undefined;
792
+ if (eyeGaze) {
793
+ gazeRay = gazeSource.ray ?? null;
794
+ gazeId = gazeSource.id;
795
+ }
796
+ else if (gazeSource?.ray) {
479
797
  gazeRay = gazeSource.ray;
480
798
  gazeId = gazeSource.id;
481
799
  }
@@ -486,7 +804,11 @@ export class InteractionRuntime {
486
804
  direction: vApplyQuat([0, 0, -1], head.quaternion),
487
805
  };
488
806
  }
489
- const gazeTarget = gazeRay && this.hitTester ? this.hitTester.hitRay(gazeRay)?.interactableId ?? null : null;
807
+ const gazeTarget = eyeGaze
808
+ ? this.eyeGazeTarget
809
+ : gazeRay && this.hitTester
810
+ ? (this.hitTester.hitRay(gazeRay)?.interactableId ?? null)
811
+ : null;
490
812
  for (const [id, registered] of this.interactables) {
491
813
  const hovered = registered.enabled && gazeTarget === id;
492
814
  if (hovered !== registered.gazeHovered) {