@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/CHANGELOG.md +11 -0
- package/README.md +1 -0
- package/dist/contract-cases.js +69 -0
- package/dist/contract-cases.js.map +1 -1
- package/dist/descriptor.d.ts +7 -1
- package/dist/descriptor.js.map +1 -1
- package/dist/gaze.d.ts +50 -0
- package/dist/gaze.js +162 -0
- package/dist/gaze.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/memory-physics.d.ts +41 -0
- package/dist/memory-physics.js +168 -0
- package/dist/memory-physics.js.map +1 -0
- package/dist/near-pointer-contract-cases.d.ts +16 -0
- package/dist/near-pointer-contract-cases.js +235 -0
- package/dist/near-pointer-contract-cases.js.map +1 -0
- package/dist/near-pointer.d.ts +131 -0
- package/dist/near-pointer.js +128 -0
- package/dist/near-pointer.js.map +1 -0
- package/dist/physics-contract-cases.d.ts +11 -0
- package/dist/physics-contract-cases.js +229 -0
- package/dist/physics-contract-cases.js.map +1 -0
- package/dist/physics.d.ts +148 -0
- package/dist/physics.js +35 -0
- package/dist/physics.js.map +1 -0
- package/dist/ports.d.ts +15 -0
- package/dist/ports.js.map +1 -1
- package/dist/runtime.d.ts +93 -4
- package/dist/runtime.js +299 -37
- package/dist/runtime.js.map +1 -1
- package/package.json +2 -2
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 ??
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
/**
|
|
264
|
-
|
|
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 (
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
|
296
|
-
|
|
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 :
|
|
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
|
-
//
|
|
339
|
-
|
|
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
|
-
|
|
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 =
|
|
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) {
|