@realitycollective/threejs-interactions 0.1.0-preview.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/dist/hit-tester.d.ts +24 -0
- package/dist/hit-tester.js +87 -0
- package/dist/hit-tester.js.map +1 -0
- package/dist/host.d.ts +34 -0
- package/dist/host.js +46 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/transform-port.d.ts +33 -0
- package/dist/transform-port.js +101 -0
- package/dist/transform-port.js.map +1 -0
- package/dist/webxr-provider.d.ts +84 -0
- package/dist/webxr-provider.js +366 -0
- package/dist/webxr-provider.js.map +1 -0
- package/package.json +61 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Change log for the Reality Collective WebXR Interaction Extensions packages. All four packages are versioned and released together; the version below is the one carried by the `v<version>` release tag.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Preview builds are not listed separately. The entry for a version accumulates while its previews are published, and is dated when that version is released.
|
|
6
|
+
|
|
7
|
+
## [0.1.0]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `@realitycollective/webxr-interactions` - engine-free core: interactables and interactors, the behaviour set (`press` including latching, `pulse`, `hinge`, `dial`, `slide`, `grab` in poseOnly and native modes, `tossScore`), gaze (`required` gating and dwell-to-press), the runtime/binder with hints > poke > ray targeting, hysteresis and lifecycle, capability negotiation with a visible `behaviourDisabled` outcome, events as the only outbound pathway, and feedback intents (haptics and audio remain the client's, with `routeHapticsToProvider` as an explicit opt-in).
|
|
12
|
+
- `@realitycollective/threejs-interactions` - the default standalone adapter: raw WebXR (`XRSession` sources, hand joints, select/squeeze plus gamepad analog, haptic pulse), three.js hit-testing and transform ports, and a desktop mouse fallback. No framework required.
|
|
13
|
+
- `@realitycollective/iwsdk-interactions` - Meta IWSDK adapter: player-rig poses and stateful gamepads as the provider, IWSDK's own targeting (`Pressed` / `Grabbed` tags) forwarded as pre-resolved hints, and native grab fulfilment when the app enables IWSDK grabbing/physics. One-call `registerInteractions(world)` setup.
|
|
14
|
+
- `@realitycollective/xrblocks-interactions` - EXPERIMENTAL Google XR Blocks adapter, structurally typed against xrblocks v0.20.0 (`Input.getFrame()` ray sources and direct touches, pooled structs copied, no haptics upstream).
|
|
15
|
+
- `demos/playground` - the standalone interaction playground: the full station set from one portable `InteractionDescriptor`, with client-side audio cues, opt-in controller haptics and client-side toss ballistics.
|
|
16
|
+
|
|
17
|
+
### Notes
|
|
18
|
+
|
|
19
|
+
- All four packages depend on `@realitycollective/webxr-input`, released independently from the [WebXR-Input](https://github.com/realitycollective/WebXR-Input) repository. That package must be published before this one.
|
|
20
|
+
|
|
21
|
+
[0.1.0]: https://github.com/realitycollective/WebXR-Interactions/commits/main
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Reality Collective
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# @realitycollective/threejs-interactions
|
|
2
|
+
|
|
3
|
+
The three.js adapter for the Reality Collective Interaction Extensions. It reads raw WebXR directly, so no other framework is needed.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @realitycollective/threejs-interactions three
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It re-exports everything from [`@realitycollective/webxr-interactions`](https://www.npmjs.com/package/@realitycollective/webxr-interactions), so this is the only interaction package your app needs.
|
|
10
|
+
|
|
11
|
+
## What it binds
|
|
12
|
+
|
|
13
|
+
| Layer | Detail |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| **Input** | Raw WebXR, the browser's own XR API: controllers, hand joints, trigger and grip pressure, and haptic pulses |
|
|
16
|
+
| **Hit-testing** | three.js raycasting against your scene graph |
|
|
17
|
+
| **Movement** | Moves and rotates three.js objects for grab, hinge, dial and slide |
|
|
18
|
+
| **Desktop** | A mouse fallback, so the same scene is testable without a headset |
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { createThreeInteractions } from "@realitycollective/threejs-interactions";
|
|
24
|
+
|
|
25
|
+
const interactions = createThreeInteractions({
|
|
26
|
+
xr: renderer.xr,
|
|
27
|
+
camera,
|
|
28
|
+
domElement: renderer.domElement,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
interactions.register({ id: "button", behaviours: [{ kind: "press" }] }, buttonMesh);
|
|
32
|
+
|
|
33
|
+
interactions.runtime.onEvent((event) => console.log(event.type));
|
|
34
|
+
|
|
35
|
+
let last = performance.now();
|
|
36
|
+
renderer.setAnimationLoop(() => {
|
|
37
|
+
const now = performance.now();
|
|
38
|
+
const dt = Math.min(0.1, (now - last) / 1000);
|
|
39
|
+
last = now;
|
|
40
|
+
interactions.update(dt);
|
|
41
|
+
renderer.render(scene, camera);
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Peer dependency
|
|
46
|
+
|
|
47
|
+
`three >= 0.170.0`. The Reality Collective demos pin the `super-three@0.181` fork that Meta's IWSDK mandates; stock three.js works equally well for this adapter.
|
|
48
|
+
|
|
49
|
+
## Live demo
|
|
50
|
+
|
|
51
|
+
The interaction playground - the full station set, mouse-capable on desktop, VR button for headsets: **[webxr-interactions.pages.dev](https://webxr-interactions.pages.dev)**
|
|
52
|
+
|
|
53
|
+
## Documentation
|
|
54
|
+
|
|
55
|
+
See the [repository README](https://github.com/realitycollective/WebXR-Interactions#readme) and the `demos/playground` client for a complete working scene.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT - see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThreeHitTester - resolves core ray/proximity queries against registered
|
|
3
|
+
* three.js objects. Ray hits use a Raycaster over the registered roots
|
|
4
|
+
* (recursive); proximity uses world-position distance minus an
|
|
5
|
+
* approximate bounding radius, good enough for poke targets.
|
|
6
|
+
*/
|
|
7
|
+
import { type Object3D } from "three";
|
|
8
|
+
import type { RayTuple, Vec3Tuple } from "@realitycollective/webxr-input";
|
|
9
|
+
import type { HitTester, InteractableHit } from "@realitycollective/webxr-interactions";
|
|
10
|
+
export declare class ThreeHitTester implements HitTester {
|
|
11
|
+
private readonly roots;
|
|
12
|
+
private readonly byObject;
|
|
13
|
+
private readonly raycaster;
|
|
14
|
+
private readonly v;
|
|
15
|
+
private readonly w;
|
|
16
|
+
private readonly sphere;
|
|
17
|
+
register(id: string, object: Object3D): void;
|
|
18
|
+
unregister(id: string): void;
|
|
19
|
+
getObject(id: string): Object3D | undefined;
|
|
20
|
+
hitRay(ray: RayTuple): InteractableHit | null;
|
|
21
|
+
hitProximity(point: Vec3Tuple, radius: number): InteractableHit | null;
|
|
22
|
+
private ownerOf;
|
|
23
|
+
private boundingRadius;
|
|
24
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThreeHitTester - resolves core ray/proximity queries against registered
|
|
3
|
+
* three.js objects. Ray hits use a Raycaster over the registered roots
|
|
4
|
+
* (recursive); proximity uses world-position distance minus an
|
|
5
|
+
* approximate bounding radius, good enough for poke targets.
|
|
6
|
+
*/
|
|
7
|
+
import { Raycaster, Sphere, Vector3 } from "three";
|
|
8
|
+
export class ThreeHitTester {
|
|
9
|
+
roots = new Map();
|
|
10
|
+
byObject = new WeakMap();
|
|
11
|
+
raycaster = new Raycaster();
|
|
12
|
+
v = new Vector3();
|
|
13
|
+
w = new Vector3();
|
|
14
|
+
sphere = new Sphere();
|
|
15
|
+
register(id, object) {
|
|
16
|
+
this.roots.set(id, object);
|
|
17
|
+
this.byObject.set(object, id);
|
|
18
|
+
}
|
|
19
|
+
unregister(id) {
|
|
20
|
+
const object = this.roots.get(id);
|
|
21
|
+
if (object)
|
|
22
|
+
this.byObject.delete(object);
|
|
23
|
+
this.roots.delete(id);
|
|
24
|
+
}
|
|
25
|
+
getObject(id) {
|
|
26
|
+
return this.roots.get(id);
|
|
27
|
+
}
|
|
28
|
+
hitRay(ray) {
|
|
29
|
+
if (this.roots.size === 0)
|
|
30
|
+
return null;
|
|
31
|
+
this.raycaster.ray.origin.set(...ray.origin);
|
|
32
|
+
this.raycaster.ray.direction.set(...ray.direction);
|
|
33
|
+
const objects = [...this.roots.values()];
|
|
34
|
+
const intersections = this.raycaster.intersectObjects(objects, true);
|
|
35
|
+
for (const intersection of intersections) {
|
|
36
|
+
const id = this.ownerOf(intersection.object);
|
|
37
|
+
if (id !== null) {
|
|
38
|
+
return {
|
|
39
|
+
interactableId: id,
|
|
40
|
+
distance: intersection.distance,
|
|
41
|
+
point: [intersection.point.x, intersection.point.y, intersection.point.z],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
hitProximity(point, radius) {
|
|
48
|
+
this.v.set(...point);
|
|
49
|
+
let best = null;
|
|
50
|
+
for (const [id, object] of this.roots) {
|
|
51
|
+
object.getWorldPosition(this.w);
|
|
52
|
+
const surfaceDistance = Math.max(0, this.w.distanceTo(this.v) - this.boundingRadius(object));
|
|
53
|
+
if (surfaceDistance <= radius && (best === null || surfaceDistance < best.distance)) {
|
|
54
|
+
best = {
|
|
55
|
+
interactableId: id,
|
|
56
|
+
distance: surfaceDistance,
|
|
57
|
+
point: [this.w.x, this.w.y, this.w.z],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return best;
|
|
62
|
+
}
|
|
63
|
+
ownerOf(object) {
|
|
64
|
+
let current = object;
|
|
65
|
+
while (current) {
|
|
66
|
+
const id = this.byObject.get(current);
|
|
67
|
+
if (id !== undefined)
|
|
68
|
+
return id;
|
|
69
|
+
current = current.parent;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
boundingRadius(object) {
|
|
74
|
+
const geometry = object.geometry;
|
|
75
|
+
if (geometry) {
|
|
76
|
+
if (!geometry.boundingSphere)
|
|
77
|
+
geometry.computeBoundingSphere();
|
|
78
|
+
if (geometry.boundingSphere) {
|
|
79
|
+
this.sphere.copy(geometry.boundingSphere);
|
|
80
|
+
const scale = object.getWorldScale(this.w);
|
|
81
|
+
return this.sphere.radius * Math.max(scale.x, scale.y, scale.z);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=hit-tester.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hit-tester.js","sourceRoot":"","sources":["../src/hit-tester.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ThreeHitTester - resolves core ray/proximity queries against registered\n * three.js objects. Ray hits use a Raycaster over the registered roots\n * (recursive); proximity uses world-position distance minus an\n * approximate bounding radius, good enough for poke targets.\n */\nimport { Raycaster, Sphere, Vector3, type Object3D, type Mesh } from \"three\";\nimport type { RayTuple, Vec3Tuple } from \"@realitycollective/webxr-input\";\nimport type { HitTester, InteractableHit } from \"@realitycollective/webxr-interactions\";\n\nexport class ThreeHitTester implements HitTester {\n private readonly roots = new Map<string, Object3D>();\n private readonly byObject = new WeakMap<Object3D, string>();\n private readonly raycaster = new Raycaster();\n private readonly v = new Vector3();\n private readonly w = new Vector3();\n private readonly sphere = new Sphere();\n\n register(id: string, object: Object3D): void {\n this.roots.set(id, object);\n this.byObject.set(object, id);\n }\n\n unregister(id: string): void {\n const object = this.roots.get(id);\n if (object) this.byObject.delete(object);\n this.roots.delete(id);\n }\n\n getObject(id: string): Object3D | undefined {\n return this.roots.get(id);\n }\n\n hitRay(ray: RayTuple): InteractableHit | null {\n if (this.roots.size === 0) return null;\n this.raycaster.ray.origin.set(...ray.origin);\n this.raycaster.ray.direction.set(...ray.direction);\n const objects = [...this.roots.values()];\n const intersections = this.raycaster.intersectObjects(objects, true);\n for (const intersection of intersections) {\n const id = this.ownerOf(intersection.object);\n if (id !== null) {\n return {\n interactableId: id,\n distance: intersection.distance,\n point: [intersection.point.x, intersection.point.y, intersection.point.z],\n };\n }\n }\n return null;\n }\n\n hitProximity(point: Vec3Tuple, radius: number): InteractableHit | null {\n this.v.set(...point);\n let best: InteractableHit | null = null;\n for (const [id, object] of this.roots) {\n object.getWorldPosition(this.w);\n const surfaceDistance = Math.max(\n 0,\n this.w.distanceTo(this.v) - this.boundingRadius(object),\n );\n if (surfaceDistance <= radius && (best === null || surfaceDistance < best.distance)) {\n best = {\n interactableId: id,\n distance: surfaceDistance,\n point: [this.w.x, this.w.y, this.w.z],\n };\n }\n }\n return best;\n }\n\n private ownerOf(object: Object3D): string | null {\n let current: Object3D | null = object;\n while (current) {\n const id = this.byObject.get(current);\n if (id !== undefined) return id;\n current = current.parent;\n }\n return null;\n }\n\n private boundingRadius(object: Object3D): number {\n const geometry = (object as Mesh).geometry;\n if (geometry) {\n if (!geometry.boundingSphere) geometry.computeBoundingSphere();\n if (geometry.boundingSphere) {\n this.sphere.copy(geometry.boundingSphere);\n const scale = object.getWorldScale(this.w);\n return this.sphere.radius * Math.max(scale.x, scale.y, scale.z);\n }\n }\n return 0;\n }\n}\n"]}
|
package/dist/host.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createThreeInteractions - one-call setup for the standalone adapter.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* const interactions = createThreeInteractions({
|
|
6
|
+
* xr: renderer.xr, camera, domElement: renderer.domElement,
|
|
7
|
+
* });
|
|
8
|
+
* interactions.register({ id: "button", behaviours: [{ kind: "press" }] }, buttonMesh);
|
|
9
|
+
* // in the render loop:
|
|
10
|
+
* interactions.update(dt);
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
import type { Object3D } from "three";
|
|
14
|
+
import { InteractionRuntime, type DwellConfig, type InteractableDescriptor } from "@realitycollective/webxr-interactions";
|
|
15
|
+
import { ThreeHitTester } from "./hit-tester.js";
|
|
16
|
+
import { ThreeTransformPort } from "./transform-port.js";
|
|
17
|
+
import { WebXRInputProvider, type WebXRProviderContext } from "./webxr-provider.js";
|
|
18
|
+
export interface ThreeInteractionsOptions extends WebXRProviderContext {
|
|
19
|
+
dwellDefaults?: DwellConfig;
|
|
20
|
+
}
|
|
21
|
+
export declare class ThreeInteractions {
|
|
22
|
+
readonly runtime: InteractionRuntime;
|
|
23
|
+
readonly provider: WebXRInputProvider;
|
|
24
|
+
readonly hitTester: ThreeHitTester;
|
|
25
|
+
private readonly ports;
|
|
26
|
+
constructor(options: ThreeInteractionsOptions);
|
|
27
|
+
/** Register an interactable with its scene object. */
|
|
28
|
+
register(descriptor: InteractableDescriptor, object: Object3D): ThreeTransformPort;
|
|
29
|
+
unregister(id: string): void;
|
|
30
|
+
getPort(id: string): ThreeTransformPort | undefined;
|
|
31
|
+
update(dt: number): void;
|
|
32
|
+
dispose(): void;
|
|
33
|
+
}
|
|
34
|
+
export declare function createThreeInteractions(options: ThreeInteractionsOptions): ThreeInteractions;
|
package/dist/host.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { InteractionRuntime, } from "@realitycollective/webxr-interactions";
|
|
2
|
+
import { ThreeHitTester } from "./hit-tester.js";
|
|
3
|
+
import { ThreeTransformPort } from "./transform-port.js";
|
|
4
|
+
import { WebXRInputProvider } from "./webxr-provider.js";
|
|
5
|
+
export class ThreeInteractions {
|
|
6
|
+
runtime;
|
|
7
|
+
provider;
|
|
8
|
+
hitTester;
|
|
9
|
+
ports = new Map();
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.provider = new WebXRInputProvider(options);
|
|
12
|
+
this.hitTester = new ThreeHitTester();
|
|
13
|
+
this.runtime = new InteractionRuntime({
|
|
14
|
+
provider: this.provider,
|
|
15
|
+
hitTester: this.hitTester,
|
|
16
|
+
...(options.dwellDefaults ? { dwellDefaults: options.dwellDefaults } : {}),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** Register an interactable with its scene object. */
|
|
20
|
+
register(descriptor, object) {
|
|
21
|
+
const port = new ThreeTransformPort(object);
|
|
22
|
+
this.ports.set(descriptor.id, port);
|
|
23
|
+
this.hitTester.register(descriptor.id, object);
|
|
24
|
+
this.runtime.registerInteractable(descriptor, { transform: port });
|
|
25
|
+
return port;
|
|
26
|
+
}
|
|
27
|
+
unregister(id) {
|
|
28
|
+
this.runtime.unregisterInteractable(id);
|
|
29
|
+
this.hitTester.unregister(id);
|
|
30
|
+
this.ports.delete(id);
|
|
31
|
+
}
|
|
32
|
+
getPort(id) {
|
|
33
|
+
return this.ports.get(id);
|
|
34
|
+
}
|
|
35
|
+
update(dt) {
|
|
36
|
+
this.runtime.update(dt);
|
|
37
|
+
}
|
|
38
|
+
dispose() {
|
|
39
|
+
this.runtime.dispose();
|
|
40
|
+
this.provider.dispose();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function createThreeInteractions(options) {
|
|
44
|
+
return new ThreeInteractions(options);
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=host.js.map
|
package/dist/host.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host.js","sourceRoot":"","sources":["../src/host.ts"],"names":[],"mappings":"AAaA,OAAO,EACL,kBAAkB,GAGnB,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,EAAE,kBAAkB,EAA6B,MAAM,qBAAqB,CAAC;AAMpF,MAAM,OAAO,iBAAiB;IACnB,OAAO,CAAqB;IAC5B,QAAQ,CAAqB;IAC7B,SAAS,CAAiB;IAClB,KAAK,GAAG,IAAI,GAAG,EAA8B,CAAC;IAE/D,YAAY,OAAiC;QAC3C,IAAI,CAAC,QAAQ,GAAG,IAAI,kBAAkB,CAAC,OAAO,CAAC,CAAC;QAChD,IAAI,CAAC,SAAS,GAAG,IAAI,cAAc,EAAE,CAAC;QACtC,IAAI,CAAC,OAAO,GAAG,IAAI,kBAAkB,CAAC;YACpC,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,SAAS,EAAE,IAAI,CAAC,SAAS;YACzB,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,OAAO,CAAC,aAAa,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC3E,CAAC,CAAC;IACL,CAAC;IAED,sDAAsD;IACtD,QAAQ,CAAC,UAAkC,EAAE,MAAgB;QAC3D,MAAM,IAAI,GAAG,IAAI,kBAAkB,CAAC,MAAM,CAAC,CAAC;QAC5C,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC;QACpC,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;QAC/C,IAAI,CAAC,OAAO,CAAC,oBAAoB,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACnE,OAAO,IAAI,CAAC;IACd,CAAC;IAED,UAAU,CAAC,EAAU;QACnB,IAAI,CAAC,OAAO,CAAC,sBAAsB,CAAC,EAAE,CAAC,CAAC;QACxC,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC;QAC9B,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IACxB,CAAC;IAED,OAAO,CAAC,EAAU;QAChB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAC5B,CAAC;IAED,MAAM,CAAC,EAAU;QACf,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IAC1B,CAAC;IAED,OAAO;QACL,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;QACvB,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC;IAC1B,CAAC;CACF;AAED,MAAM,UAAU,uBAAuB,CAAC,OAAiC;IACvE,OAAO,IAAI,iBAAiB,CAAC,OAAO,CAAC,CAAC;AACxC,CAAC","sourcesContent":["/**\n * createThreeInteractions - one-call setup for the standalone adapter.\n *\n * ```ts\n * const interactions = createThreeInteractions({\n * xr: renderer.xr, camera, domElement: renderer.domElement,\n * });\n * interactions.register({ id: \"button\", behaviours: [{ kind: \"press\" }] }, buttonMesh);\n * // in the render loop:\n * interactions.update(dt);\n * ```\n */\nimport type { Object3D } from \"three\";\nimport {\n InteractionRuntime,\n type DwellConfig,\n type InteractableDescriptor,\n} from \"@realitycollective/webxr-interactions\";\nimport { ThreeHitTester } from \"./hit-tester.js\";\nimport { ThreeTransformPort } from \"./transform-port.js\";\nimport { WebXRInputProvider, type WebXRProviderContext } from \"./webxr-provider.js\";\n\nexport interface ThreeInteractionsOptions extends WebXRProviderContext {\n dwellDefaults?: DwellConfig;\n}\n\nexport class ThreeInteractions {\n readonly runtime: InteractionRuntime;\n readonly provider: WebXRInputProvider;\n readonly hitTester: ThreeHitTester;\n private readonly ports = new Map<string, ThreeTransformPort>();\n\n constructor(options: ThreeInteractionsOptions) {\n this.provider = new WebXRInputProvider(options);\n this.hitTester = new ThreeHitTester();\n this.runtime = new InteractionRuntime({\n provider: this.provider,\n hitTester: this.hitTester,\n ...(options.dwellDefaults ? { dwellDefaults: options.dwellDefaults } : {}),\n });\n }\n\n /** Register an interactable with its scene object. */\n register(descriptor: InteractableDescriptor, object: Object3D): ThreeTransformPort {\n const port = new ThreeTransformPort(object);\n this.ports.set(descriptor.id, port);\n this.hitTester.register(descriptor.id, object);\n this.runtime.registerInteractable(descriptor, { transform: port });\n return port;\n }\n\n unregister(id: string): void {\n this.runtime.unregisterInteractable(id);\n this.hitTester.unregister(id);\n this.ports.delete(id);\n }\n\n getPort(id: string): ThreeTransformPort | undefined {\n return this.ports.get(id);\n }\n\n update(dt: number): void {\n this.runtime.update(dt);\n }\n\n dispose(): void {\n this.runtime.dispose();\n this.provider.dispose();\n }\n}\n\nexport function createThreeInteractions(options: ThreeInteractionsOptions): ThreeInteractions {\n return new ThreeInteractions(options);\n}\n"]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @realitycollective/threejs-interactions - the default, standalone engine
|
|
3
|
+
* adapter: raw WebXR input + three.js hit-testing/transform ports. The
|
|
4
|
+
* core is re-exported wholesale, so apps depend on exactly this package.
|
|
5
|
+
*/
|
|
6
|
+
export * from "@realitycollective/webxr-interactions";
|
|
7
|
+
export * from "./webxr-provider.js";
|
|
8
|
+
export * from "./hit-tester.js";
|
|
9
|
+
export * from "./transform-port.js";
|
|
10
|
+
export * from "./host.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @realitycollective/threejs-interactions - the default, standalone engine
|
|
3
|
+
* adapter: raw WebXR input + three.js hit-testing/transform ports. The
|
|
4
|
+
* core is re-exported wholesale, so apps depend on exactly this package.
|
|
5
|
+
*/
|
|
6
|
+
export * from "@realitycollective/webxr-interactions";
|
|
7
|
+
export * from "./webxr-provider.js";
|
|
8
|
+
export * from "./hit-tester.js";
|
|
9
|
+
export * from "./transform-port.js";
|
|
10
|
+
export * from "./host.js";
|
|
11
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,cAAc,uCAAuC,CAAC;AAEtD,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,qBAAqB,CAAC;AACpC,cAAc,WAAW,CAAC","sourcesContent":["/**\n * @realitycollective/threejs-interactions - the default, standalone engine\n * adapter: raw WebXR input + three.js hit-testing/transform ports. The\n * core is re-exported wholesale, so apps depend on exactly this package.\n */\nexport * from \"@realitycollective/webxr-interactions\";\n\nexport * from \"./webxr-provider.js\";\nexport * from \"./hit-tester.js\";\nexport * from \"./transform-port.js\";\nexport * from \"./host.js\";\n"]}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThreeTransformPort - the write/read surface of one interactable's
|
|
3
|
+
* Object3D, honouring the core's FROM-REST semantics: the rest pose is
|
|
4
|
+
* captured at construction, offsets/rotations apply relative to it, and
|
|
5
|
+
* `getWorldPose()` reports the REST pose in world space (external movers
|
|
6
|
+
* that reposition the object should call `recaptureRest()`).
|
|
7
|
+
*/
|
|
8
|
+
import { type Object3D } from "three";
|
|
9
|
+
import type { PoseTuple, QuatTuple, Vec3Tuple } from "@realitycollective/webxr-input";
|
|
10
|
+
import type { TransformPort } from "@realitycollective/webxr-interactions";
|
|
11
|
+
export declare class ThreeTransformPort implements TransformPort {
|
|
12
|
+
private readonly object;
|
|
13
|
+
private readonly restPosition;
|
|
14
|
+
private readonly restQuaternion;
|
|
15
|
+
private readonly restScale;
|
|
16
|
+
private baseEmissive;
|
|
17
|
+
private readonly v;
|
|
18
|
+
private readonly q;
|
|
19
|
+
private readonly q2;
|
|
20
|
+
private readonly m;
|
|
21
|
+
constructor(object: Object3D);
|
|
22
|
+
recaptureRest(): void;
|
|
23
|
+
getWorldPose(): PoseTuple;
|
|
24
|
+
getLocalOffset(): Vec3Tuple;
|
|
25
|
+
setLocalOffset(offset: Vec3Tuple): void;
|
|
26
|
+
setLocalRotation(quaternion: QuatTuple): void;
|
|
27
|
+
setWorldPose(pose: PoseTuple): void;
|
|
28
|
+
setEffect(effect: {
|
|
29
|
+
scale?: number;
|
|
30
|
+
emissive?: number;
|
|
31
|
+
}): void;
|
|
32
|
+
private firstEmissiveMaterial;
|
|
33
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThreeTransformPort - the write/read surface of one interactable's
|
|
3
|
+
* Object3D, honouring the core's FROM-REST semantics: the rest pose is
|
|
4
|
+
* captured at construction, offsets/rotations apply relative to it, and
|
|
5
|
+
* `getWorldPose()` reports the REST pose in world space (external movers
|
|
6
|
+
* that reposition the object should call `recaptureRest()`).
|
|
7
|
+
*/
|
|
8
|
+
import { Matrix4, Quaternion, Vector3, } from "three";
|
|
9
|
+
export class ThreeTransformPort {
|
|
10
|
+
object;
|
|
11
|
+
restPosition = new Vector3();
|
|
12
|
+
restQuaternion = new Quaternion();
|
|
13
|
+
restScale = new Vector3();
|
|
14
|
+
baseEmissive = null;
|
|
15
|
+
// Temps.
|
|
16
|
+
v = new Vector3();
|
|
17
|
+
q = new Quaternion();
|
|
18
|
+
q2 = new Quaternion();
|
|
19
|
+
m = new Matrix4();
|
|
20
|
+
constructor(object) {
|
|
21
|
+
this.object = object;
|
|
22
|
+
this.recaptureRest();
|
|
23
|
+
}
|
|
24
|
+
recaptureRest() {
|
|
25
|
+
this.restPosition.copy(this.object.position);
|
|
26
|
+
this.restQuaternion.copy(this.object.quaternion);
|
|
27
|
+
this.restScale.copy(this.object.scale);
|
|
28
|
+
}
|
|
29
|
+
getWorldPose() {
|
|
30
|
+
const parent = this.object.parent;
|
|
31
|
+
if (parent) {
|
|
32
|
+
parent.updateWorldMatrix(true, false);
|
|
33
|
+
this.v.copy(this.restPosition).applyMatrix4(parent.matrixWorld);
|
|
34
|
+
parent.getWorldQuaternion(this.q2);
|
|
35
|
+
this.q.copy(this.q2).multiply(this.restQuaternion);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
this.v.copy(this.restPosition);
|
|
39
|
+
this.q.copy(this.restQuaternion);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
position: [this.v.x, this.v.y, this.v.z],
|
|
43
|
+
quaternion: [this.q.x, this.q.y, this.q.z, this.q.w],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
getLocalOffset() {
|
|
47
|
+
this.v.copy(this.object.position).sub(this.restPosition);
|
|
48
|
+
this.q.copy(this.restQuaternion).invert();
|
|
49
|
+
this.v.applyQuaternion(this.q);
|
|
50
|
+
return [this.v.x, this.v.y, this.v.z];
|
|
51
|
+
}
|
|
52
|
+
setLocalOffset(offset) {
|
|
53
|
+
this.v.set(...offset).applyQuaternion(this.restQuaternion);
|
|
54
|
+
this.object.position.copy(this.restPosition).add(this.v);
|
|
55
|
+
}
|
|
56
|
+
setLocalRotation(quaternion) {
|
|
57
|
+
this.q.set(...quaternion);
|
|
58
|
+
this.object.quaternion.copy(this.restQuaternion).multiply(this.q);
|
|
59
|
+
}
|
|
60
|
+
setWorldPose(pose) {
|
|
61
|
+
const parent = this.object.parent;
|
|
62
|
+
this.v.set(...pose.position);
|
|
63
|
+
this.q.set(...pose.quaternion);
|
|
64
|
+
if (parent) {
|
|
65
|
+
parent.updateWorldMatrix(true, false);
|
|
66
|
+
this.m.copy(parent.matrixWorld).invert();
|
|
67
|
+
this.v.applyMatrix4(this.m);
|
|
68
|
+
parent.getWorldQuaternion(this.q2).invert();
|
|
69
|
+
this.q.premultiply(this.q2);
|
|
70
|
+
}
|
|
71
|
+
this.object.position.copy(this.v);
|
|
72
|
+
this.object.quaternion.copy(this.q);
|
|
73
|
+
}
|
|
74
|
+
setEffect(effect) {
|
|
75
|
+
if (effect.scale !== undefined) {
|
|
76
|
+
this.object.scale.copy(this.restScale).multiplyScalar(effect.scale);
|
|
77
|
+
}
|
|
78
|
+
if (effect.emissive !== undefined) {
|
|
79
|
+
const material = this.firstEmissiveMaterial();
|
|
80
|
+
if (material) {
|
|
81
|
+
if (this.baseEmissive === null)
|
|
82
|
+
this.baseEmissive = material.emissiveIntensity;
|
|
83
|
+
material.emissiveIntensity = this.baseEmissive + effect.emissive;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
firstEmissiveMaterial() {
|
|
88
|
+
let found = null;
|
|
89
|
+
this.object.traverse((child) => {
|
|
90
|
+
if (found)
|
|
91
|
+
return;
|
|
92
|
+
const material = child.material;
|
|
93
|
+
const single = Array.isArray(material) ? material[0] : material;
|
|
94
|
+
if (single && "emissiveIntensity" in single) {
|
|
95
|
+
found = single;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
return found;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=transform-port.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transform-port.js","sourceRoot":"","sources":["../src/transform-port.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EACL,OAAO,EACP,UAAU,EACV,OAAO,GAKR,MAAM,OAAO,CAAC;AAIf,MAAM,OAAO,kBAAkB;IACZ,MAAM,CAAW;IACjB,YAAY,GAAG,IAAI,OAAO,EAAE,CAAC;IAC7B,cAAc,GAAG,IAAI,UAAU,EAAE,CAAC;IAClC,SAAS,GAAG,IAAI,OAAO,EAAE,CAAC;IACnC,YAAY,GAAkB,IAAI,CAAC;IAE3C,SAAS;IACQ,CAAC,GAAG,IAAI,OAAO,EAAE,CAAC;IAClB,CAAC,GAAG,IAAI,UAAU,EAAE,CAAC;IACrB,EAAE,GAAG,IAAI,UAAU,EAAE,CAAC;IACtB,CAAC,GAAG,IAAI,OAAO,EAAE,CAAC;IAEnC,YAAY,MAAgB;QAC1B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,aAAa,EAAE,CAAC;IACvB,CAAC;IAED,aAAa;QACX,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC7C,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;QACjD,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACzC,CAAC;IAED,YAAY;QACV,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC;QAClC,IAAI,MAAM,EAAE,CAAC;YACX,MAAM,CAAC,iBAAiB,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;YACtC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,YAAY,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;YAChE,MAAM,CAAC,kBAAkB,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YACnC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QACrD,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;YAC/B,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QACnC,CAAC;QACD,OAAO;YACL,QAAQ,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;YACxC,UAAU,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;SACrD,CAAC;IACJ,CAAC;IAED,cAAc;QACZ,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QACzD,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,MAAM,EAAE,CAAC;QAC1C,IAAI,CAAC,CAAC,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC/B,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACxC,CAAC;IAED,cAAc,CAAC,MAAiB;QAC9B,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,eAAe,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QAC3D,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC3D,CAAC;IAED,gBAAgB,CAAC,UAAqB;QACpC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,UAAU,CAAC,CAAC;QAC1B,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACpE,CAAC;IAED,YAAY,CAAC,IAAe;QAC1B,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC;QAClC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC7B,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,UAAU,CAAC,CAAC;QAC/B,IAAI,MAAM,EAAE,CAAC;YACX,MAAM,CAAC,iBAAiB,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;YACtC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,EAAE,CAAC;YACzC,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YAC5B,MAAM,CAAC,kBAAkB,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC;YAC5C,IAAI,CAAC,CAAC,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC9B,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClC,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACtC,CAAC;IAED,SAAS,CAAC,MAA6C;QACrD,IAAI,MAAM,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;YAC/B,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,cAAc,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACtE,CAAC;QACD,IAAI,MAAM,CAAC,QAAQ,KAAK,SAAS,EAAE,CAAC;YAClC,MAAM,QAAQ,GAAG,IAAI,CAAC,qBAAqB,EAAE,CAAC;YAC9C,IAAI,QAAQ,EAAE,CAAC;gBACb,IAAI,IAAI,CAAC,YAAY,KAAK,IAAI;oBAAE,IAAI,CAAC,YAAY,GAAG,QAAQ,CAAC,iBAAiB,CAAC;gBAC/E,QAAQ,CAAC,iBAAiB,GAAG,IAAI,CAAC,YAAY,GAAG,MAAM,CAAC,QAAQ,CAAC;YACnE,CAAC;QACH,CAAC;IACH,CAAC;IAEO,qBAAqB;QAC3B,IAAI,KAAK,GAAgC,IAAI,CAAC;QAC9C,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,KAAK,EAAE,EAAE;YAC7B,IAAI,KAAK;gBAAE,OAAO;YAClB,MAAM,QAAQ,GAAI,KAAc,CAAC,QAA6C,CAAC;YAC/E,MAAM,MAAM,GAAG,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;YAChE,IAAI,MAAM,IAAI,mBAAmB,IAAI,MAAM,EAAE,CAAC;gBAC5C,KAAK,GAAG,MAA8B,CAAC;YACzC,CAAC;QACH,CAAC,CAAC,CAAC;QACH,OAAO,KAAK,CAAC;IACf,CAAC;CACF","sourcesContent":["/**\n * ThreeTransformPort - the write/read surface of one interactable's\n * Object3D, honouring the core's FROM-REST semantics: the rest pose is\n * captured at construction, offsets/rotations apply relative to it, and\n * `getWorldPose()` reports the REST pose in world space (external movers\n * that reposition the object should call `recaptureRest()`).\n */\nimport {\n Matrix4,\n Quaternion,\n Vector3,\n type Material,\n type Mesh,\n type MeshStandardMaterial,\n type Object3D,\n} from \"three\";\nimport type { PoseTuple, QuatTuple, Vec3Tuple } from \"@realitycollective/webxr-input\";\nimport type { TransformPort } from \"@realitycollective/webxr-interactions\";\n\nexport class ThreeTransformPort implements TransformPort {\n private readonly object: Object3D;\n private readonly restPosition = new Vector3();\n private readonly restQuaternion = new Quaternion();\n private readonly restScale = new Vector3();\n private baseEmissive: number | null = null;\n\n // Temps.\n private readonly v = new Vector3();\n private readonly q = new Quaternion();\n private readonly q2 = new Quaternion();\n private readonly m = new Matrix4();\n\n constructor(object: Object3D) {\n this.object = object;\n this.recaptureRest();\n }\n\n recaptureRest(): void {\n this.restPosition.copy(this.object.position);\n this.restQuaternion.copy(this.object.quaternion);\n this.restScale.copy(this.object.scale);\n }\n\n getWorldPose(): PoseTuple {\n const parent = this.object.parent;\n if (parent) {\n parent.updateWorldMatrix(true, false);\n this.v.copy(this.restPosition).applyMatrix4(parent.matrixWorld);\n parent.getWorldQuaternion(this.q2);\n this.q.copy(this.q2).multiply(this.restQuaternion);\n } else {\n this.v.copy(this.restPosition);\n this.q.copy(this.restQuaternion);\n }\n return {\n position: [this.v.x, this.v.y, this.v.z],\n quaternion: [this.q.x, this.q.y, this.q.z, this.q.w],\n };\n }\n\n getLocalOffset(): Vec3Tuple {\n this.v.copy(this.object.position).sub(this.restPosition);\n this.q.copy(this.restQuaternion).invert();\n this.v.applyQuaternion(this.q);\n return [this.v.x, this.v.y, this.v.z];\n }\n\n setLocalOffset(offset: Vec3Tuple): void {\n this.v.set(...offset).applyQuaternion(this.restQuaternion);\n this.object.position.copy(this.restPosition).add(this.v);\n }\n\n setLocalRotation(quaternion: QuatTuple): void {\n this.q.set(...quaternion);\n this.object.quaternion.copy(this.restQuaternion).multiply(this.q);\n }\n\n setWorldPose(pose: PoseTuple): void {\n const parent = this.object.parent;\n this.v.set(...pose.position);\n this.q.set(...pose.quaternion);\n if (parent) {\n parent.updateWorldMatrix(true, false);\n this.m.copy(parent.matrixWorld).invert();\n this.v.applyMatrix4(this.m);\n parent.getWorldQuaternion(this.q2).invert();\n this.q.premultiply(this.q2);\n }\n this.object.position.copy(this.v);\n this.object.quaternion.copy(this.q);\n }\n\n setEffect(effect: { scale?: number; emissive?: number }): void {\n if (effect.scale !== undefined) {\n this.object.scale.copy(this.restScale).multiplyScalar(effect.scale);\n }\n if (effect.emissive !== undefined) {\n const material = this.firstEmissiveMaterial();\n if (material) {\n if (this.baseEmissive === null) this.baseEmissive = material.emissiveIntensity;\n material.emissiveIntensity = this.baseEmissive + effect.emissive;\n }\n }\n }\n\n private firstEmissiveMaterial(): MeshStandardMaterial | null {\n let found: MeshStandardMaterial | null = null;\n this.object.traverse((child) => {\n if (found) return;\n const material = (child as Mesh).material as Material | Material[] | undefined;\n const single = Array.isArray(material) ? material[0] : material;\n if (single && \"emissiveIntensity\" in single) {\n found = single as MeshStandardMaterial;\n }\n });\n return found;\n }\n}\n"]}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebXRInputProvider - the standalone, framework-free input provider.
|
|
3
|
+
*
|
|
4
|
+
* Speaks the browser's OpenXR binding directly: `XRSession` input sources
|
|
5
|
+
* (`targetRaySpace`, `gripSpace`, `hand` joints), `selectstart/selectend`
|
|
6
|
+
* and `squeezestart/squeezeend`, gamepad analog values where present, and
|
|
7
|
+
* haptic actuators. Falls back to a desktop mouse pointer (a ray from the
|
|
8
|
+
* camera through the cursor) when no immersive session is live, so every
|
|
9
|
+
* consumer runs in a flat browser too.
|
|
10
|
+
*
|
|
11
|
+
* Capabilities are derived from the LIVE session (never the requested
|
|
12
|
+
* config) and re-published on session start/end and input-source changes.
|
|
13
|
+
*/
|
|
14
|
+
import { type Camera, type WebXRManager } from "three";
|
|
15
|
+
import { type HeadPose, type InputCapabilities, type InputProvider, type InputSourceSnapshot, type Unsubscribe } from "@realitycollective/webxr-input";
|
|
16
|
+
export interface WebXRProviderContext {
|
|
17
|
+
/** three.js `renderer.xr` (or anything with the same session surface). */
|
|
18
|
+
xr: Pick<WebXRManager, "getSession" | "getReferenceSpace" | "getFrame">;
|
|
19
|
+
/** The rendering camera - head pose in XR, pointer projection on desktop. */
|
|
20
|
+
camera: Camera;
|
|
21
|
+
/** DOM element for the desktop mouse fallback (canvas). Omit to disable. */
|
|
22
|
+
domElement?: HTMLElement;
|
|
23
|
+
/**
|
|
24
|
+
* Metres along the mouse ray at which the desktop fallback places its
|
|
25
|
+
* synthetic grip pose. Hand-driven behaviours (grab, hinge, dial, slide)
|
|
26
|
+
* track a grip position; a mouse has none, so one is projected onto the ray.
|
|
27
|
+
* Set this near the distance of the things being manipulated - too short and
|
|
28
|
+
* levers barely swing, too long and they over-swing. Default 1 metre.
|
|
29
|
+
*/
|
|
30
|
+
desktopGripDistance?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Which mouse button reports `squeeze` on desktop. Default `"left"`.
|
|
33
|
+
*
|
|
34
|
+
* The right button is NOT the default, because the shared desktop camera
|
|
35
|
+
* controls (`DesktopControls` in `@realitycollective/xrblocks-uiextensions`)
|
|
36
|
+
* bind right-drag to look and reserve the left button for interaction. That
|
|
37
|
+
* leaves the left button to carry both actions, which is fine as long as an
|
|
38
|
+
* interactable does not mix a select-driven behaviour (press) with a
|
|
39
|
+
* grab-driven one (grab, hinge, dial, slide) - such an interactable would
|
|
40
|
+
* receive both on a single click. Use `"right"` only in an app that does not
|
|
41
|
+
* use right-drag to look.
|
|
42
|
+
*/
|
|
43
|
+
desktopSqueezeButton?: "left" | "right" | "none";
|
|
44
|
+
}
|
|
45
|
+
export declare class WebXRInputProvider implements InputProvider {
|
|
46
|
+
private readonly context;
|
|
47
|
+
private capabilities;
|
|
48
|
+
private readonly capsListeners;
|
|
49
|
+
private readonly sourceListeners;
|
|
50
|
+
private readonly selectStates;
|
|
51
|
+
private boundSession;
|
|
52
|
+
private readonly sessionHandlers;
|
|
53
|
+
private mouseDown;
|
|
54
|
+
private mouseRight;
|
|
55
|
+
private readonly mouseNdc;
|
|
56
|
+
private readonly raycaster;
|
|
57
|
+
private readonly detachDom;
|
|
58
|
+
private readonly gripDistance;
|
|
59
|
+
private readonly squeezeButton;
|
|
60
|
+
private readonly v;
|
|
61
|
+
private readonly q;
|
|
62
|
+
private readonly pointerNdc;
|
|
63
|
+
private readonly gripPoint;
|
|
64
|
+
private readonly gripQuaternion;
|
|
65
|
+
constructor(context: WebXRProviderContext);
|
|
66
|
+
private attachDom;
|
|
67
|
+
/** Bind/unbind session listeners as the session comes and goes. */
|
|
68
|
+
private syncSession;
|
|
69
|
+
private selectState;
|
|
70
|
+
private refreshCapabilities;
|
|
71
|
+
getCapabilities(): InputCapabilities;
|
|
72
|
+
onCapabilitiesChanged(listener: (c: InputCapabilities) => void): Unsubscribe;
|
|
73
|
+
onSourcesChanged(listener: () => void): Unsubscribe;
|
|
74
|
+
sample(): readonly InputSourceSnapshot[];
|
|
75
|
+
/** Analog trigger where available, else the select event state. */
|
|
76
|
+
private selectValue;
|
|
77
|
+
private squeezeValue;
|
|
78
|
+
/** Is the configured squeeze button currently held? */
|
|
79
|
+
private squeezing;
|
|
80
|
+
private sampleDesktop;
|
|
81
|
+
getHeadPose(): HeadPose;
|
|
82
|
+
pulse(sourceId: string, intensity: number, durationMs: number): boolean;
|
|
83
|
+
dispose(): void;
|
|
84
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebXRInputProvider - the standalone, framework-free input provider.
|
|
3
|
+
*
|
|
4
|
+
* Speaks the browser's OpenXR binding directly: `XRSession` input sources
|
|
5
|
+
* (`targetRaySpace`, `gripSpace`, `hand` joints), `selectstart/selectend`
|
|
6
|
+
* and `squeezestart/squeezeend`, gamepad analog values where present, and
|
|
7
|
+
* haptic actuators. Falls back to a desktop mouse pointer (a ray from the
|
|
8
|
+
* camera through the cursor) when no immersive session is live, so every
|
|
9
|
+
* consumer runs in a flat browser too.
|
|
10
|
+
*
|
|
11
|
+
* Capabilities are derived from the LIVE session (never the requested
|
|
12
|
+
* config) and re-published on session start/end and input-source changes.
|
|
13
|
+
*/
|
|
14
|
+
import { Raycaster, Vector2, Vector3, Quaternion } from "three";
|
|
15
|
+
import { NO_CAPABILITIES, } from "@realitycollective/webxr-input";
|
|
16
|
+
/** Where the desktop fallback puts its synthetic grip along the mouse ray. */
|
|
17
|
+
const DEFAULT_DESKTOP_GRIP_DISTANCE = 1;
|
|
18
|
+
/** Centre of the viewport, used while the pointer is locked. */
|
|
19
|
+
const ZERO_NDC = new Vector2(0, 0);
|
|
20
|
+
export class WebXRInputProvider {
|
|
21
|
+
context;
|
|
22
|
+
capabilities;
|
|
23
|
+
capsListeners = new Set();
|
|
24
|
+
sourceListeners = new Set();
|
|
25
|
+
selectStates = new Map();
|
|
26
|
+
boundSession = null;
|
|
27
|
+
sessionHandlers = [];
|
|
28
|
+
// Desktop pointer state.
|
|
29
|
+
mouseDown = false;
|
|
30
|
+
mouseRight = false;
|
|
31
|
+
mouseNdc = new Vector2();
|
|
32
|
+
raycaster = new Raycaster();
|
|
33
|
+
detachDom;
|
|
34
|
+
gripDistance;
|
|
35
|
+
squeezeButton;
|
|
36
|
+
// Temps.
|
|
37
|
+
v = new Vector3();
|
|
38
|
+
q = new Quaternion();
|
|
39
|
+
pointerNdc = new Vector2();
|
|
40
|
+
gripPoint = new Vector3();
|
|
41
|
+
gripQuaternion = new Quaternion();
|
|
42
|
+
constructor(context) {
|
|
43
|
+
this.context = context;
|
|
44
|
+
this.gripDistance = context.desktopGripDistance ?? DEFAULT_DESKTOP_GRIP_DISTANCE;
|
|
45
|
+
// Read before attachDom - it decides whether to claim the context menu.
|
|
46
|
+
this.squeezeButton = context.desktopSqueezeButton ?? "left";
|
|
47
|
+
this.capabilities = { ...NO_CAPABILITIES, headPose: true, gaze: true };
|
|
48
|
+
this.detachDom = this.attachDom(context.domElement);
|
|
49
|
+
this.refreshCapabilities();
|
|
50
|
+
}
|
|
51
|
+
attachDom(element) {
|
|
52
|
+
if (!element)
|
|
53
|
+
return () => undefined;
|
|
54
|
+
const onMove = (event) => {
|
|
55
|
+
const rect = element.getBoundingClientRect();
|
|
56
|
+
this.mouseNdc.set(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
|
|
57
|
+
};
|
|
58
|
+
const onDown = (event) => {
|
|
59
|
+
if (event.button === 0)
|
|
60
|
+
this.mouseDown = true;
|
|
61
|
+
if (event.button === 2)
|
|
62
|
+
this.mouseRight = true;
|
|
63
|
+
onMove(event);
|
|
64
|
+
};
|
|
65
|
+
const onUp = (event) => {
|
|
66
|
+
if (event.button === 0)
|
|
67
|
+
this.mouseDown = false;
|
|
68
|
+
if (event.button === 2)
|
|
69
|
+
this.mouseRight = false;
|
|
70
|
+
};
|
|
71
|
+
element.addEventListener("pointermove", onMove);
|
|
72
|
+
element.addEventListener("pointerdown", onDown);
|
|
73
|
+
// Only when the right button carries squeeze. Otherwise the menu belongs to
|
|
74
|
+
// whoever else is listening - the shared camera controls suppress it there.
|
|
75
|
+
// Without this, a right press opens the menu, the matching pointerup never
|
|
76
|
+
// arrives, and the squeeze latches on.
|
|
77
|
+
const onContextMenu = this.squeezeButton === "right" ? (event) => event.preventDefault() : undefined;
|
|
78
|
+
if (onContextMenu)
|
|
79
|
+
element.addEventListener("contextmenu", onContextMenu);
|
|
80
|
+
window.addEventListener("pointerup", onUp);
|
|
81
|
+
return () => {
|
|
82
|
+
element.removeEventListener("pointermove", onMove);
|
|
83
|
+
element.removeEventListener("pointerdown", onDown);
|
|
84
|
+
if (onContextMenu)
|
|
85
|
+
element.removeEventListener("contextmenu", onContextMenu);
|
|
86
|
+
window.removeEventListener("pointerup", onUp);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Bind/unbind session listeners as the session comes and goes. */
|
|
90
|
+
syncSession() {
|
|
91
|
+
const session = this.context.xr.getSession();
|
|
92
|
+
if (session === this.boundSession)
|
|
93
|
+
return session;
|
|
94
|
+
if (this.boundSession) {
|
|
95
|
+
for (const [type, handler] of this.sessionHandlers) {
|
|
96
|
+
this.boundSession.removeEventListener(type, handler);
|
|
97
|
+
}
|
|
98
|
+
this.sessionHandlers.length = 0;
|
|
99
|
+
this.selectStates.clear();
|
|
100
|
+
}
|
|
101
|
+
this.boundSession = session;
|
|
102
|
+
if (session) {
|
|
103
|
+
const setSelect = (selecting) => (event) => {
|
|
104
|
+
const source = event.inputSource;
|
|
105
|
+
const state = this.selectState(source);
|
|
106
|
+
state.selecting = selecting;
|
|
107
|
+
};
|
|
108
|
+
const setSqueeze = (squeezing) => (event) => {
|
|
109
|
+
const source = event.inputSource;
|
|
110
|
+
const state = this.selectState(source);
|
|
111
|
+
state.squeezing = squeezing;
|
|
112
|
+
};
|
|
113
|
+
const onSourcesChange = () => {
|
|
114
|
+
this.refreshCapabilities();
|
|
115
|
+
for (const listener of [...this.sourceListeners])
|
|
116
|
+
listener();
|
|
117
|
+
};
|
|
118
|
+
const onEnd = () => {
|
|
119
|
+
this.boundSession = null;
|
|
120
|
+
this.sessionHandlers.length = 0;
|
|
121
|
+
this.selectStates.clear();
|
|
122
|
+
this.refreshCapabilities();
|
|
123
|
+
};
|
|
124
|
+
const pairs = [
|
|
125
|
+
["selectstart", setSelect(true)],
|
|
126
|
+
["selectend", setSelect(false)],
|
|
127
|
+
["squeezestart", setSqueeze(true)],
|
|
128
|
+
["squeezeend", setSqueeze(false)],
|
|
129
|
+
["inputsourceschange", onSourcesChange],
|
|
130
|
+
["end", onEnd],
|
|
131
|
+
];
|
|
132
|
+
for (const [type, handler] of pairs) {
|
|
133
|
+
session.addEventListener(type, handler);
|
|
134
|
+
this.sessionHandlers.push([type, handler]);
|
|
135
|
+
}
|
|
136
|
+
this.refreshCapabilities();
|
|
137
|
+
}
|
|
138
|
+
return session;
|
|
139
|
+
}
|
|
140
|
+
selectState(source) {
|
|
141
|
+
let state = this.selectStates.get(source);
|
|
142
|
+
if (!state) {
|
|
143
|
+
state = { selecting: false, squeezing: false };
|
|
144
|
+
this.selectStates.set(source, state);
|
|
145
|
+
}
|
|
146
|
+
return state;
|
|
147
|
+
}
|
|
148
|
+
refreshCapabilities() {
|
|
149
|
+
const session = this.boundSession ?? this.context.xr.getSession();
|
|
150
|
+
const desktop = this.context.domElement !== undefined && session === null;
|
|
151
|
+
const next = {
|
|
152
|
+
...NO_CAPABILITIES,
|
|
153
|
+
headPose: true,
|
|
154
|
+
gaze: true,
|
|
155
|
+
pointer2d: desktop,
|
|
156
|
+
// NOTE: `rays` stays false on desktop on purpose. sample() uses it as the
|
|
157
|
+
// "a session just ended" sentinel, so setting it here would re-derive
|
|
158
|
+
// capabilities on every frame for the whole life of the page.
|
|
159
|
+
// The desktop fallback synthesises a grip on the mouse ray, so hand-driven
|
|
160
|
+
// behaviours (grab, hinge, dial, slide) negotiate successfully. Without
|
|
161
|
+
// this `grabs` stays "none" off-headset and capability negotiation
|
|
162
|
+
// disables every one of them, leaving press as the only usable behaviour.
|
|
163
|
+
grabs: desktop ? "poseOnly" : NO_CAPABILITIES.grabs,
|
|
164
|
+
};
|
|
165
|
+
if (session) {
|
|
166
|
+
for (const source of session.inputSources) {
|
|
167
|
+
if (source.targetRaySpace)
|
|
168
|
+
next.rays = true;
|
|
169
|
+
if (source.gripSpace || source.hand)
|
|
170
|
+
next.grabs = "poseOnly";
|
|
171
|
+
if (source.hand) {
|
|
172
|
+
next.handJoints = true;
|
|
173
|
+
next.pokes = true;
|
|
174
|
+
next.pinch = true;
|
|
175
|
+
}
|
|
176
|
+
if (source.gamepad) {
|
|
177
|
+
next.buttonsAxes = true;
|
|
178
|
+
if ((source.gamepad.hapticActuators?.length ?? 0) > 0)
|
|
179
|
+
next.haptics = true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const changed = JSON.stringify(next) !== JSON.stringify(this.capabilities);
|
|
184
|
+
this.capabilities = next;
|
|
185
|
+
if (changed) {
|
|
186
|
+
for (const listener of [...this.capsListeners])
|
|
187
|
+
listener(next);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
getCapabilities() {
|
|
191
|
+
return this.capabilities;
|
|
192
|
+
}
|
|
193
|
+
onCapabilitiesChanged(listener) {
|
|
194
|
+
this.capsListeners.add(listener);
|
|
195
|
+
return () => this.capsListeners.delete(listener);
|
|
196
|
+
}
|
|
197
|
+
onSourcesChanged(listener) {
|
|
198
|
+
this.sourceListeners.add(listener);
|
|
199
|
+
return () => this.sourceListeners.delete(listener);
|
|
200
|
+
}
|
|
201
|
+
sample() {
|
|
202
|
+
const session = this.syncSession();
|
|
203
|
+
// The session-null path also re-derives capabilities when a session
|
|
204
|
+
// just ended between frames.
|
|
205
|
+
if (!session) {
|
|
206
|
+
if (this.capabilities.rays)
|
|
207
|
+
this.refreshCapabilities();
|
|
208
|
+
return this.sampleDesktop();
|
|
209
|
+
}
|
|
210
|
+
const frame = this.context.xr.getFrame();
|
|
211
|
+
const referenceSpace = this.context.xr.getReferenceSpace();
|
|
212
|
+
if (!frame || !referenceSpace)
|
|
213
|
+
return [];
|
|
214
|
+
const snapshots = [];
|
|
215
|
+
let index = 0;
|
|
216
|
+
for (const source of session.inputSources) {
|
|
217
|
+
const state = this.selectState(source);
|
|
218
|
+
const id = `${source.handedness}-${source.hand ? "hand" : "controller"}-${index++}`;
|
|
219
|
+
const snapshot = {
|
|
220
|
+
id,
|
|
221
|
+
kind: source.hand ? "hand" : "controller",
|
|
222
|
+
handedness: source.handedness === "left" || source.handedness === "right"
|
|
223
|
+
? source.handedness
|
|
224
|
+
: "none",
|
|
225
|
+
select: this.selectValue(source, state),
|
|
226
|
+
squeeze: this.squeezeValue(source, state),
|
|
227
|
+
};
|
|
228
|
+
const rayPose = frame.getPose(source.targetRaySpace, referenceSpace);
|
|
229
|
+
if (rayPose) {
|
|
230
|
+
const p = rayPose.transform.position;
|
|
231
|
+
const o = rayPose.transform.orientation;
|
|
232
|
+
this.q.set(o.x, o.y, o.z, o.w);
|
|
233
|
+
this.v.set(0, 0, -1).applyQuaternion(this.q);
|
|
234
|
+
snapshot.ray = {
|
|
235
|
+
origin: [p.x, p.y, p.z],
|
|
236
|
+
direction: [this.v.x, this.v.y, this.v.z],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (source.gripSpace) {
|
|
240
|
+
const gripPose = frame.getPose(source.gripSpace, referenceSpace);
|
|
241
|
+
if (gripPose)
|
|
242
|
+
snapshot.gripPose = xrPoseToTuple(gripPose);
|
|
243
|
+
}
|
|
244
|
+
if (source.hand) {
|
|
245
|
+
const joint = source.hand.get("index-finger-tip");
|
|
246
|
+
const jointPose = joint ? frame.getJointPose?.(joint, referenceSpace) : undefined;
|
|
247
|
+
if (jointPose) {
|
|
248
|
+
const p = jointPose.transform.position;
|
|
249
|
+
snapshot.indexTip = [p.x, p.y, p.z];
|
|
250
|
+
}
|
|
251
|
+
// Hands without a gripSpace still need a carry pose: use the wrist.
|
|
252
|
+
if (!snapshot.gripPose) {
|
|
253
|
+
const wrist = source.hand.get("wrist");
|
|
254
|
+
const wristPose = wrist ? frame.getJointPose?.(wrist, referenceSpace) : undefined;
|
|
255
|
+
if (wristPose)
|
|
256
|
+
snapshot.gripPose = xrPoseToTuple(wristPose);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if ((source.gamepad?.hapticActuators?.length ?? 0) > 0) {
|
|
260
|
+
snapshot.hapticsAvailable = true;
|
|
261
|
+
}
|
|
262
|
+
snapshots.push(snapshot);
|
|
263
|
+
}
|
|
264
|
+
return snapshots;
|
|
265
|
+
}
|
|
266
|
+
/** Analog trigger where available, else the select event state. */
|
|
267
|
+
selectValue(source, state) {
|
|
268
|
+
const analog = source.gamepad?.buttons[0]?.value;
|
|
269
|
+
if (analog !== undefined && analog > 0)
|
|
270
|
+
return analog;
|
|
271
|
+
return state.selecting ? 1 : 0;
|
|
272
|
+
}
|
|
273
|
+
squeezeValue(source, state) {
|
|
274
|
+
const analog = source.gamepad?.buttons[1]?.value;
|
|
275
|
+
if (analog !== undefined && analog > 0)
|
|
276
|
+
return analog;
|
|
277
|
+
return state.squeezing ? 1 : 0;
|
|
278
|
+
}
|
|
279
|
+
/** Is the configured squeeze button currently held? */
|
|
280
|
+
squeezing() {
|
|
281
|
+
if (this.squeezeButton === "left")
|
|
282
|
+
return this.mouseDown;
|
|
283
|
+
if (this.squeezeButton === "right")
|
|
284
|
+
return this.mouseRight;
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
sampleDesktop() {
|
|
288
|
+
const element = this.context.domElement;
|
|
289
|
+
if (!element)
|
|
290
|
+
return [];
|
|
291
|
+
// Under pointer lock the cursor stops moving and clientX/clientY freeze, so
|
|
292
|
+
// the tracked NDC would stick wherever the pointer was when it locked. A
|
|
293
|
+
// locked pointer aims from the centre of the viewport instead.
|
|
294
|
+
const locked = typeof document !== "undefined" && document.pointerLockElement === element;
|
|
295
|
+
this.pointerNdc.copy(locked ? ZERO_NDC : this.mouseNdc);
|
|
296
|
+
this.raycaster.setFromCamera(this.pointerNdc, this.context.camera);
|
|
297
|
+
const { origin, direction } = this.raycaster.ray;
|
|
298
|
+
// A mouse has no grip. Hand-driven behaviours read `gripPose.position` (or
|
|
299
|
+
// fall back to `ray.origin`, which is the camera and barely moves), so
|
|
300
|
+
// project a grip onto the ray to give them something that tracks the
|
|
301
|
+
// pointer.
|
|
302
|
+
this.gripPoint.copy(direction).multiplyScalar(this.gripDistance).add(origin);
|
|
303
|
+
this.context.camera.getWorldQuaternion(this.gripQuaternion);
|
|
304
|
+
return [
|
|
305
|
+
{
|
|
306
|
+
id: "mouse",
|
|
307
|
+
kind: "pointer2d",
|
|
308
|
+
handedness: "none",
|
|
309
|
+
ray: {
|
|
310
|
+
origin: [origin.x, origin.y, origin.z],
|
|
311
|
+
direction: [direction.x, direction.y, direction.z],
|
|
312
|
+
},
|
|
313
|
+
gripPose: {
|
|
314
|
+
position: [this.gripPoint.x, this.gripPoint.y, this.gripPoint.z],
|
|
315
|
+
quaternion: [
|
|
316
|
+
this.gripQuaternion.x,
|
|
317
|
+
this.gripQuaternion.y,
|
|
318
|
+
this.gripQuaternion.z,
|
|
319
|
+
this.gripQuaternion.w,
|
|
320
|
+
],
|
|
321
|
+
},
|
|
322
|
+
select: this.mouseDown ? 1 : 0,
|
|
323
|
+
squeeze: this.squeezing() ? 1 : 0,
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
}
|
|
327
|
+
getHeadPose() {
|
|
328
|
+
this.context.camera.getWorldPosition(this.v);
|
|
329
|
+
this.context.camera.getWorldQuaternion(this.q);
|
|
330
|
+
return {
|
|
331
|
+
position: [this.v.x, this.v.y, this.v.z],
|
|
332
|
+
quaternion: [this.q.x, this.q.y, this.q.z, this.q.w],
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
pulse(sourceId, intensity, durationMs) {
|
|
336
|
+
const session = this.boundSession;
|
|
337
|
+
if (!session)
|
|
338
|
+
return false;
|
|
339
|
+
let index = 0;
|
|
340
|
+
for (const source of session.inputSources) {
|
|
341
|
+
const id = `${source.handedness}-${source.hand ? "hand" : "controller"}-${index++}`;
|
|
342
|
+
if (id !== sourceId)
|
|
343
|
+
continue;
|
|
344
|
+
const actuator = source.gamepad?.hapticActuators?.[0];
|
|
345
|
+
if (!actuator)
|
|
346
|
+
return false;
|
|
347
|
+
void actuator.pulse?.(Math.min(1, Math.max(0, intensity)), durationMs);
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
dispose() {
|
|
353
|
+
this.detachDom();
|
|
354
|
+
if (this.boundSession) {
|
|
355
|
+
for (const [type, handler] of this.sessionHandlers) {
|
|
356
|
+
this.boundSession.removeEventListener(type, handler);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function xrPoseToTuple(pose) {
|
|
362
|
+
const p = pose.transform.position;
|
|
363
|
+
const o = pose.transform.orientation;
|
|
364
|
+
return { position: [p.x, p.y, p.z], quaternion: [o.x, o.y, o.z, o.w] };
|
|
365
|
+
}
|
|
366
|
+
//# sourceMappingURL=webxr-provider.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webxr-provider.js","sourceRoot":"","sources":["../src/webxr-provider.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * WebXRInputProvider - the standalone, framework-free input provider.\n *\n * Speaks the browser's OpenXR binding directly: `XRSession` input sources\n * (`targetRaySpace`, `gripSpace`, `hand` joints), `selectstart/selectend`\n * and `squeezestart/squeezeend`, gamepad analog values where present, and\n * haptic actuators. Falls back to a desktop mouse pointer (a ray from the\n * camera through the cursor) when no immersive session is live, so every\n * consumer runs in a flat browser too.\n *\n * Capabilities are derived from the LIVE session (never the requested\n * config) and re-published on session start/end and input-source changes.\n */\nimport { Raycaster, Vector2, Vector3, Quaternion, type Camera, type WebXRManager } from \"three\";\nimport {\n NO_CAPABILITIES,\n type HeadPose,\n type InputCapabilities,\n type InputProvider,\n type InputSourceSnapshot,\n type PoseTuple,\n type Unsubscribe,\n} from \"@realitycollective/webxr-input\";\n\nexport interface WebXRProviderContext {\n /** three.js `renderer.xr` (or anything with the same session surface). */\n xr: Pick<WebXRManager, \"getSession\" | \"getReferenceSpace\" | \"getFrame\">;\n /** The rendering camera - head pose in XR, pointer projection on desktop. */\n camera: Camera;\n /** DOM element for the desktop mouse fallback (canvas). Omit to disable. */\n domElement?: HTMLElement;\n /**\n * Metres along the mouse ray at which the desktop fallback places its\n * synthetic grip pose. Hand-driven behaviours (grab, hinge, dial, slide)\n * track a grip position; a mouse has none, so one is projected onto the ray.\n * Set this near the distance of the things being manipulated - too short and\n * levers barely swing, too long and they over-swing. Default 1 metre.\n */\n desktopGripDistance?: number;\n /**\n * Which mouse button reports `squeeze` on desktop. Default `\"left\"`.\n *\n * The right button is NOT the default, because the shared desktop camera\n * controls (`DesktopControls` in `@realitycollective/xrblocks-uiextensions`)\n * bind right-drag to look and reserve the left button for interaction. That\n * leaves the left button to carry both actions, which is fine as long as an\n * interactable does not mix a select-driven behaviour (press) with a\n * grab-driven one (grab, hinge, dial, slide) - such an interactable would\n * receive both on a single click. Use `\"right\"` only in an app that does not\n * use right-drag to look.\n */\n desktopSqueezeButton?: \"left\" | \"right\" | \"none\";\n}\n\ninterface SelectState {\n selecting: boolean;\n squeezing: boolean;\n}\n\n/** Where the desktop fallback puts its synthetic grip along the mouse ray. */\nconst DEFAULT_DESKTOP_GRIP_DISTANCE = 1;\n\n/** Centre of the viewport, used while the pointer is locked. */\nconst ZERO_NDC = new Vector2(0, 0);\n\nexport class WebXRInputProvider implements InputProvider {\n private readonly context: WebXRProviderContext;\n private capabilities: InputCapabilities;\n private readonly capsListeners = new Set<(c: InputCapabilities) => void>();\n private readonly sourceListeners = new Set<() => void>();\n private readonly selectStates = new Map<XRInputSource, SelectState>();\n private boundSession: XRSession | null = null;\n private readonly sessionHandlers: Array<[string, EventListener]> = [];\n\n // Desktop pointer state.\n private mouseDown = false;\n private mouseRight = false;\n private readonly mouseNdc = new Vector2();\n private readonly raycaster = new Raycaster();\n private readonly detachDom: Unsubscribe;\n private readonly gripDistance: number;\n private readonly squeezeButton: \"left\" | \"right\" | \"none\";\n\n // Temps.\n private readonly v = new Vector3();\n private readonly q = new Quaternion();\n private readonly pointerNdc = new Vector2();\n private readonly gripPoint = new Vector3();\n private readonly gripQuaternion = new Quaternion();\n\n constructor(context: WebXRProviderContext) {\n this.context = context;\n this.gripDistance = context.desktopGripDistance ?? DEFAULT_DESKTOP_GRIP_DISTANCE;\n // Read before attachDom - it decides whether to claim the context menu.\n this.squeezeButton = context.desktopSqueezeButton ?? \"left\";\n this.capabilities = { ...NO_CAPABILITIES, headPose: true, gaze: true };\n this.detachDom = this.attachDom(context.domElement);\n this.refreshCapabilities();\n }\n\n private attachDom(element: HTMLElement | undefined): Unsubscribe {\n if (!element) return () => undefined;\n const onMove = (event: PointerEvent) => {\n const rect = element.getBoundingClientRect();\n this.mouseNdc.set(\n ((event.clientX - rect.left) / rect.width) * 2 - 1,\n -((event.clientY - rect.top) / rect.height) * 2 + 1,\n );\n };\n const onDown = (event: PointerEvent) => {\n if (event.button === 0) this.mouseDown = true;\n if (event.button === 2) this.mouseRight = true;\n onMove(event);\n };\n const onUp = (event: PointerEvent) => {\n if (event.button === 0) this.mouseDown = false;\n if (event.button === 2) this.mouseRight = false;\n };\n element.addEventListener(\"pointermove\", onMove);\n element.addEventListener(\"pointerdown\", onDown);\n // Only when the right button carries squeeze. Otherwise the menu belongs to\n // whoever else is listening - the shared camera controls suppress it there.\n // Without this, a right press opens the menu, the matching pointerup never\n // arrives, and the squeeze latches on.\n const onContextMenu =\n this.squeezeButton === \"right\" ? (event: Event) => event.preventDefault() : undefined;\n if (onContextMenu) element.addEventListener(\"contextmenu\", onContextMenu);\n window.addEventListener(\"pointerup\", onUp);\n return () => {\n element.removeEventListener(\"pointermove\", onMove);\n element.removeEventListener(\"pointerdown\", onDown);\n if (onContextMenu) element.removeEventListener(\"contextmenu\", onContextMenu);\n window.removeEventListener(\"pointerup\", onUp);\n };\n }\n\n /** Bind/unbind session listeners as the session comes and goes. */\n private syncSession(): XRSession | null {\n const session = this.context.xr.getSession();\n if (session === this.boundSession) return session;\n\n if (this.boundSession) {\n for (const [type, handler] of this.sessionHandlers) {\n this.boundSession.removeEventListener(type, handler);\n }\n this.sessionHandlers.length = 0;\n this.selectStates.clear();\n }\n this.boundSession = session;\n if (session) {\n const setSelect = (selecting: boolean) => (event: Event) => {\n const source = (event as XRInputSourceEvent).inputSource;\n const state = this.selectState(source);\n state.selecting = selecting;\n };\n const setSqueeze = (squeezing: boolean) => (event: Event) => {\n const source = (event as XRInputSourceEvent).inputSource;\n const state = this.selectState(source);\n state.squeezing = squeezing;\n };\n const onSourcesChange = () => {\n this.refreshCapabilities();\n for (const listener of [...this.sourceListeners]) listener();\n };\n const onEnd = () => {\n this.boundSession = null;\n this.sessionHandlers.length = 0;\n this.selectStates.clear();\n this.refreshCapabilities();\n };\n const pairs: Array<[string, EventListener]> = [\n [\"selectstart\", setSelect(true)],\n [\"selectend\", setSelect(false)],\n [\"squeezestart\", setSqueeze(true)],\n [\"squeezeend\", setSqueeze(false)],\n [\"inputsourceschange\", onSourcesChange],\n [\"end\", onEnd],\n ];\n for (const [type, handler] of pairs) {\n session.addEventListener(type, handler);\n this.sessionHandlers.push([type, handler]);\n }\n this.refreshCapabilities();\n }\n return session;\n }\n\n private selectState(source: XRInputSource): SelectState {\n let state = this.selectStates.get(source);\n if (!state) {\n state = { selecting: false, squeezing: false };\n this.selectStates.set(source, state);\n }\n return state;\n }\n\n private refreshCapabilities(): void {\n const session = this.boundSession ?? this.context.xr.getSession();\n const desktop = this.context.domElement !== undefined && session === null;\n const next: InputCapabilities = {\n ...NO_CAPABILITIES,\n headPose: true,\n gaze: true,\n pointer2d: desktop,\n // NOTE: `rays` stays false on desktop on purpose. sample() uses it as the\n // \"a session just ended\" sentinel, so setting it here would re-derive\n // capabilities on every frame for the whole life of the page.\n // The desktop fallback synthesises a grip on the mouse ray, so hand-driven\n // behaviours (grab, hinge, dial, slide) negotiate successfully. Without\n // this `grabs` stays \"none\" off-headset and capability negotiation\n // disables every one of them, leaving press as the only usable behaviour.\n grabs: desktop ? \"poseOnly\" : NO_CAPABILITIES.grabs,\n };\n if (session) {\n for (const source of session.inputSources) {\n if (source.targetRaySpace) next.rays = true;\n if (source.gripSpace || source.hand) next.grabs = \"poseOnly\";\n if (source.hand) {\n next.handJoints = true;\n next.pokes = true;\n next.pinch = true;\n }\n if (source.gamepad) {\n next.buttonsAxes = true;\n if ((source.gamepad.hapticActuators?.length ?? 0) > 0) next.haptics = true;\n }\n }\n }\n const changed = JSON.stringify(next) !== JSON.stringify(this.capabilities);\n this.capabilities = next;\n if (changed) {\n for (const listener of [...this.capsListeners]) listener(next);\n }\n }\n\n getCapabilities(): InputCapabilities {\n return this.capabilities;\n }\n\n onCapabilitiesChanged(listener: (c: InputCapabilities) => void): Unsubscribe {\n this.capsListeners.add(listener);\n return () => this.capsListeners.delete(listener);\n }\n\n onSourcesChanged(listener: () => void): Unsubscribe {\n this.sourceListeners.add(listener);\n return () => this.sourceListeners.delete(listener);\n }\n\n sample(): readonly InputSourceSnapshot[] {\n const session = this.syncSession();\n // The session-null path also re-derives capabilities when a session\n // just ended between frames.\n if (!session) {\n if (this.capabilities.rays) this.refreshCapabilities();\n return this.sampleDesktop();\n }\n\n const frame = this.context.xr.getFrame();\n const referenceSpace = this.context.xr.getReferenceSpace();\n if (!frame || !referenceSpace) return [];\n\n const snapshots: InputSourceSnapshot[] = [];\n let index = 0;\n for (const source of session.inputSources) {\n const state = this.selectState(source);\n const id = `${source.handedness}-${source.hand ? \"hand\" : \"controller\"}-${index++}`;\n const snapshot: InputSourceSnapshot = {\n id,\n kind: source.hand ? \"hand\" : \"controller\",\n handedness:\n source.handedness === \"left\" || source.handedness === \"right\"\n ? source.handedness\n : \"none\",\n select: this.selectValue(source, state),\n squeeze: this.squeezeValue(source, state),\n };\n\n const rayPose = frame.getPose(source.targetRaySpace, referenceSpace);\n if (rayPose) {\n const p = rayPose.transform.position;\n const o = rayPose.transform.orientation;\n this.q.set(o.x, o.y, o.z, o.w);\n this.v.set(0, 0, -1).applyQuaternion(this.q);\n snapshot.ray = {\n origin: [p.x, p.y, p.z],\n direction: [this.v.x, this.v.y, this.v.z],\n };\n }\n if (source.gripSpace) {\n const gripPose = frame.getPose(source.gripSpace, referenceSpace);\n if (gripPose) snapshot.gripPose = xrPoseToTuple(gripPose);\n }\n if (source.hand) {\n const joint = source.hand.get(\"index-finger-tip\");\n const jointPose = joint ? frame.getJointPose?.(joint, referenceSpace) : undefined;\n if (jointPose) {\n const p = jointPose.transform.position;\n snapshot.indexTip = [p.x, p.y, p.z];\n }\n // Hands without a gripSpace still need a carry pose: use the wrist.\n if (!snapshot.gripPose) {\n const wrist = source.hand.get(\"wrist\");\n const wristPose = wrist ? frame.getJointPose?.(wrist, referenceSpace) : undefined;\n if (wristPose) snapshot.gripPose = xrPoseToTuple(wristPose);\n }\n }\n if ((source.gamepad?.hapticActuators?.length ?? 0) > 0) {\n snapshot.hapticsAvailable = true;\n }\n snapshots.push(snapshot);\n }\n return snapshots;\n }\n\n /** Analog trigger where available, else the select event state. */\n private selectValue(source: XRInputSource, state: SelectState): number {\n const analog = source.gamepad?.buttons[0]?.value;\n if (analog !== undefined && analog > 0) return analog;\n return state.selecting ? 1 : 0;\n }\n\n private squeezeValue(source: XRInputSource, state: SelectState): number {\n const analog = source.gamepad?.buttons[1]?.value;\n if (analog !== undefined && analog > 0) return analog;\n return state.squeezing ? 1 : 0;\n }\n\n /** Is the configured squeeze button currently held? */\n private squeezing(): boolean {\n if (this.squeezeButton === \"left\") return this.mouseDown;\n if (this.squeezeButton === \"right\") return this.mouseRight;\n return false;\n }\n\n private sampleDesktop(): readonly InputSourceSnapshot[] {\n const element = this.context.domElement;\n if (!element) return [];\n\n // Under pointer lock the cursor stops moving and clientX/clientY freeze, so\n // the tracked NDC would stick wherever the pointer was when it locked. A\n // locked pointer aims from the centre of the viewport instead.\n const locked =\n typeof document !== \"undefined\" && document.pointerLockElement === element;\n this.pointerNdc.copy(locked ? ZERO_NDC : this.mouseNdc);\n this.raycaster.setFromCamera(this.pointerNdc, this.context.camera);\n\n const { origin, direction } = this.raycaster.ray;\n // A mouse has no grip. Hand-driven behaviours read `gripPose.position` (or\n // fall back to `ray.origin`, which is the camera and barely moves), so\n // project a grip onto the ray to give them something that tracks the\n // pointer.\n this.gripPoint.copy(direction).multiplyScalar(this.gripDistance).add(origin);\n this.context.camera.getWorldQuaternion(this.gripQuaternion);\n\n return [\n {\n id: \"mouse\",\n kind: \"pointer2d\",\n handedness: \"none\",\n ray: {\n origin: [origin.x, origin.y, origin.z],\n direction: [direction.x, direction.y, direction.z],\n },\n gripPose: {\n position: [this.gripPoint.x, this.gripPoint.y, this.gripPoint.z],\n quaternion: [\n this.gripQuaternion.x,\n this.gripQuaternion.y,\n this.gripQuaternion.z,\n this.gripQuaternion.w,\n ],\n },\n select: this.mouseDown ? 1 : 0,\n squeeze: this.squeezing() ? 1 : 0,\n },\n ];\n }\n\n getHeadPose(): HeadPose {\n this.context.camera.getWorldPosition(this.v);\n this.context.camera.getWorldQuaternion(this.q);\n return {\n position: [this.v.x, this.v.y, this.v.z],\n quaternion: [this.q.x, this.q.y, this.q.z, this.q.w],\n };\n }\n\n pulse(sourceId: string, intensity: number, durationMs: number): boolean {\n const session = this.boundSession;\n if (!session) return false;\n let index = 0;\n for (const source of session.inputSources) {\n const id = `${source.handedness}-${source.hand ? \"hand\" : \"controller\"}-${index++}`;\n if (id !== sourceId) continue;\n const actuator = source.gamepad?.hapticActuators?.[0];\n if (!actuator) return false;\n void (actuator as { pulse?: (i: number, d: number) => Promise<boolean> }).pulse?.(\n Math.min(1, Math.max(0, intensity)),\n durationMs,\n );\n return true;\n }\n return false;\n }\n\n dispose(): void {\n this.detachDom();\n if (this.boundSession) {\n for (const [type, handler] of this.sessionHandlers) {\n this.boundSession.removeEventListener(type, handler);\n }\n }\n }\n}\n\nfunction xrPoseToTuple(pose: XRPose): PoseTuple {\n const p = pose.transform.position;\n const o = pose.transform.orientation;\n return { position: [p.x, p.y, p.z], quaternion: [o.x, o.y, o.z, o.w] };\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@realitycollective/threejs-interactions",
|
|
3
|
+
"version": "0.1.0-preview.0",
|
|
4
|
+
"description": "The default, standalone engine adapter for the Reality Collective Interaction Extensions: raw WebXR (the browser's OpenXR binding - XRSession, XRInputSource, select/squeeze, hand joints) plus three.js hit-testing and transform ports, with a desktop mouse fallback. No framework required. Re-exports the @realitycollective/webxr-interactions core.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"realitycollective",
|
|
7
|
+
"webxr",
|
|
8
|
+
"threejs",
|
|
9
|
+
"xr",
|
|
10
|
+
"interaction",
|
|
11
|
+
"input",
|
|
12
|
+
"openxr",
|
|
13
|
+
"typescript"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Reality Collective",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"CHANGELOG.md"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc -p tsconfig.build.json",
|
|
33
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@realitycollective/webxr-input": "^0.1.0",
|
|
37
|
+
"@realitycollective/webxr-interactions": "^0.1.0-preview.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"three": ">=0.170.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"three": "npm:super-three@0.181.0",
|
|
44
|
+
"@types/three": "*"
|
|
45
|
+
},
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/realitycollective/WebXR-Interactions.git",
|
|
49
|
+
"directory": "packages/threejs-interactions"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/realitycollective/WebXR-Interactions#readme",
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/realitycollective/WebXR-Interactions/issues"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=20.19.0 <21.0.0-0 || >=22.12.0 <23.0.0-0 || >=24.0.0"
|
|
60
|
+
}
|
|
61
|
+
}
|