@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.
- package/CHANGELOG.md +29 -2
- package/README.md +17 -2
- package/dist/behaviours/behaviour.d.ts +17 -1
- package/dist/behaviours/behaviour.js +12 -2
- package/dist/behaviours/behaviour.js.map +1 -1
- package/dist/behaviours/grab.d.ts +1 -0
- package/dist/behaviours/grab.js +10 -4
- package/dist/behaviours/grab.js.map +1 -1
- package/dist/contract-cases.js +84 -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 +9 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/math.d.ts +8 -0
- package/dist/math.js +17 -0
- package/dist/math.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/memory-ports.d.ts +55 -0
- package/dist/memory-ports.js +128 -0
- package/dist/memory-ports.js.map +1 -0
- package/dist/near-pointer-contract-cases.d.ts +16 -0
- package/dist/near-pointer-contract-cases.js +283 -0
- package/dist/near-pointer-contract-cases.js.map +1 -0
- package/dist/near-pointer.d.ts +88 -0
- package/dist/near-pointer.js +114 -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/release-velocity.d.ts +58 -0
- package/dist/release-velocity.js +89 -0
- package/dist/release-velocity.js.map +1 -0
- package/dist/runtime.d.ts +129 -4
- package/dist/runtime.js +362 -40
- package/dist/runtime.js.map +1 -1
- package/dist/trace-synthetic.d.ts +12 -0
- package/dist/trace-synthetic.js +194 -0
- package/dist/trace-synthetic.js.map +1 -0
- package/dist/trace.d.ts +215 -0
- package/dist/trace.js +350 -0
- package/dist/trace.js.map +1 -0
- 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 ??
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
/**
|
|
264
|
-
|
|
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 (
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
|
296
|
-
|
|
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 :
|
|
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
|
-
//
|
|
339
|
-
|
|
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
|
-
|
|
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 =
|
|
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) {
|