@found-in-space/skykit 0.2.0-alpha.1 → 0.2.0-alpha.20260528

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 (40) hide show
  1. package/README.md +142 -11
  2. package/examples/custom-object-layer/custom-object-layer.js +1 -24
  3. package/examples/xr-free-roam/index.html +62 -4
  4. package/examples/xr-free-roam/xr-free-roam.css +249 -18
  5. package/examples/xr-free-roam/xr-free-roam.js +675 -274
  6. package/package.json +22 -6
  7. package/src/__tests__/skykit-anchored-images.test.js +32 -4
  8. package/src/__tests__/skykit-browser.test.js +267 -0
  9. package/src/__tests__/skykit-data.test.js +131 -0
  10. package/src/__tests__/skykit-parallax.test.js +4 -4
  11. package/src/__tests__/skykit-touch-os.test.js +71 -0
  12. package/src/__tests__/skykit-xr.test.js +179 -2
  13. package/src/__tests__/skykit.test.js +142 -506
  14. package/src/actions.js +0 -8
  15. package/src/anchored-images.js +14 -15
  16. package/src/browser-addons.d.ts +16 -0
  17. package/src/browser-addons.js +155 -0
  18. package/src/browser-constellations.d.ts +13 -0
  19. package/src/browser-constellations.js +387 -0
  20. package/src/browser.d.ts +81 -0
  21. package/src/browser.js +192 -13
  22. package/src/data.d.ts +133 -0
  23. package/src/data.js +447 -0
  24. package/src/embed.d.ts +5 -0
  25. package/src/embed.js +53 -2
  26. package/src/hr-diagram.js +23 -5
  27. package/src/index.d.ts +21 -73
  28. package/src/index.js +0 -1
  29. package/src/plugins.js +22 -708
  30. package/src/three-shim.d.ts +32 -0
  31. package/src/touch-os.d.ts +70 -0
  32. package/src/touch-os.js +275 -0
  33. package/src/utils.js +96 -6
  34. package/src/viewer-entry.d.ts +10 -0
  35. package/src/viewer-entry.js +4 -0
  36. package/src/viewer.js +110 -12
  37. package/src/xr/plugins.js +298 -13
  38. package/src/xr/session.js +60 -14
  39. package/src/xr.d.ts +40 -0
  40. package/src/xr.js +2 -0
package/src/viewer.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  mountRenderer,
16
16
  normalizeViewState,
17
17
  positiveFinite,
18
+ resolveViewLookAtInput,
18
19
  setupPlugin,
19
20
  snapshotPart,
20
21
  syncRootsFromView,
@@ -71,7 +72,7 @@ export async function createSkykitViewer(options = {}) {
71
72
  let started = false;
72
73
  let elapsedSeconds = 0;
73
74
  const initialProjectionView = resolveCameraProjectionView(camera);
74
- let view = normalizeViewState({
75
+ const initialViewInput = await resolveViewLookAtInput({
75
76
  ...options.view,
76
77
  ...(options.view?.verticalFovDeg === undefined && initialProjectionView.verticalFovDeg !== undefined
77
78
  ? { verticalFovDeg: initialProjectionView.verticalFovDeg }
@@ -83,8 +84,11 @@ export async function createSkykitViewer(options = {}) {
83
84
  renderObserverPosition: options.view?.renderObserverPosition ?? observerRig.getRenderObserverPosition(),
84
85
  orientationIcrs: options.view?.orientationIcrs ?? observerRig.getOrientationIcrs?.() ?? null,
85
86
  motion: options.view?.motion ?? observerRig.getMotion?.() ?? null,
86
- }, 0);
87
+ }, viewLookResolverOptions(options.view?.observerPc ?? observerRig.getObserverPc()));
88
+ let view = normalizeViewState(initialViewInput, 0, viewLookResolverOptions(initialViewInput.observerPc));
87
89
  const initialView = cloneViewState(view);
90
+ observerRig.setObserverPc?.(view.observerPc);
91
+ if (view.orientationIcrs) observerRig.setOrientationIcrs?.(view.orientationIcrs);
88
92
 
89
93
  addRootToScene(scene, roots.originContentRoot);
90
94
  addRootToScene(scene, roots.observerContentRoot);
@@ -112,6 +116,7 @@ export async function createSkykitViewer(options = {}) {
112
116
  observerRig,
113
117
  actions,
114
118
  addPart,
119
+ addPlugin,
115
120
  getViewState,
116
121
  requestViewState,
117
122
  update,
@@ -139,10 +144,7 @@ export async function createSkykitViewer(options = {}) {
139
144
  }
140
145
 
141
146
  for (const plugin of pluginInputs) {
142
- const teardown = await setupPlugin(plugin, context);
143
- if (typeof teardown === 'function') {
144
- disposables.push(teardown);
145
- }
147
+ await installPlugin(plugin);
146
148
  }
147
149
 
148
150
  for (const part of orderedParts()) {
@@ -176,6 +178,32 @@ export async function createSkykitViewer(options = {}) {
176
178
  };
177
179
  }
178
180
 
181
+ /**
182
+ * @param {import('./index.d.ts').SkykitPluginInput} plugin
183
+ * @returns {Promise<SkykitPluginTeardown>}
184
+ */
185
+ async function addPlugin(plugin) {
186
+ assertActive();
187
+ return installPlugin(plugin, true);
188
+ }
189
+
190
+ /**
191
+ * @param {import('./index.d.ts').SkykitPluginInput} plugin
192
+ * @param {boolean} [record]
193
+ * @returns {Promise<SkykitPluginTeardown>}
194
+ */
195
+ async function installPlugin(plugin, record = false) {
196
+ const teardown = await setupPlugin(plugin, context);
197
+ if (record) pluginInputs.push(plugin);
198
+ if (typeof teardown !== 'function') return () => {};
199
+ disposables.push(teardown);
200
+ return () => {
201
+ const index = disposables.indexOf(teardown);
202
+ if (index >= 0) disposables.splice(index, 1);
203
+ void teardown();
204
+ };
205
+ }
206
+
179
207
  /**
180
208
  * @param {SkykitThreePart} part
181
209
  */
@@ -192,9 +220,8 @@ export async function createSkykitViewer(options = {}) {
192
220
  */
193
221
  async function removePart(part) {
194
222
  const index = parts.indexOf(part);
195
- if (index >= 0) {
196
- parts.splice(index, 1);
197
- }
223
+ if (index < 0) return;
224
+ parts.splice(index, 1);
198
225
  emit({ type: 'part/remove', part });
199
226
  await detachAndDisposePart(part);
200
227
  }
@@ -209,11 +236,32 @@ export async function createSkykitViewer(options = {}) {
209
236
  */
210
237
  function requestViewState(patch, reason) {
211
238
  assertActive();
239
+ const normalizedPatch = normalizeRequestedViewPatch(patch);
240
+ if (lookAtNeedsAsyncResolution(normalizedPatch)) {
241
+ const observerPc = /** @type {Partial<SkykitViewState>} */ (normalizedPatch).observerPc ?? view.observerPc;
242
+ void resolveViewLookAtInput(normalizedPatch, viewLookResolverOptions(observerPc))
243
+ .then((resolvedPatch) => {
244
+ if (disposed) return;
245
+ pendingViewPatch = {
246
+ ...(pendingViewPatch ?? {}),
247
+ ...resolvedPatch,
248
+ };
249
+ })
250
+ .catch((error) => {
251
+ emit({
252
+ type: 'view/lookAt-error',
253
+ reason,
254
+ error: error instanceof Error ? error.message : String(error),
255
+ });
256
+ });
257
+ emit({ type: 'view/request', reason, patch: normalizedPatch });
258
+ return;
259
+ }
212
260
  pendingViewPatch = {
213
261
  ...(pendingViewPatch ?? {}),
214
- ...patch,
262
+ ...normalizedPatch,
215
263
  };
216
- emit({ type: 'view/request', reason, patch });
264
+ emit({ type: 'view/request', reason, patch: normalizedPatch });
217
265
  }
218
266
 
219
267
  /**
@@ -356,7 +404,7 @@ export async function createSkykitViewer(options = {}) {
356
404
  }
357
405
  }
358
406
  for (const part of [...orderedParts()].reverse()) {
359
- await detachAndDisposePart(part);
407
+ await removePart(part);
360
408
  }
361
409
  for (const disposable of [...disposables].reverse()) {
362
410
  await disposable();
@@ -454,6 +502,56 @@ export async function createSkykitViewer(options = {}) {
454
502
  return [...parts].sort(compareParts);
455
503
  }
456
504
 
505
+ /** @param {Partial<SkykitViewState>} patch */
506
+ function normalizeRequestedViewPatch(patch) {
507
+ if (!patch || typeof patch !== 'object') return patch;
508
+ if ('lookAt' in patch) return patch;
509
+ if (patch.orientationIcrs) {
510
+ return {
511
+ ...patch,
512
+ lookAt: { orientationIcrs: patch.orientationIcrs },
513
+ };
514
+ }
515
+ if (patch.targetPc) {
516
+ return {
517
+ ...patch,
518
+ lookAt: { targetPc: patch.targetPc },
519
+ };
520
+ }
521
+ return patch;
522
+ }
523
+
524
+ /** @param {Partial<SkykitViewState>} patch */
525
+ function lookAtNeedsAsyncResolution(patch) {
526
+ if (!patch || typeof patch !== 'object' || !('lookAt' in patch)) return false;
527
+ const lookAt = /** @type {Record<string, unknown> | null} */ (patch.lookAt);
528
+ return !!lookAt
529
+ && typeof lookAt === 'object'
530
+ && 'star' in lookAt
531
+ && !('targetPc' in lookAt)
532
+ && typeof options.resolveLookAtStar === 'function';
533
+ }
534
+
535
+ /** @param {unknown} observerPc */
536
+ function viewLookResolverOptions(observerPc) {
537
+ return {
538
+ observerPc,
539
+ resolveStar: typeof options.resolveLookAtStar === 'function'
540
+ ? /** @type {import('@found-in-space/spatial').ResolveSpatialLookAtOptions['resolveStar']} */ (
541
+ (star, lookAt) => options.resolveLookAtStar?.(
542
+ star,
543
+ /** @type {import('./index.d.ts').SkykitLookAtInput} */ (lookAt),
544
+ )
545
+ )
546
+ : undefined,
547
+ resolveBookmark: typeof options.resolveLookAtBookmark === 'function'
548
+ ? /** @type {import('@found-in-space/spatial').ResolveSpatialLookAtOptions['resolveBookmark']} */ (
549
+ (bookmarkId, lookAt) => options.resolveLookAtBookmark?.(bookmarkId, lookAt)
550
+ )
551
+ : undefined,
552
+ };
553
+ }
554
+
457
555
  /**
458
556
  * @param {SkykitViewer} currentViewer
459
557
  * @returns {SkykitThreePluginContext}
package/src/xr/plugins.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  positiveFinite,
12
12
  } from '../utils.js';
13
13
  import { createSkykitXrControlBindings } from './controls.js';
14
+ import { createSkykitXrBodyTracker } from './body.js';
14
15
  import { createSkykitXrRaySource } from './rays.js';
15
16
  import { enterSkykitXrSession, exitSkykitXrSession, isSkykitXrModeSupported } from './session.js';
16
17
 
@@ -121,6 +122,69 @@ export function createSkykitXrObserverRig(options) {
121
122
  }
122
123
  }
123
124
 
125
+ /**
126
+ * @param {import('../xr.d.ts').SkykitXrBodyPluginOptions} [options]
127
+ * @returns {import('../xr.d.ts').SkykitXrBodyPlugin}
128
+ */
129
+ export function createSkykitXrBodyPlugin(options = {}) {
130
+ const id = options.id ?? 'skykit-xr-body';
131
+ const tracker = options.tracker ?? createSkykitXrBodyTracker();
132
+ const hideUntrackedHands = options.hideUntrackedHands !== false;
133
+ let disposed = false;
134
+ let body = tracker.getBody();
135
+
136
+ const part = {
137
+ id,
138
+ priority: options.priority ?? -900,
139
+ /** @param {import('../index.d.ts').SkykitThreeFrame} frame */
140
+ update(frame) {
141
+ if (disposed) return;
142
+ const session = frame.xr?.session && typeof frame.xr.session === 'object'
143
+ ? /** @type {{ inputSources?: Iterable<unknown> }} */ (frame.xr.session)
144
+ : null;
145
+ body = tracker.update({
146
+ frame: frame.xr?.frame,
147
+ referenceSpace: frame.xr?.referenceSpace,
148
+ session: /** @type {any} */ (frame.xr?.session),
149
+ inputSources: session?.inputSources ?? [],
150
+ rig: options.rig,
151
+ shipPose: options.rig?.getNavigationPose?.(),
152
+ });
153
+ if (hideUntrackedHands && options.rig) {
154
+ setObjectVisible(options.rig.leftHandRoot, Boolean(body.leftHand?.grip ?? body.leftHand?.targetRay));
155
+ setObjectVisible(options.rig.rightHandRoot, Boolean(body.rightHand?.grip ?? body.rightHand?.targetRay));
156
+ }
157
+ options.onBody?.(body, frame);
158
+ },
159
+ dispose() {
160
+ disposed = true;
161
+ if (options.disposeTracker !== false) {
162
+ tracker.dispose?.();
163
+ }
164
+ },
165
+ getSnapshot,
166
+ };
167
+
168
+ return {
169
+ id,
170
+ setup(context) {
171
+ context.addPart(part);
172
+ },
173
+ getBody() {
174
+ return body;
175
+ },
176
+ getSnapshot,
177
+ };
178
+
179
+ function getSnapshot() {
180
+ return {
181
+ id,
182
+ disposed,
183
+ body,
184
+ };
185
+ }
186
+ }
187
+
124
188
  /**
125
189
  * @param {import('../xr.d.ts').SkykitXrSessionPluginOptions} options
126
190
  * @returns {import('../index.d.ts').SkykitPlugin & { enter(): Promise<import('../xr.d.ts').SkykitXrSessionHandle>; exit(): Promise<void>; getSnapshot(): unknown }}
@@ -137,6 +201,10 @@ export function createSkykitXrSessionPlugin(options = {}) {
137
201
  let disposed = false;
138
202
  /** @type {boolean | null} */
139
203
  let supported = null;
204
+ /** @type {string} */
205
+ let enterStage = 'idle';
206
+ /** @type {string | null} */
207
+ let lastError = null;
140
208
 
141
209
  const part = {
142
210
  id,
@@ -198,15 +266,40 @@ export function createSkykitXrSessionPlugin(options = {}) {
198
266
  const activeRenderer = renderer ?? context?.renderer;
199
267
  const rendererXr = resolveRendererXr(activeRenderer);
200
268
  if (rendererXr) rendererXr.enabled = true;
201
- handle = await enterSkykitXrSession({
202
- navigator: options.navigator,
203
- mode,
204
- referenceSpaceType,
205
- sessionInit: options.sessionInit,
206
- onSessionStarted: options.onSessionStarted,
207
- });
208
- if (rendererXr && typeof rendererXr.setSession === 'function') {
209
- await rendererXr.setSession(handle.session);
269
+ rendererXr?.setReferenceSpaceType?.(referenceSpaceType);
270
+ enterStage = 'requesting-session';
271
+ lastError = null;
272
+ /** @type {import('../xr.d.ts').SkykitXrSessionHandle | null} */
273
+ let nextHandle = null;
274
+ try {
275
+ nextHandle = await enterSkykitXrSession({
276
+ navigator: options.navigator,
277
+ mode,
278
+ referenceSpaceType,
279
+ sessionInit: options.sessionInit,
280
+ requestReferenceSpace: false,
281
+ });
282
+ enterStage = 'binding-renderer';
283
+ if (rendererXr && typeof rendererXr.setSession === 'function') {
284
+ await rendererXr.setSession(nextHandle.session);
285
+ }
286
+ handle = nextHandle;
287
+ enterStage = 'presenting';
288
+ options.onSessionStarted?.(handle);
289
+ } catch (error) {
290
+ enterStage = 'failed';
291
+ lastError = error instanceof Error ? error.message : String(error);
292
+ if (nextHandle) {
293
+ await exitSkykitXrSession(nextHandle).catch(() => {});
294
+ }
295
+ const activeSession = rendererXr?.getSession?.();
296
+ if (activeSession && typeof /** @type {{ end?: unknown }} */ (activeSession).end === 'function') {
297
+ const endResult = /** @type {{ end: () => Promise<void> | void }} */ (activeSession).end();
298
+ if (endResult && typeof /** @type {Promise<void>} */ (endResult).catch === 'function') {
299
+ await /** @type {Promise<void>} */ (endResult).catch(() => {});
300
+ }
301
+ }
302
+ throw error;
210
303
  }
211
304
  context?.emit?.({
212
305
  type: 'xr/session-start',
@@ -220,14 +313,20 @@ export function createSkykitXrSessionPlugin(options = {}) {
220
313
  async function exit() {
221
314
  const previous = handle;
222
315
  handle = null;
316
+ enterStage = 'exiting';
223
317
  const activeRenderer = renderer ?? context?.renderer;
224
318
  const rendererXr = resolveRendererXr(activeRenderer);
225
- if (rendererXr && typeof rendererXr.setSession === 'function' && rendererXr.getSession?.()) {
226
- await rendererXr.setSession(null);
227
- }
228
319
  if (previous) {
229
320
  await exitSkykitXrSession(previous);
321
+ } else {
322
+ const activeSession = rendererXr?.getSession?.();
323
+ if (activeSession && typeof /** @type {{ end?: unknown }} */ (activeSession).end === 'function') {
324
+ await /** @type {{ end: () => Promise<void> | void }} */ (activeSession).end();
325
+ } else if (rendererXr && typeof rendererXr.setSession === 'function' && rendererXr.getSession?.()) {
326
+ await rendererXr.setSession(null);
327
+ }
230
328
  }
329
+ enterStage = 'idle';
231
330
  context?.emit?.({
232
331
  type: 'xr/session-end',
233
332
  id,
@@ -246,7 +345,9 @@ export function createSkykitXrSessionPlugin(options = {}) {
246
345
  referenceSpaceType,
247
346
  supported,
248
347
  presenting: isPresenting(),
348
+ enterStage,
249
349
  session: handle?.getSnapshot?.() ?? null,
350
+ lastError,
250
351
  disposed,
251
352
  };
252
353
  }
@@ -365,6 +466,134 @@ export function createSkykitXrNavigationPlugin(options = {}) {
365
466
  };
366
467
  }
367
468
 
469
+ /**
470
+ * @param {import('../xr.d.ts').SkykitXrRayVisualPluginOptions} options
471
+ * @returns {import('../index.d.ts').SkykitPlugin & { getSnapshot(): unknown }}
472
+ */
473
+ export function createSkykitXrRayVisualPlugin(options) {
474
+ if (!options?.raySource || typeof options.raySource.getRay !== 'function') {
475
+ throw new TypeError('createSkykitXrRayVisualPlugin() requires a raySource.');
476
+ }
477
+ const id = options.id ?? 'skykit-xr-ray-visual';
478
+ const root = new THREE.Group();
479
+ root.name = id;
480
+ root.visible = false;
481
+ const positions = new Float32Array(6);
482
+ const geometry = new THREE.BufferGeometry();
483
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
484
+ const material = options.material ?? new THREE.LineBasicMaterial({
485
+ color: options.color ?? 0x66ffe8,
486
+ transparent: true,
487
+ opacity: options.opacity ?? 0.72,
488
+ depthTest: options.depthTest ?? false,
489
+ depthWrite: false,
490
+ });
491
+ const ownsMaterial = options.material == null;
492
+ const line = new THREE.Line(geometry, material);
493
+ line.name = `${id}:line`;
494
+ line.frustumCulled = false;
495
+ line.renderOrder = finiteNumber(options.renderOrder, 10_000);
496
+ root.add(line);
497
+
498
+ /** @type {THREE.Object3D | null} */
499
+ let parent = null;
500
+ let disposed = false;
501
+ let visible = false;
502
+ let blocked = false;
503
+ let lastLength = 0;
504
+
505
+ const part = {
506
+ id,
507
+ priority: options.priority ?? 45,
508
+ object3d: root,
509
+ /** @param {import('../index.d.ts').SkykitThreePluginContext} context */
510
+ attach(context) {
511
+ parent = resolveRayVisualParent(options.parent, context);
512
+ parent.add(root);
513
+ },
514
+ /** @param {import('../index.d.ts').SkykitThreeFrame} frame */
515
+ update(frame) {
516
+ if (disposed || frame.xr?.presenting !== true) {
517
+ setVisible(false);
518
+ return;
519
+ }
520
+ const session = frame.xr.session && typeof frame.xr.session === 'object'
521
+ ? /** @type {{ inputSources?: Iterable<unknown> }} */ (frame.xr.session)
522
+ : null;
523
+ const ray = options.raySource.getRay({
524
+ frame: frame.xr.frame,
525
+ referenceSpace: frame.xr.referenceSpace,
526
+ session: /** @type {any} */ (frame.xr.session),
527
+ inputSources: session?.inputSources ?? [],
528
+ rig: options.rig,
529
+ viewer: frame.viewer,
530
+ });
531
+ if (!ray) {
532
+ setVisible(false);
533
+ return;
534
+ }
535
+
536
+ const resolved = resolveRayVisualLength(ray, frame, options);
537
+ blocked = resolved.blocked;
538
+ if (!(resolved.length > 0)) {
539
+ setVisible(false);
540
+ return;
541
+ }
542
+
543
+ const direction = new THREE.Vector3(ray.direction.x, ray.direction.y, ray.direction.z).normalize();
544
+ positions[0] = ray.origin.x;
545
+ positions[1] = ray.origin.y;
546
+ positions[2] = ray.origin.z;
547
+ positions[3] = ray.origin.x + direction.x * resolved.length;
548
+ positions[4] = ray.origin.y + direction.y * resolved.length;
549
+ positions[5] = ray.origin.z + direction.z * resolved.length;
550
+ geometry.attributes.position.needsUpdate = true;
551
+ geometry.computeBoundingSphere();
552
+ lastLength = resolved.length;
553
+ setVisible(true);
554
+ },
555
+ detach() {
556
+ parent?.remove(root);
557
+ parent = null;
558
+ },
559
+ dispose() {
560
+ disposed = true;
561
+ parent?.remove(root);
562
+ parent = null;
563
+ geometry.dispose();
564
+ if (ownsMaterial) {
565
+ material.dispose();
566
+ }
567
+ options.raySource.dispose?.();
568
+ },
569
+ getSnapshot() {
570
+ return {
571
+ id,
572
+ disposed,
573
+ visible,
574
+ blocked,
575
+ lastLength,
576
+ parentName: parent?.name ?? null,
577
+ raySource: options.raySource.getSnapshot?.() ?? null,
578
+ };
579
+ },
580
+ };
581
+
582
+ return {
583
+ id,
584
+ setup(context) {
585
+ context.addPart(part);
586
+ },
587
+ getSnapshot: () => part.getSnapshot(),
588
+ };
589
+
590
+ /** @param {boolean} nextVisible */
591
+ function setVisible(nextVisible) {
592
+ visible = nextVisible;
593
+ root.visible = nextVisible;
594
+ }
595
+ }
596
+
368
597
  /**
369
598
  * @param {import('../xr.d.ts').SkykitXrStarPickingPluginOptions} options
370
599
  * @returns {import('../index.d.ts').SkykitPlugin & { getSnapshot(): unknown }}
@@ -505,7 +734,7 @@ export function createSkykitXrStarPickingPlugin(options) {
505
734
  */
506
735
  function resolveRendererXr(renderer) {
507
736
  return renderer && typeof renderer === 'object'
508
- ? /** @type {{ enabled?: boolean; isPresenting?: boolean; getSession?: () => unknown; getReferenceSpace?: () => unknown; setSession?: (session: unknown) => Promise<void> | void }} */ (
737
+ ? /** @type {{ enabled?: boolean; isPresenting?: boolean; getSession?: () => unknown; getReferenceSpace?: () => unknown; setReferenceSpaceType?: (type: string) => void; setSession?: (session: unknown) => Promise<void> | void }} */ (
509
738
  /** @type {{ xr?: unknown }} */ (renderer).xr
510
739
  )
511
740
  : null;
@@ -587,3 +816,59 @@ function callBlocker(blocker, ray, context) {
587
816
  }
588
817
  return blocker.blockRay?.(ray, context) ?? blocker.pick?.(ray, context) ?? null;
589
818
  }
819
+
820
+ /**
821
+ * @param {import('../xr.d.ts').SkykitXrRay} ray
822
+ * @param {import('../index.d.ts').SkykitThreeFrame} frame
823
+ * @param {import('../xr.d.ts').SkykitXrRayVisualPluginOptions} options
824
+ */
825
+ function resolveRayVisualLength(ray, frame, options) {
826
+ const fallbackLength = positiveFinite(options.length, 12);
827
+ let length = positiveFinite(ray.length, fallbackLength);
828
+ let blocked = false;
829
+ for (const blocker of options.blockers ?? []) {
830
+ const result = callBlocker(blocker, ray, {
831
+ frame: frame.xr?.frame,
832
+ referenceSpace: frame.xr?.referenceSpace,
833
+ session: /** @type {any} */ (frame.xr?.session),
834
+ ray,
835
+ viewer: frame.viewer,
836
+ maxDistance: length,
837
+ });
838
+ if (!result) continue;
839
+ const hitDistance = finiteNumber(
840
+ result.distance
841
+ ?? result.maxDistance
842
+ ?? /** @type {{ length?: unknown }} */ (result.hit ?? {}).length,
843
+ Number.NaN,
844
+ );
845
+ if (Number.isFinite(hitDistance) && hitDistance >= 0) {
846
+ length = Math.min(length, hitDistance);
847
+ } else if (result.consumed === true || result.blocked === true) {
848
+ length = 0;
849
+ }
850
+ blocked = blocked || result.consumed === true || result.blocked === true;
851
+ }
852
+ return { length, blocked };
853
+ }
854
+
855
+ /**
856
+ * @param {import('../xr.d.ts').SkykitXrRayVisualPluginOptions['parent']} parent
857
+ * @param {import('../index.d.ts').SkykitThreePluginContext} context
858
+ */
859
+ function resolveRayVisualParent(parent, context) {
860
+ if (typeof parent === 'function') {
861
+ return parent(context) ?? context.scene;
862
+ }
863
+ return parent ?? context.scene;
864
+ }
865
+
866
+ /**
867
+ * @param {unknown} object
868
+ * @param {boolean} visible
869
+ */
870
+ function setObjectVisible(object, visible) {
871
+ if (object && typeof object === 'object' && 'visible' in object) {
872
+ /** @type {{ visible: boolean }} */ (object).visible = visible;
873
+ }
874
+ }
package/src/xr/session.js CHANGED
@@ -26,20 +26,35 @@ export async function enterSkykitXrSession(options = {}) {
26
26
  if (!xr || typeof xr.requestSession !== 'function') {
27
27
  throw new Error('WebXR is not available.');
28
28
  }
29
- const session = /** @type {{ requestReferenceSpace?: (type: string) => Promise<unknown> }} */ (
30
- await xr.requestSession(mode, options.sessionInit)
31
- );
32
- const referenceSpace = typeof session.requestReferenceSpace === 'function'
33
- ? await session.requestReferenceSpace(referenceSpaceType)
34
- : null;
35
- const handle = createSessionHandle({
36
- mode,
37
- referenceSpaceType,
38
- session,
39
- referenceSpace,
40
- });
41
- options.onSessionStarted?.(handle);
42
- return handle;
29
+ /** @type {{ requestReferenceSpace?: (type: string) => Promise<unknown>; end?: () => Promise<void> | void } | null} */
30
+ let session = null;
31
+ try {
32
+ session = /** @type {{ requestReferenceSpace?: (type: string) => Promise<unknown>; end?: () => Promise<void> | void }} */ (
33
+ await xr.requestSession(mode, createSessionInit(options.sessionInit, referenceSpaceType))
34
+ );
35
+ const referenceSpace = options.requestReferenceSpace === false
36
+ ? null
37
+ : typeof session.requestReferenceSpace === 'function'
38
+ ? await session.requestReferenceSpace(referenceSpaceType)
39
+ : null;
40
+ const handle = createSessionHandle({
41
+ mode,
42
+ referenceSpaceType,
43
+ session,
44
+ referenceSpace,
45
+ });
46
+ options.onSessionStarted?.(handle);
47
+ return handle;
48
+ } catch (error) {
49
+ if (session && typeof session.end === 'function') {
50
+ try {
51
+ await session.end();
52
+ } catch {
53
+ // Preserve the original session-enter error.
54
+ }
55
+ }
56
+ throw error;
57
+ }
43
58
  }
44
59
 
45
60
  /**
@@ -106,3 +121,34 @@ function resolveSkykitXr(navigatorLike) {
106
121
  const nav = navigatorLike ?? globalThis.navigator;
107
122
  return /** @type {{ xr?: { isSessionSupported?: (mode: string) => Promise<boolean>; requestSession?: (mode: string, init?: unknown) => Promise<unknown> } }} */ (nav)?.xr ?? null;
108
123
  }
124
+
125
+ /**
126
+ * @param {unknown} sessionInit
127
+ * @param {string} referenceSpaceType
128
+ */
129
+ function createSessionInit(sessionInit, referenceSpaceType) {
130
+ const base = sessionInit && typeof sessionInit === 'object'
131
+ ? /** @type {Record<string, unknown>} */ (sessionInit)
132
+ : {};
133
+ const requiredFeatures = normalizeFeatureList(base.requiredFeatures);
134
+ const optionalFeatures = normalizeFeatureList(base.optionalFeatures);
135
+ if (
136
+ referenceSpaceType &&
137
+ !requiredFeatures.includes(referenceSpaceType) &&
138
+ !optionalFeatures.includes(referenceSpaceType)
139
+ ) {
140
+ optionalFeatures.push(referenceSpaceType);
141
+ }
142
+ return {
143
+ ...base,
144
+ ...(requiredFeatures.length > 0 ? { requiredFeatures } : {}),
145
+ ...(optionalFeatures.length > 0 ? { optionalFeatures } : {}),
146
+ };
147
+ }
148
+
149
+ /** @param {unknown} value */
150
+ function normalizeFeatureList(value) {
151
+ return Array.isArray(value)
152
+ ? Array.from(new Set(value.filter((entry) => typeof entry === 'string' && entry.trim())))
153
+ : [];
154
+ }