@realitycollective/webxr-interactions 0.1.0-preview.0

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 (58) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +42 -0
  4. package/dist/behaviours/behaviour.d.ts +63 -0
  5. package/dist/behaviours/behaviour.js +5 -0
  6. package/dist/behaviours/behaviour.js.map +1 -0
  7. package/dist/behaviours/dial.d.ts +36 -0
  8. package/dist/behaviours/dial.js +88 -0
  9. package/dist/behaviours/dial.js.map +1 -0
  10. package/dist/behaviours/factory.d.ts +24 -0
  11. package/dist/behaviours/factory.js +29 -0
  12. package/dist/behaviours/factory.js.map +1 -0
  13. package/dist/behaviours/grab.d.ts +21 -0
  14. package/dist/behaviours/grab.js +64 -0
  15. package/dist/behaviours/grab.js.map +1 -0
  16. package/dist/behaviours/hinge.d.ts +42 -0
  17. package/dist/behaviours/hinge.js +70 -0
  18. package/dist/behaviours/hinge.js.map +1 -0
  19. package/dist/behaviours/press.d.ts +57 -0
  20. package/dist/behaviours/press.js +109 -0
  21. package/dist/behaviours/press.js.map +1 -0
  22. package/dist/behaviours/pulse.d.ts +21 -0
  23. package/dist/behaviours/pulse.js +37 -0
  24. package/dist/behaviours/pulse.js.map +1 -0
  25. package/dist/behaviours/slide.d.ts +47 -0
  26. package/dist/behaviours/slide.js +104 -0
  27. package/dist/behaviours/slide.js.map +1 -0
  28. package/dist/behaviours/toss-score.d.ts +33 -0
  29. package/dist/behaviours/toss-score.js +58 -0
  30. package/dist/behaviours/toss-score.js.map +1 -0
  31. package/dist/descriptor.d.ts +21 -0
  32. package/dist/descriptor.js +2 -0
  33. package/dist/descriptor.js.map +1 -0
  34. package/dist/events.d.ts +32 -0
  35. package/dist/events.js +16 -0
  36. package/dist/events.js.map +1 -0
  37. package/dist/feedback.d.ts +31 -0
  38. package/dist/feedback.js +15 -0
  39. package/dist/feedback.js.map +1 -0
  40. package/dist/gaze.d.ts +44 -0
  41. package/dist/gaze.js +61 -0
  42. package/dist/gaze.js.map +1 -0
  43. package/dist/index.d.ts +34 -0
  44. package/dist/index.js +35 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/math.d.ts +68 -0
  47. package/dist/math.js +164 -0
  48. package/dist/math.js.map +1 -0
  49. package/dist/pointer-bridge.d.ts +24 -0
  50. package/dist/pointer-bridge.js +60 -0
  51. package/dist/pointer-bridge.js.map +1 -0
  52. package/dist/ports.d.ts +50 -0
  53. package/dist/ports.js +2 -0
  54. package/dist/ports.js.map +1 -0
  55. package/dist/runtime.d.ts +97 -0
  56. package/dist/runtime.js +511 -0
  57. package/dist/runtime.js.map +1 -0
  58. package/package.json +51 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * InteractionRuntime - the engine-free orchestrator.
3
+ *
4
+ * Once per host frame the runtime pulls the input provider, resolves
5
+ * targeting (provider hints first - provider power wins - then poke
6
+ * proximity, then ray hits), applies press/grab transitions with
7
+ * hysteresis, runs gaze gating + dwell, ticks every behaviour, and emits
8
+ * interaction events and feedback intents. It never touches an engine:
9
+ * space queries and transform writes go through the adapter-implemented
10
+ * {@link HitTester} and {@link TransformPort}.
11
+ *
12
+ * Lifecycle is first-class (the pale-signal gap): interactables register,
13
+ * unregister, enable and disable; behaviours disabled by capability
14
+ * negotiation announce themselves with a `behaviourDisabled` event.
15
+ */
16
+ import { type InputCapabilities, type InputProvider, type InputSourceSnapshot, type Unsubscribe } from "@realitycollective/webxr-input";
17
+ import type { Behaviour } from "./behaviours/behaviour.js";
18
+ import type { InteractableDescriptor } from "./descriptor.js";
19
+ import { type InteractionEventListener } from "./events.js";
20
+ import type { FeedbackListener } from "./feedback.js";
21
+ import { type DwellConfig } from "./gaze.js";
22
+ import type { HitTester, TransformPort } from "./ports.js";
23
+ /** Interactor id used when gaze is synthesized from the head pose. */
24
+ export declare const HEAD_GAZE_INTERACTOR_ID = "head-gaze";
25
+ export interface InteractableState {
26
+ id: string;
27
+ enabled: boolean;
28
+ hovered: boolean;
29
+ gazeHovered: boolean;
30
+ pressed: boolean;
31
+ grabbed: boolean;
32
+ /** Primary behaviour's logical value. */
33
+ value: number;
34
+ /** Gaze-dwell meter 0..1 (0 when dwell is not enabled). */
35
+ dwell: number;
36
+ }
37
+ export interface InteractionRuntimeOptions {
38
+ provider: InputProvider;
39
+ hitTester?: HitTester;
40
+ dwellDefaults?: DwellConfig;
41
+ }
42
+ export interface RegisterPorts {
43
+ transform?: TransformPort;
44
+ }
45
+ export declare class InteractionRuntime {
46
+ private readonly provider;
47
+ private hitTester;
48
+ private readonly dwellDefaults;
49
+ private readonly interactables;
50
+ private readonly sourceStates;
51
+ private readonly events;
52
+ private readonly feedbackEmitter;
53
+ private readonly afterSample;
54
+ private capabilities;
55
+ private readonly unsubscribeCaps;
56
+ private disposed;
57
+ constructor(options: InteractionRuntimeOptions);
58
+ registerInteractable(def: InteractableDescriptor, ports?: RegisterPorts): void;
59
+ unregisterInteractable(id: string): void;
60
+ setInteractableEnabled(id: string, enabled: boolean): void;
61
+ setHitTester(hitTester: HitTester | null): void;
62
+ /** Reach a live behaviour instance for tuning (playground steppers). */
63
+ getBehaviour<T extends Behaviour = Behaviour>(interactableId: string, kind: string): T | undefined;
64
+ getState(id: string): InteractableState | undefined;
65
+ getInteractableIds(): string[];
66
+ onEvent(listener: InteractionEventListener): Unsubscribe;
67
+ onFeedback(listener: FeedbackListener): Unsubscribe;
68
+ /** Sampled sources for this frame - the UI Extensions pointer bridge hook. */
69
+ onSourcesSampled(listener: (sources: readonly InputSourceSnapshot[]) => void): Unsubscribe;
70
+ getCapabilities(): InputCapabilities;
71
+ getProvider(): InputProvider;
72
+ update(dt: number): void;
73
+ dispose(): void;
74
+ private sourceState;
75
+ private interactorInfo;
76
+ /** Resolve this source's target: hints beat poke beats ray. */
77
+ private resolveTarget;
78
+ private targetable;
79
+ private flags;
80
+ private updateSource;
81
+ private gazeAllows;
82
+ private setHover;
83
+ private routePressStart;
84
+ private routePressEnd;
85
+ private endGrab;
86
+ private forceRelease;
87
+ private releaseAllHolds;
88
+ private updateGaze;
89
+ private tickBehaviours;
90
+ private contextFor;
91
+ private renegotiate;
92
+ private renegotiateAll;
93
+ }
94
+ /** Build a runtime's interactables from a portable descriptor. */
95
+ export declare function registerDescriptor(runtime: InteractionRuntime, descriptor: {
96
+ interactables: InteractableDescriptor[];
97
+ }, portsFor?: (id: string) => RegisterPorts): void;
@@ -0,0 +1,511 @@
1
+ /**
2
+ * InteractionRuntime - the engine-free orchestrator.
3
+ *
4
+ * Once per host frame the runtime pulls the input provider, resolves
5
+ * targeting (provider hints first - provider power wins - then poke
6
+ * proximity, then ray hits), applies press/grab transitions with
7
+ * hysteresis, runs gaze gating + dwell, ticks every behaviour, and emits
8
+ * interaction events and feedback intents. It never touches an engine:
9
+ * space queries and transform writes go through the adapter-implemented
10
+ * {@link HitTester} and {@link TransformPort}.
11
+ *
12
+ * Lifecycle is first-class (the pale-signal gap): interactables register,
13
+ * unregister, enable and disable; behaviours disabled by capability
14
+ * negotiation announce themselves with a `behaviourDisabled` event.
15
+ */
16
+ import { SELECT_PRESS_THRESHOLD, SELECT_RELEASE_THRESHOLD, unmetRequirements, } from "@realitycollective/webxr-input";
17
+ import { createBehaviour, } from "./behaviours/factory.js";
18
+ import { GrabBehaviour } from "./behaviours/grab.js";
19
+ import { Emitter } from "./events.js";
20
+ import { DwellState, resolveDwellConfig, DWELL_DEFAULTS } from "./gaze.js";
21
+ import { vApplyQuat } from "./math.js";
22
+ const DEFAULT_POKE_RADIUS = 0.05;
23
+ /** Interactor id used when gaze is synthesized from the head pose. */
24
+ export const HEAD_GAZE_INTERACTOR_ID = "head-gaze";
25
+ export class InteractionRuntime {
26
+ provider;
27
+ hitTester;
28
+ dwellDefaults;
29
+ interactables = new Map();
30
+ sourceStates = new Map();
31
+ events = new Emitter();
32
+ feedbackEmitter = new Emitter();
33
+ afterSample = new Emitter();
34
+ capabilities;
35
+ unsubscribeCaps;
36
+ disposed = false;
37
+ constructor(options) {
38
+ this.provider = options.provider;
39
+ this.hitTester = options.hitTester ?? null;
40
+ const d = options.dwellDefaults;
41
+ this.dwellDefaults = {
42
+ holdSeconds: d?.holdSeconds ?? DWELL_DEFAULTS.holdSeconds,
43
+ decayFactor: d?.decayFactor ?? DWELL_DEFAULTS.decayFactor,
44
+ rearmBelow: d?.rearmBelow ?? DWELL_DEFAULTS.rearmBelow,
45
+ };
46
+ this.capabilities = this.provider.getCapabilities();
47
+ this.unsubscribeCaps = this.provider.onCapabilitiesChanged((caps) => {
48
+ this.capabilities = caps;
49
+ this.renegotiateAll();
50
+ });
51
+ }
52
+ // -- registry -------------------------------------------------------------
53
+ registerInteractable(def, ports = {}) {
54
+ if (this.interactables.has(def.id)) {
55
+ throw new Error(`Interactable "${def.id}" is already registered`);
56
+ }
57
+ const context = {
58
+ grabFulfilment: this.capabilities.grabs === "native" ? "native" : "poseOnly",
59
+ };
60
+ const registered = {
61
+ def,
62
+ behaviours: def.behaviours.map((config) => ({
63
+ instance: createBehaviour(config, context),
64
+ config,
65
+ disabled: false,
66
+ disabledReason: "",
67
+ })),
68
+ transform: ports.transform,
69
+ enabled: def.enabled !== false,
70
+ pokeRadius: def.pokeRadius ?? DEFAULT_POKE_RADIUS,
71
+ gazeRequired: def.gaze?.required === true,
72
+ dwellConfig: resolveDwellConfig(def.gaze?.dwell, this.dwellDefaults),
73
+ dwell: new DwellState(),
74
+ gazeHovered: false,
75
+ hoveredBy: new Set(),
76
+ pressedBy: new Set(),
77
+ grabbedBy: null,
78
+ };
79
+ this.interactables.set(def.id, registered);
80
+ this.renegotiate(def.id, registered);
81
+ }
82
+ unregisterInteractable(id) {
83
+ const registered = this.interactables.get(id);
84
+ if (!registered)
85
+ return;
86
+ this.releaseAllHolds(id);
87
+ this.interactables.delete(id);
88
+ }
89
+ setInteractableEnabled(id, enabled) {
90
+ const registered = this.interactables.get(id);
91
+ if (!registered || registered.enabled === enabled)
92
+ return;
93
+ registered.enabled = enabled;
94
+ if (!enabled)
95
+ this.releaseAllHolds(id);
96
+ this.events.emit({
97
+ type: enabled ? "interactableEnabled" : "interactableDisabled",
98
+ interactableId: id,
99
+ });
100
+ }
101
+ setHitTester(hitTester) {
102
+ this.hitTester = hitTester;
103
+ }
104
+ /** Reach a live behaviour instance for tuning (playground steppers). */
105
+ getBehaviour(interactableId, kind) {
106
+ const registered = this.interactables.get(interactableId);
107
+ return registered?.behaviours.find((b) => b.instance.kind === kind)?.instance;
108
+ }
109
+ getState(id) {
110
+ const r = this.interactables.get(id);
111
+ if (!r)
112
+ return undefined;
113
+ const primary = r.behaviours.find((b) => !b.disabled)?.instance;
114
+ return {
115
+ id,
116
+ enabled: r.enabled,
117
+ hovered: r.hoveredBy.size > 0 || r.gazeHovered,
118
+ gazeHovered: r.gazeHovered,
119
+ pressed: r.pressedBy.size > 0,
120
+ grabbed: r.grabbedBy !== null,
121
+ value: primary?.getValue() ?? 0,
122
+ dwell: r.dwell.getProgress(),
123
+ };
124
+ }
125
+ getInteractableIds() {
126
+ return [...this.interactables.keys()];
127
+ }
128
+ // -- subscriptions ----------------------------------------------------------
129
+ onEvent(listener) {
130
+ return this.events.subscribe(listener);
131
+ }
132
+ onFeedback(listener) {
133
+ return this.feedbackEmitter.subscribe(listener);
134
+ }
135
+ /** Sampled sources for this frame - the UI Extensions pointer bridge hook. */
136
+ onSourcesSampled(listener) {
137
+ return this.afterSample.subscribe(listener);
138
+ }
139
+ getCapabilities() {
140
+ return this.capabilities;
141
+ }
142
+ getProvider() {
143
+ return this.provider;
144
+ }
145
+ // -- frame ------------------------------------------------------------------
146
+ update(dt) {
147
+ if (this.disposed)
148
+ return;
149
+ const sources = this.provider.sample();
150
+ const hints = this.provider.sampleHints?.() ?? [];
151
+ this.afterSample.emit(sources);
152
+ const hintBySource = new Map();
153
+ for (const hint of hints) {
154
+ const list = hintBySource.get(hint.sourceId);
155
+ if (list)
156
+ list.push(hint);
157
+ else
158
+ hintBySource.set(hint.sourceId, [hint]);
159
+ }
160
+ const seen = new Set();
161
+ for (const source of sources) {
162
+ seen.add(source.id);
163
+ this.updateSource(source, hintBySource.get(source.id) ?? []);
164
+ }
165
+ // Sources that vanished mid-hold release everything they held.
166
+ for (const [id, state] of this.sourceStates) {
167
+ if (!seen.has(id)) {
168
+ this.forceRelease(id, state);
169
+ this.sourceStates.delete(id);
170
+ }
171
+ }
172
+ this.updateGaze(sources, dt);
173
+ this.tickBehaviours(sources, dt);
174
+ }
175
+ dispose() {
176
+ this.disposed = true;
177
+ this.unsubscribeCaps();
178
+ this.interactables.clear();
179
+ this.sourceStates.clear();
180
+ }
181
+ // -- internals ---------------------------------------------------------------
182
+ sourceState(id) {
183
+ let state = this.sourceStates.get(id);
184
+ if (!state) {
185
+ state = { hoverTarget: null, pressed: false, pressTarget: null, grabbed: false, grabTarget: null };
186
+ this.sourceStates.set(id, state);
187
+ }
188
+ return state;
189
+ }
190
+ interactorInfo(source) {
191
+ const info = {
192
+ id: source.id,
193
+ kind: source.kind,
194
+ select: source.select,
195
+ squeeze: source.squeeze,
196
+ };
197
+ if (source.ray)
198
+ info.ray = source.ray;
199
+ if (source.gripPose)
200
+ info.gripPose = source.gripPose;
201
+ if (source.indexTip)
202
+ info.indexTip = source.indexTip;
203
+ return info;
204
+ }
205
+ /** Resolve this source's target: hints beat poke beats ray. */
206
+ resolveTarget(source, hints) {
207
+ const hint = hints.find((h) => h.state === "grab") ??
208
+ hints.find((h) => h.state === "press") ??
209
+ hints.find((h) => h.state === "hover");
210
+ if (hint && this.targetable(hint.targetId))
211
+ return hint.targetId;
212
+ if (source.indexTip && this.hitTester) {
213
+ const poke = this.hitTester.hitProximity(source.indexTip, DEFAULT_POKE_RADIUS);
214
+ if (poke && this.targetable(poke.interactableId))
215
+ return poke.interactableId;
216
+ }
217
+ if (source.ray && this.hitTester) {
218
+ const hit = this.hitTester.hitRay(source.ray);
219
+ if (hit && this.targetable(hit.interactableId))
220
+ return hit.interactableId;
221
+ }
222
+ return null;
223
+ }
224
+ targetable(id) {
225
+ const registered = this.interactables.get(id);
226
+ return registered !== undefined && registered.enabled;
227
+ }
228
+ flags(registered) {
229
+ let pressable = false;
230
+ let grabbable = false;
231
+ for (const slot of registered.behaviours) {
232
+ if (slot.disabled)
233
+ continue;
234
+ if (slot.instance.onPressStart)
235
+ pressable = true;
236
+ if (slot.instance.grabbable)
237
+ grabbable = true;
238
+ }
239
+ return { pressable, grabbable };
240
+ }
241
+ updateSource(source, hints) {
242
+ const state = this.sourceState(source.id);
243
+ const target = this.resolveTarget(source, hints);
244
+ // Hover transitions.
245
+ if (state.hoverTarget !== target) {
246
+ if (state.hoverTarget)
247
+ this.setHover(state.hoverTarget, source.id, false);
248
+ if (target)
249
+ this.setHover(target, source.id, true);
250
+ state.hoverTarget = target;
251
+ }
252
+ const registered = target ? this.interactables.get(target) : undefined;
253
+ const roles = registered ? this.flags(registered) : { pressable: false, grabbable: false };
254
+ // Signal resolution. A pinching hand (or a 2D pointer click) acts as a
255
+ // grab when the target is grab-only; controllers keep trigger=press,
256
+ // squeeze=grab.
257
+ const selectAsGrab = roles.grabbable && !roles.pressable && source.kind !== "controller";
258
+ const pressSignal = selectAsGrab ? 0 : source.select;
259
+ const grabSignal = Math.max(source.squeeze, selectAsGrab ? source.select : 0);
260
+ const hintPress = hints.some((h) => h.state === "press");
261
+ const hintGrab = hints.some((h) => h.state === "grab") || source.nativeGrabbing === true;
262
+ // Press with hysteresis (hints override).
263
+ const pressActive = hintPress || (state.pressed
264
+ ? pressSignal > SELECT_RELEASE_THRESHOLD
265
+ : pressSignal >= SELECT_PRESS_THRESHOLD);
266
+ if (pressActive && !state.pressed) {
267
+ state.pressed = true;
268
+ if (target && registered && roles.pressable && this.gazeAllows(registered)) {
269
+ state.pressTarget = target;
270
+ this.routePressStart(target, registered, this.interactorInfo(source));
271
+ }
272
+ }
273
+ else if (!pressActive && state.pressed) {
274
+ state.pressed = false;
275
+ if (state.pressTarget) {
276
+ this.routePressEnd(state.pressTarget, source.id, this.interactorInfo(source));
277
+ state.pressTarget = null;
278
+ }
279
+ }
280
+ // Grab with hysteresis (hints / native grabbing override).
281
+ const grabActive = hintGrab || (state.grabbed
282
+ ? grabSignal > SELECT_RELEASE_THRESHOLD
283
+ : grabSignal >= SELECT_PRESS_THRESHOLD);
284
+ if (grabActive && !state.grabbed) {
285
+ state.grabbed = true;
286
+ const hintTargetId = hints.find((h) => h.state === "grab")?.targetId;
287
+ const grabTargetId = hintTargetId && this.targetable(hintTargetId) ? hintTargetId : target;
288
+ const grabTarget = grabTargetId ? this.interactables.get(grabTargetId) : undefined;
289
+ if (grabTargetId &&
290
+ grabTarget &&
291
+ this.flags(grabTarget).grabbable &&
292
+ grabTarget.grabbedBy === null &&
293
+ this.gazeAllows(grabTarget)) {
294
+ state.grabTarget = grabTargetId;
295
+ grabTarget.grabbedBy = source.id;
296
+ const info = this.interactorInfo(source);
297
+ for (const slot of grabTarget.behaviours) {
298
+ if (!slot.disabled && slot.instance.grabbable) {
299
+ slot.instance.onGrabStart?.(this.contextFor(grabTargetId, grabTarget, 0), info);
300
+ }
301
+ }
302
+ }
303
+ }
304
+ else if (!grabActive && state.grabbed) {
305
+ state.grabbed = false;
306
+ if (state.grabTarget) {
307
+ this.endGrab(state.grabTarget, source.id, this.interactorInfo(source));
308
+ state.grabTarget = null;
309
+ }
310
+ }
311
+ }
312
+ gazeAllows(registered) {
313
+ return !registered.gazeRequired || registered.gazeHovered;
314
+ }
315
+ setHover(id, sourceId, hovered) {
316
+ const registered = this.interactables.get(id);
317
+ if (!registered)
318
+ return;
319
+ if (hovered) {
320
+ registered.hoveredBy.add(sourceId);
321
+ if (registered.hoveredBy.size === 1) {
322
+ this.events.emit({ type: "hoverEnter", interactableId: id, interactorId: sourceId });
323
+ this.feedbackEmitter.emit({
324
+ cue: "hover",
325
+ interactableId: id,
326
+ sourceId,
327
+ intensity: 0.1,
328
+ durationMs: 10,
329
+ });
330
+ }
331
+ }
332
+ else {
333
+ registered.hoveredBy.delete(sourceId);
334
+ if (registered.hoveredBy.size === 0) {
335
+ this.events.emit({ type: "hoverExit", interactableId: id, interactorId: sourceId });
336
+ }
337
+ }
338
+ }
339
+ routePressStart(id, registered, info) {
340
+ registered.pressedBy.add(info.id);
341
+ this.events.emit({ type: "pressStart", interactableId: id, interactorId: info.id });
342
+ const ctx = this.contextFor(id, registered, 0);
343
+ for (const slot of registered.behaviours) {
344
+ if (!slot.disabled)
345
+ slot.instance.onPressStart?.(ctx, info);
346
+ }
347
+ }
348
+ routePressEnd(id, sourceId, info) {
349
+ const registered = this.interactables.get(id);
350
+ if (!registered)
351
+ return;
352
+ registered.pressedBy.delete(sourceId);
353
+ this.events.emit({ type: "pressEnd", interactableId: id, interactorId: sourceId });
354
+ const ctx = this.contextFor(id, registered, 0);
355
+ for (const slot of registered.behaviours) {
356
+ if (!slot.disabled)
357
+ slot.instance.onPressEnd?.(ctx, info);
358
+ }
359
+ }
360
+ endGrab(id, sourceId, info) {
361
+ const registered = this.interactables.get(id);
362
+ if (!registered || registered.grabbedBy !== sourceId)
363
+ return;
364
+ registered.grabbedBy = null;
365
+ const ctx = this.contextFor(id, registered, 0);
366
+ for (const slot of registered.behaviours) {
367
+ if (!slot.disabled && slot.instance.grabbable)
368
+ slot.instance.onGrabEnd?.(ctx, info);
369
+ }
370
+ }
371
+ forceRelease(sourceId, state) {
372
+ const info = { id: sourceId, kind: "other", select: 0, squeeze: 0 };
373
+ if (state.pressTarget)
374
+ this.routePressEnd(state.pressTarget, sourceId, info);
375
+ if (state.grabTarget)
376
+ this.endGrab(state.grabTarget, sourceId, info);
377
+ if (state.hoverTarget)
378
+ this.setHover(state.hoverTarget, sourceId, false);
379
+ }
380
+ releaseAllHolds(id) {
381
+ for (const [sourceId, state] of this.sourceStates) {
382
+ if (state.pressTarget === id) {
383
+ this.routePressEnd(id, sourceId, { id: sourceId, kind: "other", select: 0, squeeze: 0 });
384
+ state.pressTarget = null;
385
+ state.pressed = false;
386
+ }
387
+ if (state.grabTarget === id) {
388
+ this.endGrab(id, sourceId, { id: sourceId, kind: "other", select: 0, squeeze: 0 });
389
+ state.grabTarget = null;
390
+ state.grabbed = false;
391
+ }
392
+ if (state.hoverTarget === id) {
393
+ this.setHover(id, sourceId, false);
394
+ state.hoverTarget = null;
395
+ }
396
+ }
397
+ }
398
+ updateGaze(sources, dt) {
399
+ let gazeRay = null;
400
+ let gazeId = HEAD_GAZE_INTERACTOR_ID;
401
+ const gazeSource = sources.find((s) => s.kind === "gaze");
402
+ if (gazeSource?.ray) {
403
+ gazeRay = gazeSource.ray;
404
+ gazeId = gazeSource.id;
405
+ }
406
+ else if (this.capabilities.headPose && this.provider.getHeadPose) {
407
+ const head = this.provider.getHeadPose();
408
+ gazeRay = {
409
+ origin: head.position,
410
+ direction: vApplyQuat([0, 0, -1], head.quaternion),
411
+ };
412
+ }
413
+ const gazeTarget = gazeRay && this.hitTester ? this.hitTester.hitRay(gazeRay)?.interactableId ?? null : null;
414
+ for (const [id, registered] of this.interactables) {
415
+ const hovered = registered.enabled && gazeTarget === id;
416
+ if (hovered !== registered.gazeHovered) {
417
+ registered.gazeHovered = hovered;
418
+ this.events.emit({
419
+ type: hovered ? "hoverEnter" : "hoverExit",
420
+ interactableId: id,
421
+ interactorId: gazeId,
422
+ });
423
+ }
424
+ if (registered.dwellConfig && registered.enabled) {
425
+ const tick = registered.dwell.update(hovered, dt, registered.dwellConfig);
426
+ if (tick.changed) {
427
+ this.events.emit({
428
+ type: "dwellProgress",
429
+ interactableId: id,
430
+ interactorId: gazeId,
431
+ value: tick.progress,
432
+ });
433
+ }
434
+ if (tick.fired) {
435
+ // A completed dwell is a synthesized click: press + release.
436
+ const info = { id: gazeId, kind: "gaze", select: 1, squeeze: 0 };
437
+ this.routePressStart(id, registered, info);
438
+ this.routePressEnd(id, gazeId, { ...info, select: 0 });
439
+ this.feedbackEmitter.emit({
440
+ cue: "dwellComplete",
441
+ interactableId: id,
442
+ intensity: 0.6,
443
+ durationMs: 50,
444
+ });
445
+ }
446
+ }
447
+ }
448
+ }
449
+ tickBehaviours(sources, dt) {
450
+ const bySourceId = new Map(sources.map((s) => [s.id, s]));
451
+ for (const [id, registered] of this.interactables) {
452
+ if (!registered.enabled)
453
+ continue;
454
+ const holderSource = registered.grabbedBy ? bySourceId.get(registered.grabbedBy) : undefined;
455
+ const holder = holderSource ? this.interactorInfo(holderSource) : undefined;
456
+ const ctx = this.contextFor(id, registered, dt);
457
+ for (const slot of registered.behaviours) {
458
+ if (slot.disabled)
459
+ continue;
460
+ slot.instance.update(ctx, slot.instance.grabbable ? holder : undefined);
461
+ }
462
+ }
463
+ }
464
+ contextFor(id, registered, dt) {
465
+ return {
466
+ dt,
467
+ interactableId: id,
468
+ transform: registered.transform,
469
+ emit: (event) => this.events.emit({ ...event, interactableId: id }),
470
+ feedback: (intent) => this.feedbackEmitter.emit({ ...intent, interactableId: id }),
471
+ getWorldPose: (otherId) => this.interactables.get(otherId)?.transform?.getWorldPose(),
472
+ };
473
+ }
474
+ renegotiate(id, registered) {
475
+ const fulfilment = this.capabilities.grabs === "native" ? "native" : "poseOnly";
476
+ for (const slot of registered.behaviours) {
477
+ if (slot.instance instanceof GrabBehaviour) {
478
+ slot.instance.setFulfilment(fulfilment);
479
+ }
480
+ const unmet = [...unmetRequirements(this.capabilities, slot.instance.requires)];
481
+ for (const group of slot.instance.requiresAnyOf ?? []) {
482
+ if (group.length > 0 && unmetRequirements(this.capabilities, group).length === group.length) {
483
+ unmet.push(group[0]);
484
+ }
485
+ }
486
+ const disabled = unmet.length > 0;
487
+ if (disabled !== slot.disabled) {
488
+ slot.disabled = disabled;
489
+ slot.disabledReason = disabled ? `unmet input capabilities: ${unmet.join(", ")}` : "";
490
+ this.events.emit({
491
+ type: disabled ? "behaviourDisabled" : "behaviourEnabled",
492
+ interactableId: id,
493
+ behaviourKind: slot.instance.kind,
494
+ ...(disabled ? { reason: slot.disabledReason } : {}),
495
+ });
496
+ }
497
+ }
498
+ }
499
+ renegotiateAll() {
500
+ for (const [id, registered] of this.interactables) {
501
+ this.renegotiate(id, registered);
502
+ }
503
+ }
504
+ }
505
+ /** Build a runtime's interactables from a portable descriptor. */
506
+ export function registerDescriptor(runtime, descriptor, portsFor = () => ({})) {
507
+ for (const interactable of descriptor.interactables) {
508
+ runtime.registerInteractable(interactable, portsFor(interactable.id));
509
+ }
510
+ }
511
+ //# sourceMappingURL=runtime.js.map