@found-in-space/skykit 0.2.0-dev.20260527.0 → 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.
package/README.md CHANGED
@@ -28,11 +28,11 @@ The beginner website entries are use-case bounded:
28
28
  | --- | --- | --- |
29
29
  | Viewer | "Put stars on my page and let me customize the scene." | `embed.js`, `viewer.js` |
30
30
  | Data | "Give me star data so I can render, list, map, or game it myself." | `data.js` |
31
- | Story | "Let me tell a curated story through space." | `story.js` |
32
31
 
33
32
  `embed.js` is the no-code viewer entry. It is not a separate use-case.
34
33
  `viewer.js` is the JavaScript-customizable viewer entry. `data.js` is renderer
35
- independent. `story.js` is authored chapters plus a viewer.
34
+ independent. Authored chapters stay in website or lesson code and call SkyKit
35
+ navigation actions directly.
36
36
 
37
37
  ## Paste into a static page or CMS
38
38
 
@@ -69,17 +69,29 @@ Optional attributes keep small tweaks HTML-only:
69
69
  data-skykit-magnitude="7"
70
70
  data-skykit-speed="4"
71
71
  data-skykit-exposure="2600"
72
- data-skykit-look-at="ra=4.496h, dec=16.948"
72
+ data-skykit-observer="06h 45m 08.9s, -16d 42m 58s, 2.64pc"
73
+ data-skykit-look-at="05h 35m 17.3s, -05d 23m 28s, 414pc"
74
+ data-skykit-coordinate-origin="solar"
73
75
  data-skykit-mouse-mode="strafe"
76
+ data-skykit-persistent-cache="off"
74
77
  style="width: 100%; height: 520px; background: #02040b"
75
78
  ></div>
76
79
  ```
77
80
 
81
+ `data-skykit-observer` accepts fixed parsec-space `x,y,z` coordinates or
82
+ RA/Dec/distance text such as `06h 45m 08.9s, -16d 42m 58s, 2.64pc`.
78
83
  `data-skykit-look-at` accepts RA/Dec text such as
79
- `ra=4.496h, dec=16.948`, decimal degrees such as `67.447,16.948`, or a
80
- parsec-space `x,y,z` target for exact generated coordinates. `data-skykit-mouse-mode`
81
- defaults to `grab`; use `look` or `strafe` for the first-person mouse-look
82
- direction, or `none` to disable mouse drag controls.
84
+ `05h 36m 12.81s, −01° 12′ 06.9″`, decimal degrees such as
85
+ `84.053393,-1.201926`, RA/Dec/distance text for a fixed heliocentric target,
86
+ or a parsec-space `x,y,z` target for exact generated coordinates.
87
+ RA/Dec/distance resolves from the solar origin; pure RA/Dec remains a
88
+ directional look. `data-skykit-coordinate-origin="solar"` is accepted as
89
+ clarifying markup, while observer-relative shorthand is not part of this alpha
90
+ embed yet. `data-skykit-mouse-mode` defaults to `grab`; use `look` or
91
+ `strafe` for the first-person mouse-look direction, or `none` to disable mouse
92
+ drag controls. Persistent browser Cache API storage is enabled by default for
93
+ octree ranges; set
94
+ `data-skykit-persistent-cache="off"` to keep caching session-only.
83
95
 
84
96
  The host dispatches `skykit-browser-ready` with `{ browser, viewer }` in
85
97
  `event.detail` after startup, and `skykit-browser-error` if startup fails. The
@@ -115,12 +127,20 @@ For small scripted interactions, use the browser handle:
115
127
 
116
128
  ```html
117
129
  <script type="module">
130
+ import {
131
+ SKYKIT_ACTIONS,
132
+ createRaDecLookAt,
133
+ createSkykitNavigationPlugin,
134
+ } from 'https://esm.sh/@found-in-space/skykit';
135
+
118
136
  const browser = await Skykit.whenReady();
137
+ await browser.install(createSkykitNavigationPlugin());
138
+ const alnilam = createRaDecLookAt('05h 36m 12.81s', '−01° 12′ 06.9″');
119
139
 
120
140
  document.querySelector('#orion').addEventListener('click', () => {
121
- browser.journey.transitionTo({
122
- lookAt: 'ra=5.919h, dec=7.407',
123
- durationSecs: 3,
141
+ browser.viewer.actions.invoke(SKYKIT_ACTIONS.navigation.transitionTo, {
142
+ view: { lookAt: alnilam },
143
+ movement: { durationSecs: 3 },
124
144
  });
125
145
  });
126
146
  </script>
@@ -190,25 +210,11 @@ const stars = await loadStarRows({
190
210
  });
191
211
  ```
192
212
 
193
- ## Create a Guided Story
194
-
195
- Use `story.js` when the page is an authored article or tour:
196
-
197
- ```html
198
- <div data-skykit-story style="height:600px;background:#02040b">
199
- <section data-skykit-chapter data-title="The Sun" data-target-pc="0,0,0">
200
- We start at the Sun.
201
- </section>
202
- <section data-skykit-chapter data-title="The Hyades" data-target-pc="17.574,42.316,13.963">
203
- Now jump to the Hyades cluster.
204
- </section>
205
- </div>
213
+ ## Author Chapters
206
214
 
207
- <script
208
- type="module"
209
- src="https://esm.sh/@found-in-space/skykit@0.2.0-alpha.2/story?bundle&deps=three@0.170.0"
210
- ></script>
211
- ```
215
+ Keep named chapters in the website or lesson script. Each chapter can call
216
+ navigation actions such as `skykit:navigation.transitionTo` and
217
+ `skykit:navigation.orbit` from its own `goTo(id)` dispatcher.
212
218
 
213
219
  Use the lower-level factories when a lesson is teaching composition or replacing
214
220
  a part of the stack:
@@ -323,7 +329,7 @@ names, not renderer or loader factory names:
323
329
  SKYKIT_ACTIONS.ship.moveForward; // "skykit:ship.move.forward"
324
330
  SKYKIT_CONTROLS.observer.parallaxOffset; // "skykit:observer.control.parallaxOffset"
325
331
  SKYKIT_ACTIONS.viewer.reset; // "skykit:viewer.reset"
326
- SKYKIT_ACTIONS.journey.goToChapter; // "skykit:journey.goToChapter"
332
+ SKYKIT_ACTIONS.navigation.transitionTo; // "skykit:navigation.transitionTo"
327
333
  ```
328
334
 
329
335
  Plugins can add their own namespaces:
@@ -336,13 +342,13 @@ const firePlugin = (ctx) => {
336
342
  };
337
343
  ```
338
344
 
339
- DOM buttons, touch surfaces, keyboard bindings, XR controls, journeys, and debug
340
- tools can all call the same action:
345
+ DOM buttons, touch surfaces, keyboard bindings, XR controls, app-owned chapters,
346
+ and debug tools can all call the same action:
341
347
 
342
348
  ```js
343
349
  button.addEventListener('click', () => {
344
- viewer.actions.invoke(SKYKIT_ACTIONS.journey.goToChapter, {
345
- chapterId: 'hyades-arrival',
350
+ viewer.actions.invoke('website:chapter.goTo', {
351
+ id: 'hyades-arrival',
346
352
  });
347
353
  });
348
354
  ```
@@ -397,8 +403,7 @@ Skykit.registerBrowserAddon({
397
403
  ```
398
404
 
399
405
  See `docs/skykit-browser-plugins.md` for the browser add-on spec,
400
- `Skykit.whenReady()`, first-party constellation support, and the
401
- `browser.journey` API.
406
+ `Skykit.whenReady()`, and first-party constellation support.
402
407
 
403
408
  Browser lessons:
404
409
 
@@ -24,6 +24,7 @@ import {
24
24
  import {
25
25
  applySkykitXrDepthRange,
26
26
  computeSkykitXrDepthRange,
27
+ createSkykitXrBodyPlugin,
27
28
  createSkykitXrControlBindings,
28
29
  createSkykitXrNavigationPlugin,
29
30
  createSkykitXrObserverRig,
@@ -169,6 +170,7 @@ async function main() {
169
170
  let cachedPanelRevision = -1;
170
171
  let cachedPanelRoot = null;
171
172
  let latestPanelFrame = null;
173
+ let leftHandPanelTracked = false;
172
174
  let activeXrHandle = null;
173
175
  let artController = null;
174
176
  let preflightController = null;
@@ -207,7 +209,7 @@ async function main() {
207
209
  touchPanel = createTouchOsPanelPlugin({
208
210
  id: 'xr-free-roam-touch-panel',
209
211
  priority: 20,
210
- driver: 'pose-anchored',
212
+ driver: 'scene',
211
213
  root: createPanelRoot,
212
214
  surfaceMetrics: XR_PANEL_SURFACE,
213
215
  runtimeOptions: {
@@ -215,18 +217,23 @@ async function main() {
215
217
  longPressDelay: 360,
216
218
  },
217
219
  pointerSources: [touchPointerSource],
218
- anchorPose(frame) {
219
- latestPanelFrame = frame;
220
- return resolveLeftHandPanelPose(frame, xrRig.xrOrigin);
220
+ parent() {
221
+ return xrRig.leftHandRoot;
221
222
  },
222
223
  driverOptions: {
223
224
  panelWidth: 0.32,
224
225
  panelHeight: 0.44,
225
- offset: { x: 0.04, y: 0.02, z: -0.08 },
226
- tiltRadians: -0.22,
227
226
  transparent: true,
228
227
  depthTest: false,
229
228
  renderOrder: 50,
229
+ updatePlacement(mesh) {
230
+ if (!leftHandPanelTracked) return false;
231
+ applyLocalTabletPlacement(mesh, {
232
+ offset: { x: 0.04, y: 0.02, z: -0.08 },
233
+ tiltRadians: -0.22,
234
+ });
235
+ return true;
236
+ },
230
237
  },
231
238
  });
232
239
 
@@ -271,6 +278,12 @@ async function main() {
271
278
  createStreamingStarsPlugin({ id: 'xr-stars', source, renderer: starField }),
272
279
  hrDiagram,
273
280
  ...(artPlugin ? [artPlugin] : []),
281
+ createSkykitXrBodyPlugin({
282
+ rig: xrRig,
283
+ onBody(body) {
284
+ leftHandPanelTracked = Boolean(body.leftHand?.grip ?? body.leftHand?.targetRay);
285
+ },
286
+ }),
274
287
  createXrFreeRoamFrameSyncPlugin({
275
288
  update() {
276
289
  selectedTarget.update(camera);
@@ -389,7 +402,8 @@ async function main() {
389
402
  });
390
403
  }
391
404
 
392
- function createPanelRoot() {
405
+ function createPanelRoot(rootContext) {
406
+ latestPanelFrame = rootContext?.frame ?? latestPanelFrame;
393
407
  if (cachedPanelRoot && cachedPanelRevision === panelRevision) return cachedPanelRoot;
394
408
  cachedPanelRevision = panelRevision;
395
409
  cachedPanelRoot = createSkykitTabletRoot({
@@ -1023,57 +1037,17 @@ function createWorldXrRaySource(source, transformRoot) {
1023
1037
  };
1024
1038
  }
1025
1039
 
1026
- function resolveLeftHandPanelPose(frame, transformRoot) {
1027
- const gripPose = resolveInputPose(frame, 'left', 'gripSpace');
1028
- if (!gripPose) return undefined;
1029
- return transformPoseByObject(gripPose, transformRoot);
1030
- }
1031
-
1032
- function resolveInputPose(frame, handedness, spaceKey) {
1033
- const xr = frame.xr;
1034
- const xrFrame = xr?.frame;
1035
- const referenceSpace = xr?.referenceSpace;
1036
- const inputSources = xr?.session && typeof xr.session === 'object'
1037
- ? xr.session.inputSources ?? []
1038
- : [];
1039
- if (!xrFrame || !referenceSpace || typeof xrFrame.getPose !== 'function') return null;
1040
- for (const inputSource of inputSources) {
1041
- if (inputSource?.handedness !== handedness || !inputSource[spaceKey]) continue;
1042
- const pose = xrFrame.getPose(inputSource[spaceKey], referenceSpace);
1043
- const transform = pose?.transform;
1044
- if (!transform) continue;
1045
- return {
1046
- position: {
1047
- x: Number(transform.position?.x ?? 0),
1048
- y: Number(transform.position?.y ?? 0),
1049
- z: Number(transform.position?.z ?? 0),
1050
- },
1051
- orientation: {
1052
- x: Number(transform.orientation?.x ?? 0),
1053
- y: Number(transform.orientation?.y ?? 0),
1054
- z: Number(transform.orientation?.z ?? 0),
1055
- w: Number(transform.orientation?.w ?? 1),
1056
- },
1057
- };
1040
+ function applyLocalTabletPlacement(mesh, options = {}) {
1041
+ const offset = options.offset ?? {};
1042
+ mesh.position.set(0, 0, 0);
1043
+ mesh.quaternion.identity();
1044
+ mesh.scale.set(1, 1, 1);
1045
+ if (Number.isFinite(options.tiltRadians)) {
1046
+ mesh.rotateX(options.tiltRadians);
1058
1047
  }
1059
- return null;
1060
- }
1061
-
1062
- function transformPoseByObject(pose, object) {
1063
- object.updateMatrixWorld(true);
1064
- const position = new THREE.Vector3(pose.position.x, pose.position.y, pose.position.z)
1065
- .applyMatrix4(object.matrixWorld);
1066
- const objectQuaternion = object.getWorldQuaternion(new THREE.Quaternion());
1067
- const orientation = new THREE.Quaternion(
1068
- pose.orientation.x,
1069
- pose.orientation.y,
1070
- pose.orientation.z,
1071
- pose.orientation.w,
1072
- ).premultiply(objectQuaternion).normalize();
1073
- return {
1074
- position: { x: position.x, y: position.y, z: position.z },
1075
- orientation: { x: orientation.x, y: orientation.y, z: orientation.z, w: orientation.w },
1076
- };
1048
+ mesh.translateX(offset.x ?? 0);
1049
+ mesh.translateY(offset.y ?? 0);
1050
+ mesh.translateZ(offset.z ?? 0);
1077
1051
  }
1078
1052
 
1079
1053
  function createSelectedStarTarget() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@found-in-space/skykit",
3
- "version": "0.2.0-dev.20260527.0",
3
+ "version": "0.2.0",
4
4
  "description": "Slim composition and teaching layer for Found in Space packages",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,10 +31,6 @@
31
31
  "types": "./src/browser-constellations.d.ts",
32
32
  "default": "./src/browser-constellations.js"
33
33
  },
34
- "./browser-journey": {
35
- "types": "./src/browser-journey.d.ts",
36
- "default": "./src/browser-journey.js"
37
- },
38
34
  "./embed": {
39
35
  "types": "./src/embed.d.ts",
40
36
  "default": "./src/embed.js"
@@ -47,10 +43,6 @@
47
43
  "types": "./src/data.d.ts",
48
44
  "default": "./src/data.js"
49
45
  },
50
- "./story": {
51
- "types": "./src/story.d.ts",
52
- "default": "./src/story.js"
53
- },
54
46
  "./parallax": {
55
47
  "types": "./src/parallax.d.ts",
56
48
  "default": "./src/parallax.js"
@@ -70,28 +62,26 @@
70
62
  "README.md"
71
63
  ],
72
64
  "sideEffects": [
73
- "./src/embed.js",
74
- "./src/story.js"
65
+ "./src/embed.js"
75
66
  ],
76
67
  "scripts": {
77
68
  "typecheck": "tsc -p tsconfig.json --noEmit",
78
69
  "test": "node --test"
79
70
  },
80
71
  "dependencies": {
81
- "@found-in-space/anchored-image": "0.2.0-alpha.0",
82
- "@found-in-space/hr-diagram": "0.2.0-alpha.0",
83
- "@found-in-space/journey": "0.2.0-alpha.0",
84
- "@found-in-space/meta-sidecar-provider": "0.2.0-alpha.0",
85
- "@found-in-space/spatial": "0.2.0-dev.20260527.0",
86
- "@found-in-space/star-octree-provider": "0.2.0-alpha.0",
87
- "@found-in-space/star-trees": "0.2.0-alpha.0",
88
- "@found-in-space/three-star-field": "0.2.0-alpha.0"
72
+ "@found-in-space/anchored-image": "0.2.0",
73
+ "@found-in-space/hr-diagram": "0.2.0",
74
+ "@found-in-space/meta-sidecar-provider": "0.2.0",
75
+ "@found-in-space/spatial": "0.2.0",
76
+ "@found-in-space/star-octree-provider": "0.2.0",
77
+ "@found-in-space/star-trees": "0.2.0",
78
+ "@found-in-space/three-star-field": "0.2.0"
89
79
  },
90
80
  "devDependencies": {
91
- "@found-in-space/touch-os": "0.2.0-dev.3"
81
+ "@found-in-space/touch-os": "0.2.0"
92
82
  },
93
83
  "peerDependencies": {
94
- "@found-in-space/touch-os": ">=0.2.0-dev.3 <1",
84
+ "@found-in-space/touch-os": ">=0.2.0 <1",
95
85
  "three": "^0.170.0"
96
86
  },
97
87
  "peerDependenciesMeta": {
@@ -137,6 +137,35 @@ test('createSkykitBrowser accepts startup lookAt and mouse look mode', async ()
137
137
  });
138
138
  });
139
139
 
140
+ test('createSkykitBrowser starts from observer and solar RA/Dec distance lookAt', async () => {
141
+ await withFakeWindow(async () => {
142
+ const browser = await createSkykitBrowser({
143
+ host: createHost(),
144
+ status: false,
145
+ renderer: createRenderer(),
146
+ provider: createProvider(),
147
+ starField: createStarField(),
148
+ autoResize: false,
149
+ autoDispose: false,
150
+ autoStart: false,
151
+ lookAt: { raDeg: 90, decDeg: 0, distancePc: 10 },
152
+ view: {
153
+ observerPc: { x: 1, y: 0, z: 0 },
154
+ },
155
+ });
156
+
157
+ const view = browser.viewer.getViewState();
158
+ assert.deepEqual(view.observerPc, { x: 1, y: 0, z: 0 });
159
+ assertVectorApprox(view.targetPc, { x: 0, y: 10, z: 0 });
160
+ assertVectorApprox(
161
+ localVectorFromView(view, { x: 0, y: 0, z: -1 }),
162
+ normalizeVector({ x: -1, y: 10, z: 0 }),
163
+ );
164
+
165
+ await browser.dispose();
166
+ });
167
+ });
168
+
140
169
  test('createSkykitBrowser can disable mouse drag controls', async () => {
141
170
  await withFakeWindow(async () => {
142
171
  const browser = await createSkykitBrowser({
@@ -160,6 +189,56 @@ test('createSkykitBrowser can disable mouse drag controls', async () => {
160
189
  });
161
190
  });
162
191
 
192
+ test('createSkykitBrowser enables persistent provider cache by default and can opt out', async () => {
193
+ await withFakeWindow(async () => {
194
+ await withFakeCaches(async () => {
195
+ const cached = await createSkykitBrowser({
196
+ host: createHost(),
197
+ status: false,
198
+ renderer: createRenderer(),
199
+ starField: createStarField(),
200
+ autoResize: false,
201
+ autoDispose: false,
202
+ autoStart: false,
203
+ });
204
+ assert.equal(cached.provider.describe().capabilities.persistentCache, true);
205
+ await cached.dispose();
206
+
207
+ const uncached = await createSkykitBrowser({
208
+ host: createHost(),
209
+ status: false,
210
+ renderer: createRenderer(),
211
+ starField: createStarField(),
212
+ persistentCache: 'off',
213
+ autoResize: false,
214
+ autoDispose: false,
215
+ autoStart: false,
216
+ });
217
+ assert.equal(uncached.provider.describe().capabilities.persistentCache, false);
218
+ await uncached.dispose();
219
+ });
220
+ });
221
+ });
222
+
223
+ test('createSkykitBrowser treats forbidden Cache API access as disabled', async () => {
224
+ await withFakeWindow(async () => {
225
+ await withForbiddenCaches(async () => {
226
+ const browser = await createSkykitBrowser({
227
+ host: createHost(),
228
+ status: false,
229
+ renderer: createRenderer(),
230
+ starField: createStarField(),
231
+ autoResize: false,
232
+ autoDispose: false,
233
+ autoStart: false,
234
+ });
235
+
236
+ assert.equal(browser.provider.describe().capabilities.persistentCache, false);
237
+ await browser.dispose();
238
+ });
239
+ });
240
+ });
241
+
163
242
  test('browser.install adds plugins after startup and cleans returned teardowns', async () => {
164
243
  await withFakeWindow(async () => {
165
244
  const calls = [];
@@ -193,46 +272,6 @@ test('browser.install adds plugins after startup and cleans returned teardowns',
193
272
  });
194
273
  });
195
274
 
196
- test('browser journey capability transitions through navigation actions and loads instances', async () => {
197
- await withFakeWindow(async () => {
198
- const browser = await createSkykitBrowser({
199
- host: createHost(),
200
- status: false,
201
- renderer: createRenderer(),
202
- provider: createProvider(),
203
- starField: createStarField(),
204
- autoResize: false,
205
- autoDispose: false,
206
- autoStart: false,
207
- });
208
-
209
- await browser.journey.transitionTo({
210
- lookAt: { targetPc: { x: 10, y: 0, z: 0 } },
211
- durationSecs: 1,
212
- });
213
- browser.viewer.update(1);
214
- browser.viewer.update(0);
215
- assert.ok(browser.viewer.getViewState().orientationIcrs);
216
- assert.equal(browser.capabilities.has('skykit:navigation'), true);
217
-
218
- const journey = await browser.journey.load({
219
- initial: 'home',
220
- scenes: {
221
- home: { view: { observerPc: { x: 0, y: 0, z: 0 } } },
222
- away: { view: { observerPc: { x: 1, y: 2, z: 3 } } },
223
- },
224
- });
225
- await journey.goTo('away');
226
- browser.viewer.update(0);
227
- assert.deepEqual(browser.viewer.getViewState().observerPc, { x: 1, y: 2, z: 3 });
228
- assert.equal(journey.getSnapshot().disposed, false);
229
- journey.dispose();
230
- assert.equal(journey.getSnapshot().disposed, true);
231
-
232
- await browser.dispose();
233
- });
234
- });
235
-
236
275
  test('browser constellations capability loads manifest boundaries without art', async () => {
237
276
  await withFakeWindow(async () => {
238
277
  const browser = await createSkykitBrowser({
@@ -338,6 +377,88 @@ async function withFakeWindow(callback) {
338
377
  }
339
378
  }
340
379
 
380
+ async function withFakeCaches(callback) {
381
+ const previousCaches = Object.getOwnPropertyDescriptor(globalThis, 'caches');
382
+ const previousFetch = globalThis.fetch;
383
+ const cache = {
384
+ async match() {
385
+ return null;
386
+ },
387
+ async put() {},
388
+ };
389
+
390
+ Object.defineProperty(globalThis, 'caches', {
391
+ configurable: true,
392
+ value: {
393
+ async open() {
394
+ return cache;
395
+ },
396
+ },
397
+ });
398
+ Object.defineProperty(globalThis, 'fetch', {
399
+ configurable: true,
400
+ value(_url, options = {}) {
401
+ return new Promise((_resolve, reject) => {
402
+ if (options.signal?.aborted) {
403
+ const error = new Error('Range fetch aborted.');
404
+ error.name = 'AbortError';
405
+ reject(error);
406
+ return;
407
+ }
408
+ options.signal?.addEventListener?.('abort', () => {
409
+ const error = new Error('Range fetch aborted.');
410
+ error.name = 'AbortError';
411
+ reject(error);
412
+ }, { once: true });
413
+ });
414
+ },
415
+ });
416
+
417
+ try {
418
+ await callback();
419
+ } finally {
420
+ restoreGlobalProperty('caches', previousCaches);
421
+ if (previousFetch === undefined) {
422
+ delete globalThis.fetch;
423
+ } else {
424
+ Object.defineProperty(globalThis, 'fetch', {
425
+ configurable: true,
426
+ value: previousFetch,
427
+ });
428
+ }
429
+ }
430
+ }
431
+
432
+ async function withForbiddenCaches(callback) {
433
+ const previousCaches = Object.getOwnPropertyDescriptor(globalThis, 'caches');
434
+ const previousWarn = console.warn;
435
+
436
+ Object.defineProperty(globalThis, 'caches', {
437
+ configurable: true,
438
+ get() {
439
+ const error = new Error('Cache API storage is blocked.');
440
+ error.name = 'SecurityError';
441
+ throw error;
442
+ },
443
+ });
444
+ console.warn = () => {};
445
+
446
+ try {
447
+ await callback();
448
+ } finally {
449
+ console.warn = previousWarn;
450
+ restoreGlobalProperty('caches', previousCaches);
451
+ }
452
+ }
453
+
454
+ function restoreGlobalProperty(name, descriptor) {
455
+ if (descriptor) {
456
+ Object.defineProperty(globalThis, name, descriptor);
457
+ } else {
458
+ delete globalThis[name];
459
+ }
460
+ }
461
+
341
462
  function createHost() {
342
463
  return {
343
464
  children: [],
@@ -440,3 +561,26 @@ function createStarField() {
440
561
  },
441
562
  };
442
563
  }
564
+
565
+ function localVectorFromView(view, vector) {
566
+ const q = view.orientationIcrs ?? { x: 0, y: 0, z: 0, w: 1 };
567
+ const result = new THREE.Vector3(vector.x, vector.y, vector.z).applyQuaternion(
568
+ new THREE.Quaternion(q.x, q.y, q.z, q.w),
569
+ );
570
+ return { x: result.x, y: result.y, z: result.z };
571
+ }
572
+
573
+ function assertVectorApprox(actual, expected, epsilon = 1e-9) {
574
+ assert.ok(Math.abs(actual.x - expected.x) < epsilon, `x ${actual.x} !== ${expected.x}`);
575
+ assert.ok(Math.abs(actual.y - expected.y) < epsilon, `y ${actual.y} !== ${expected.y}`);
576
+ assert.ok(Math.abs(actual.z - expected.z) < epsilon, `z ${actual.z} !== ${expected.z}`);
577
+ }
578
+
579
+ function normalizeVector(vector) {
580
+ const length = Math.hypot(vector.x, vector.y, vector.z);
581
+ return {
582
+ x: vector.x / length,
583
+ y: vector.y / length,
584
+ z: vector.z / length,
585
+ };
586
+ }
@@ -6,6 +6,7 @@ import * as THREE from 'three';
6
6
  import {
7
7
  applySkykitXrDepthRange,
8
8
  computeSkykitXrDepthRange,
9
+ createSkykitXrBodyPlugin,
9
10
  createSkykitXrBodyTracker,
10
11
  createSkykitXrControlBindings,
11
12
  createSkykitXrNavigationPlugin,
@@ -28,6 +29,7 @@ test('xr free-roam demo uses restored alpha XR regressions defaults', () => {
28
29
  assert.match(source, /createDefaultThreeStarFieldMaterialProfile/);
29
30
  assert.doesNotMatch(source, /createVrThreeStarFieldMaterialProfile/);
30
31
  assert.match(source, /createSkykitXrRayVisualPlugin/);
32
+ assert.match(source, /createSkykitXrBodyPlugin/);
31
33
  assert.match(source, /createSurfaceShell/);
32
34
  assert.match(source, /createMetaSidecarProviderService/);
33
35
  assert.match(source, /deriveMetaSidecarUrlFromRenderUrl/);
@@ -39,7 +41,12 @@ test('xr free-roam demo uses restored alpha XR regressions defaults', () => {
39
41
  assert.match(source, /primaryActionLabel:\s*'Fly to'/);
40
42
  assert.match(source, /homeControl:\s*'button'/);
41
43
  assert.match(source, /pointerType:\s*'ray'/);
44
+ assert.match(source, /return xrRig\.leftHandRoot/);
45
+ assert.match(source, /latestPanelFrame = rootContext\?\.frame \?\? latestPanelFrame/);
42
46
  assert.doesNotMatch(source, /dragThreshold/);
47
+ assert.doesNotMatch(source, /driver:\s*'pose-anchored'/);
48
+ assert.doesNotMatch(source, /anchorPose/);
49
+ assert.doesNotMatch(source, /latestPanelFrame = frame/);
43
50
  assert.doesNotMatch(source, /createChoiceGroup/);
44
51
  assert.doesNotMatch(source, /createSlider/);
45
52
  assert.doesNotMatch(source, /createToggle/);
@@ -112,6 +119,55 @@ test('skykit/xr body, rays, and pick router compose generic route results', () =
112
119
  assert.equal(route.hit.object, 'target');
113
120
  });
114
121
 
122
+ test('skykit/xr body plugin drives tracked hand roots before dependent parts', () => {
123
+ const rig = createSkykitXrRig();
124
+ const leftGripSpace = {};
125
+ const referenceSpace = {};
126
+ const bodyUpdates = [];
127
+ let part = null;
128
+ const plugin = createSkykitXrBodyPlugin({
129
+ rig,
130
+ onBody(body) {
131
+ bodyUpdates.push(body);
132
+ },
133
+ });
134
+ plugin.setup(createPluginContext({
135
+ addPart(nextPart) {
136
+ part = nextPart;
137
+ },
138
+ }));
139
+
140
+ part.update(createXrFrame({
141
+ referenceSpace,
142
+ inputSources: [{
143
+ handedness: 'left',
144
+ gripSpace: leftGripSpace,
145
+ gamepad: {
146
+ buttons: [{ pressed: false, touched: false, value: 0 }],
147
+ axes: [0, 0],
148
+ },
149
+ }],
150
+ xrFrame: {
151
+ getPose(space, ref) {
152
+ assert.equal(ref, referenceSpace);
153
+ if (space !== leftGripSpace) return null;
154
+ return {
155
+ transform: {
156
+ position: { x: 0.1, y: 0.2, z: 0.3 },
157
+ orientation: { x: 0, y: 0, z: 0, w: 1 },
158
+ },
159
+ };
160
+ },
161
+ },
162
+ }));
163
+
164
+ assert.deepEqual(rig.leftHandRoot.position.toArray(), [0.1, 0.2, 0.3]);
165
+ assert.equal(rig.leftHandRoot.visible, true);
166
+ assert.equal(rig.rightHandRoot.visible, false);
167
+ assert.equal(plugin.getBody().leftHand?.buttons, 1);
168
+ assert.equal(bodyUpdates.length, 1);
169
+ });
170
+
115
171
  test('skykit/xr depth helpers compute and apply render state', () => {
116
172
  const range = computeSkykitXrDepthRange({
117
173
  visibleBounds: { min: { x: -1, y: -1, z: -20 }, max: { x: 1, y: 1, z: -10 } },