@opsydyn/effect-three-foldkit 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/LICENSE +21 -0
  3. package/README.md +99 -2
  4. package/dist/assets.d.ts +53 -0
  5. package/dist/assets.d.ts.map +1 -0
  6. package/dist/dom/animationFrames.d.ts +12 -0
  7. package/dist/dom/animationFrames.d.ts.map +1 -0
  8. package/dist/dom/canvas.d.ts +26 -0
  9. package/dist/dom/canvas.d.ts.map +1 -0
  10. package/dist/dom/latch.d.ts +15 -0
  11. package/dist/dom/latch.d.ts.map +1 -0
  12. package/dist/dom/models.d.ts +10 -0
  13. package/dist/dom/models.d.ts.map +1 -0
  14. package/dist/dom/overlays.d.ts +24 -0
  15. package/dist/dom/overlays.d.ts.map +1 -0
  16. package/dist/dom/pointer.d.ts +49 -0
  17. package/dist/dom/pointer.d.ts.map +1 -0
  18. package/dist/dom/postprocessing.d.ts +22 -0
  19. package/dist/dom/postprocessing.d.ts.map +1 -0
  20. package/dist/dom/property.d.ts +15 -0
  21. package/dist/dom/property.d.ts.map +1 -0
  22. package/dist/dom/reconciled.d.ts +10 -0
  23. package/dist/dom/reconciled.d.ts.map +1 -0
  24. package/dist/dom/renderSettings.d.ts +58 -0
  25. package/dist/dom/renderSettings.d.ts.map +1 -0
  26. package/dist/dom/typedScenes.d.ts +14 -0
  27. package/dist/dom/typedScenes.d.ts.map +1 -0
  28. package/dist/dom/updates.d.ts +8 -0
  29. package/dist/dom/updates.d.ts.map +1 -0
  30. package/dist/dom/viewport.d.ts +40 -0
  31. package/dist/dom/viewport.d.ts.map +1 -0
  32. package/dist/dom/webgl.d.ts +12 -0
  33. package/dist/dom/webgl.d.ts.map +1 -0
  34. package/dist/dom/webgpu.d.ts +24 -0
  35. package/dist/dom/webgpu.d.ts.map +1 -0
  36. package/dist/dom/webgpuPostprocessing.d.ts +4 -0
  37. package/dist/dom/webgpuPostprocessing.d.ts.map +1 -0
  38. package/dist/dom/xr.d.ts +53 -0
  39. package/dist/dom/xr.d.ts.map +1 -0
  40. package/dist/dom/xrGlide.d.ts +49 -0
  41. package/dist/dom/xrGlide.d.ts.map +1 -0
  42. package/dist/dom/xrHub.d.ts +45 -0
  43. package/dist/dom/xrHub.d.ts.map +1 -0
  44. package/dist/dom/xrInputs.d.ts +40 -0
  45. package/dist/dom/xrInputs.d.ts.map +1 -0
  46. package/dist/dom/xrSpaces.d.ts +33 -0
  47. package/dist/dom/xrSpaces.d.ts.map +1 -0
  48. package/dist/dom/xrTarget.d.ts +36 -0
  49. package/dist/dom/xrTarget.d.ts.map +1 -0
  50. package/dist/element.d.ts +771 -0
  51. package/dist/element.d.ts.map +1 -0
  52. package/dist/environment.d.ts +64 -0
  53. package/dist/environment.d.ts.map +1 -0
  54. package/dist/index.d.ts +32 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +2041 -0
  57. package/dist/index.js.map +38 -0
  58. package/dist/mount.d.ts +14 -0
  59. package/dist/mount.d.ts.map +1 -0
  60. package/dist/overlayView.d.ts +23 -0
  61. package/dist/overlayView.d.ts.map +1 -0
  62. package/dist/view.d.ts +179 -0
  63. package/dist/view.d.ts.map +1 -0
  64. package/dist/xr.d.ts +34 -0
  65. package/dist/xr.d.ts.map +1 -0
  66. package/package.json +72 -4
  67. package/src/assets.ts +151 -0
  68. package/src/dom/animationFrames.ts +32 -0
  69. package/src/dom/canvas.ts +54 -0
  70. package/src/dom/latch.ts +36 -0
  71. package/src/dom/models.ts +37 -0
  72. package/src/dom/overlays.ts +63 -0
  73. package/src/dom/pointer.ts +163 -0
  74. package/src/dom/postprocessing.ts +116 -0
  75. package/src/dom/property.ts +43 -0
  76. package/src/dom/reconciled.ts +51 -0
  77. package/src/dom/renderSettings.ts +117 -0
  78. package/src/dom/typedScenes.ts +21 -0
  79. package/src/dom/updates.ts +14 -0
  80. package/src/dom/viewport.ts +97 -0
  81. package/src/dom/webgl.ts +216 -0
  82. package/src/dom/webgpu.ts +205 -0
  83. package/src/dom/webgpuPostprocessing.ts +102 -0
  84. package/src/dom/xr.ts +211 -0
  85. package/src/dom/xrGlide.ts +114 -0
  86. package/src/dom/xrHub.ts +118 -0
  87. package/src/dom/xrInputs.ts +309 -0
  88. package/src/dom/xrSpaces.ts +287 -0
  89. package/src/dom/xrTarget.ts +71 -0
  90. package/src/element.ts +114 -0
  91. package/src/environment.ts +78 -0
  92. package/src/index.ts +71 -0
  93. package/src/mount.ts +305 -0
  94. package/src/overlayView.ts +71 -0
  95. package/src/view.ts +254 -0
  96. package/src/xr.ts +36 -0
@@ -0,0 +1,309 @@
1
+ /**
2
+ * A session's input sources, each frame: where each points (`Aim`), its gamepad's sticks
3
+ * and buttons, in AR where its ray meets a real surface, and drawing its ray and a hand's
4
+ * joints into the presented scene.
5
+ *
6
+ * Behavioural references: pmndrs/xr at `0e1acd8` (`packages/xr/src/pointer` ray-casts
7
+ * along every source's target ray each frame; `controller-locomotion.ts` reads the
8
+ * `xr-standard` thumbstick, axes 2 and 3, forward negative; `hit-test.ts` requests a hit
9
+ * test source per space and reads `getHitTestResults` each frame), and three.js's
10
+ * `XRHandPrimitiveModel` at 0.186.1 (a sphere per hand joint, sized by its radius).
11
+ *
12
+ * @module
13
+ */
14
+ import type {
15
+ Ray,
16
+ Vec3,
17
+ Xr,
18
+ XrButtonName,
19
+ XrHand,
20
+ XrInput,
21
+ XrStickDirection,
22
+ } from '@opsydyn/effect-three';
23
+ import {
24
+ BufferGeometry,
25
+ Float32BufferAttribute,
26
+ Group,
27
+ InstancedMesh,
28
+ Line,
29
+ LineBasicMaterial,
30
+ Matrix4,
31
+ MeshBasicMaterial,
32
+ Quaternion,
33
+ SphereGeometry,
34
+ Vector3,
35
+ } from 'three';
36
+
37
+ /** A target ray pose as a ray in the reference space: from its position along its −z. */
38
+ export const rayOf = (pose: XRPose): Ray => {
39
+ const { position, orientation } = pose.transform;
40
+ const { x, y, z, w } = orientation;
41
+ // −z rotated by the orientation quaternion.
42
+ return {
43
+ origin: [position.x, position.y, position.z],
44
+ direction: [-2 * (x * z + w * y), -2 * (y * z - w * x), -(1 - 2 * (x * x + y * y))],
45
+ };
46
+ };
47
+
48
+ /** The `xr-standard` mapping's buttons, by index. */
49
+ const buttonNames: ReadonlyArray<XrButtonName> = [
50
+ 'trigger',
51
+ 'squeeze',
52
+ 'touchpad',
53
+ 'thumbstick',
54
+ 'primary',
55
+ 'secondary',
56
+ ];
57
+
58
+ /** Past this far a stick is pushed; back under the lower mark it is centred again. */
59
+ const stickThresholds = { push: 0.6, release: 0.3 } as const;
60
+
61
+ /** Where a stick at `(x, y)` points, keeping `previous` until it clearly changes. */
62
+ export const stickDirection = (
63
+ x: number,
64
+ y: number,
65
+ previous: XrStickDirection,
66
+ ): XrStickDirection => {
67
+ if (Math.max(Math.abs(x), Math.abs(y)) < stickThresholds.release) return 'center';
68
+ if (Math.max(Math.abs(x), Math.abs(y)) < stickThresholds.push) return previous;
69
+ if (Math.abs(x) >= Math.abs(y)) return x < 0 ? 'left' : 'right';
70
+ return y < 0 ? 'forward' : 'back';
71
+ };
72
+
73
+ const handOf = (source: XRInputSource): XrHand => source.handedness;
74
+
75
+ /** What the visuals show: a line along each ray, spheres on each hand's joints. */
76
+ const rayLength = 1.5;
77
+ const jointCount = 25;
78
+ const rayMaterial = new LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.6 });
79
+ const jointMaterial = new MeshBasicMaterial({ color: 0xe8e4dc });
80
+ const jointGeometry = new SphereGeometry(1, 10, 8);
81
+
82
+ type Visual = { readonly ray: Line; readonly joints: InstancedMesh | undefined };
83
+
84
+ const makeVisual = (source: XRInputSource, group: Group): Visual => {
85
+ const geometry = new BufferGeometry();
86
+ geometry.setAttribute('position', new Float32BufferAttribute([0, 0, 0, 0, 0, -rayLength], 3));
87
+ const ray = new Line(geometry, rayMaterial);
88
+ ray.raycast = () => undefined;
89
+ ray.matrixAutoUpdate = false;
90
+ group.add(ray);
91
+ const joints =
92
+ source.hand === undefined
93
+ ? undefined
94
+ : new InstancedMesh(jointGeometry, jointMaterial, jointCount);
95
+ if (joints !== undefined) {
96
+ joints.raycast = () => undefined;
97
+ joints.frustumCulled = false;
98
+ group.add(joints);
99
+ }
100
+ return { ray, joints };
101
+ };
102
+
103
+ const disposeVisual = (visual: Visual): void => {
104
+ visual.ray.removeFromParent();
105
+ visual.ray.geometry.dispose();
106
+ visual.joints?.removeFromParent();
107
+ visual.joints?.dispose();
108
+ };
109
+
110
+ const matrix = new Matrix4();
111
+ const up = new Vector3(0, 1, 0);
112
+ const position = new Vector3();
113
+ const turn = new Quaternion();
114
+ const size = new Vector3();
115
+
116
+ /** Places a hand's joint spheres from this frame's joint poses. */
117
+ const placeJoints = (
118
+ joints: InstancedMesh,
119
+ hand: XRHand,
120
+ frame: XRFrame,
121
+ space: XRReferenceSpace,
122
+ ): void => {
123
+ let slot = 0;
124
+ for (const jointSpace of hand.values()) {
125
+ const pose = frame.getJointPose?.(jointSpace, space);
126
+ if (pose !== undefined && pose !== null && slot < jointCount) {
127
+ const { x, y, z } = pose.transform.position;
128
+ const o = pose.transform.orientation;
129
+ const radius = pose.radius ?? 0.008;
130
+ matrix.compose(position.set(x, y, z), turn.set(o.x, o.y, o.z, o.w), size.setScalar(radius));
131
+ joints.setMatrixAt(slot, matrix);
132
+ slot++;
133
+ }
134
+ }
135
+ joints.count = slot;
136
+ joints.instanceMatrix.needsUpdate = true;
137
+ };
138
+
139
+ type Tracked = {
140
+ readonly id: number;
141
+ visual: Visual | undefined;
142
+ stick: XrStickDirection;
143
+ /** The thumbstick's axes this frame, `[x, y]`. */
144
+ axes: readonly [number, number];
145
+ readonly pressed: Array<boolean>;
146
+ hitTest: XRHitTestSource | undefined;
147
+ /** Where its ray last met a real surface. */
148
+ surface: { readonly point: Vec3; readonly normal: Vec3 } | undefined;
149
+ };
150
+
151
+ /** A hit test result's pose as a point and the surface's normal (the pose's +y). */
152
+ const surfaceOf = (pose: XRPose): { readonly point: Vec3; readonly normal: Vec3 } => {
153
+ const { position: p, orientation: q } = pose.transform;
154
+ // +y rotated by the orientation quaternion.
155
+ return {
156
+ point: [p.x, p.y, p.z],
157
+ normal: [
158
+ 2 * (q.x * q.y - q.w * q.z),
159
+ 1 - 2 * (q.x * q.x + q.z * q.z),
160
+ 2 * (q.y * q.z + q.w * q.x),
161
+ ],
162
+ };
163
+ };
164
+
165
+ export type XrInputs = {
166
+ /** Reads every input source in this frame, in `space` (the scene's). */
167
+ readonly frame: (frame: XRFrame, space: XRReferenceSpace) => void;
168
+ /** A source's id, the same for its selects as for its aims. */
169
+ readonly idOf: (source: XRInputSource) => number;
170
+ /** A select ended: in AR with hit tests, report where its ray met a surface. */
171
+ readonly selected: (source: XRInputSource) => void;
172
+ /** Ends tracking: every source is lost, and the visuals go. */
173
+ readonly stop: () => void;
174
+ /** Each hand's thumbstick this frame (`[0, 0]` without one). */
175
+ readonly sticks: () => {
176
+ readonly left: readonly [number, number];
177
+ readonly right: readonly [number, number];
178
+ };
179
+ };
180
+
181
+ /**
182
+ * Tracks `session`'s input sources, sending what they do to `input` and drawing them into
183
+ * `group` (added to the presented scene) while `settings().showInputs`.
184
+ */
185
+ export const trackInputs = (
186
+ session: XRSession,
187
+ group: Group,
188
+ settings: () => Xr | null,
189
+ input: (input: XrInput) => void,
190
+ ): XrInputs => {
191
+ const tracked = new Map<XRInputSource, Tracked>();
192
+ let nextId = 0;
193
+
194
+ const trackedOf = (source: XRInputSource): Tracked => {
195
+ const known = tracked.get(source);
196
+ if (known !== undefined) return known;
197
+ const created: Tracked = {
198
+ id: nextId++,
199
+ visual: undefined,
200
+ stick: 'center',
201
+ axes: [0, 0],
202
+ pressed: [],
203
+ hitTest: undefined,
204
+ surface: undefined,
205
+ };
206
+ tracked.set(source, created);
207
+ if (settings()?.hitTest === true && session.requestHitTestSource !== undefined) {
208
+ // Refused where the session lacks hit testing (it is optional): no surfaces, then.
209
+ void session.requestHitTestSource({ space: source.targetRaySpace })?.then(
210
+ (hitTest) => {
211
+ if (tracked.get(source) === created) created.hitTest = hitTest;
212
+ else hitTest.cancel();
213
+ },
214
+ () => undefined,
215
+ );
216
+ }
217
+ return created;
218
+ };
219
+
220
+ const forget = (source: XRInputSource, state: Tracked): void => {
221
+ tracked.delete(source);
222
+ if (state.visual !== undefined) disposeVisual(state.visual);
223
+ state.hitTest?.cancel();
224
+ input({ _tag: 'Lost', source: state.id });
225
+ };
226
+
227
+ const gamepad = (source: XRInputSource, state: Tracked): void => {
228
+ const pad = source.gamepad;
229
+ if (pad === undefined || pad === null || pad.mapping !== 'xr-standard') return;
230
+ const hand = handOf(source);
231
+ buttonNames.forEach((button, index) => {
232
+ const pressed = pad.buttons[index]?.pressed ?? false;
233
+ if (pressed !== (state.pressed[index] ?? false)) {
234
+ state.pressed[index] = pressed;
235
+ input({ _tag: 'Button', source: state.id, hand, button, pressed });
236
+ }
237
+ });
238
+ state.axes = [pad.axes[2] ?? 0, pad.axes[3] ?? 0];
239
+ const direction = stickDirection(state.axes[0], state.axes[1], state.stick);
240
+ if (direction !== state.stick) {
241
+ state.stick = direction;
242
+ input({ _tag: 'Stick', source: state.id, hand, direction });
243
+ }
244
+ };
245
+
246
+ const show = (
247
+ source: XRInputSource,
248
+ state: Tracked,
249
+ ray: Ray,
250
+ frame: XRFrame,
251
+ space: XRReferenceSpace,
252
+ ) => {
253
+ if (settings()?.showInputs !== true) {
254
+ if (state.visual !== undefined) disposeVisual(state.visual);
255
+ state.visual = undefined;
256
+ return;
257
+ }
258
+ state.visual ??= makeVisual(source, group);
259
+ const [ox, oy, oz] = ray.origin;
260
+ const [dx, dy, dz] = ray.direction;
261
+ // Turn −z (along which the line runs) to the ray: lookAt points +z from the target.
262
+ matrix.lookAt(position.set(0, 0, 0), size.set(dx, dy, dz), up);
263
+ matrix.setPosition(ox, oy, oz);
264
+ state.visual.ray.matrix.copy(matrix);
265
+ // A tracked hand points from its wrist; its joints show where it is, not a ray.
266
+ state.visual.ray.visible = source.hand === undefined;
267
+ if (state.visual.joints !== undefined && source.hand !== undefined) {
268
+ placeJoints(state.visual.joints, source.hand, frame, space);
269
+ }
270
+ };
271
+
272
+ return {
273
+ frame: (frame, space) => {
274
+ const present = new Set<XRInputSource>();
275
+ for (const source of session.inputSources) {
276
+ present.add(source);
277
+ const state = trackedOf(source);
278
+ const pose = frame.getPose(source.targetRaySpace, space);
279
+ if (pose !== undefined && pose !== null) {
280
+ const ray = rayOf(pose);
281
+ input({ _tag: 'Aim', source: state.id, ray });
282
+ show(source, state, ray, frame, space);
283
+ }
284
+ gamepad(source, state);
285
+ if (state.hitTest !== undefined) {
286
+ const result = frame.getHitTestResults(state.hitTest)[0]?.getPose(space);
287
+ state.surface = result === undefined || result === null ? undefined : surfaceOf(result);
288
+ }
289
+ }
290
+ for (const [source, state] of tracked) if (!present.has(source)) forget(source, state);
291
+ },
292
+ idOf: (source) => trackedOf(source).id,
293
+ selected: (source) => {
294
+ const state = tracked.get(source);
295
+ if (state?.surface === undefined) return;
296
+ input({ _tag: 'Surface', source: state.id, hand: handOf(source), ...state.surface });
297
+ },
298
+ sticks: () => {
299
+ const axesOf = (hand: XrHand): readonly [number, number] => {
300
+ for (const [source, state] of tracked) if (source.handedness === hand) return state.axes;
301
+ return [0, 0];
302
+ };
303
+ return { left: axesOf('left'), right: axesOf('right') };
304
+ },
305
+ stop: () => {
306
+ for (const [source, state] of tracked) forget(source, state);
307
+ },
308
+ };
309
+ };
@@ -0,0 +1,287 @@
1
+ /**
2
+ * What a session tracks for the scene's description, each frame: where every space a
3
+ * group follows is (`group({ follow })`), the anchors the scene placed
4
+ * (`xr({ anchors })`), and the planes the session detected (`xr({ planes: true })`).
5
+ *
6
+ * Behavioural reference: pmndrs/xr at `0e1acd8`: `space.ts` `createGetXRSpaceMatrix` (an
7
+ * object on a space takes the space's pose in the reference space each frame, and none
8
+ * while the frame has no pose for it), `anchor.ts` `requestXRAnchor` (`frame.createAnchor`
9
+ * with a rigid transform in the origin's reference space), `store.ts` `updateSession` (the
10
+ * frame's `detectedPlanes` and `detectedMeshes` read each frame, passed on when the set
11
+ * changes), `plane.ts` (a plane is made again once it changes) and `mesh.ts` (a detected
12
+ * mesh's geometry built from its `vertices` and `indices`, again when they change).
13
+ *
14
+ * @module
15
+ */
16
+ import {
17
+ type BodyPose,
18
+ type Vec3,
19
+ type Xr,
20
+ type XrAnchor,
21
+ type XrInput,
22
+ type XrMesh,
23
+ xrMeshGeometryId,
24
+ type XrPlane,
25
+ type XrSpace,
26
+ } from '@opsydyn/effect-three';
27
+ import { BufferAttribute, BufferGeometry, Euler, Quaternion } from 'three';
28
+
29
+ /** Places the scene's following groups where `locate` finds their spaces. */
30
+ export type Follow = (locate: (space: XrSpace) => BodyPose | undefined) => void;
31
+
32
+ /** Lends a geometry to the scene's store under `id`; returns a function that withdraws it. */
33
+ export type LendGeometry = (id: string, geometry: BufferGeometry) => () => void;
34
+
35
+ export type XrSpaces = {
36
+ /** Brings anchors and planes up to date in this frame, then places the following groups. */
37
+ readonly frame: (frame: XRFrame, space: XRReferenceSpace) => void;
38
+ /** Ends tracking: anchors are deleted, and every following group is hidden. */
39
+ readonly stop: () => void;
40
+ };
41
+
42
+ const turn = new Quaternion();
43
+ const angles = new Euler();
44
+
45
+ const poseOf = (pose: XRPose | null | undefined): BodyPose | undefined => {
46
+ if (pose === null || pose === undefined) return undefined;
47
+ const { position: p, orientation: o } = pose.transform;
48
+ return { position: [p.x, p.y, p.z], quaternion: [o.x, o.y, o.z, o.w] };
49
+ };
50
+
51
+ const sameVec3 = (a: Vec3, b: Vec3): boolean => a.every((value, i) => value === b[i]);
52
+
53
+ const sameAnchor = (a: XrAnchor, b: XrAnchor): boolean =>
54
+ sameVec3(a.position, b.position) && sameVec3(a.rotation, b.rotation);
55
+
56
+ /** An anchor's place as a rigid transform: its position, its Euler rotation turned. */
57
+ const transformOf = ({ position: [x, y, z], rotation }: XrAnchor): XRRigidTransform => {
58
+ turn.setFromEuler(angles.fromArray([...rotation]));
59
+ return new XRRigidTransform({ x, y, z }, { x: turn.x, y: turn.y, z: turn.z, w: turn.w });
60
+ };
61
+
62
+ /** A plane as the scene's data: its pose in the scene, its outline in its own space. */
63
+ const planeOf = (id: string, plane: XRPlane, pose: BodyPose): XrPlane => {
64
+ angles.setFromQuaternion(turn.fromArray([...pose.quaternion]));
65
+ return {
66
+ id,
67
+ orientation: plane.orientation,
68
+ label: plane.semanticLabel ?? '',
69
+ position: pose.position,
70
+ rotation: [angles.x, angles.y, angles.z],
71
+ polygon: plane.polygon.map((point): Vec3 => [point.x, point.y, point.z]),
72
+ };
73
+ };
74
+
75
+ /** A plane's report in a few characters, to tell when one changes. */
76
+ const signatureOf = (plane: XrPlane): string =>
77
+ [plane.id, plane.orientation, plane.label, ...plane.position, ...plane.rotation]
78
+ .concat(plane.polygon.flat())
79
+ .join(',');
80
+
81
+ /** A detected mesh's report in a few characters, to tell when one changes. */
82
+ const meshSignatureOf = (mesh: XrMesh): string =>
83
+ [mesh.id, mesh.label, ...mesh.position, ...mesh.rotation].join(',');
84
+
85
+ /** A detected mesh's geometry, as pmndrs/xr's `updateXRMeshGeometry` builds it. */
86
+ const meshGeometryOf = (mesh: XRMesh): BufferGeometry => {
87
+ const geometry = new BufferGeometry();
88
+ geometry.setIndex(new BufferAttribute(mesh.indices, 1));
89
+ geometry.setAttribute('position', new BufferAttribute(mesh.vertices, 3));
90
+ geometry.computeVertexNormals();
91
+ return geometry;
92
+ };
93
+
94
+ /** A detected mesh's lent geometry: what it was built from, and how to take it back. */
95
+ type Lent = {
96
+ readonly vertices: Float32Array;
97
+ readonly indices: Uint32Array;
98
+ readonly geometry: BufferGeometry;
99
+ readonly withdraw: () => void;
100
+ };
101
+
102
+ const withdrawLent = (lent: Lent): void => {
103
+ lent.withdraw();
104
+ lent.geometry.dispose();
105
+ };
106
+
107
+ type Made = { readonly spec: XrAnchor; anchor: XRAnchor | undefined };
108
+
109
+ /**
110
+ * Tracks the spaces `session` can locate for the description `settings()`, placing its
111
+ * following groups with `follow` and reporting detected planes to `input`.
112
+ */
113
+ export const trackSpaces = (
114
+ session: XRSession,
115
+ settings: () => Xr | null,
116
+ follow: Follow,
117
+ input: (input: XrInput) => void,
118
+ lend: LendGeometry,
119
+ ): XrSpaces => {
120
+ const anchors = new Map<string, Made>();
121
+ const planeIds = new WeakMap<XRPlane, string>();
122
+ const planes = new Map<string, XRPlane>();
123
+ let nextPlane = 0;
124
+ /** The planes last reported. */
125
+ let reported: string | undefined;
126
+ const meshIds = new WeakMap<XRMesh, string>();
127
+ const meshes = new Map<string, XRMesh>();
128
+ const lentMeshes = new Map<string, Lent>();
129
+ let nextMesh = 0;
130
+ let reportedMeshes: string | undefined;
131
+
132
+ /** Anchors made, made again where they moved, and deleted where they went. */
133
+ const syncAnchors = (frame: XRFrame, space: XRReferenceSpace): void => {
134
+ const wanted = settings()?.anchors ?? [];
135
+ const seen = new Set<string>();
136
+ for (const spec of wanted) {
137
+ seen.add(spec.id);
138
+ const made = anchors.get(spec.id);
139
+ if (made !== undefined && sameAnchor(made.spec, spec)) continue;
140
+ made?.anchor?.delete();
141
+ const entry: Made = { spec, anchor: undefined };
142
+ anchors.set(spec.id, entry);
143
+ // Refused where the session lacks anchors (they are optional): never found, then.
144
+ void frame.createAnchor?.(transformOf(spec), space).then(
145
+ (anchor) => {
146
+ if (anchors.get(spec.id) === entry) entry.anchor = anchor;
147
+ else anchor.delete();
148
+ },
149
+ () => undefined,
150
+ );
151
+ }
152
+ for (const [id, made] of anchors) {
153
+ if (seen.has(id)) continue;
154
+ made.anchor?.delete();
155
+ anchors.delete(id);
156
+ }
157
+ };
158
+
159
+ /**
160
+ * Reports the detected planes when what is reported changes: one found or lost,
161
+ * reshaped, or seen from where the player has moved to. (Compared by content, not
162
+ * `lastChangedTime`, which some runtimes move on every frame.)
163
+ */
164
+ const syncPlanes = (frame: XRFrame, space: XRReferenceSpace): void => {
165
+ planes.clear();
166
+ if (settings()?.planes !== true || frame.detectedPlanes === undefined) return;
167
+ const found: Array<XrPlane> = [];
168
+ for (const plane of frame.detectedPlanes) {
169
+ const id = planeIds.get(plane) ?? `plane-${nextPlane++}`;
170
+ planeIds.set(plane, id);
171
+ planes.set(id, plane);
172
+ const pose = poseOf(frame.getPose(plane.planeSpace, space));
173
+ if (pose !== undefined) found.push(planeOf(id, plane, pose));
174
+ }
175
+ const now = found.map(signatureOf).join(' ');
176
+ if (now === reported) return;
177
+ reported = now;
178
+ input({ _tag: 'Planes', planes: found });
179
+ };
180
+
181
+ /** Lends each detected mesh's geometry, again when its vertices change; takes back the lost. */
182
+ const lendMesh = (id: string, mesh: XRMesh): void => {
183
+ const lent = lentMeshes.get(id);
184
+ // Runtimes hand over new arrays when a mesh changes (IWER moves `lastChangedTime` every
185
+ // frame, keeping them): rebuild only then.
186
+ if (lent?.vertices === mesh.vertices && lent.indices === mesh.indices) return;
187
+ if (lent !== undefined) withdrawLent(lent);
188
+ const geometry = meshGeometryOf(mesh);
189
+ lentMeshes.set(id, {
190
+ vertices: mesh.vertices,
191
+ indices: mesh.indices,
192
+ geometry,
193
+ withdraw: lend(xrMeshGeometryId(id), geometry),
194
+ });
195
+ };
196
+
197
+ /** Reports the detected meshes when what is reported changes, and lends their geometry. */
198
+ const syncMeshes = (frame: XRFrame, space: XRReferenceSpace): void => {
199
+ meshes.clear();
200
+ if (settings()?.meshes === true && frame.detectedMeshes !== undefined) {
201
+ const found: Array<XrMesh> = [];
202
+ for (const mesh of frame.detectedMeshes) {
203
+ const id = meshIds.get(mesh) ?? `mesh-${nextMesh++}`;
204
+ meshIds.set(mesh, id);
205
+ meshes.set(id, mesh);
206
+ lendMesh(id, mesh);
207
+ const pose = poseOf(frame.getPose(mesh.meshSpace, space));
208
+ if (pose !== undefined) {
209
+ angles.setFromQuaternion(turn.fromArray([...pose.quaternion]));
210
+ found.push({
211
+ id,
212
+ label: mesh.semanticLabel ?? '',
213
+ position: pose.position,
214
+ rotation: [angles.x, angles.y, angles.z],
215
+ });
216
+ }
217
+ }
218
+ const now = found.map(meshSignatureOf).join(' ');
219
+ if (now !== reportedMeshes) {
220
+ reportedMeshes = now;
221
+ input({ _tag: 'Meshes', meshes: found });
222
+ }
223
+ }
224
+ for (const [id, lent] of lentMeshes) {
225
+ if (meshes.has(id)) continue;
226
+ withdrawLent(lent);
227
+ lentMeshes.delete(id);
228
+ }
229
+ };
230
+
231
+ const sourceOf = (hand: string, tracked: boolean): XRInputSource | undefined => {
232
+ for (const source of session.inputSources) {
233
+ if (source.handedness === hand && (source.hand !== undefined) === tracked) return source;
234
+ }
235
+ return undefined;
236
+ };
237
+
238
+ const locateIn =
239
+ (frame: XRFrame, space: XRReferenceSpace) =>
240
+ (target: XrSpace): BodyPose | undefined => {
241
+ switch (target._tag) {
242
+ case 'Head':
243
+ return poseOf(frame.getViewerPose(space));
244
+ case 'Joint': {
245
+ const joint = sourceOf(target.hand, true)?.hand?.get(target.joint);
246
+ return joint === undefined ? undefined : poseOf(frame.getJointPose?.(joint, space));
247
+ }
248
+ case 'Controller': {
249
+ const source = sourceOf(target.hand, false);
250
+ const located = target.space === 'grip' ? source?.gripSpace : source?.targetRaySpace;
251
+ return located === undefined ? undefined : poseOf(frame.getPose(located, space));
252
+ }
253
+ case 'Anchor': {
254
+ const anchor = anchors.get(target.id)?.anchor;
255
+ return anchor === undefined
256
+ ? undefined
257
+ : poseOf(frame.getPose(anchor.anchorSpace, space));
258
+ }
259
+ case 'Plane': {
260
+ const plane = planes.get(target.id);
261
+ return plane === undefined ? undefined : poseOf(frame.getPose(plane.planeSpace, space));
262
+ }
263
+ case 'Mesh': {
264
+ const mesh = meshes.get(target.id);
265
+ return mesh === undefined ? undefined : poseOf(frame.getPose(mesh.meshSpace, space));
266
+ }
267
+ }
268
+ };
269
+
270
+ return {
271
+ frame: (frame, space) => {
272
+ syncAnchors(frame, space);
273
+ syncPlanes(frame, space);
274
+ syncMeshes(frame, space);
275
+ follow(locateIn(frame, space));
276
+ },
277
+ stop: () => {
278
+ for (const made of anchors.values()) made.anchor?.delete();
279
+ anchors.clear();
280
+ planes.clear();
281
+ meshes.clear();
282
+ for (const lent of lentMeshes.values()) withdrawLent(lent);
283
+ lentMeshes.clear();
284
+ follow(() => undefined);
285
+ },
286
+ };
287
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * A mounted scene as an immersive session target: its frames handed to the session while
3
+ * it presents, and the session's start and end dispatched as `scene-xr` events.
4
+ *
5
+ * @module
6
+ */
7
+ import type { GatedFrames, SceneEvent, Xr, XrInput } from '@opsydyn/effect-three';
8
+ import type { Object3D } from 'three';
9
+
10
+ import type { Latch } from './latch.ts';
11
+ import type { XrMode, XrTarget } from './xrHub.ts';
12
+ import type { Follow, LendGeometry } from './xrSpaces.ts';
13
+
14
+ /**
15
+ * The page's frames for a scene: open while it is visible and no session presents it
16
+ * (a session brings its own frames, as React Three Fiber's loop stands aside).
17
+ */
18
+ export const createFrameGate = (frames: GatedFrames) => {
19
+ let visible = true;
20
+ let presenting = false;
21
+ const apply = (): void => frames.setOpen(visible && !presenting);
22
+ return {
23
+ setVisible: (next: boolean): void => {
24
+ visible = next;
25
+ apply();
26
+ },
27
+ setPresenting: (next: boolean): void => {
28
+ presenting = next;
29
+ apply();
30
+ },
31
+ };
32
+ };
33
+
34
+ export type FrameGate = ReturnType<typeof createFrameGate>;
35
+
36
+ /** The `scene-xr` event: the mode presenting, or `null` when the session ended. */
37
+ export const dispatchXrSession = (element: Element, live: Latch, mode: XrMode | null): void => {
38
+ if (live.isOpen()) element.dispatchEvent(new CustomEvent('scene-xr', { detail: { mode } }));
39
+ };
40
+
41
+ /** A scene as a target for the page's immersive sessions. */
42
+ export const xrTarget = (scene: {
43
+ readonly element: Element;
44
+ readonly canvas: HTMLCanvasElement;
45
+ readonly live: Latch;
46
+ readonly gate: FrameGate;
47
+ readonly settings: () => Xr | null;
48
+ readonly draw: () => void;
49
+ readonly scene: Object3D;
50
+ readonly follow: Follow;
51
+ readonly lend: LendGeometry;
52
+ readonly input: (input: XrInput) => ReadonlyArray<SceneEvent>;
53
+ }): XrTarget => ({
54
+ canvas: scene.canvas,
55
+ element: scene.element,
56
+ follow: scene.follow,
57
+ lend: scene.lend,
58
+ settings: scene.settings,
59
+ draw: scene.draw,
60
+ scene: scene.scene,
61
+ // Dropped while time travel pauses the scene, as pointer input is.
62
+ input: (input) => {
63
+ if (scene.live.isOpen()) scene.input(input);
64
+ },
65
+ presenting: (mode) => {
66
+ scene.gate.setPresenting(mode !== null);
67
+ // Back on the page's frames: draw once, as React Three Fiber invalidates on session end.
68
+ if (mode === null) scene.draw();
69
+ dispatchXrSession(scene.element, scene.live, mode);
70
+ },
71
+ });