@realitycollective/webxr-interactions 0.1.1-preview.3 → 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.
@@ -21,9 +21,16 @@
21
21
  * - **Priority.** Per source, the first of touch, grab, ray with a candidate
22
22
  * is the active pointer (`PRIORITY_ORDER`), and once it is pressing or
23
23
  * grabbing it stays active until the release (IWSDK's selection lock).
24
+ * Since 29 September 2026 that decision is made ONCE per source across
25
+ * interactables and UI panels by the `PointerArbiter` in
26
+ * `@realitycollective/webxr-input` (`pointer-arbiter.ts`), which this
27
+ * runtime feeds and reads; `POINTER_PRIORITY`, `pickActivePointer`,
28
+ * `PointerCandidate` and `PointerVisuals` live there and are re-exported
29
+ * here unchanged.
24
30
  * - **Visuals.** The ray and its cursor show only while the ray is the active
25
31
  * pointer or nothing is (`shouldHideRay`); a near pointer owning the hand
26
- * hides them, and the shared cursor sits at the active pointer's hit point.
32
+ * hides them, and the shared cursor sits at the active pointer's hit. What
33
+ * the app then shows of them is its `PointerDisplay` configuration.
27
34
  *
28
35
  * One rule is deliberately stricter than the template, as the Pale Signal
29
36
  * work order of 28 September 2026 required: a touch never presses from inside
@@ -33,10 +40,7 @@
33
40
  * press on that first frame; `touch-reference.test.ts` records the one frame
34
41
  * on which the two differ, and that they agree everywhere else.
35
42
  */
36
- /** The pointer that owns a source this frame. `"gaze"` is the eye-gaze source's own pointer. */
37
- export type ActivePointerKind = "touch" | "grab" | "ray" | "gaze";
38
- /** IWSDK's order: the first with a candidate wins. `multi-pointer.js` `PRIORITY_ORDER`. */
39
- export declare const POINTER_PRIORITY: readonly ActivePointerKind[];
43
+ export { POINTER_PRIORITY, pickActivePointer, pointerVisualsFor, type ActivePointerKind, type PointerCandidate, type PointerVisuals, } from "@realitycollective/webxr-input";
40
44
  export interface NearPointerOptions {
41
45
  /** Surface distance, metres, within which a fingertip starts hovering a target. IWSDK `enterHoverDistance` 0.15. */
42
46
  touchHoverEnter: number;
@@ -82,50 +86,3 @@ export declare class TouchPointerState {
82
86
  /** Drop everything without reporting (the source went away). */
83
87
  reset(): void;
84
88
  }
85
- /** A candidate one pointer found: the target and the point the cursor sits at. */
86
- export interface PointerCandidate {
87
- targetId: string;
88
- /** World-space point: the ray's hit, or the closest point of a near target. */
89
- point: readonly [number, number, number];
90
- /** Metres: the ray parameter for a ray, the surface distance for a near pointer. */
91
- distance: number;
92
- }
93
- /**
94
- * IWSDK's `pickActiveByPriority` with its selection lock: while the current
95
- * active pointer is selecting it stays; otherwise the first pointer in
96
- * `POINTER_PRIORITY` with a candidate wins, or none.
97
- */
98
- export declare function pickActivePointer(candidates: {
99
- touch: boolean;
100
- grab: boolean;
101
- ray: boolean;
102
- }, current: ActivePointerKind | null, selecting: boolean): ActivePointerKind | null;
103
- /**
104
- * What a binding shows for one source: IWSDK's `shouldHideRay` and the shared
105
- * cursor from `MultiPointer.update`. A host draws exactly this and decides
106
- * nothing.
107
- */
108
- export interface PointerVisuals {
109
- /** The source these visuals belong to. */
110
- sourceId: string;
111
- /** The pointer owning the source, or null when none has a candidate. */
112
- activePointer: ActivePointerKind | null;
113
- /**
114
- * Draw this source's ray. True while the source has a ray and no near
115
- * pointer owns it: the ray is active, or nothing is. False while touch or
116
- * grab owns the hand, while eye gaze has taken the far ray, and for a
117
- * source with no ray at all.
118
- */
119
- ray: boolean;
120
- /**
121
- * Draw the cursor disc at `cursorPoint`. True exactly while the active
122
- * pointer has a candidate: at the ray's hit, or on the surface the
123
- * fingertip or grip reaches. Never drawn "at every ray hit" regardless of
124
- * ownership, which is what the September 2026 native contract said.
125
- */
126
- cursor: boolean;
127
- /** World-space cursor position while `cursor` is true, else null. */
128
- cursorPoint: readonly [number, number, number] | null;
129
- }
130
- /** Build the visuals for one source from its active pointer and that pointer's candidate. */
131
- export declare function pointerVisualsFor(sourceId: string, hasRay: boolean, active: ActivePointerKind | null, candidate: PointerCandidate | null): PointerVisuals;
@@ -21,9 +21,16 @@
21
21
  * - **Priority.** Per source, the first of touch, grab, ray with a candidate
22
22
  * is the active pointer (`PRIORITY_ORDER`), and once it is pressing or
23
23
  * grabbing it stays active until the release (IWSDK's selection lock).
24
+ * Since 29 September 2026 that decision is made ONCE per source across
25
+ * interactables and UI panels by the `PointerArbiter` in
26
+ * `@realitycollective/webxr-input` (`pointer-arbiter.ts`), which this
27
+ * runtime feeds and reads; `POINTER_PRIORITY`, `pickActivePointer`,
28
+ * `PointerCandidate` and `PointerVisuals` live there and are re-exported
29
+ * here unchanged.
24
30
  * - **Visuals.** The ray and its cursor show only while the ray is the active
25
31
  * pointer or nothing is (`shouldHideRay`); a near pointer owning the hand
26
- * hides them, and the shared cursor sits at the active pointer's hit point.
32
+ * hides them, and the shared cursor sits at the active pointer's hit. What
33
+ * the app then shows of them is its `PointerDisplay` configuration.
27
34
  *
28
35
  * One rule is deliberately stricter than the template, as the Pale Signal
29
36
  * work order of 28 September 2026 required: a touch never presses from inside
@@ -33,8 +40,7 @@
33
40
  * press on that first frame; `touch-reference.test.ts` records the one frame
34
41
  * on which the two differ, and that they agree everywhere else.
35
42
  */
36
- /** IWSDK's order: the first with a candidate wins. `multi-pointer.js` `PRIORITY_ORDER`. */
37
- export const POINTER_PRIORITY = Object.freeze(["touch", "grab", "ray"]);
43
+ export { POINTER_PRIORITY, pickActivePointer, pointerVisualsFor, } from "@realitycollective/webxr-input";
38
44
  /** IWSDK 1.0.0's constants: `TOUCH_HYSTERESIS`, `TOUCH_DEFAULTS.downRadius`, `createGrabPointer`'s radius. */
39
45
  export const NEAR_POINTER_DEFAULTS = Object.freeze({
40
46
  touchHoverEnter: 0.15,
@@ -105,24 +111,4 @@ export class TouchPointerState {
105
111
  this.armed = false;
106
112
  }
107
113
  }
108
- /**
109
- * IWSDK's `pickActiveByPriority` with its selection lock: while the current
110
- * active pointer is selecting it stays; otherwise the first pointer in
111
- * `POINTER_PRIORITY` with a candidate wins, or none.
112
- */
113
- export function pickActivePointer(candidates, current, selecting) {
114
- if (selecting && current !== null)
115
- return current;
116
- for (const kind of POINTER_PRIORITY) {
117
- if (candidates[kind])
118
- return kind;
119
- }
120
- return null;
121
- }
122
- /** Build the visuals for one source from its active pointer and that pointer's candidate. */
123
- export function pointerVisualsFor(sourceId, hasRay, active, candidate) {
124
- const ray = hasRay && (active === null || active === "ray");
125
- const cursor = active !== null && candidate !== null;
126
- return { sourceId, activePointer: active, ray, cursor, cursorPoint: cursor ? candidate.point : null };
127
- }
128
114
  //# sourceMappingURL=near-pointer.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"near-pointer.js","sourceRoot":"","sources":["../src/near-pointer.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Near pointers - the touch press, the grip grab, which pointer owns a hand\n * and what that means for the ray and cursor. Pure logic, no engine.\n *\n * The template is IWSDK 1.0.0's pointer stack (`@iwsdk/xr-input`\n * `multi-pointer.js`, `touch-pointer.js`, `grab-pointer.js`, over\n * `@pmndrs/pointer-events`), restated here so every binding runs the same\n * rule from the facts it already reports (`indexTip`, `gripPose`, `ray`):\n *\n * - **Touch.** A fingertip (a controller's ray origin stands in for one)\n * hovers a target once its distance to the target's SURFACE is within\n * `touchHoverEnter` (0.15 m) and keeps hovering until it is beyond\n * `touchHoverExit` (0.2 m): `TOUCH_HYSTERESIS`. It presses while that\n * distance is at or under `touchDown` (0.02 m): `TOUCH_DEFAULTS.downRadius`,\n * which `@pmndrs/pointer-events` compares every frame, so the release is\n * the way out past the same distance.\n * - **Grab.** The grip is a sphere of `grabRadius` (0.07 m,\n * `createGrabPointer`'s default) and a target whose surface it reaches is\n * the grab candidate. The squeeze (or a hand's pinch on a grab-only\n * target) then grabs it.\n * - **Priority.** Per source, the first of touch, grab, ray with a candidate\n * is the active pointer (`PRIORITY_ORDER`), and once it is pressing or\n * grabbing it stays active until the release (IWSDK's selection lock).\n * - **Visuals.** The ray and its cursor show only while the ray is the active\n * pointer or nothing is (`shouldHideRay`); a near pointer owning the hand\n * hides them, and the shared cursor sits at the active pointer's hit point.\n *\n * One rule is deliberately stricter than the template, as the Pale Signal\n * work order of 28 September 2026 required: a touch never presses from inside\n * or from behind. A fingertip first seen already within the press band, a\n * hand that pops into tracking inside the target, does not press until it has\n * been seen outside the band and comes back in. IWSDK's touch pointer would\n * press on that first frame; `touch-reference.test.ts` records the one frame\n * on which the two differ, and that they agree everywhere else.\n */\n\n/** The pointer that owns a source this frame. `\"gaze\"` is the eye-gaze source's own pointer. */\nexport type ActivePointerKind = \"touch\" | \"grab\" | \"ray\" | \"gaze\";\n\n/** IWSDK's order: the first with a candidate wins. `multi-pointer.js` `PRIORITY_ORDER`. */\nexport const POINTER_PRIORITY: readonly ActivePointerKind[] = Object.freeze([\"touch\", \"grab\", \"ray\"]);\n\nexport interface NearPointerOptions {\n /** Surface distance, metres, within which a fingertip starts hovering a target. IWSDK `enterHoverDistance` 0.15. */\n touchHoverEnter: number;\n /** Surface distance, metres, beyond which a hovering fingertip stops hovering. IWSDK `exitHoverDistance` 0.2. Never less than `touchHoverEnter`. */\n touchHoverExit: number;\n /** Surface distance, metres, at or under which a hovering fingertip presses, and above which it releases. IWSDK `downRadius` 0.02. */\n touchDown: number;\n /** Radius, metres, of the grip sphere that finds a grab candidate. `createGrabPointer` default 0.07. */\n grabRadius: number;\n}\n\n/** IWSDK 1.0.0's constants: `TOUCH_HYSTERESIS`, `TOUCH_DEFAULTS.downRadius`, `createGrabPointer`'s radius. */\nexport const NEAR_POINTER_DEFAULTS: Readonly<NearPointerOptions> = Object.freeze({\n touchHoverEnter: 0.15,\n touchHoverExit: 0.2,\n touchDown: 0.02,\n grabRadius: 0.07,\n});\n\nexport function resolveNearPointerOptions(options: Partial<NearPointerOptions> = {}): NearPointerOptions {\n const resolved = { ...NEAR_POINTER_DEFAULTS, ...options };\n if (resolved.touchDown < 0 || resolved.touchHoverEnter < 0 || resolved.grabRadius < 0) {\n throw new Error(\"[webxr-interactions] near pointer distances must be >= 0\");\n }\n if (resolved.touchHoverExit < resolved.touchHoverEnter) {\n throw new Error(\n `[webxr-interactions] touchHoverExit (${resolved.touchHoverExit}) must be >= touchHoverEnter (${resolved.touchHoverEnter})`,\n );\n }\n return resolved;\n}\n\n/** What the touch machine reports after one frame. */\nexport interface TouchPointerUpdate {\n /** The fingertip is hovering a target this frame (the touch pointer has a candidate). */\n hovering: boolean;\n /** The touch is pressing this frame. */\n pressed: boolean;\n /** True on the exact frame a press started. */\n pressStarted: boolean;\n /** True on the exact frame a press ended. */\n pressEnded: boolean;\n}\n\n/**\n * One source's touch pointer: hover with hysteresis, press at `touchDown`\n * armed only from outside the band. Feed it the fingertip's surface distance\n * to the nearest target each frame, or `null` when no target is in reach.\n */\nexport class TouchPointerState {\n private hovering = false;\n private pressed = false;\n /** The fingertip has been seen outside the press band since it last lost contact. */\n private armed = false;\n\n get isHovering(): boolean {\n return this.hovering;\n }\n\n get isPressed(): boolean {\n return this.pressed;\n }\n\n /**\n * Advance one frame. `enter`/`exit` are the hover band for the target in\n * question (an interactable may widen the default), `down` the press\n * distance.\n */\n update(distance: number | null, enter: number, exit: number, down: number): TouchPointerUpdate {\n const wasPressed = this.pressed;\n const threshold = this.hovering ? exit : enter;\n if (distance === null || distance > threshold) {\n this.hovering = false;\n this.pressed = false;\n this.armed = false;\n return { hovering: false, pressed: false, pressStarted: false, pressEnded: wasPressed };\n }\n this.hovering = true;\n if (distance > down) {\n // Outside the press band: released if it was pressing, and armed for the next press.\n this.armed = true;\n this.pressed = false;\n return { hovering: true, pressed: false, pressStarted: false, pressEnded: wasPressed };\n }\n // Within the press band. IWSDK presses here unconditionally; the core\n // requires the fingertip to have arrived from outside (see the file comment).\n if (!this.pressed && this.armed) {\n this.pressed = true;\n this.armed = false;\n return { hovering: true, pressed: true, pressStarted: true, pressEnded: false };\n }\n return { hovering: true, pressed: this.pressed, pressStarted: false, pressEnded: false };\n }\n\n /** Drop everything without reporting (the source went away). */\n reset(): void {\n this.hovering = false;\n this.pressed = false;\n this.armed = false;\n }\n}\n\n/** A candidate one pointer found: the target and the point the cursor sits at. */\nexport interface PointerCandidate {\n targetId: string;\n /** World-space point: the ray's hit, or the closest point of a near target. */\n point: readonly [number, number, number];\n /** Metres: the ray parameter for a ray, the surface distance for a near pointer. */\n distance: number;\n}\n\n/**\n * IWSDK's `pickActiveByPriority` with its selection lock: while the current\n * active pointer is selecting it stays; otherwise the first pointer in\n * `POINTER_PRIORITY` with a candidate wins, or none.\n */\nexport function pickActivePointer(\n candidates: { touch: boolean; grab: boolean; ray: boolean },\n current: ActivePointerKind | null,\n selecting: boolean,\n): ActivePointerKind | null {\n if (selecting && current !== null) return current;\n for (const kind of POINTER_PRIORITY) {\n if (candidates[kind as \"touch\" | \"grab\" | \"ray\"]) return kind;\n }\n return null;\n}\n\n/**\n * What a binding shows for one source: IWSDK's `shouldHideRay` and the shared\n * cursor from `MultiPointer.update`. A host draws exactly this and decides\n * nothing.\n */\nexport interface PointerVisuals {\n /** The source these visuals belong to. */\n sourceId: string;\n /** The pointer owning the source, or null when none has a candidate. */\n activePointer: ActivePointerKind | null;\n /**\n * Draw this source's ray. True while the source has a ray and no near\n * pointer owns it: the ray is active, or nothing is. False while touch or\n * grab owns the hand, while eye gaze has taken the far ray, and for a\n * source with no ray at all.\n */\n ray: boolean;\n /**\n * Draw the cursor disc at `cursorPoint`. True exactly while the active\n * pointer has a candidate: at the ray's hit, or on the surface the\n * fingertip or grip reaches. Never drawn \"at every ray hit\" regardless of\n * ownership, which is what the September 2026 native contract said.\n */\n cursor: boolean;\n /** World-space cursor position while `cursor` is true, else null. */\n cursorPoint: readonly [number, number, number] | null;\n}\n\n/** Build the visuals for one source from its active pointer and that pointer's candidate. */\nexport function pointerVisualsFor(\n sourceId: string,\n hasRay: boolean,\n active: ActivePointerKind | null,\n candidate: PointerCandidate | null,\n): PointerVisuals {\n const ray = hasRay && (active === null || active === \"ray\");\n const cursor = active !== null && candidate !== null;\n return { sourceId, activePointer: active, ray, cursor, cursorPoint: cursor ? candidate!.point : null };\n}\n"]}
1
+ {"version":3,"file":"near-pointer.js","sourceRoot":"","sources":["../src/near-pointer.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Near pointers - the touch press, the grip grab, which pointer owns a hand\n * and what that means for the ray and cursor. Pure logic, no engine.\n *\n * The template is IWSDK 1.0.0's pointer stack (`@iwsdk/xr-input`\n * `multi-pointer.js`, `touch-pointer.js`, `grab-pointer.js`, over\n * `@pmndrs/pointer-events`), restated here so every binding runs the same\n * rule from the facts it already reports (`indexTip`, `gripPose`, `ray`):\n *\n * - **Touch.** A fingertip (a controller's ray origin stands in for one)\n * hovers a target once its distance to the target's SURFACE is within\n * `touchHoverEnter` (0.15 m) and keeps hovering until it is beyond\n * `touchHoverExit` (0.2 m): `TOUCH_HYSTERESIS`. It presses while that\n * distance is at or under `touchDown` (0.02 m): `TOUCH_DEFAULTS.downRadius`,\n * which `@pmndrs/pointer-events` compares every frame, so the release is\n * the way out past the same distance.\n * - **Grab.** The grip is a sphere of `grabRadius` (0.07 m,\n * `createGrabPointer`'s default) and a target whose surface it reaches is\n * the grab candidate. The squeeze (or a hand's pinch on a grab-only\n * target) then grabs it.\n * - **Priority.** Per source, the first of touch, grab, ray with a candidate\n * is the active pointer (`PRIORITY_ORDER`), and once it is pressing or\n * grabbing it stays active until the release (IWSDK's selection lock).\n * Since 29 September 2026 that decision is made ONCE per source across\n * interactables and UI panels by the `PointerArbiter` in\n * `@realitycollective/webxr-input` (`pointer-arbiter.ts`), which this\n * runtime feeds and reads; `POINTER_PRIORITY`, `pickActivePointer`,\n * `PointerCandidate` and `PointerVisuals` live there and are re-exported\n * here unchanged.\n * - **Visuals.** The ray and its cursor show only while the ray is the active\n * pointer or nothing is (`shouldHideRay`); a near pointer owning the hand\n * hides them, and the shared cursor sits at the active pointer's hit. What\n * the app then shows of them is its `PointerDisplay` configuration.\n *\n * One rule is deliberately stricter than the template, as the Pale Signal\n * work order of 28 September 2026 required: a touch never presses from inside\n * or from behind. A fingertip first seen already within the press band, a\n * hand that pops into tracking inside the target, does not press until it has\n * been seen outside the band and comes back in. IWSDK's touch pointer would\n * press on that first frame; `touch-reference.test.ts` records the one frame\n * on which the two differ, and that they agree everywhere else.\n */\nexport {\n POINTER_PRIORITY,\n pickActivePointer,\n pointerVisualsFor,\n type ActivePointerKind,\n type PointerCandidate,\n type PointerVisuals,\n} from \"@realitycollective/webxr-input\";\n\nexport interface NearPointerOptions {\n /** Surface distance, metres, within which a fingertip starts hovering a target. IWSDK `enterHoverDistance` 0.15. */\n touchHoverEnter: number;\n /** Surface distance, metres, beyond which a hovering fingertip stops hovering. IWSDK `exitHoverDistance` 0.2. Never less than `touchHoverEnter`. */\n touchHoverExit: number;\n /** Surface distance, metres, at or under which a hovering fingertip presses, and above which it releases. IWSDK `downRadius` 0.02. */\n touchDown: number;\n /** Radius, metres, of the grip sphere that finds a grab candidate. `createGrabPointer` default 0.07. */\n grabRadius: number;\n}\n\n/** IWSDK 1.0.0's constants: `TOUCH_HYSTERESIS`, `TOUCH_DEFAULTS.downRadius`, `createGrabPointer`'s radius. */\nexport const NEAR_POINTER_DEFAULTS: Readonly<NearPointerOptions> = Object.freeze({\n touchHoverEnter: 0.15,\n touchHoverExit: 0.2,\n touchDown: 0.02,\n grabRadius: 0.07,\n});\n\nexport function resolveNearPointerOptions(options: Partial<NearPointerOptions> = {}): NearPointerOptions {\n const resolved = { ...NEAR_POINTER_DEFAULTS, ...options };\n if (resolved.touchDown < 0 || resolved.touchHoverEnter < 0 || resolved.grabRadius < 0) {\n throw new Error(\"[webxr-interactions] near pointer distances must be >= 0\");\n }\n if (resolved.touchHoverExit < resolved.touchHoverEnter) {\n throw new Error(\n `[webxr-interactions] touchHoverExit (${resolved.touchHoverExit}) must be >= touchHoverEnter (${resolved.touchHoverEnter})`,\n );\n }\n return resolved;\n}\n\n/** What the touch machine reports after one frame. */\nexport interface TouchPointerUpdate {\n /** The fingertip is hovering a target this frame (the touch pointer has a candidate). */\n hovering: boolean;\n /** The touch is pressing this frame. */\n pressed: boolean;\n /** True on the exact frame a press started. */\n pressStarted: boolean;\n /** True on the exact frame a press ended. */\n pressEnded: boolean;\n}\n\n/**\n * One source's touch pointer: hover with hysteresis, press at `touchDown`\n * armed only from outside the band. Feed it the fingertip's surface distance\n * to the nearest target each frame, or `null` when no target is in reach.\n */\nexport class TouchPointerState {\n private hovering = false;\n private pressed = false;\n /** The fingertip has been seen outside the press band since it last lost contact. */\n private armed = false;\n\n get isHovering(): boolean {\n return this.hovering;\n }\n\n get isPressed(): boolean {\n return this.pressed;\n }\n\n /**\n * Advance one frame. `enter`/`exit` are the hover band for the target in\n * question (an interactable may widen the default), `down` the press\n * distance.\n */\n update(distance: number | null, enter: number, exit: number, down: number): TouchPointerUpdate {\n const wasPressed = this.pressed;\n const threshold = this.hovering ? exit : enter;\n if (distance === null || distance > threshold) {\n this.hovering = false;\n this.pressed = false;\n this.armed = false;\n return { hovering: false, pressed: false, pressStarted: false, pressEnded: wasPressed };\n }\n this.hovering = true;\n if (distance > down) {\n // Outside the press band: released if it was pressing, and armed for the next press.\n this.armed = true;\n this.pressed = false;\n return { hovering: true, pressed: false, pressStarted: false, pressEnded: wasPressed };\n }\n // Within the press band. IWSDK presses here unconditionally; the core\n // requires the fingertip to have arrived from outside (see the file comment).\n if (!this.pressed && this.armed) {\n this.pressed = true;\n this.armed = false;\n return { hovering: true, pressed: true, pressStarted: true, pressEnded: false };\n }\n return { hovering: true, pressed: this.pressed, pressStarted: false, pressEnded: false };\n }\n\n /** Drop everything without reporting (the source went away). */\n reset(): void {\n this.hovering = false;\n this.pressed = false;\n this.armed = false;\n }\n}\n"]}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The release velocity of a throw - the core rule, restated from what Meta
3
+ * IWSDK 1.0.0 does with a held object so every platform's throw carries the
4
+ * same speed on the same input.
5
+ *
6
+ * On the web a thrown object is carried by IWSDK's grab system and Havok.
7
+ * While the object is `Grabbed`, `physics-system.js` sends the body a
8
+ * `SetTargetTransform` command every frame with the object's pose (the pose
9
+ * the grab pointer moved it to), so Havok drives the body kinematically to
10
+ * that target at each 1/60 s step (`PHYSICS_DEFAULTS.stepsPerSecond`). When
11
+ * the grab ends the tag is removed and nothing resets the body: it keeps the
12
+ * velocity the engine gave it to reach the last target, the displacement of
13
+ * the OBJECT over the last physics step divided by the step. So IWSDK's
14
+ * throw is:
15
+ *
16
+ * - the velocity of the held OBJECT, not of the grip: for a rigid carry the
17
+ * two agree, but the rule is stated on the object, which every platform
18
+ * has, rather than on a runtime-reported hand velocity, which most do not;
19
+ * - measured over the last physics step, `RELEASE_WINDOW_SECONDS` (1/60 s):
20
+ * at 72 Hz that is one frame, at 90 Hz two, at 120 Hz two;
21
+ * - angular velocity from the orientation delta over the same window.
22
+ *
23
+ * `ReleaseVelocityTracker` keeps the poses the carry wrote and their frame
24
+ * times; `release()` differentiates over the most recent frames that cover
25
+ * the window, or over the last frame when only one has been seen. A hold
26
+ * that never moved (or was released the frame it started) throws nothing:
27
+ * zeros, so the object rests where it was let go. A synthesised release
28
+ * (source lost, target unregistered, runtime disposed) never reads this: the
29
+ * grab behaviour hands the port zeros, as `grab.ts` says.
30
+ */
31
+ import { type PoseTuple } from "@realitycollective/webxr-input";
32
+ import type { HoldRelease } from "./ports.js";
33
+ /** IWSDK's physics step, 1/60 s: the window a throw's velocity is measured over. */
34
+ export declare const RELEASE_WINDOW_SECONDS: number;
35
+ export interface ReleaseVelocityTrackerOptions {
36
+ /** Seconds the release velocity is measured over. Default `RELEASE_WINDOW_SECONDS`. */
37
+ windowSeconds?: number;
38
+ }
39
+ export declare class ReleaseVelocityTracker {
40
+ private readonly window;
41
+ private readonly samples;
42
+ constructor(options?: ReleaseVelocityTrackerOptions);
43
+ /** Forget every pose: the hold starts afresh. */
44
+ reset(): void;
45
+ /**
46
+ * Record the pose the carry wrote this frame, `dt` seconds after the last
47
+ * one. The tuples are copied, so the caller may reuse them.
48
+ */
49
+ record(pose: PoseTuple, dt: number): void;
50
+ /** How many poses are recorded. */
51
+ get size(): number;
52
+ /**
53
+ * The velocity the object carries at release: linear m/s and angular
54
+ * rad/s (axis scaled), world space, over the most recent frames that span
55
+ * at least the window. Zeros with fewer than two poses, or no elapsed time.
56
+ */
57
+ release(): HoldRelease;
58
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The release velocity of a throw - the core rule, restated from what Meta
3
+ * IWSDK 1.0.0 does with a held object so every platform's throw carries the
4
+ * same speed on the same input.
5
+ *
6
+ * On the web a thrown object is carried by IWSDK's grab system and Havok.
7
+ * While the object is `Grabbed`, `physics-system.js` sends the body a
8
+ * `SetTargetTransform` command every frame with the object's pose (the pose
9
+ * the grab pointer moved it to), so Havok drives the body kinematically to
10
+ * that target at each 1/60 s step (`PHYSICS_DEFAULTS.stepsPerSecond`). When
11
+ * the grab ends the tag is removed and nothing resets the body: it keeps the
12
+ * velocity the engine gave it to reach the last target, the displacement of
13
+ * the OBJECT over the last physics step divided by the step. So IWSDK's
14
+ * throw is:
15
+ *
16
+ * - the velocity of the held OBJECT, not of the grip: for a rigid carry the
17
+ * two agree, but the rule is stated on the object, which every platform
18
+ * has, rather than on a runtime-reported hand velocity, which most do not;
19
+ * - measured over the last physics step, `RELEASE_WINDOW_SECONDS` (1/60 s):
20
+ * at 72 Hz that is one frame, at 90 Hz two, at 120 Hz two;
21
+ * - angular velocity from the orientation delta over the same window.
22
+ *
23
+ * `ReleaseVelocityTracker` keeps the poses the carry wrote and their frame
24
+ * times; `release()` differentiates over the most recent frames that cover
25
+ * the window, or over the last frame when only one has been seen. A hold
26
+ * that never moved (or was released the frame it started) throws nothing:
27
+ * zeros, so the object rests where it was let go. A synthesised release
28
+ * (source lost, target unregistered, runtime disposed) never reads this: the
29
+ * grab behaviour hands the port zeros, as `grab.ts` says.
30
+ */
31
+ import { velocityBetween } from "@realitycollective/webxr-input";
32
+ /** IWSDK's physics step, 1/60 s: the window a throw's velocity is measured over. */
33
+ export const RELEASE_WINDOW_SECONDS = 1 / 60;
34
+ /** How many past frames the tracker keeps: enough to cover the window at any refresh rate down to 30 Hz. */
35
+ const MAX_SAMPLES = 8;
36
+ export class ReleaseVelocityTracker {
37
+ window;
38
+ samples = [];
39
+ constructor(options = {}) {
40
+ const window = options.windowSeconds ?? RELEASE_WINDOW_SECONDS;
41
+ if (!Number.isFinite(window) || window <= 0) {
42
+ throw new Error(`[webxr-interactions] release window must be a positive number of seconds, got ${String(window)}`);
43
+ }
44
+ this.window = window;
45
+ }
46
+ /** Forget every pose: the hold starts afresh. */
47
+ reset() {
48
+ this.samples.length = 0;
49
+ }
50
+ /**
51
+ * Record the pose the carry wrote this frame, `dt` seconds after the last
52
+ * one. The tuples are copied, so the caller may reuse them.
53
+ */
54
+ record(pose, dt) {
55
+ const copy = {
56
+ position: [pose.position[0], pose.position[1], pose.position[2]],
57
+ quaternion: [pose.quaternion[0], pose.quaternion[1], pose.quaternion[2], pose.quaternion[3]],
58
+ };
59
+ const step = this.samples.length === 0 || !Number.isFinite(dt) || dt < 0 ? 0 : dt;
60
+ this.samples.push({ pose: copy, dt: step });
61
+ if (this.samples.length > MAX_SAMPLES)
62
+ this.samples.shift();
63
+ }
64
+ /** How many poses are recorded. */
65
+ get size() {
66
+ return this.samples.length;
67
+ }
68
+ /**
69
+ * The velocity the object carries at release: linear m/s and angular
70
+ * rad/s (axis scaled), world space, over the most recent frames that span
71
+ * at least the window. Zeros with fewer than two poses, or no elapsed time.
72
+ */
73
+ release() {
74
+ const n = this.samples.length;
75
+ if (n < 2)
76
+ return { linearVelocity: [0, 0, 0], angularVelocity: [0, 0, 0] };
77
+ let elapsed = 0;
78
+ let start = n - 1;
79
+ while (start > 0 && elapsed < this.window) {
80
+ elapsed += this.samples[start].dt;
81
+ start -= 1;
82
+ }
83
+ if (elapsed <= 0)
84
+ return { linearVelocity: [0, 0, 0], angularVelocity: [0, 0, 0] };
85
+ const velocity = velocityBetween(this.samples[start].pose, this.samples[n - 1].pose, elapsed);
86
+ return { linearVelocity: velocity.linear, angularVelocity: velocity.angular };
87
+ }
88
+ }
89
+ //# sourceMappingURL=release-velocity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"release-velocity.js","sourceRoot":"","sources":["../src/release-velocity.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The release velocity of a throw - the core rule, restated from what Meta\n * IWSDK 1.0.0 does with a held object so every platform's throw carries the\n * same speed on the same input.\n *\n * On the web a thrown object is carried by IWSDK's grab system and Havok.\n * While the object is `Grabbed`, `physics-system.js` sends the body a\n * `SetTargetTransform` command every frame with the object's pose (the pose\n * the grab pointer moved it to), so Havok drives the body kinematically to\n * that target at each 1/60 s step (`PHYSICS_DEFAULTS.stepsPerSecond`). When\n * the grab ends the tag is removed and nothing resets the body: it keeps the\n * velocity the engine gave it to reach the last target, the displacement of\n * the OBJECT over the last physics step divided by the step. So IWSDK's\n * throw is:\n *\n * - the velocity of the held OBJECT, not of the grip: for a rigid carry the\n * two agree, but the rule is stated on the object, which every platform\n * has, rather than on a runtime-reported hand velocity, which most do not;\n * - measured over the last physics step, `RELEASE_WINDOW_SECONDS` (1/60 s):\n * at 72 Hz that is one frame, at 90 Hz two, at 120 Hz two;\n * - angular velocity from the orientation delta over the same window.\n *\n * `ReleaseVelocityTracker` keeps the poses the carry wrote and their frame\n * times; `release()` differentiates over the most recent frames that cover\n * the window, or over the last frame when only one has been seen. A hold\n * that never moved (or was released the frame it started) throws nothing:\n * zeros, so the object rests where it was let go. A synthesised release\n * (source lost, target unregistered, runtime disposed) never reads this: the\n * grab behaviour hands the port zeros, as `grab.ts` says.\n */\nimport { velocityBetween, type PoseTuple } from \"@realitycollective/webxr-input\";\nimport type { HoldRelease } from \"./ports.js\";\n\n/** IWSDK's physics step, 1/60 s: the window a throw's velocity is measured over. */\nexport const RELEASE_WINDOW_SECONDS = 1 / 60;\n\n/** How many past frames the tracker keeps: enough to cover the window at any refresh rate down to 30 Hz. */\nconst MAX_SAMPLES = 8;\n\ninterface Sample {\n pose: PoseTuple;\n /** Seconds between this pose and the one before it (0 for the first). */\n dt: number;\n}\n\nexport interface ReleaseVelocityTrackerOptions {\n /** Seconds the release velocity is measured over. Default `RELEASE_WINDOW_SECONDS`. */\n windowSeconds?: number;\n}\n\nexport class ReleaseVelocityTracker {\n private readonly window: number;\n private readonly samples: Sample[] = [];\n\n constructor(options: ReleaseVelocityTrackerOptions = {}) {\n const window = options.windowSeconds ?? RELEASE_WINDOW_SECONDS;\n if (!Number.isFinite(window) || window <= 0) {\n throw new Error(`[webxr-interactions] release window must be a positive number of seconds, got ${String(window)}`);\n }\n this.window = window;\n }\n\n /** Forget every pose: the hold starts afresh. */\n reset(): void {\n this.samples.length = 0;\n }\n\n /**\n * Record the pose the carry wrote this frame, `dt` seconds after the last\n * one. The tuples are copied, so the caller may reuse them.\n */\n record(pose: PoseTuple, dt: number): void {\n const copy: PoseTuple = {\n position: [pose.position[0], pose.position[1], pose.position[2]],\n quaternion: [pose.quaternion[0], pose.quaternion[1], pose.quaternion[2], pose.quaternion[3]],\n };\n const step = this.samples.length === 0 || !Number.isFinite(dt) || dt < 0 ? 0 : dt;\n this.samples.push({ pose: copy, dt: step });\n if (this.samples.length > MAX_SAMPLES) this.samples.shift();\n }\n\n /** How many poses are recorded. */\n get size(): number {\n return this.samples.length;\n }\n\n /**\n * The velocity the object carries at release: linear m/s and angular\n * rad/s (axis scaled), world space, over the most recent frames that span\n * at least the window. Zeros with fewer than two poses, or no elapsed time.\n */\n release(): HoldRelease {\n const n = this.samples.length;\n if (n < 2) return { linearVelocity: [0, 0, 0], angularVelocity: [0, 0, 0] };\n let elapsed = 0;\n let start = n - 1;\n while (start > 0 && elapsed < this.window) {\n elapsed += this.samples[start]!.dt;\n start -= 1;\n }\n if (elapsed <= 0) return { linearVelocity: [0, 0, 0], angularVelocity: [0, 0, 0] };\n const velocity = velocityBetween(this.samples[start]!.pose, this.samples[n - 1]!.pose, elapsed);\n return { linearVelocity: velocity.linear, angularVelocity: velocity.angular };\n }\n}\n"]}
package/dist/runtime.d.ts 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 { type EyeGazeOptions, type InputCapabilities, type InputProvider, type InputSourceSnapshot, type Unsubscribe } from "@realitycollective/webxr-input";
16
+ import { PointerArbiter, PointerDisplay, type ActivePointerKind, type EyeGazeOptions, type InputCapabilities, type InputProvider, type InputSourceSnapshot, type PointerDisplayConfig, type PointerDrawing, type PointerVisuals, type Unsubscribe } from "@realitycollective/webxr-input";
17
17
  import type { Behaviour } from "./behaviours/behaviour.js";
18
18
  import type { InteractableDescriptor } from "./descriptor.js";
19
19
  import { type InteractionEventListener } from "./events.js";
20
20
  import type { FeedbackListener } from "./feedback.js";
21
21
  import { type DwellConfig } from "./gaze.js";
22
22
  import type { HitTester, TransformPort } from "./ports.js";
23
- import { type ActivePointerKind, type NearPointerOptions, type PointerVisuals } from "./near-pointer.js";
23
+ import { type NearPointerOptions } from "./near-pointer.js";
24
24
  import { type VelocityTrackerOptions } from "./velocity-tracker.js";
25
25
  /** Interactor id used when gaze is synthesized from the head pose. */
26
26
  export declare const HEAD_GAZE_INTERACTOR_ID = "head-gaze";
@@ -60,7 +60,23 @@ export interface InteractionRuntimeOptions {
60
60
  * the touch hover enter distance for that target alone.
61
61
  */
62
62
  nearPointer?: Partial<NearPointerOptions>;
63
+ /**
64
+ * The pointer arbiter shared with everything else a pointer can reach (the
65
+ * UI Extensions window hosts), so one decision per source covers panels
66
+ * and interactables alike, as IWSDK's `MultiPointer` does. Omit for a
67
+ * runtime used on its own: it makes one, and decides over its own targets.
68
+ */
69
+ pointers?: PointerArbiter;
70
+ /**
71
+ * The app's pointer display settings (ray never, always or while hitting;
72
+ * cursors on objects and on panels; the ray and cursor look), or a
73
+ * `PointerDisplay` to share. Defaults are IWSDK 1.0.0's. Settable at run
74
+ * time through `getPointerDisplay().set(...)`.
75
+ */
76
+ pointerDisplay?: PointerDisplay | Partial<PointerDisplayConfig>;
63
77
  }
78
+ /** The target set this runtime registers with its arbiter. */
79
+ export declare const INTERACTIONS_POINTER_SET = "interactions";
64
80
  export interface RegisterPorts {
65
81
  transform?: TransformPort;
66
82
  }
@@ -74,8 +90,13 @@ export declare class InteractionRuntime {
74
90
  private readonly feedbackEmitter;
75
91
  private readonly afterSample;
76
92
  private readonly visualsEmitter;
93
+ private readonly drawingEmitter;
77
94
  private readonly nearPointer;
95
+ private readonly pointers;
96
+ private readonly targetSet;
97
+ private readonly pointerDisplay;
78
98
  private lastVisuals;
99
+ private lastDrawings;
79
100
  private readonly velocityTracker;
80
101
  private lastSources;
81
102
  private capabilities;
@@ -149,6 +170,21 @@ export declare class InteractionRuntime {
149
170
  onPointerVisuals(listener: (visuals: readonly PointerVisuals[]) => void): Unsubscribe;
150
171
  /** The visuals last published for a source, or undefined before its first frame. */
151
172
  getPointerVisuals(sourceId: string): PointerVisuals | undefined;
173
+ /**
174
+ * What to draw for every source this frame under the app's pointer display
175
+ * settings: the visuals above resolved through `pointerDrawing`. Published
176
+ * right after `onPointerVisuals`. A binding that draws its own ray and
177
+ * cursor applies exactly this; a native binding hands it to the host.
178
+ */
179
+ onPointerDrawing(listener: (drawings: readonly PointerDrawing[]) => void): Unsubscribe;
180
+ /** The drawing last published for a source, or undefined before its first frame. */
181
+ getPointerDrawing(sourceId: string): PointerDrawing | undefined;
182
+ /** The arbiter deciding which pointer owns each source, shared with the UI Extensions hosts when the app passed one. */
183
+ getPointerArbiter(): PointerArbiter;
184
+ /** The app's pointer display settings; `set` changes them at run time. */
185
+ getPointerDisplay(): PointerDisplay;
186
+ /** Whether a source is pressing or grabbing this frame (the selection that recolours its pointer). */
187
+ isSelecting(sourceId: string): boolean;
152
188
  update(dt: number): void;
153
189
  dispose(): void;
154
190
  /**
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, rayPoseFromRay, resolveEyeGazeOptions, 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
20
  import { DwellState, GazeConsensus, resolveDwellConfig, DWELL_DEFAULTS } from "./gaze.js";
21
21
  import { quatConjugate, quatMultiply, vApplyQuat, vLength, vNormalize, vSub } from "./math.js";
22
- import { pickActivePointer, pointerVisualsFor, resolveNearPointerOptions, TouchPointerState, } from "./near-pointer.js";
22
+ import { resolveNearPointerOptions, TouchPointerState } from "./near-pointer.js";
23
23
  import { VelocityTracker } from "./velocity-tracker.js";
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;
@@ -33,8 +35,13 @@ export class InteractionRuntime {
33
35
  feedbackEmitter = new Emitter();
34
36
  afterSample = new Emitter();
35
37
  visualsEmitter = new Emitter();
38
+ drawingEmitter = new Emitter();
36
39
  nearPointer;
40
+ pointers;
41
+ targetSet;
42
+ pointerDisplay;
37
43
  lastVisuals = [];
44
+ lastDrawings = [];
38
45
  velocityTracker;
39
46
  lastSources = new Map();
40
47
  capabilities;
@@ -51,6 +58,10 @@ export class InteractionRuntime {
51
58
  this.provider = options.provider;
52
59
  this.hitTester = options.hitTester ?? null;
53
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);
54
65
  this.eyeGaze = resolveEyeGazeOptions(options.eyeGaze);
55
66
  this.gazeConsensus = new GazeConsensus(this.eyeGaze.dwellWindowSeconds);
56
67
  const d = options.dwellDefaults;
@@ -218,6 +229,32 @@ export class InteractionRuntime {
218
229
  getPointerVisuals(sourceId) {
219
230
  return this.lastVisuals.find((visuals) => visuals.sourceId === sourceId);
220
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
+ }
221
258
  // -- frame ------------------------------------------------------------------
222
259
  update(dt) {
223
260
  if (this.disposed)
@@ -267,14 +304,19 @@ export class InteractionRuntime {
267
304
  if (!seen.has(id)) {
268
305
  this.forceRelease(id, state);
269
306
  this.sourceStates.delete(id);
307
+ this.pointers.forget(id);
270
308
  }
271
309
  }
272
310
  this.updateGaze(sources, dt);
273
311
  this.tickBehaviours(sources, dt);
274
312
  this.lastVisuals = visuals;
275
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);
276
317
  }
277
318
  dispose() {
319
+ this.targetSet.dispose();
278
320
  if (this.disposed)
279
321
  return;
280
322
  this.disposed = true;
@@ -460,7 +502,9 @@ export class InteractionRuntime {
460
502
  state.touch.reset();
461
503
  const gazeTarget = this.eyeGazeTarget;
462
504
  const point = gazeTarget ? this.gazePoints.get(gazeTarget) : undefined;
463
- state.candidate = gazeTarget && point ? { targetId: gazeTarget, point, distance: 0 } : null;
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 });
464
508
  if (hint && this.targetable(hint.targetId)) {
465
509
  state.resolvedBy = "hint";
466
510
  return hint.targetId;
@@ -469,21 +513,31 @@ export class InteractionRuntime {
469
513
  state.resolvedBy = "gaze";
470
514
  return gazeTarget;
471
515
  }
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;
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;
479
528
  if (hint && this.targetable(hint.targetId)) {
480
529
  state.resolvedBy = "hint";
481
530
  return hint.targetId;
482
531
  }
483
- if (!state.candidate || !active)
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)
484
538
  return null;
485
539
  state.resolvedBy = active === "touch" ? "poke" : active;
486
- return state.candidate.targetId;
540
+ return decision.candidate.targetId;
487
541
  }
488
542
  /**
489
543
  * The touch pointer's candidate this frame, advancing the source's touch
@@ -628,6 +682,12 @@ export class InteractionRuntime {
628
682
  state.grabTarget = null;
629
683
  }
630
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
+ }
631
691
  if (this.capabilities.eyeGaze && source.kind === "gaze")
632
692
  this.trackGazeHold(source, state, target);
633
693
  }
@@ -691,7 +751,7 @@ export class InteractionRuntime {
691
751
  }
692
752
  }
693
753
  forceRelease(sourceId, state) {
694
- const info = { id: sourceId, kind: "other", select: 0, squeeze: 0 };
754
+ const info = { id: sourceId, kind: "other", select: 0, squeeze: 0, synthesized: true };
695
755
  if (state.pressTarget)
696
756
  this.routePressEnd(state.pressTarget, sourceId, info);
697
757
  if (state.grabTarget)
@@ -705,12 +765,12 @@ export class InteractionRuntime {
705
765
  releaseAllHolds(id) {
706
766
  for (const [sourceId, state] of this.sourceStates) {
707
767
  if (state.pressTarget === id) {
708
- 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 });
709
769
  state.pressTarget = null;
710
770
  state.pressed = false;
711
771
  }
712
772
  if (state.grabTarget === id) {
713
- 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 });
714
774
  state.grabTarget = null;
715
775
  state.grabbed = false;
716
776
  }