@realitycollective/webxr-interactions 0.1.1-preview.1 → 0.1.1-preview.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/gaze.d.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  * The dwell state machine is pure and headless-tested. The runtime feeds it
12
12
  * hover booleans; it never sees the engine.
13
13
  */
14
+ import type { RayTuple, Vec3Tuple } from "@realitycollective/webxr-input";
15
+ import type { InteractableHit } from "./ports.js";
14
16
  export interface DwellConfig {
15
17
  /** Seconds of sustained gaze to fire. */
16
18
  holdSeconds?: number;
@@ -42,3 +44,51 @@ export declare class DwellState {
42
44
  update(hovered: boolean, dt: number, config: Required<DwellConfig>): DwellTick;
43
45
  getProgress(): number;
44
46
  }
47
+ /** Comparer tolerance: angular distances within this are "the same", and distance decides. IWSDK `ANGLE_EPSILON`. */
48
+ export declare const GAZE_CONE_ANGLE_EPSILON: number;
49
+ /** A cone hit: {@link InteractableHit} plus how far off the gaze axis the target's silhouette sat, radians (0 when the ray itself hit). */
50
+ export interface GazeConeHit extends InteractableHit {
51
+ angularDistance: number;
52
+ }
53
+ /** A sphere target, as the approximate hit testers and the native host model one. */
54
+ export interface SphereTarget {
55
+ id: string;
56
+ center: Vec3Tuple;
57
+ radius: number;
58
+ }
59
+ /**
60
+ * IWSDK's `GazeConecaster.findFrameBest` over sphere targets: the best
61
+ * target inside a cone of `halfAngle` radians about `ray`, no farther than
62
+ * `maxLength`. A sphere the ray passes through is hit where the ray enters
63
+ * it, with an angular distance of 0. Otherwise the angle from the ray to the
64
+ * sphere's silhouette (the angle to its centre less the angle it subtends)
65
+ * must be within the cone, and the point aimed at is the point of the sphere
66
+ * nearest the ray. Closer by angle wins; within {@link GAZE_CONE_ANGLE_EPSILON}
67
+ * the nearer target wins. A sphere the origin is inside counts as on axis.
68
+ */
69
+ export declare function coneHitForSpheres(ray: RayTuple, targets: Iterable<SphereTarget>, halfAngle: number, maxLength: number): GazeConeHit | null;
70
+ /** IWSDK's default comparer: closer by angle, then closer by distance. Negative when `a` is better. */
71
+ export declare function compareConeHits(a: GazeConeHit, b: GazeConeHit): number;
72
+ /**
73
+ * IWSDK's dwell consensus (`GazeConecaster.recordDwell` / `pruneDwell` /
74
+ * `electDwellWinner` / `resolveDwellWinner`): each frame's best candidate is
75
+ * recorded with its `dt`; samples older than the window are dropped; the
76
+ * candidate with the largest cumulative `dt` inside the window wins. A winner
77
+ * that is no longer available (unregistered, disabled, hidden) is struck
78
+ * from the record and the next is elected. A window of 0 keeps only this
79
+ * frame's sample, so the frame's best wins outright.
80
+ */
81
+ export declare class GazeConsensus {
82
+ windowSeconds: number;
83
+ private readonly samples;
84
+ private time;
85
+ constructor(windowSeconds?: number);
86
+ /** Record this frame's best (or none), advance the window and elect. */
87
+ update(frameBestId: string | null, dt: number, available: (id: string) => boolean): string | null;
88
+ /** Advance the window with nothing recorded (a frame gaze was suppressed or had no pose). */
89
+ skip(dt: number): void;
90
+ reset(): void;
91
+ private prune;
92
+ private elect;
93
+ private remove;
94
+ }
package/dist/gaze.js CHANGED
@@ -58,4 +58,166 @@ export class DwellState {
58
58
  return this.progress;
59
59
  }
60
60
  }
61
+ // ---------------------------------------------------------------------------
62
+ // Eye gaze: the targeting half of the rule `@realitycollective/webxr-input`'s
63
+ // `eye-gaze.ts` states. IWSDK's `GazeConecaster` picks the frame's best
64
+ // in-cone candidate and then holds a time-weighted vote over the dwell
65
+ // window, so a flick across a neighbour does not steal hover.
66
+ // ---------------------------------------------------------------------------
67
+ /** Comparer tolerance: angular distances within this are "the same", and distance decides. IWSDK `ANGLE_EPSILON`. */
68
+ export const GAZE_CONE_ANGLE_EPSILON = 0.5 * (Math.PI / 180);
69
+ /**
70
+ * IWSDK's `GazeConecaster.findFrameBest` over sphere targets: the best
71
+ * target inside a cone of `halfAngle` radians about `ray`, no farther than
72
+ * `maxLength`. A sphere the ray passes through is hit where the ray enters
73
+ * it, with an angular distance of 0. Otherwise the angle from the ray to the
74
+ * sphere's silhouette (the angle to its centre less the angle it subtends)
75
+ * must be within the cone, and the point aimed at is the point of the sphere
76
+ * nearest the ray. Closer by angle wins; within {@link GAZE_CONE_ANGLE_EPSILON}
77
+ * the nearer target wins. A sphere the origin is inside counts as on axis.
78
+ */
79
+ export function coneHitForSpheres(ray, targets, halfAngle, maxLength) {
80
+ const [ox, oy, oz] = ray.origin;
81
+ const [dx, dy, dz] = ray.direction;
82
+ let best = null;
83
+ for (const target of targets) {
84
+ const [cx, cy, cz] = target.center;
85
+ const r = target.radius;
86
+ const vx = cx - ox;
87
+ const vy = cy - oy;
88
+ const vz = cz - oz;
89
+ const dist = Math.hypot(vx, vy, vz);
90
+ if (dist - r > maxLength)
91
+ continue;
92
+ let hit;
93
+ if (dist <= r) {
94
+ // The origin is inside the sphere: fully on axis, aimed at the centre.
95
+ hit = { interactableId: target.id, distance: dist, point: [cx, cy, cz], angularDistance: 0 };
96
+ }
97
+ else {
98
+ const t = vx * dx + vy * dy + vz * dz;
99
+ const missSq = Math.max(0, dist * dist - t * t);
100
+ const miss = Math.sqrt(missSq);
101
+ if (t > 0 && miss <= r) {
102
+ // The ray enters the sphere: the raw ray wins, at the entry point.
103
+ const entry = t - Math.sqrt(r * r - missSq);
104
+ if (entry > maxLength)
105
+ continue;
106
+ hit = {
107
+ interactableId: target.id,
108
+ distance: entry,
109
+ point: [ox + dx * entry, oy + dy * entry, oz + dz * entry],
110
+ angularDistance: 0,
111
+ };
112
+ }
113
+ else {
114
+ const toCentre = Math.atan2(miss, t);
115
+ const angular = Math.max(0, toCentre - Math.asin(Math.min(1, r / dist)));
116
+ if (angular > halfAngle)
117
+ continue;
118
+ // The point of the sphere nearest the ray: from the centre toward the
119
+ // ray's closest point (never behind the origin), out to the surface.
120
+ const tc = Math.max(0, t);
121
+ let nx = ox + dx * tc - cx;
122
+ let ny = oy + dy * tc - cy;
123
+ let nz = oz + dz * tc - cz;
124
+ const nl = Math.hypot(nx, ny, nz);
125
+ if (nl > 1e-9) {
126
+ nx /= nl;
127
+ ny /= nl;
128
+ nz /= nl;
129
+ }
130
+ else {
131
+ nx = -vx / dist;
132
+ ny = -vy / dist;
133
+ nz = -vz / dist;
134
+ }
135
+ const point = [cx + nx * r, cy + ny * r, cz + nz * r];
136
+ const distance = Math.hypot(point[0] - ox, point[1] - oy, point[2] - oz);
137
+ if (distance > maxLength)
138
+ continue;
139
+ hit = { interactableId: target.id, distance, point, angularDistance: angular };
140
+ }
141
+ }
142
+ if (best === null || compareConeHits(hit, best) < 0)
143
+ best = hit;
144
+ }
145
+ return best;
146
+ }
147
+ /** IWSDK's default comparer: closer by angle, then closer by distance. Negative when `a` is better. */
148
+ export function compareConeHits(a, b) {
149
+ const dAngle = a.angularDistance - b.angularDistance;
150
+ if (Math.abs(dAngle) > GAZE_CONE_ANGLE_EPSILON)
151
+ return dAngle;
152
+ return a.distance - b.distance;
153
+ }
154
+ /**
155
+ * IWSDK's dwell consensus (`GazeConecaster.recordDwell` / `pruneDwell` /
156
+ * `electDwellWinner` / `resolveDwellWinner`): each frame's best candidate is
157
+ * recorded with its `dt`; samples older than the window are dropped; the
158
+ * candidate with the largest cumulative `dt` inside the window wins. A winner
159
+ * that is no longer available (unregistered, disabled, hidden) is struck
160
+ * from the record and the next is elected. A window of 0 keeps only this
161
+ * frame's sample, so the frame's best wins outright.
162
+ */
163
+ export class GazeConsensus {
164
+ windowSeconds;
165
+ samples = [];
166
+ time = 0;
167
+ constructor(windowSeconds = 0.15) {
168
+ this.windowSeconds = windowSeconds;
169
+ }
170
+ /** Record this frame's best (or none), advance the window and elect. */
171
+ update(frameBestId, dt, available) {
172
+ this.time += dt;
173
+ if (frameBestId !== null)
174
+ this.samples.push({ id: frameBestId, time: this.time, dt });
175
+ this.prune();
176
+ for (;;) {
177
+ const elected = this.elect();
178
+ if (elected === null)
179
+ return null;
180
+ if (available(elected))
181
+ return elected;
182
+ this.remove(elected);
183
+ }
184
+ }
185
+ /** Advance the window with nothing recorded (a frame gaze was suppressed or had no pose). */
186
+ skip(dt) {
187
+ this.time += dt;
188
+ }
189
+ reset() {
190
+ this.samples.length = 0;
191
+ }
192
+ prune() {
193
+ const cutoff = this.time - this.windowSeconds;
194
+ let drop = 0;
195
+ while (drop < this.samples.length && this.samples[drop].time < cutoff)
196
+ drop += 1;
197
+ if (drop > 0)
198
+ this.samples.splice(0, drop);
199
+ }
200
+ elect() {
201
+ let bestId = null;
202
+ let bestDt = -1;
203
+ const sums = new Map();
204
+ for (const sample of this.samples)
205
+ sums.set(sample.id, (sums.get(sample.id) ?? 0) + sample.dt);
206
+ // First recorded wins a tie, as IWSDK's scan from the ring head does.
207
+ for (const sample of this.samples) {
208
+ const sum = sums.get(sample.id);
209
+ if (sum > bestDt) {
210
+ bestDt = sum;
211
+ bestId = sample.id;
212
+ }
213
+ }
214
+ return bestId;
215
+ }
216
+ remove(id) {
217
+ for (let i = this.samples.length - 1; i >= 0; i--) {
218
+ if (this.samples[i].id === id)
219
+ this.samples.splice(i, 1);
220
+ }
221
+ }
222
+ }
61
223
  //# sourceMappingURL=gaze.js.map
package/dist/gaze.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"gaze.js","sourceRoot":"","sources":["../src/gaze.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAkBH,MAAM,CAAC,MAAM,cAAc,GAA0B;IACnD,WAAW,EAAE,GAAG;IAChB,WAAW,EAAE,GAAG;IAChB,UAAU,EAAE,GAAG;CAChB,CAAC;AAEF,MAAM,UAAU,kBAAkB,CAChC,MAAyC,EACzC,WAAkC,cAAc;IAEhD,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IACzB,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,QAAQ,CAAC;IACrC,OAAO;QACL,WAAW,EAAE,MAAM,CAAC,WAAW,IAAI,QAAQ,CAAC,WAAW;QACvD,WAAW,EAAE,MAAM,CAAC,WAAW,IAAI,QAAQ,CAAC,WAAW;QACvD,UAAU,EAAE,MAAM,CAAC,UAAU,IAAI,QAAQ,CAAC,UAAU;KACrD,CAAC;AACJ,CAAC;AAWD,MAAM,OAAO,UAAU;IACb,QAAQ,GAAG,CAAC,CAAC;IACb,YAAY,GAAG,CAAC,CAAC;IACjB,KAAK,GAAG,IAAI,CAAC;IAErB,MAAM,CAAC,OAAgB,EAAE,EAAU,EAAE,MAA6B;QAChE,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC;QAC7B,IAAI,OAAO,EAAE,CAAC;YACZ,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,QAAQ,GAAG,EAAE,GAAG,MAAM,CAAC,WAAW,CAAC,CAAC;QACvE,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,GAAG,CACtB,CAAC,EACD,IAAI,CAAC,QAAQ,GAAG,CAAC,EAAE,GAAG,MAAM,CAAC,WAAW,CAAC,GAAG,MAAM,CAAC,WAAW,CAC/D,CAAC;QACJ,CAAC;QAED,IAAI,KAAK,GAAG,KAAK,CAAC;QAClB,IAAI,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,QAAQ,IAAI,CAAC,EAAE,CAAC;YACrC,KAAK,GAAG,IAAI,CAAC;YACb,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;YACnB,IAAI,CAAC,QAAQ,GAAG,CAAC,CAAC;QACpB,CAAC;aAAM,IAAI,CAAC,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,QAAQ,IAAI,MAAM,CAAC,UAAU,EAAE,CAAC;YAC7D,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;QACpB,CAAC;QAED,MAAM,OAAO,GACX,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,YAAY,CAAC,IAAI,IAAI;YACnD,CAAC,IAAI,CAAC,QAAQ,KAAK,CAAC,IAAI,MAAM,KAAK,CAAC,CAAC,CAAC;QACxC,IAAI,OAAO;YAAE,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC,QAAQ,CAAC;QAC/C,OAAO,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;IACrD,CAAC;IAED,WAAW;QACT,OAAO,IAAI,CAAC,QAAQ,CAAC;IACvB,CAAC;CACF","sourcesContent":["/**\n * Gaze support - first-class, per the design decision that gaze serves two\n * roles:\n *\n * 1. **Combination gating** (`gaze.required`): input on an interactable only\n * counts while the viewer is looking at it.\n * 2. **Accessibility** (`gaze.dwell`): looking at an interactable fills a\n * dwell meter; a full meter fires a synthesized press - no hands needed.\n * Consumers render the meter from `dwellProgress` events.\n *\n * The dwell state machine is pure and headless-tested. The runtime feeds it\n * hover booleans; it never sees the engine.\n */\n\nexport interface DwellConfig {\n /** Seconds of sustained gaze to fire. */\n holdSeconds?: number;\n /** Drain speed multiplier when looking away. */\n decayFactor?: number;\n /** After firing, progress must fall below this before it can re-arm. */\n rearmBelow?: number;\n}\n\nexport interface GazeConfig {\n /** Non-gaze input on this interactable only counts while gazed at. */\n required?: boolean;\n /** Enable dwell-to-press. `true` uses defaults. */\n dwell?: boolean | DwellConfig;\n}\n\nexport const DWELL_DEFAULTS: Required<DwellConfig> = {\n holdSeconds: 1.8,\n decayFactor: 2.5,\n rearmBelow: 0.1,\n};\n\nexport function resolveDwellConfig(\n config: boolean | DwellConfig | undefined,\n defaults: Required<DwellConfig> = DWELL_DEFAULTS,\n): Required<DwellConfig> | null {\n if (!config) return null;\n if (config === true) return defaults;\n return {\n holdSeconds: config.holdSeconds ?? defaults.holdSeconds,\n decayFactor: config.decayFactor ?? defaults.decayFactor,\n rearmBelow: config.rearmBelow ?? defaults.rearmBelow,\n };\n}\n\nexport interface DwellTick {\n /** Dwell meter 0..1. */\n progress: number;\n /** Progress moved enough to be worth re-rendering (≥ 1%). */\n changed: boolean;\n /** The meter just completed - fire the synthesized press. */\n fired: boolean;\n}\n\nexport class DwellState {\n private progress = 0;\n private lastReported = 0;\n private armed = true;\n\n update(hovered: boolean, dt: number, config: Required<DwellConfig>): DwellTick {\n const before = this.progress;\n if (hovered) {\n this.progress = Math.min(1, this.progress + dt / config.holdSeconds);\n } else {\n this.progress = Math.max(\n 0,\n this.progress - (dt * config.decayFactor) / config.holdSeconds,\n );\n }\n\n let fired = false;\n if (this.armed && this.progress >= 1) {\n fired = true;\n this.armed = false;\n this.progress = 0;\n } else if (!this.armed && this.progress <= config.rearmBelow) {\n this.armed = true;\n }\n\n const changed =\n Math.abs(this.progress - this.lastReported) >= 0.01 ||\n (this.progress === 0 && before !== 0);\n if (changed) this.lastReported = this.progress;\n return { progress: this.progress, changed, fired };\n }\n\n getProgress(): number {\n return this.progress;\n }\n}\n"]}
1
+ {"version":3,"file":"gaze.js","sourceRoot":"","sources":["../src/gaze.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Gaze support - first-class, per the design decision that gaze serves two\n * roles:\n *\n * 1. **Combination gating** (`gaze.required`): input on an interactable only\n * counts while the viewer is looking at it.\n * 2. **Accessibility** (`gaze.dwell`): looking at an interactable fills a\n * dwell meter; a full meter fires a synthesized press - no hands needed.\n * Consumers render the meter from `dwellProgress` events.\n *\n * The dwell state machine is pure and headless-tested. The runtime feeds it\n * hover booleans; it never sees the engine.\n */\n\nimport type { RayTuple, Vec3Tuple } from \"@realitycollective/webxr-input\";\nimport type { InteractableHit } from \"./ports.js\";\n\nexport interface DwellConfig {\n /** Seconds of sustained gaze to fire. */\n holdSeconds?: number;\n /** Drain speed multiplier when looking away. */\n decayFactor?: number;\n /** After firing, progress must fall below this before it can re-arm. */\n rearmBelow?: number;\n}\n\nexport interface GazeConfig {\n /** Non-gaze input on this interactable only counts while gazed at. */\n required?: boolean;\n /** Enable dwell-to-press. `true` uses defaults. */\n dwell?: boolean | DwellConfig;\n}\n\nexport const DWELL_DEFAULTS: Required<DwellConfig> = {\n holdSeconds: 1.8,\n decayFactor: 2.5,\n rearmBelow: 0.1,\n};\n\nexport function resolveDwellConfig(\n config: boolean | DwellConfig | undefined,\n defaults: Required<DwellConfig> = DWELL_DEFAULTS,\n): Required<DwellConfig> | null {\n if (!config) return null;\n if (config === true) return defaults;\n return {\n holdSeconds: config.holdSeconds ?? defaults.holdSeconds,\n decayFactor: config.decayFactor ?? defaults.decayFactor,\n rearmBelow: config.rearmBelow ?? defaults.rearmBelow,\n };\n}\n\nexport interface DwellTick {\n /** Dwell meter 0..1. */\n progress: number;\n /** Progress moved enough to be worth re-rendering (≥ 1%). */\n changed: boolean;\n /** The meter just completed - fire the synthesized press. */\n fired: boolean;\n}\n\nexport class DwellState {\n private progress = 0;\n private lastReported = 0;\n private armed = true;\n\n update(hovered: boolean, dt: number, config: Required<DwellConfig>): DwellTick {\n const before = this.progress;\n if (hovered) {\n this.progress = Math.min(1, this.progress + dt / config.holdSeconds);\n } else {\n this.progress = Math.max(\n 0,\n this.progress - (dt * config.decayFactor) / config.holdSeconds,\n );\n }\n\n let fired = false;\n if (this.armed && this.progress >= 1) {\n fired = true;\n this.armed = false;\n this.progress = 0;\n } else if (!this.armed && this.progress <= config.rearmBelow) {\n this.armed = true;\n }\n\n const changed =\n Math.abs(this.progress - this.lastReported) >= 0.01 ||\n (this.progress === 0 && before !== 0);\n if (changed) this.lastReported = this.progress;\n return { progress: this.progress, changed, fired };\n }\n\n getProgress(): number {\n return this.progress;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Eye gaze: the targeting half of the rule `@realitycollective/webxr-input`'s\n// `eye-gaze.ts` states. IWSDK's `GazeConecaster` picks the frame's best\n// in-cone candidate and then holds a time-weighted vote over the dwell\n// window, so a flick across a neighbour does not steal hover.\n// ---------------------------------------------------------------------------\n\n\n/** Comparer tolerance: angular distances within this are \"the same\", and distance decides. IWSDK `ANGLE_EPSILON`. */\nexport const GAZE_CONE_ANGLE_EPSILON = 0.5 * (Math.PI / 180);\n\n/** A cone hit: {@link InteractableHit} plus how far off the gaze axis the target's silhouette sat, radians (0 when the ray itself hit). */\nexport interface GazeConeHit extends InteractableHit {\n angularDistance: number;\n}\n\n/** A sphere target, as the approximate hit testers and the native host model one. */\nexport interface SphereTarget {\n id: string;\n center: Vec3Tuple;\n radius: number;\n}\n\n/**\n * IWSDK's `GazeConecaster.findFrameBest` over sphere targets: the best\n * target inside a cone of `halfAngle` radians about `ray`, no farther than\n * `maxLength`. A sphere the ray passes through is hit where the ray enters\n * it, with an angular distance of 0. Otherwise the angle from the ray to the\n * sphere's silhouette (the angle to its centre less the angle it subtends)\n * must be within the cone, and the point aimed at is the point of the sphere\n * nearest the ray. Closer by angle wins; within {@link GAZE_CONE_ANGLE_EPSILON}\n * the nearer target wins. A sphere the origin is inside counts as on axis.\n */\nexport function coneHitForSpheres(\n ray: RayTuple,\n targets: Iterable<SphereTarget>,\n halfAngle: number,\n maxLength: number,\n): GazeConeHit | null {\n const [ox, oy, oz] = ray.origin;\n const [dx, dy, dz] = ray.direction;\n let best: GazeConeHit | null = null;\n for (const target of targets) {\n const [cx, cy, cz] = target.center;\n const r = target.radius;\n const vx = cx - ox;\n const vy = cy - oy;\n const vz = cz - oz;\n const dist = Math.hypot(vx, vy, vz);\n if (dist - r > maxLength) continue;\n let hit: GazeConeHit;\n if (dist <= r) {\n // The origin is inside the sphere: fully on axis, aimed at the centre.\n hit = { interactableId: target.id, distance: dist, point: [cx, cy, cz], angularDistance: 0 };\n } else {\n const t = vx * dx + vy * dy + vz * dz;\n const missSq = Math.max(0, dist * dist - t * t);\n const miss = Math.sqrt(missSq);\n if (t > 0 && miss <= r) {\n // The ray enters the sphere: the raw ray wins, at the entry point.\n const entry = t - Math.sqrt(r * r - missSq);\n if (entry > maxLength) continue;\n hit = {\n interactableId: target.id,\n distance: entry,\n point: [ox + dx * entry, oy + dy * entry, oz + dz * entry],\n angularDistance: 0,\n };\n } else {\n const toCentre = Math.atan2(miss, t);\n const angular = Math.max(0, toCentre - Math.asin(Math.min(1, r / dist)));\n if (angular > halfAngle) continue;\n // The point of the sphere nearest the ray: from the centre toward the\n // ray's closest point (never behind the origin), out to the surface.\n const tc = Math.max(0, t);\n let nx = ox + dx * tc - cx;\n let ny = oy + dy * tc - cy;\n let nz = oz + dz * tc - cz;\n const nl = Math.hypot(nx, ny, nz);\n if (nl > 1e-9) {\n nx /= nl;\n ny /= nl;\n nz /= nl;\n } else {\n nx = -vx / dist;\n ny = -vy / dist;\n nz = -vz / dist;\n }\n const point: Vec3Tuple = [cx + nx * r, cy + ny * r, cz + nz * r];\n const distance = Math.hypot(point[0] - ox, point[1] - oy, point[2] - oz);\n if (distance > maxLength) continue;\n hit = { interactableId: target.id, distance, point, angularDistance: angular };\n }\n }\n if (best === null || compareConeHits(hit, best) < 0) best = hit;\n }\n return best;\n}\n\n/** IWSDK's default comparer: closer by angle, then closer by distance. Negative when `a` is better. */\nexport function compareConeHits(a: GazeConeHit, b: GazeConeHit): number {\n const dAngle = a.angularDistance - b.angularDistance;\n if (Math.abs(dAngle) > GAZE_CONE_ANGLE_EPSILON) return dAngle;\n return a.distance - b.distance;\n}\n\n/**\n * IWSDK's dwell consensus (`GazeConecaster.recordDwell` / `pruneDwell` /\n * `electDwellWinner` / `resolveDwellWinner`): each frame's best candidate is\n * recorded with its `dt`; samples older than the window are dropped; the\n * candidate with the largest cumulative `dt` inside the window wins. A winner\n * that is no longer available (unregistered, disabled, hidden) is struck\n * from the record and the next is elected. A window of 0 keeps only this\n * frame's sample, so the frame's best wins outright.\n */\nexport class GazeConsensus {\n private readonly samples: Array<{ id: string; time: number; dt: number }> = [];\n private time = 0;\n\n constructor(public windowSeconds: number = 0.15) {}\n\n /** Record this frame's best (or none), advance the window and elect. */\n update(frameBestId: string | null, dt: number, available: (id: string) => boolean): string | null {\n this.time += dt;\n if (frameBestId !== null) this.samples.push({ id: frameBestId, time: this.time, dt });\n this.prune();\n for (;;) {\n const elected = this.elect();\n if (elected === null) return null;\n if (available(elected)) return elected;\n this.remove(elected);\n }\n }\n\n /** Advance the window with nothing recorded (a frame gaze was suppressed or had no pose). */\n skip(dt: number): void {\n this.time += dt;\n }\n\n reset(): void {\n this.samples.length = 0;\n }\n\n private prune(): void {\n const cutoff = this.time - this.windowSeconds;\n let drop = 0;\n while (drop < this.samples.length && this.samples[drop]!.time < cutoff) drop += 1;\n if (drop > 0) this.samples.splice(0, drop);\n }\n\n private elect(): string | null {\n let bestId: string | null = null;\n let bestDt = -1;\n const sums = new Map<string, number>();\n for (const sample of this.samples) sums.set(sample.id, (sums.get(sample.id) ?? 0) + sample.dt);\n // First recorded wins a tie, as IWSDK's scan from the ring head does.\n for (const sample of this.samples) {\n const sum = sums.get(sample.id)!;\n if (sum > bestDt) {\n bestDt = sum;\n bestId = sample.id;\n }\n }\n return bestId;\n }\n\n private remove(id: string): void {\n for (let i = this.samples.length - 1; i >= 0; i--) {\n if (this.samples[i]!.id === id) this.samples.splice(i, 1);\n }\n }\n}\n"]}
package/dist/index.d.ts CHANGED
@@ -25,6 +25,11 @@ export * from "./descriptor.js";
25
25
  export * from "./velocity-tracker.js";
26
26
  export * from "./runtime.js";
27
27
  export * from "./pointer-bridge.js";
28
+ export * from "./near-pointer.js";
29
+ export * from "./near-pointer-contract-cases.js";
30
+ export * from "./physics.js";
31
+ export * from "./physics-contract-cases.js";
32
+ export * from "./memory-physics.js";
28
33
  export * from "./behaviours/behaviour.js";
29
34
  export * from "./behaviours/press.js";
30
35
  export * from "./behaviours/pulse.js";
package/dist/index.js CHANGED
@@ -25,6 +25,11 @@ export * from "./descriptor.js";
25
25
  export * from "./velocity-tracker.js";
26
26
  export * from "./runtime.js";
27
27
  export * from "./pointer-bridge.js";
28
+ export * from "./near-pointer.js";
29
+ export * from "./near-pointer-contract-cases.js";
30
+ export * from "./physics.js";
31
+ export * from "./physics-contract-cases.js";
32
+ export * from "./memory-physics.js";
28
33
  export * from "./behaviours/behaviour.js";
29
34
  export * from "./behaviours/press.js";
30
35
  export * from "./behaviours/pulse.js";
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,cAAc,gCAAgC,CAAC;AAE/C,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC;AAC9B,cAAc,YAAY,CAAC;AAC3B,cAAc,qBAAqB,CAAC;AACpC,cAAc,WAAW,CAAC;AAC1B,cAAc,iBAAiB,CAAC;AAChC,cAAc,uBAAuB,CAAC;AACtC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AAEpC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,sBAAsB,CAAC;AACrC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,yBAAyB,CAAC","sourcesContent":["/**\n * @realitycollective/webxr-interactions - the engine-free interaction core.\n *\n * Layers (each strictly on the one below):\n * - behaviours (press, pulse, hinge, dial, slide, grab, tossScore) -\n * pure state machines driven by `update(dt)`;\n * - the runtime/binder - targeting, press/grab transitions, gaze gating\n * + dwell, capability negotiation, events, feedback intents;\n * - the shared input contracts, re-exported from\n * `@realitycollective/webxr-input`.\n *\n * Pair with an engine adapter: `@realitycollective/threejs-interactions`\n * (standalone WebXR + three.js), `@realitycollective/iwsdk-interactions`\n * (Meta IWSDK) or `@realitycollective/xrblocks-interactions` (Google XR\n * Blocks, experimental).\n */\nexport * from \"@realitycollective/webxr-input\";\n\nexport * from \"./math.js\";\nexport * from \"./events.js\";\nexport * from \"./feedback.js\";\nexport * from \"./ports.js\";\nexport * from \"./contract-cases.js\";\nexport * from \"./gaze.js\";\nexport * from \"./descriptor.js\";\nexport * from \"./velocity-tracker.js\";\nexport * from \"./runtime.js\";\nexport * from \"./pointer-bridge.js\";\n\nexport * from \"./behaviours/behaviour.js\";\nexport * from \"./behaviours/press.js\";\nexport * from \"./behaviours/pulse.js\";\nexport * from \"./behaviours/hinge.js\";\nexport * from \"./behaviours/dial.js\";\nexport * from \"./behaviours/slide.js\";\nexport * from \"./behaviours/grab.js\";\nexport * from \"./behaviours/toss-score.js\";\nexport * from \"./behaviours/factory.js\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,cAAc,gCAAgC,CAAC;AAE/C,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC;AAC9B,cAAc,YAAY,CAAC;AAC3B,cAAc,qBAAqB,CAAC;AACpC,cAAc,WAAW,CAAC;AAC1B,cAAc,iBAAiB,CAAC;AAChC,cAAc,uBAAuB,CAAC;AACtC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,kCAAkC,CAAC;AACjD,cAAc,cAAc,CAAC;AAC7B,cAAc,6BAA6B,CAAC;AAC5C,cAAc,qBAAqB,CAAC;AAEpC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,sBAAsB,CAAC;AACrC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,yBAAyB,CAAC","sourcesContent":["/**\n * @realitycollective/webxr-interactions - the engine-free interaction core.\n *\n * Layers (each strictly on the one below):\n * - behaviours (press, pulse, hinge, dial, slide, grab, tossScore) -\n * pure state machines driven by `update(dt)`;\n * - the runtime/binder - targeting, press/grab transitions, gaze gating\n * + dwell, capability negotiation, events, feedback intents;\n * - the shared input contracts, re-exported from\n * `@realitycollective/webxr-input`.\n *\n * Pair with an engine adapter: `@realitycollective/threejs-interactions`\n * (standalone WebXR + three.js), `@realitycollective/iwsdk-interactions`\n * (Meta IWSDK) or `@realitycollective/xrblocks-interactions` (Google XR\n * Blocks, experimental).\n */\nexport * from \"@realitycollective/webxr-input\";\n\nexport * from \"./math.js\";\nexport * from \"./events.js\";\nexport * from \"./feedback.js\";\nexport * from \"./ports.js\";\nexport * from \"./contract-cases.js\";\nexport * from \"./gaze.js\";\nexport * from \"./descriptor.js\";\nexport * from \"./velocity-tracker.js\";\nexport * from \"./runtime.js\";\nexport * from \"./pointer-bridge.js\";\nexport * from \"./near-pointer.js\";\nexport * from \"./near-pointer-contract-cases.js\";\nexport * from \"./physics.js\";\nexport * from \"./physics-contract-cases.js\";\nexport * from \"./memory-physics.js\";\n\nexport * from \"./behaviours/behaviour.js\";\nexport * from \"./behaviours/press.js\";\nexport * from \"./behaviours/pulse.js\";\nexport * from \"./behaviours/hinge.js\";\nexport * from \"./behaviours/dial.js\";\nexport * from \"./behaviours/slide.js\";\nexport * from \"./behaviours/grab.js\";\nexport * from \"./behaviours/toss-score.js\";\nexport * from \"./behaviours/factory.js\";\n"]}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * MemoryPhysicsFacility - the family's in-memory reference physics: an
3
+ * engine-free `PhysicsFacility` that runs headlessly with the core defaults,
4
+ * so the contract suite has a correct subject to prove itself against and a
5
+ * new binding has a reference to compare with (the same idea as
6
+ * `MemoryInputProvider` in `@realitycollective/webxr-input`).
7
+ *
8
+ * It simulates exactly what the contract cases need and states its limits:
9
+ * gravity (with per-body factor and damping) integrated per step, spheres
10
+ * against axis-aligned static or kinematic boxes with restitution combined
11
+ * as the average of the two bodies (Rapier's default rule), and nothing
12
+ * else: no sphere-to-sphere contact, no friction dynamics, no rotation of
13
+ * boxes. It is not a game engine; a platform binding names a real one.
14
+ */
15
+ import type { PoseTuple, Vec3Tuple } from "@realitycollective/webxr-input";
16
+ import type { HoldRelease } from "./ports.js";
17
+ import { type PhysicsBodySpec, type PhysicsBodyState, type PhysicsFacility, type PhysicsShapeSpec, type PhysicsVelocity } from "./physics.js";
18
+ export declare class MemoryPhysicsFacility implements PhysicsFacility {
19
+ readonly engine: string;
20
+ private gravity;
21
+ private readonly bodies;
22
+ getGravity(): Vec3Tuple;
23
+ setGravity(gravity: Vec3Tuple): void;
24
+ addBody(id: string, pose: PoseTuple, body?: PhysicsBodySpec, shape?: PhysicsShapeSpec): void;
25
+ removeBody(id: string): void;
26
+ hasBody(id: string): boolean;
27
+ setBodyState(id: string, state: PhysicsBodyState): void;
28
+ getBodyState(id: string): PhysicsBodyState;
29
+ getBodyPose(id: string): PoseTuple;
30
+ setBodyPose(id: string, pose: PoseTuple): void;
31
+ getVelocity(id: string): PhysicsVelocity;
32
+ setVelocity(id: string, velocity: PhysicsVelocity): void;
33
+ suspend(id: string): void;
34
+ resume(id: string, release: HoldRelease): void;
35
+ isSuspended(id: string): boolean;
36
+ step(dtSeconds: number): void;
37
+ dispose(): void;
38
+ private body;
39
+ /** A moving sphere against every non-dynamic axis-aligned box: push out along the least-penetrated axis and reflect the normal velocity. */
40
+ private collideSphere;
41
+ }
@@ -0,0 +1,168 @@
1
+ import { PHYSICS_DEFAULTS, missingBody, resolvePhysicsBody, resolvePhysicsShape, } from "./physics.js";
2
+ function copy3(v) {
3
+ return [v[0], v[1], v[2]];
4
+ }
5
+ export class MemoryPhysicsFacility {
6
+ engine = "memory";
7
+ gravity = copy3(PHYSICS_DEFAULTS.gravity);
8
+ bodies = new Map();
9
+ getGravity() {
10
+ return copy3(this.gravity);
11
+ }
12
+ setGravity(gravity) {
13
+ this.gravity = copy3(gravity);
14
+ }
15
+ addBody(id, pose, body = {}, shape = {}) {
16
+ const spec = resolvePhysicsBody(body);
17
+ this.bodies.set(id, {
18
+ state: spec.state,
19
+ spec,
20
+ shape: resolvePhysicsShape(shape),
21
+ position: copy3(pose.position),
22
+ quaternion: [pose.quaternion[0], pose.quaternion[1], pose.quaternion[2], pose.quaternion[3]],
23
+ linear: [0, 0, 0],
24
+ angular: [0, 0, 0],
25
+ suspended: false,
26
+ });
27
+ }
28
+ removeBody(id) {
29
+ this.bodies.delete(id);
30
+ }
31
+ hasBody(id) {
32
+ return this.bodies.has(id);
33
+ }
34
+ setBodyState(id, state) {
35
+ const body = this.body(id);
36
+ body.state = state;
37
+ if (state !== "dynamic") {
38
+ body.linear = [0, 0, 0];
39
+ body.angular = [0, 0, 0];
40
+ }
41
+ }
42
+ getBodyState(id) {
43
+ return this.body(id).state;
44
+ }
45
+ getBodyPose(id) {
46
+ const body = this.body(id);
47
+ return { position: copy3(body.position), quaternion: [...body.quaternion] };
48
+ }
49
+ setBodyPose(id, pose) {
50
+ const body = this.body(id);
51
+ body.position = copy3(pose.position);
52
+ body.quaternion = [pose.quaternion[0], pose.quaternion[1], pose.quaternion[2], pose.quaternion[3]];
53
+ // A teleport rests the body; a write while held carries nothing either.
54
+ body.linear = [0, 0, 0];
55
+ body.angular = [0, 0, 0];
56
+ }
57
+ getVelocity(id) {
58
+ const body = this.body(id);
59
+ return { linear: copy3(body.linear), angular: copy3(body.angular) };
60
+ }
61
+ setVelocity(id, velocity) {
62
+ const body = this.body(id);
63
+ body.linear = copy3(velocity.linear);
64
+ body.angular = copy3(velocity.angular);
65
+ }
66
+ suspend(id) {
67
+ const body = this.body(id);
68
+ if (body.suspended)
69
+ return;
70
+ body.suspended = true;
71
+ body.linear = [0, 0, 0];
72
+ body.angular = [0, 0, 0];
73
+ }
74
+ resume(id, release) {
75
+ const body = this.body(id);
76
+ if (!body.suspended)
77
+ return;
78
+ body.suspended = false;
79
+ body.linear = copy3(release.linearVelocity);
80
+ body.angular = copy3(release.angularVelocity);
81
+ }
82
+ isSuspended(id) {
83
+ return this.body(id).suspended;
84
+ }
85
+ step(dtSeconds) {
86
+ if (dtSeconds <= 0)
87
+ return;
88
+ for (const body of this.bodies.values()) {
89
+ if (body.state !== "dynamic" || body.suspended)
90
+ continue;
91
+ const g = body.spec.gravityFactor;
92
+ body.linear[0] += this.gravity[0] * g * dtSeconds;
93
+ body.linear[1] += this.gravity[1] * g * dtSeconds;
94
+ body.linear[2] += this.gravity[2] * g * dtSeconds;
95
+ const linearKeep = Math.max(0, 1 - body.spec.linearDamping * dtSeconds);
96
+ const angularKeep = Math.max(0, 1 - body.spec.angularDamping * dtSeconds);
97
+ body.linear = [body.linear[0] * linearKeep, body.linear[1] * linearKeep, body.linear[2] * linearKeep];
98
+ body.angular = [body.angular[0] * angularKeep, body.angular[1] * angularKeep, body.angular[2] * angularKeep];
99
+ body.position = [
100
+ body.position[0] + body.linear[0] * dtSeconds,
101
+ body.position[1] + body.linear[1] * dtSeconds,
102
+ body.position[2] + body.linear[2] * dtSeconds,
103
+ ];
104
+ body.quaternion = integrateRotation(body.quaternion, body.angular, dtSeconds);
105
+ if (body.shape.kind === "sphere")
106
+ this.collideSphere(body);
107
+ }
108
+ }
109
+ dispose() {
110
+ this.bodies.clear();
111
+ }
112
+ body(id) {
113
+ const body = this.bodies.get(id);
114
+ if (!body)
115
+ throw missingBody(id);
116
+ return body;
117
+ }
118
+ /** A moving sphere against every non-dynamic axis-aligned box: push out along the least-penetrated axis and reflect the normal velocity. */
119
+ collideSphere(sphere) {
120
+ const radius = sphere.shape.dimensions[0];
121
+ for (const other of this.bodies.values()) {
122
+ if (other === sphere || other.shape.kind !== "box" || other.state === "dynamic")
123
+ continue;
124
+ const half = [other.shape.dimensions[0] / 2, other.shape.dimensions[1] / 2, other.shape.dimensions[2] / 2];
125
+ let bestAxis = -1;
126
+ let bestDepth = Number.POSITIVE_INFINITY;
127
+ let bestSign = 1;
128
+ for (let axis = 0; axis < 3; axis++) {
129
+ const delta = sphere.position[axis] - other.position[axis];
130
+ const depth = half[axis] + radius - Math.abs(delta);
131
+ if (depth <= 0) {
132
+ bestAxis = -1;
133
+ break;
134
+ }
135
+ if (depth < bestDepth) {
136
+ bestDepth = depth;
137
+ bestAxis = axis;
138
+ bestSign = delta >= 0 ? 1 : -1;
139
+ }
140
+ }
141
+ if (bestAxis < 0)
142
+ continue;
143
+ sphere.position[bestAxis] = sphere.position[bestAxis] + bestSign * bestDepth;
144
+ const normalVelocity = sphere.linear[bestAxis] * bestSign;
145
+ if (normalVelocity < 0) {
146
+ const restitution = (sphere.shape.restitution + other.shape.restitution) / 2;
147
+ sphere.linear[bestAxis] = -normalVelocity * restitution * bestSign;
148
+ }
149
+ }
150
+ }
151
+ }
152
+ /** Advance a quaternion by an angular velocity (axis scaled by rad/s) over dt, first order. */
153
+ function integrateRotation(q, angular, dt) {
154
+ const angle = Math.hypot(angular[0], angular[1], angular[2]) * dt;
155
+ if (angle < 1e-9)
156
+ return q;
157
+ const half = angle / 2;
158
+ const s = Math.sin(half) / (angle / dt) * dt;
159
+ const d = [angular[0] * s, angular[1] * s, angular[2] * s, Math.cos(half)];
160
+ // d * q
161
+ return [
162
+ d[3] * q[0] + d[0] * q[3] + d[1] * q[2] - d[2] * q[1],
163
+ d[3] * q[1] - d[0] * q[2] + d[1] * q[3] + d[2] * q[0],
164
+ d[3] * q[2] + d[0] * q[1] - d[1] * q[0] + d[2] * q[3],
165
+ d[3] * q[3] - d[0] * q[0] - d[1] * q[1] - d[2] * q[2],
166
+ ];
167
+ }
168
+ //# sourceMappingURL=memory-physics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory-physics.js","sourceRoot":"","sources":["../src/memory-physics.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * MemoryPhysicsFacility - the family's in-memory reference physics: an\n * engine-free `PhysicsFacility` that runs headlessly with the core defaults,\n * so the contract suite has a correct subject to prove itself against and a\n * new binding has a reference to compare with (the same idea as\n * `MemoryInputProvider` in `@realitycollective/webxr-input`).\n *\n * It simulates exactly what the contract cases need and states its limits:\n * gravity (with per-body factor and damping) integrated per step, spheres\n * against axis-aligned static or kinematic boxes with restitution combined\n * as the average of the two bodies (Rapier's default rule), and nothing\n * else: no sphere-to-sphere contact, no friction dynamics, no rotation of\n * boxes. It is not a game engine; a platform binding names a real one.\n */\nimport type { PoseTuple, QuatTuple, Vec3Tuple } from \"@realitycollective/webxr-input\";\nimport type { HoldRelease } from \"./ports.js\";\nimport {\n PHYSICS_DEFAULTS,\n missingBody,\n resolvePhysicsBody,\n resolvePhysicsShape,\n type PhysicsBodySpec,\n type PhysicsBodyState,\n type PhysicsFacility,\n type PhysicsShapeSpec,\n type PhysicsVelocity,\n} from \"./physics.js\";\n\ninterface Body {\n state: PhysicsBodyState;\n spec: Required<PhysicsBodySpec>;\n shape: Required<PhysicsShapeSpec>;\n position: Vec3Tuple;\n quaternion: QuatTuple;\n linear: Vec3Tuple;\n angular: Vec3Tuple;\n suspended: boolean;\n}\n\nfunction copy3(v: Readonly<Vec3Tuple>): Vec3Tuple {\n return [v[0], v[1], v[2]];\n}\n\nexport class MemoryPhysicsFacility implements PhysicsFacility {\n readonly engine: string = \"memory\";\n private gravity: Vec3Tuple = copy3(PHYSICS_DEFAULTS.gravity);\n private readonly bodies = new Map<string, Body>();\n\n getGravity(): Vec3Tuple {\n return copy3(this.gravity);\n }\n\n setGravity(gravity: Vec3Tuple): void {\n this.gravity = copy3(gravity);\n }\n\n addBody(id: string, pose: PoseTuple, body: PhysicsBodySpec = {}, shape: PhysicsShapeSpec = {}): void {\n const spec = resolvePhysicsBody(body);\n this.bodies.set(id, {\n state: spec.state,\n spec,\n shape: resolvePhysicsShape(shape),\n position: copy3(pose.position),\n quaternion: [pose.quaternion[0], pose.quaternion[1], pose.quaternion[2], pose.quaternion[3]],\n linear: [0, 0, 0],\n angular: [0, 0, 0],\n suspended: false,\n });\n }\n\n removeBody(id: string): void {\n this.bodies.delete(id);\n }\n\n hasBody(id: string): boolean {\n return this.bodies.has(id);\n }\n\n setBodyState(id: string, state: PhysicsBodyState): void {\n const body = this.body(id);\n body.state = state;\n if (state !== \"dynamic\") {\n body.linear = [0, 0, 0];\n body.angular = [0, 0, 0];\n }\n }\n\n getBodyState(id: string): PhysicsBodyState {\n return this.body(id).state;\n }\n\n getBodyPose(id: string): PoseTuple {\n const body = this.body(id);\n return { position: copy3(body.position), quaternion: [...body.quaternion] as QuatTuple };\n }\n\n setBodyPose(id: string, pose: PoseTuple): void {\n const body = this.body(id);\n body.position = copy3(pose.position);\n body.quaternion = [pose.quaternion[0], pose.quaternion[1], pose.quaternion[2], pose.quaternion[3]];\n // A teleport rests the body; a write while held carries nothing either.\n body.linear = [0, 0, 0];\n body.angular = [0, 0, 0];\n }\n\n getVelocity(id: string): PhysicsVelocity {\n const body = this.body(id);\n return { linear: copy3(body.linear), angular: copy3(body.angular) };\n }\n\n setVelocity(id: string, velocity: PhysicsVelocity): void {\n const body = this.body(id);\n body.linear = copy3(velocity.linear);\n body.angular = copy3(velocity.angular);\n }\n\n suspend(id: string): void {\n const body = this.body(id);\n if (body.suspended) return;\n body.suspended = true;\n body.linear = [0, 0, 0];\n body.angular = [0, 0, 0];\n }\n\n resume(id: string, release: HoldRelease): void {\n const body = this.body(id);\n if (!body.suspended) return;\n body.suspended = false;\n body.linear = copy3(release.linearVelocity);\n body.angular = copy3(release.angularVelocity);\n }\n\n isSuspended(id: string): boolean {\n return this.body(id).suspended;\n }\n\n step(dtSeconds: number): void {\n if (dtSeconds <= 0) return;\n for (const body of this.bodies.values()) {\n if (body.state !== \"dynamic\" || body.suspended) continue;\n const g = body.spec.gravityFactor;\n body.linear[0] += this.gravity[0] * g * dtSeconds;\n body.linear[1] += this.gravity[1] * g * dtSeconds;\n body.linear[2] += this.gravity[2] * g * dtSeconds;\n const linearKeep = Math.max(0, 1 - body.spec.linearDamping * dtSeconds);\n const angularKeep = Math.max(0, 1 - body.spec.angularDamping * dtSeconds);\n body.linear = [body.linear[0] * linearKeep, body.linear[1] * linearKeep, body.linear[2] * linearKeep];\n body.angular = [body.angular[0] * angularKeep, body.angular[1] * angularKeep, body.angular[2] * angularKeep];\n body.position = [\n body.position[0] + body.linear[0] * dtSeconds,\n body.position[1] + body.linear[1] * dtSeconds,\n body.position[2] + body.linear[2] * dtSeconds,\n ];\n body.quaternion = integrateRotation(body.quaternion, body.angular, dtSeconds);\n if (body.shape.kind === \"sphere\") this.collideSphere(body);\n }\n }\n\n dispose(): void {\n this.bodies.clear();\n }\n\n private body(id: string): Body {\n const body = this.bodies.get(id);\n if (!body) throw missingBody(id);\n return body;\n }\n\n /** A moving sphere against every non-dynamic axis-aligned box: push out along the least-penetrated axis and reflect the normal velocity. */\n private collideSphere(sphere: Body): void {\n const radius = sphere.shape.dimensions[0];\n for (const other of this.bodies.values()) {\n if (other === sphere || other.shape.kind !== \"box\" || other.state === \"dynamic\") continue;\n const half: Vec3Tuple = [other.shape.dimensions[0] / 2, other.shape.dimensions[1] / 2, other.shape.dimensions[2] / 2];\n let bestAxis = -1;\n let bestDepth = Number.POSITIVE_INFINITY;\n let bestSign = 1;\n for (let axis = 0; axis < 3; axis++) {\n const delta = sphere.position[axis]! - other.position[axis]!;\n const depth = half[axis]! + radius - Math.abs(delta);\n if (depth <= 0) {\n bestAxis = -1;\n break;\n }\n if (depth < bestDepth) {\n bestDepth = depth;\n bestAxis = axis;\n bestSign = delta >= 0 ? 1 : -1;\n }\n }\n if (bestAxis < 0) continue;\n sphere.position[bestAxis] = sphere.position[bestAxis]! + bestSign * bestDepth;\n const normalVelocity = sphere.linear[bestAxis]! * bestSign;\n if (normalVelocity < 0) {\n const restitution = (sphere.shape.restitution + other.shape.restitution) / 2;\n sphere.linear[bestAxis] = -normalVelocity * restitution * bestSign;\n }\n }\n }\n}\n\n/** Advance a quaternion by an angular velocity (axis scaled by rad/s) over dt, first order. */\nfunction integrateRotation(q: QuatTuple, angular: Vec3Tuple, dt: number): QuatTuple {\n const angle = Math.hypot(angular[0], angular[1], angular[2]) * dt;\n if (angle < 1e-9) return q;\n const half = angle / 2;\n const s = Math.sin(half) / (angle / dt) * dt;\n const d: QuatTuple = [angular[0] * s, angular[1] * s, angular[2] * s, Math.cos(half)];\n // d * q\n return [\n d[3] * q[0] + d[0] * q[3] + d[1] * q[2] - d[2] * q[1],\n d[3] * q[1] - d[0] * q[2] + d[1] * q[3] + d[2] * q[0],\n d[3] * q[2] + d[0] * q[1] - d[1] * q[0] + d[2] * q[3],\n d[3] * q[3] - d[0] * q[0] - d[1] * q[1] - d[2] * q[2],\n ];\n}\n"]}
@@ -0,0 +1,16 @@
1
+ import type { HitTesterContractSubject } from "./contract-cases.js";
2
+ /** One check a binding's hit tester must pass under the near-pointer rule. */
3
+ export interface NearPointerContractCase {
4
+ name: string;
5
+ run(subject: HitTesterContractSubject): void;
6
+ }
7
+ /**
8
+ * The shared near-pointer conformance suite. An adapter's test file:
9
+ *
10
+ * ```ts
11
+ * for (const contractCase of nearPointerContractCases()) {
12
+ * it(contractCase.name, () => contractCase.run(makeSubject()));
13
+ * }
14
+ * ```
15
+ */
16
+ export declare function nearPointerContractCases(): readonly NearPointerContractCase[];