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

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 +23 -7
  7. package/src/__tests__/skykit-anchored-images.test.js +32 -4
  8. package/src/__tests__/skykit-browser.test.js +309 -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
@@ -8,6 +8,7 @@ import {
8
8
  createSkyGrabPlugin,
9
9
  createSkykitAnimationLoop,
10
10
  createSkykitDebugBridge,
11
+ createSkykitHrDiagramPlugin,
11
12
  createSkykitNavigationPlugin,
12
13
  createSkykitStarSourcePlugin,
13
14
  createSkykitViewer,
@@ -15,68 +16,87 @@ import {
15
16
  createViewAnchoredImageController,
16
17
  installSkykitDebugGlobal,
17
18
  } from '@found-in-space/skykit';
18
- import { createTouchOsPanelPlugin } from '@found-in-space/skykit/touch-os';
19
+ import {
20
+ createSkykitSurfaceApp,
21
+ createSkykitTabletRoot,
22
+ createTouchOsPanelPlugin,
23
+ } from '@found-in-space/skykit/touch-os';
19
24
  import {
20
25
  applySkykitXrDepthRange,
21
26
  computeSkykitXrDepthRange,
27
+ createSkykitXrBodyPlugin,
22
28
  createSkykitXrControlBindings,
23
29
  createSkykitXrNavigationPlugin,
24
30
  createSkykitXrObserverRig,
25
31
  createSkykitXrRaySource,
32
+ createSkykitXrRayVisualPlugin,
26
33
  createSkykitXrRig,
27
34
  createSkykitXrSessionPlugin,
28
35
  createSkykitXrStarPickingPlugin,
29
36
  } from '@found-in-space/skykit/xr';
30
37
  import {
38
+ createActionCard,
31
39
  createButton,
32
- createChoiceGroup,
33
- createColumn,
34
- createSlider,
35
- createTextLabel,
36
- createToggle,
37
- createValueReadout,
40
+ createSurfaceShell,
41
+ defineControlsApp,
42
+ defineTouchApp,
38
43
  } from '@found-in-space/touch-os';
39
44
  import { createXrRayPointerSource } from '@found-in-space/touch-os/hosts/three';
40
45
  import {
41
46
  OCTREE_DEFAULT,
42
47
  createStarOctreeProviderService,
43
48
  } from '@found-in-space/star-octree-provider';
49
+ import {
50
+ createMetaSidecarProviderService,
51
+ deriveMetaSidecarUrlFromRenderUrl,
52
+ metaSidecarEntryDisplayFields,
53
+ } from '@found-in-space/meta-sidecar-provider';
44
54
  import {
45
55
  createThreeStarField,
46
- createVrThreeStarFieldMaterialProfile,
56
+ createDefaultThreeStarFieldMaterialProfile,
47
57
  } from '@found-in-space/three-star-field';
48
58
 
49
59
  const WESTERN_SKYCULTURE_MANIFEST_URL = 'https://unpkg.com/@found-in-space/stellarium-skycultures-western@0.1.0/dist/manifest.json';
50
- const PROXIMA_CENTAURI_PC = { x: -0.47, y: -0.36, z: -1.16 };
51
- const SIRIUS_PC = { x: -0.49, y: 2.48, z: -0.76 };
52
- const BETELGEUSE_PC = { x: 4.2, y: 198.3, z: 25.8 };
53
- const DEFAULT_WORLD_SCALE = 0.001;
60
+ const DATASET_ID_c56103 = 'c56103e6-ad4c-41f9-be06-048b48ec632b';
61
+ const SOL_PC = { x: 0, y: 0, z: 0 };
62
+ const ORION_CENTER_PC = { x: 62.775, y: 602.667, z: -12.713 };
63
+ const DEFAULT_WORLD_SCALE = 1;
54
64
  const DEFAULT_LIMITING_MAGNITUDE = 7.5;
55
65
  const DEFAULT_EXPOSURE_LOG10 = 5;
56
66
  const DEFAULT_EXPOSURE = 10 ** DEFAULT_EXPOSURE_LOG10;
57
67
  const XR_CONSTELLATION_RADIUS_PC = 8;
68
+ const XR_PANEL_SURFACE = Object.freeze({ width: 420, height: 560, pixelDensity: 1 });
69
+ const XR_PANEL_THEME = Object.freeze({
70
+ backgroundColor: '#07111e',
71
+ surfaceColor: '#101b2a',
72
+ textColor: '#eef8ff',
73
+ mutedTextColor: '#8aa7b4',
74
+ accentColor: '#35d6c8',
75
+ accentTextColor: '#031416',
76
+ borderColor: 'rgba(120, 210, 220, 0.28)',
77
+ focusColor: '#79ffe8',
78
+ overlayColor: 'rgba(4, 12, 23, 0.65)',
79
+ controlHeight: 28,
80
+ spacing: 5,
81
+ padding: 7,
82
+ radius: 6,
83
+ typography: {
84
+ fontFamily: 'ui-sans-serif',
85
+ fontSize: 11,
86
+ lineHeight: 14,
87
+ fontWeight: 500,
88
+ },
89
+ });
58
90
  const XR_DEMO_ACTIONS = Object.freeze({
59
91
  goSelected: 'xr-demo:selected.go',
60
- pages: Object.freeze({
61
- home: 'xr-demo:page.home',
62
- waypoints: 'xr-demo:page.waypoints',
63
- selected: 'xr-demo:page.selected',
64
- rendering: 'xr-demo:page.rendering',
65
- }),
66
- waypointPrefix: 'xr-demo:waypoint.',
92
+ selectSun: 'xr-demo:selected.sun',
67
93
  });
68
- const PAGE_OPTIONS = Object.freeze([
69
- { value: 'home', label: 'Home' },
70
- { value: 'waypoints', label: 'Waypoints' },
71
- { value: 'selected', label: 'Target' },
72
- { value: 'rendering', label: 'Rendering' },
73
- ]);
74
- const WAYPOINTS = Object.freeze([
75
- { id: 'sol', label: 'Sol', targetPc: { x: 0, y: 0, z: 0 }, approachPc: 2 },
76
- { id: 'proxima', label: 'Proxima', targetPc: PROXIMA_CENTAURI_PC, approachPc: 1.6 },
77
- { id: 'sirius', label: 'Sirius', targetPc: SIRIUS_PC, approachPc: 1.6 },
78
- { id: 'betelgeuse', label: 'Betelgeuse', targetPc: BETELGEUSE_PC, approachPc: 8 },
79
- ]);
94
+ const XR_TABLET_APP_IDS = Object.freeze({
95
+ target: 'space.found.skykit.xr-free-roam.target',
96
+ rendering: 'space.found.skykit.xr-free-roam.rendering',
97
+ hrDiagram: 'space.found.skykit.xr-free-roam.hr-diagram',
98
+ });
99
+ const HR_SURFACE_SIZE = Object.freeze({ width: 1024, height: 640 });
80
100
 
81
101
  const debug = createSkykitDebugBridge();
82
102
  installSkykitDebugGlobal(debug);
@@ -101,30 +121,44 @@ async function main() {
101
121
  renderer.xr.enabled = true;
102
122
 
103
123
  const camera = new THREE.PerspectiveCamera(60, 1, 0.02, 2000000);
104
- const xrRig = createSkykitXrRig({ scaleBandIds: ['constellation-art'] });
105
- xrRig.deckRoot.add(createShipDeckSlab());
124
+ const initialOrientation = orientationLookingAt(SOL_PC, ORION_CENTER_PC);
125
+ const xrRig = createSkykitXrRig({
126
+ navigationPose: {
127
+ position: SOL_PC,
128
+ orientation: initialOrientation,
129
+ },
130
+ });
131
+ const shipDeck = createShipDeckSlab();
132
+ shipDeck.visible = false;
133
+ xrRig.deckRoot.add(shipDeck);
106
134
 
107
- const provider = createStarOctreeProviderService({ url: OCTREE_DEFAULT });
135
+ const provider = createStarOctreeProviderService({
136
+ url: OCTREE_DEFAULT,
137
+ datasetId: DATASET_ID_c56103,
138
+ });
139
+ const metaProvider = createMetaSidecarProviderService({
140
+ url: deriveMetaSidecarUrlFromRenderUrl(OCTREE_DEFAULT),
141
+ parentDatasetId: DATASET_ID_c56103,
142
+ });
108
143
  const source = createSkykitStarSourcePlugin({ provider });
109
144
  const starField = createThreeStarField({
110
145
  limitingMagnitude: DEFAULT_LIMITING_MAGNITUDE,
111
146
  coordinateUnitsPerParsec: DEFAULT_WORLD_SCALE,
112
147
  exposure: DEFAULT_EXPOSURE,
113
- materialProfile: createVrThreeStarFieldMaterialProfile({
148
+ materialProfile: createDefaultThreeStarFieldMaterialProfile({
114
149
  limitingMagnitude: DEFAULT_LIMITING_MAGNITUDE,
115
150
  coordinateUnitsPerParsec: DEFAULT_WORLD_SCALE,
116
151
  exposure: DEFAULT_EXPOSURE,
117
152
  }),
118
153
  });
119
154
  const selectedTarget = createSelectedStarTarget();
120
- starField.object3d.add(selectedTarget.object3d);
155
+ xrRig.originContentRoot.add(selectedTarget.object3d);
121
156
  const rightRaySource = createWorldXrRaySource(
122
157
  createSkykitXrRaySource({ kind: 'target-ray', handedness: 'right', length: 2000000 }),
123
158
  xrRig.xrOrigin,
124
159
  );
125
160
  const touchPointerSource = createRightHandTouchPointerSource(rightRaySource);
126
161
  const panelState = {
127
- page: 'home',
128
162
  limitingMagnitude: DEFAULT_LIMITING_MAGNITUDE,
129
163
  exposureLog10: DEFAULT_EXPOSURE_LOG10,
130
164
  worldScaleLog10: Math.log10(DEFAULT_WORLD_SCALE),
@@ -136,8 +170,31 @@ async function main() {
136
170
  let cachedPanelRevision = -1;
137
171
  let cachedPanelRoot = null;
138
172
  let latestPanelFrame = null;
173
+ let leftHandPanelTracked = false;
139
174
  let activeXrHandle = null;
140
175
  let artController = null;
176
+ let preflightController = null;
177
+ let touchPanel = null;
178
+ let selectionGeneration = 0;
179
+ const hrDiagram = createSkykitHrDiagramPlugin({
180
+ id: 'xr-free-roam-hr-diagram',
181
+ source,
182
+ mode: 'frustum',
183
+ limitingMagnitude: DEFAULT_LIMITING_MAGNITUDE,
184
+ width: HR_SURFACE_SIZE.width,
185
+ height: HR_SURFACE_SIZE.height,
186
+ touchOs: {
187
+ sourceId: 'xr-free-roam-hr-diagram:surface',
188
+ componentId: 'xr-free-roam-hr-diagram:node',
189
+ surfaces: () => touchPanel?.getRuntime()?.getServices().surfaces,
190
+ width: HR_SURFACE_SIZE.width,
191
+ height: HR_SURFACE_SIZE.height,
192
+ },
193
+ });
194
+ const tabletApps = createXrTabletApps({
195
+ hrDiagram,
196
+ renderTargetReadout: createSelectedTargetReadout,
197
+ });
141
198
 
142
199
  const artPlugin = await createConstellationArtPlugin().catch((error) => {
143
200
  debug.recordDiagnostic({
@@ -149,28 +206,34 @@ async function main() {
149
206
  return null;
150
207
  });
151
208
 
152
- const touchPanel = createTouchOsPanelPlugin({
209
+ touchPanel = createTouchOsPanelPlugin({
153
210
  id: 'xr-free-roam-touch-panel',
154
211
  priority: 20,
155
- driver: 'pose-anchored',
212
+ driver: 'scene',
156
213
  root: createPanelRoot,
157
- surfaceMetrics: { width: 392, height: 430, pixelDensity: 1 },
214
+ surfaceMetrics: XR_PANEL_SURFACE,
215
+ runtimeOptions: {
216
+ theme: XR_PANEL_THEME,
217
+ longPressDelay: 360,
218
+ },
158
219
  pointerSources: [touchPointerSource],
159
- anchorPose(frame) {
160
- latestPanelFrame = frame;
161
- return resolveLeftHandPanelPose(frame, xrRig.xrOrigin);
220
+ parent() {
221
+ return xrRig.leftHandRoot;
162
222
  },
163
223
  driverOptions: {
164
- panelWidth: 0.34,
165
- panelHeight: 0.42,
166
- offset: { x: 0.04, y: 0.02, z: -0.08 },
167
- tiltRadians: -0.22,
224
+ panelWidth: 0.32,
225
+ panelHeight: 0.44,
168
226
  transparent: true,
169
227
  depthTest: false,
170
228
  renderOrder: 50,
171
- },
172
- onOutput(output) {
173
- handlePanelOutput(output);
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
+ },
174
237
  },
175
238
  });
176
239
 
@@ -191,9 +254,11 @@ async function main() {
191
254
  scaleBandedContentRoots: new Map(Object.entries(xrRig.scaleBandedContentRoots)),
192
255
  },
193
256
  view: {
257
+ observerPc: SOL_PC,
258
+ targetPc: ORION_CENTER_PC,
194
259
  limitingMagnitude: DEFAULT_LIMITING_MAGNITUDE,
195
260
  coordinateUnitsPerParsec: DEFAULT_WORLD_SCALE,
196
- orientationIcrs: { x: 0, y: 0, z: 0, w: 1 },
261
+ lookAt: { orientationIcrs: initialOrientation },
197
262
  },
198
263
  plugins: [
199
264
  createSkykitXrSessionPlugin({
@@ -201,6 +266,9 @@ async function main() {
201
266
  referenceSpaceType: 'local-floor',
202
267
  onSessionStarted(handle) {
203
268
  activeXrHandle = handle;
269
+ shipDeck.visible = true;
270
+ preflightController?.setSessionStatus('XR session active');
271
+ preflightController?.sync();
204
272
  invalidatePanel();
205
273
  updateXrDepthRange(handle);
206
274
  },
@@ -208,24 +276,41 @@ async function main() {
208
276
  createSkykitNavigationPlugin(),
209
277
  source,
210
278
  createStreamingStarsPlugin({ id: 'xr-stars', source, renderer: starField }),
279
+ hrDiagram,
211
280
  ...(artPlugin ? [artPlugin] : []),
281
+ createSkykitXrBodyPlugin({
282
+ rig: xrRig,
283
+ onBody(body) {
284
+ leftHandPanelTracked = Boolean(body.leftHand?.grip ?? body.leftHand?.targetRay);
285
+ },
286
+ }),
287
+ createXrFreeRoamFrameSyncPlugin({
288
+ update() {
289
+ selectedTarget.update(camera);
290
+ updateXrDepthRange(activeXrHandle);
291
+ },
292
+ }),
212
293
  createKeyboardNavigationPlugin({ speedPcPerSec: 2, rotationSpeedDegPerSec: 55 }),
213
294
  createSkyGrabPlugin({ target: host, sensitivityRadiansPerPixel: 0.0007 }),
214
295
  touchPanel,
215
296
  createSkykitXrNavigationPlugin({ moveSpeedPcPerSec: 4 }),
297
+ createSkykitXrRayVisualPlugin({
298
+ id: 'xr-free-roam-right-ray',
299
+ raySource: rightRaySource,
300
+ blockers: [touchPanel],
301
+ color: 0x78ffe7,
302
+ opacity: 0.82,
303
+ length: 2000000,
304
+ renderOrder: 60,
305
+ }),
216
306
  createSkykitXrStarPickingPlugin({
217
307
  renderer: starField,
218
308
  source,
219
309
  raySource: rightRaySource,
220
310
  blockers: [touchPanel],
311
+ attributes: ['objectRef', 'pickMeta'],
221
312
  onPick(event) {
222
- panelState.selected = {
223
- label: event.label,
224
- position: { ...event.pick.position },
225
- targetPc: renderPositionToPc(event.pick.position, viewer.getViewState().coordinateUnitsPerParsec),
226
- };
227
- selectedTarget.setPosition(event.pick.position);
228
- invalidatePanel();
313
+ selectPickedStar(event);
229
314
  },
230
315
  }),
231
316
  ],
@@ -238,12 +323,26 @@ async function main() {
238
323
  });
239
324
  viewer.on('xr/session-end', () => {
240
325
  activeXrHandle = null;
326
+ shipDeck.visible = false;
327
+ preflightController?.setSessionStatus('Session ended');
328
+ preflightController?.sync();
241
329
  invalidatePanel();
242
330
  });
243
331
 
244
332
  registerDemoActions(viewer);
245
- wireDomActions(viewer);
246
333
  applyRenderState(viewer, starField, source);
334
+ preflightController = createPreflightController({
335
+ viewer,
336
+ panelState,
337
+ starField,
338
+ source,
339
+ applyRenderState,
340
+ invalidatePanel,
341
+ setConstellationArtEnabled,
342
+ isPresenting: () => activeXrHandle?.presenting === true,
343
+ });
344
+ preflightController.sync();
345
+ void preflightController.refreshXrSupport();
247
346
  resize();
248
347
  window.addEventListener('resize', resize);
249
348
  window.addEventListener('beforeunload', () => {
@@ -251,6 +350,7 @@ async function main() {
251
350
  selectedTarget.dispose();
252
351
  void viewer.dispose();
253
352
  void provider.dispose?.();
353
+ void metaProvider.dispose?.();
254
354
  });
255
355
  loop.start();
256
356
 
@@ -266,10 +366,15 @@ async function main() {
266
366
 
267
367
  async function createConstellationArtPlugin() {
268
368
  const catalog = await createAnchoredImageCatalog({ manifestUrl: WESTERN_SKYCULTURE_MANIFEST_URL });
369
+ debug.recordDiagnostic({
370
+ level: 'info',
371
+ type: 'xr-free-roam/constellation-art-catalog',
372
+ message: `Constellation art catalog loaded with ${catalog.list().length} entries.`,
373
+ });
269
374
  artController = createViewAnchoredImageController({
270
- strategy: 'nearest',
271
- maxAngleDeg: 32,
272
- hysteresisSeconds: 0.25,
375
+ strategy: 'within-angle',
376
+ maxAngleDeg: 34,
377
+ hysteresisSeconds: 0,
273
378
  });
274
379
  return createAnchoredImageSkyPlugin({
275
380
  id: 'xr-constellation-art',
@@ -281,156 +386,203 @@ async function main() {
281
386
  opacity: 0.38,
282
387
  fadeInSeconds: 0.25,
283
388
  fadeOutSeconds: 0.25,
284
- scaleBandId: 'constellation-art',
285
- skipTextureErrors: true,
389
+ skipTextureErrors: false,
390
+ onTextureError(event) {
391
+ debug.recordDiagnostic({
392
+ level: 'warn',
393
+ type: 'xr-free-roam/constellation-art-texture-error',
394
+ message: `Constellation art texture failed for ${event.entry.label}.`,
395
+ data: {
396
+ key: event.entry.key,
397
+ imageUrl: event.imageUrl,
398
+ },
399
+ error: event.error,
400
+ });
401
+ },
286
402
  });
287
403
  }
288
404
 
289
- function createPanelRoot() {
405
+ function createPanelRoot(rootContext) {
406
+ latestPanelFrame = rootContext?.frame ?? latestPanelFrame;
290
407
  if (cachedPanelRoot && cachedPanelRevision === panelRevision) return cachedPanelRoot;
291
408
  cachedPanelRevision = panelRevision;
292
- cachedPanelRoot = createColumn('xr-free-roam-panel', {
293
- gap: 8,
294
- padding: 10,
295
- backgroundColor: 'rgba(4, 12, 23, 0.82)',
296
- children: [
297
- createChoiceGroup('xr-panel-page', {
298
- field: 'xrPage',
299
- selectionMode: 'single',
300
- value: panelState.page,
301
- orientation: 'horizontal',
302
- options: PAGE_OPTIONS,
303
- }),
304
- ...createPanelPageChildren(),
305
- ],
409
+ cachedPanelRoot = createSkykitTabletRoot({
410
+ id: 'xr-free-roam-tablet',
411
+ apps: tabletApps,
412
+ appStates: {
413
+ [XR_TABLET_APP_IDS.target]: panelState,
414
+ [XR_TABLET_APP_IDS.rendering]: panelState,
415
+ [XR_TABLET_APP_IDS.hrDiagram]: panelState,
416
+ },
417
+ homeControl: 'button',
418
+ taskSwitcher: 'cards',
419
+ taskCloseControl: 'button',
420
+ launcherLayout: {
421
+ tileWidth: 82,
422
+ tileHeight: 86,
423
+ gap: 9,
424
+ bodyPadding: 9,
425
+ iconMinSize: 36,
426
+ iconMaxSize: 44,
427
+ labelGap: 5,
428
+ },
429
+ onAppEvent: handleTabletAppEvent,
306
430
  });
307
431
  return cachedPanelRoot;
308
432
  }
309
433
 
310
- function createPanelPageChildren() {
311
- if (panelState.page === 'waypoints') {
312
- return WAYPOINTS.map((waypoint) => createButton(`xr-waypoint-${waypoint.id}`, {
313
- label: waypoint.label,
314
- actionId: `${XR_DEMO_ACTIONS.waypointPrefix}${waypoint.id}`,
315
- }));
316
- }
317
- if (panelState.page === 'rendering') {
434
+ function createSelectedTargetReadout(state = panelState) {
435
+ const selected = state.selected;
436
+ if (!selected) {
318
437
  return [
319
- createSlider('xr-mag-limit', {
320
- label: 'Magnitude',
321
- field: 'limitingMagnitude',
322
- value: panelState.limitingMagnitude,
323
- min: 4,
324
- max: 10,
325
- step: 0.1,
326
- valueText: panelState.limitingMagnitude.toFixed(1),
327
- }),
328
- createSlider('xr-exposure', {
329
- label: 'Exposure',
330
- field: 'exposureLog10',
331
- value: panelState.exposureLog10,
332
- min: 3.5,
333
- max: 5.5,
334
- step: 0.05,
335
- valueText: `${Math.round(10 ** panelState.exposureLog10).toLocaleString()}`,
336
- }),
337
- createSlider('xr-world-scale', {
338
- label: 'World scale',
339
- field: 'worldScaleLog10',
340
- value: panelState.worldScaleLog10,
341
- min: -3,
342
- max: 0,
343
- step: 0.05,
344
- valueText: `${(10 ** panelState.worldScaleLog10).toPrecision(2)} u/pc`,
345
- }),
346
- createToggle('xr-near-floor', {
347
- label: 'Near floor',
348
- field: 'nearFloor',
349
- value: panelState.nearFloor,
350
- }),
351
- createToggle('xr-constellation-art', {
352
- label: 'Constellation art',
353
- field: 'constellationArt',
354
- value: panelState.constellationArt,
355
- }),
356
- ];
357
- }
358
- if (panelState.page === 'selected') {
359
- return [
360
- createValueReadout('xr-selected-name', {
361
- label: 'Target',
362
- value: panelState.selected?.label ?? 'None',
363
- }),
364
- createButton('xr-go-selected', {
365
- label: 'Go to selected',
366
- actionId: XR_DEMO_ACTIONS.goSelected,
367
- disabled: !panelState.selected,
438
+ createActionCard('xr-selected-empty', {
439
+ title: 'Target',
440
+ emptyStateText: 'Pick a star or select Sun',
368
441
  }),
369
442
  ];
370
443
  }
371
444
  return [
372
- createValueReadout('xr-home-target', {
373
- label: 'Target',
374
- value: panelState.selected?.label ?? 'None',
375
- }),
376
- createButton('xr-home-waypoints', {
377
- label: 'Waypoints',
378
- actionId: XR_DEMO_ACTIONS.pages.waypoints,
379
- }),
380
- createButton('xr-home-selected', {
381
- label: 'Selected Target',
382
- actionId: XR_DEMO_ACTIONS.pages.selected,
383
- disabled: !panelState.selected,
384
- }),
385
- createButton('xr-home-rendering', {
386
- label: 'Rendering',
387
- actionId: XR_DEMO_ACTIONS.pages.rendering,
388
- }),
389
- createTextLabel('xr-home-mode', {
390
- text: activeXrHandle?.presenting ? 'XR active' : 'Desktop',
391
- tone: 'muted',
445
+ createActionCard('xr-selected-details', {
446
+ title: selected.label || 'Selected star',
447
+ lines: createSelectedIdentifierLines(selected),
448
+ primaryActionId: XR_DEMO_ACTIONS.goSelected,
449
+ primaryActionLabel: 'Fly to',
392
450
  }),
393
451
  ];
394
452
  }
395
453
 
396
- function handlePanelOutput(output) {
397
- if (!output || output.type !== 'change-request') return;
398
- if (output.field === 'xrPage' && typeof output.value === 'string') {
399
- panelState.page = output.value;
400
- invalidatePanel();
454
+ function handleTabletAppEvent(event) {
455
+ if (event.type === 'app-change') {
456
+ if (applyTabletStateChange(event.payload)) {
457
+ applyRenderState(viewer, starField, source);
458
+ preflightController?.sync();
459
+ invalidatePanel();
460
+ }
401
461
  return;
402
462
  }
403
- if (output.field === 'limitingMagnitude') {
404
- panelState.limitingMagnitude = clampNumber(output.value, 4, 10, panelState.limitingMagnitude);
405
- applyRenderState(viewer, starField, source);
406
- invalidatePanel();
407
- return;
463
+
464
+ if (event.type !== 'app-action') return;
465
+ if (event.name === XR_DEMO_ACTIONS.goSelected) {
466
+ if (panelState.selected) {
467
+ goToTarget(viewer, panelState.selected.targetPc, 0.65);
468
+ }
469
+ } else if (event.name === XR_DEMO_ACTIONS.selectSun) {
470
+ selectSunTarget();
408
471
  }
409
- if (output.field === 'exposureLog10') {
410
- panelState.exposureLog10 = clampNumber(output.value, 3.5, 5.5, panelState.exposureLog10);
411
- applyRenderState(viewer, starField, source);
412
- invalidatePanel();
413
- return;
472
+ }
473
+
474
+ function applyTabletStateChange(payload) {
475
+ const field = payload?.field;
476
+ const value = payload?.value;
477
+ if (field === 'limitingMagnitude') {
478
+ panelState.limitingMagnitude = clampNumber(value, 4, 10, panelState.limitingMagnitude);
479
+ return true;
414
480
  }
415
- if (output.field === 'worldScaleLog10') {
416
- panelState.worldScaleLog10 = clampNumber(output.value, -3, 0, panelState.worldScaleLog10);
417
- applyRenderState(viewer, starField, source);
418
- invalidatePanel();
419
- return;
481
+ if (field === 'exposureLog10') {
482
+ panelState.exposureLog10 = clampNumber(value, 3.5, 5.5, panelState.exposureLog10);
483
+ return true;
484
+ }
485
+ if (field === 'worldScaleLog10') {
486
+ panelState.worldScaleLog10 = clampNumber(value, -3, 0, panelState.worldScaleLog10);
487
+ return true;
420
488
  }
421
- if (output.field === 'nearFloor') {
422
- panelState.nearFloor = output.value === true;
423
- applyRenderState(viewer, starField, source);
424
- invalidatePanel();
489
+ if (field === 'nearFloor') {
490
+ panelState.nearFloor = Boolean(value);
491
+ return true;
492
+ }
493
+ if (field === 'constellationArt') {
494
+ setConstellationArtEnabled(Boolean(value));
495
+ return true;
496
+ }
497
+ return false;
498
+ }
499
+
500
+ function setConstellationArtEnabled(enabled) {
501
+ panelState.constellationArt = enabled;
502
+ artController?.setSelection?.(enabled ? undefined : () => false);
503
+ }
504
+
505
+ function selectPickedStar(event) {
506
+ const targetPc = renderPositionToPc(event.pick.position, viewer.getViewState().coordinateUnitsPerParsec);
507
+ const generation = selectTarget({
508
+ label: 'Selected star',
509
+ position: { ...event.pick.position },
510
+ targetPc,
511
+ identifiers: createEmptyIdentifierFields(),
512
+ identifierStatus: 'loading',
513
+ }, event.pick.position);
514
+ void resolveSelectedStarIdentifiers(event.pick, generation);
515
+ }
516
+
517
+ function selectSunTarget() {
518
+ const generation = selectTarget({
519
+ label: 'Sun',
520
+ position: {
521
+ x: SOL_PC.x * viewer.getViewState().coordinateUnitsPerParsec,
522
+ y: SOL_PC.y * viewer.getViewState().coordinateUnitsPerParsec,
523
+ z: SOL_PC.z * viewer.getViewState().coordinateUnitsPerParsec,
524
+ },
525
+ targetPc: { ...SOL_PC },
526
+ identifiers: {
527
+ ...createEmptyIdentifierFields(),
528
+ properName: 'Sol',
529
+ primaryLabel: 'Sun',
530
+ },
531
+ identifierStatus: 'synthetic',
532
+ }, SOL_PC);
533
+ selectionGeneration = generation;
534
+ }
535
+
536
+ function selectTarget(selection, renderPosition) {
537
+ selectionGeneration += 1;
538
+ panelState.selected = selection;
539
+ selectedTarget.setPosition(renderPosition);
540
+ invalidatePanel();
541
+ return selectionGeneration;
542
+ }
543
+
544
+ async function resolveSelectedStarIdentifiers(pick, generation) {
545
+ const ref = pick.objectRef ?? pick.pickMeta ?? null;
546
+ if (!ref) {
547
+ debug.recordDiagnostic({
548
+ level: 'warn',
549
+ type: 'xr-free-roam/star-pick-missing-sidecar-ref',
550
+ message: 'Selected star did not include sidecar lookup metadata.',
551
+ });
552
+ updateSelectedIdentifiers(generation, null, 'unavailable');
425
553
  return;
426
554
  }
427
- if (output.field === 'constellationArt') {
428
- panelState.constellationArt = output.value === true;
429
- artController?.setSelection?.(panelState.constellationArt ? undefined : () => false);
430
- invalidatePanel();
555
+
556
+ try {
557
+ const entry = await metaProvider.getMeta(ref);
558
+ updateSelectedIdentifiers(
559
+ generation,
560
+ metaSidecarEntryDisplayFields(entry),
561
+ entry ? 'ready' : 'unavailable',
562
+ );
563
+ } catch (error) {
564
+ debug.recordDiagnostic({
565
+ level: 'warn',
566
+ type: 'xr-free-roam/star-sidecar-lookup-error',
567
+ message: 'Selected star identifiers could not be loaded from the metadata sidecar.',
568
+ error,
569
+ });
570
+ updateSelectedIdentifiers(generation, null, 'error');
431
571
  }
432
572
  }
433
573
 
574
+ function updateSelectedIdentifiers(generation, fields, status) {
575
+ if (generation !== selectionGeneration || !panelState.selected) return;
576
+ const identifiers = fields ?? createEmptyIdentifierFields();
577
+ panelState.selected = {
578
+ ...panelState.selected,
579
+ label: identifiers.primaryLabel || 'Selected star',
580
+ identifiers,
581
+ identifierStatus: status,
582
+ };
583
+ invalidatePanel();
584
+ }
585
+
434
586
  function applyRenderState(activeViewer, activeStarField, activeSource) {
435
587
  const worldScale = 10 ** panelState.worldScaleLog10;
436
588
  const exposure = 10 ** panelState.exposureLog10;
@@ -452,25 +604,14 @@ async function main() {
452
604
  }
453
605
 
454
606
  function registerDemoActions(activeViewer) {
455
- activeViewer.actions.registerAction(XR_DEMO_ACTIONS.pages.home, () => setPanelPage('home'), { label: 'Panel home' });
456
- activeViewer.actions.registerAction(XR_DEMO_ACTIONS.pages.waypoints, () => setPanelPage('waypoints'), { label: 'Panel waypoints' });
457
- activeViewer.actions.registerAction(XR_DEMO_ACTIONS.pages.selected, () => setPanelPage('selected'), { label: 'Panel selected target' });
458
- activeViewer.actions.registerAction(XR_DEMO_ACTIONS.pages.rendering, () => setPanelPage('rendering'), { label: 'Panel rendering' });
459
607
  activeViewer.actions.registerAction(XR_DEMO_ACTIONS.goSelected, () => {
460
608
  if (panelState.selected) {
461
609
  goToTarget(activeViewer, panelState.selected.targetPc, 0.65);
462
610
  }
463
611
  }, { label: 'Go to selected star' });
464
- for (const waypoint of WAYPOINTS) {
465
- activeViewer.actions.registerAction(`${XR_DEMO_ACTIONS.waypointPrefix}${waypoint.id}`, () => {
466
- goToTarget(activeViewer, waypoint.targetPc, waypoint.approachPc);
467
- }, { label: waypoint.label });
468
- }
469
- }
470
-
471
- function setPanelPage(page) {
472
- panelState.page = page;
473
- invalidatePanel();
612
+ activeViewer.actions.registerAction(XR_DEMO_ACTIONS.selectSun, () => {
613
+ selectSunTarget();
614
+ }, { label: 'Select Sun' });
474
615
  }
475
616
 
476
617
  function goToTarget(activeViewer, targetPc, approachPc) {
@@ -480,7 +621,7 @@ async function main() {
480
621
  view: {
481
622
  observerPc,
482
623
  targetPc,
483
- orientationIcrs: orientationLookingAt(observerPc, targetPc),
624
+ lookAt: { orientationIcrs: orientationLookingAt(observerPc, targetPc) },
484
625
  },
485
626
  durationSecs: 2.4,
486
627
  movement: 'smoothstep',
@@ -491,9 +632,13 @@ async function main() {
491
632
  function updateXrDepthRange(handle) {
492
633
  if (!handle) return;
493
634
  const worldScale = 10 ** panelState.worldScaleLog10;
635
+ const view = viewer.getViewState();
636
+ const visibleBounds = starField.getVisibleBounds({ units: 'parsec' });
494
637
  const range = computeSkykitXrDepthRange({
638
+ observer: view.observerPc,
639
+ visibleBounds,
495
640
  observerCentricSpheres: [
496
- { radiusNavigationUnits: Math.max(500, XR_CONSTELLATION_RADIUS_PC * 2) },
641
+ { radiusNavigationUnits: XR_CONSTELLATION_RADIUS_PC * 2 },
497
642
  ],
498
643
  scale: {
499
644
  navigationUnits: 'pc',
@@ -524,15 +669,306 @@ async function main() {
524
669
  touchPointerSource.getLatestPanelFrame = getLatestPanelFrame;
525
670
  }
526
671
 
527
- function wireDomActions(viewer) {
528
- document.querySelector('[data-action="enter-xr"]')?.addEventListener('click', () => {
529
- void viewer.actions.invoke(SKYKIT_ACTIONS.xr.enter, null, { source: 'xr-free-roam-dom' });
672
+ function createXrFreeRoamFrameSyncPlugin(options) {
673
+ return {
674
+ id: 'xr-free-roam-frame-sync',
675
+ setup(context) {
676
+ context.addPart({
677
+ id: 'xr-free-roam-frame-sync',
678
+ priority: 80,
679
+ update(frame) {
680
+ options.update?.(frame);
681
+ },
682
+ });
683
+ },
684
+ };
685
+ }
686
+
687
+ function createXrTabletApps(options) {
688
+ return [
689
+ createTargetTabletApp(options),
690
+ createRenderingTabletApp(),
691
+ createSkykitSurfaceApp({
692
+ id: XR_TABLET_APP_IDS.hrDiagram,
693
+ name: 'HR',
694
+ icon: { kind: 'symbol', value: 'HR' },
695
+ node: () => options.hrDiagram.getNode(),
696
+ preferredWindow: {
697
+ width: 420,
698
+ height: 526,
699
+ minWidth: 360,
700
+ minHeight: 300,
701
+ resizable: false,
702
+ },
703
+ backgroundColor: '#050c16',
704
+ emptyLabel: 'HR diagram unavailable',
705
+ }),
706
+ ];
707
+ }
708
+
709
+ function createTargetTabletApp(options) {
710
+ return defineTouchApp({
711
+ manifest: {
712
+ id: XR_TABLET_APP_IDS.target,
713
+ name: 'Target',
714
+ version: '1.0.0',
715
+ icon: { kind: 'symbol', value: 'TG' },
716
+ preferredWindow: {
717
+ width: 420,
718
+ height: 526,
719
+ minWidth: 320,
720
+ minHeight: 260,
721
+ resizable: false,
722
+ },
723
+ },
724
+ createApp(ctx) {
725
+ return {
726
+ render(state) {
727
+ return createSurfaceShell('xr-target-app', {
728
+ pointerOpaque: true,
729
+ gap: 6,
730
+ padding: 6,
731
+ bodyGap: 5,
732
+ bodyPadding: 0,
733
+ scrollId: 'xr-target-app-scroll',
734
+ backgroundColor: 'rgba(4, 12, 23, 0.86)',
735
+ children: options.renderTargetReadout(state),
736
+ footer: createButton('xr-selected-sun', {
737
+ label: 'Sun',
738
+ actionId: XR_DEMO_ACTIONS.selectSun,
739
+ }),
740
+ });
741
+ },
742
+ handleOutput(output) {
743
+ emitTabletAppOutput(ctx, output);
744
+ },
745
+ };
746
+ },
530
747
  });
531
- document.querySelector('[data-action="exit-xr"]')?.addEventListener('click', () => {
532
- void viewer.actions.invoke(SKYKIT_ACTIONS.xr.exit, null, { source: 'xr-free-roam-dom' });
748
+ }
749
+
750
+ function createRenderingTabletApp() {
751
+ return defineControlsApp({
752
+ id: XR_TABLET_APP_IDS.rendering,
753
+ name: 'Render',
754
+ icon: { kind: 'symbol', value: 'RD' },
755
+ preferredSurface: {
756
+ width: 420,
757
+ height: 526,
758
+ minWidth: 320,
759
+ minHeight: 280,
760
+ resizable: false,
761
+ },
762
+ controls: ({ section, slider, status, toggle }) => [
763
+ section('Stars', [
764
+ slider('Limit', 'limitingMagnitude', {
765
+ min: 4,
766
+ max: 10,
767
+ step: 0.1,
768
+ valueText: (state) => `Mag ${state.limitingMagnitude.toFixed(1)}`,
769
+ }),
770
+ slider('Exposure', 'exposureLog10', {
771
+ min: 3.5,
772
+ max: 5.5,
773
+ step: 0.1,
774
+ valueText: (state) => Math.round(10 ** state.exposureLog10).toLocaleString(),
775
+ }),
776
+ slider('Scale', 'worldScaleLog10', {
777
+ min: -3,
778
+ max: 0,
779
+ step: 0.1,
780
+ valueText: (state) => formatWorldScale(10 ** state.worldScaleLog10),
781
+ }),
782
+ ]),
783
+ section('Context', [
784
+ toggle('Nearby glow', 'nearFloor'),
785
+ toggle('Constellation art', 'constellationArt'),
786
+ status('Target', (state) => state.selected?.label ?? 'None', { tone: 'muted' }),
787
+ ]),
788
+ ],
533
789
  });
534
790
  }
535
791
 
792
+ function emitTabletAppOutput(ctx, output) {
793
+ if (output?.type === 'action') {
794
+ ctx.actions.emit({
795
+ type: 'app-action',
796
+ appId: ctx.appId,
797
+ instanceId: ctx.instanceId,
798
+ windowId: ctx.windowId,
799
+ name: output.actionId,
800
+ ...(output.payload === undefined ? {} : { payload: output.payload }),
801
+ });
802
+ }
803
+ }
804
+
805
+ function createPreflightController(options) {
806
+ const shell = document.querySelector('.xr-free-roam-shell');
807
+ const enterButton = document.querySelector('[data-action="enter-xr"]');
808
+ const exitButton = document.querySelector('[data-action="exit-xr"]');
809
+ const supportValue = document.querySelector('[data-xr-supported]');
810
+ const sessionStatus = document.querySelector('[data-session-status]');
811
+ const settingInputs = Array.from(document.querySelectorAll('[data-setting]'))
812
+ .filter((input) => input instanceof HTMLInputElement);
813
+ let xrSupported = null;
814
+
815
+ for (const input of settingInputs) {
816
+ input.addEventListener('input', () => {
817
+ readSettingInput(input, options.panelState, options.setConstellationArtEnabled);
818
+ syncSettingOutputs(options.panelState);
819
+ });
820
+ input.addEventListener('change', () => {
821
+ readSettingInput(input, options.panelState, options.setConstellationArtEnabled);
822
+ options.applyRenderState(options.viewer, options.starField, options.source);
823
+ options.invalidatePanel();
824
+ sync();
825
+ });
826
+ }
827
+
828
+ enterButton?.addEventListener('click', async () => {
829
+ setSessionStatus('Opening XR session');
830
+ sync();
831
+ const results = await options.viewer.actions.invoke(SKYKIT_ACTIONS.xr.enter, null, {
832
+ source: 'xr-free-roam-dom',
833
+ });
834
+ const rejected = results.find((result) => result.status === 'rejected');
835
+ if (rejected) {
836
+ const reason = rejected.reason instanceof Error ? rejected.reason.message : String(rejected.reason);
837
+ setSessionStatus(reason || 'XR session failed');
838
+ } else {
839
+ setSessionStatus('XR session requested');
840
+ }
841
+ sync();
842
+ });
843
+
844
+ exitButton?.addEventListener('click', async () => {
845
+ setSessionStatus('Ending XR session');
846
+ await options.viewer.actions.invoke(SKYKIT_ACTIONS.xr.exit, null, {
847
+ source: 'xr-free-roam-dom',
848
+ });
849
+ sync();
850
+ });
851
+
852
+ return {
853
+ sync,
854
+ setSessionStatus,
855
+ refreshXrSupport,
856
+ };
857
+
858
+ function sync() {
859
+ const presenting = options.isPresenting();
860
+ shell?.classList.toggle('is-presenting', presenting);
861
+ if (enterButton instanceof HTMLButtonElement) {
862
+ enterButton.hidden = presenting;
863
+ enterButton.disabled = xrSupported === false || presenting;
864
+ }
865
+ if (exitButton instanceof HTMLButtonElement) {
866
+ exitButton.hidden = !presenting;
867
+ exitButton.disabled = !presenting;
868
+ }
869
+ syncSettingInputs(options.panelState, settingInputs);
870
+ syncSettingOutputs(options.panelState);
871
+ }
872
+
873
+ function setSessionStatus(text) {
874
+ if (sessionStatus) {
875
+ sessionStatus.textContent = text;
876
+ }
877
+ }
878
+
879
+ async function refreshXrSupport() {
880
+ try {
881
+ xrSupported = await globalThis.navigator?.xr?.isSessionSupported?.('immersive-vr') ?? false;
882
+ } catch {
883
+ xrSupported = false;
884
+ }
885
+ if (supportValue) {
886
+ supportValue.textContent = xrSupported ? 'Available' : 'Unavailable';
887
+ }
888
+ sync();
889
+ return xrSupported;
890
+ }
891
+ }
892
+
893
+ function readSettingInput(input, panelState, setConstellationArtEnabled) {
894
+ const field = input.dataset.setting;
895
+ if (field === 'limitingMagnitude') {
896
+ panelState.limitingMagnitude = clampNumber(input.value, 4, 10, panelState.limitingMagnitude);
897
+ } else if (field === 'exposureLog10') {
898
+ panelState.exposureLog10 = clampNumber(input.value, 3.5, 5.5, panelState.exposureLog10);
899
+ } else if (field === 'worldScaleLog10') {
900
+ panelState.worldScaleLog10 = clampNumber(input.value, -3, 0, panelState.worldScaleLog10);
901
+ } else if (field === 'nearFloor') {
902
+ panelState.nearFloor = input.checked;
903
+ } else if (field === 'constellationArt') {
904
+ setConstellationArtEnabled(input.checked);
905
+ }
906
+ }
907
+
908
+ function syncSettingInputs(panelState, inputs) {
909
+ for (const input of inputs) {
910
+ const field = input.dataset.setting;
911
+ if (field === 'limitingMagnitude') {
912
+ input.value = String(panelState.limitingMagnitude);
913
+ } else if (field === 'exposureLog10') {
914
+ input.value = String(panelState.exposureLog10);
915
+ } else if (field === 'worldScaleLog10') {
916
+ input.value = String(panelState.worldScaleLog10);
917
+ } else if (field === 'nearFloor') {
918
+ input.checked = panelState.nearFloor;
919
+ } else if (field === 'constellationArt') {
920
+ input.checked = panelState.constellationArt;
921
+ }
922
+ }
923
+ }
924
+
925
+ function syncSettingOutputs(panelState) {
926
+ for (const output of document.querySelectorAll('[data-setting-value]')) {
927
+ const field = output.dataset.settingValue;
928
+ if (field === 'limitingMagnitude') {
929
+ output.textContent = `Mag ${panelState.limitingMagnitude.toFixed(1)}`;
930
+ } else if (field === 'exposureLog10') {
931
+ output.textContent = Math.round(10 ** panelState.exposureLog10).toLocaleString();
932
+ } else if (field === 'worldScaleLog10') {
933
+ output.textContent = formatWorldScale(10 ** panelState.worldScaleLog10);
934
+ }
935
+ }
936
+ }
937
+
938
+ function formatWorldScale(value) {
939
+ return `${value.toLocaleString('en-US', { maximumSignificantDigits: 3 })} m/pc`;
940
+ }
941
+
942
+ function createEmptyIdentifierFields() {
943
+ return {
944
+ properName: '',
945
+ bayer: '',
946
+ hd: '',
947
+ hip: '',
948
+ gaia: '',
949
+ primaryLabel: '',
950
+ };
951
+ }
952
+
953
+ function createSelectedIdentifierLines(selected) {
954
+ const fields = selected.identifiers ?? createEmptyIdentifierFields();
955
+ const lines = [
956
+ `Proper: ${fields.properName || '-'}`,
957
+ `Bayer: ${fields.bayer || '-'}`,
958
+ `HD: ${fields.hd || '-'}`,
959
+ `HIP: ${fields.hip || '-'}`,
960
+ `Gaia: ${fields.gaia || '-'}`,
961
+ ];
962
+ if (selected.identifierStatus === 'loading') {
963
+ lines.push('Loading identifiers...');
964
+ } else if (selected.identifierStatus === 'error') {
965
+ lines.push('Identifiers unavailable');
966
+ } else if (selected.identifierStatus === 'unavailable') {
967
+ lines.push('No catalog identifiers');
968
+ }
969
+ return lines;
970
+ }
971
+
536
972
  function createRightHandTouchPointerSource(raySource) {
537
973
  const controls = createSkykitXrControlBindings({
538
974
  buttons: {
@@ -557,10 +993,10 @@ function createRightHandTouchPointerSource(raySource) {
557
993
 
558
994
  const select = controls.getButton('select');
559
995
  const phase = select.pressedEdge ? 'down' : select.releasedEdge ? 'up' : 'move';
560
- return {
561
- pointerId: 'right-trigger',
562
- pointerType: 'xr',
563
- handedness: 'right',
996
+ return {
997
+ pointerId: 'right-trigger',
998
+ pointerType: 'ray',
999
+ handedness: 'right',
564
1000
  phase,
565
1001
  timestamp: skykitFrame.elapsedSeconds * 1000,
566
1002
  sourceId: 'right-controller',
@@ -601,57 +1037,17 @@ function createWorldXrRaySource(source, transformRoot) {
601
1037
  };
602
1038
  }
603
1039
 
604
- function resolveLeftHandPanelPose(frame, transformRoot) {
605
- const gripPose = resolveInputPose(frame, 'left', 'gripSpace');
606
- if (!gripPose) return undefined;
607
- return transformPoseByObject(gripPose, transformRoot);
608
- }
609
-
610
- function resolveInputPose(frame, handedness, spaceKey) {
611
- const xr = frame.xr;
612
- const xrFrame = xr?.frame;
613
- const referenceSpace = xr?.referenceSpace;
614
- const inputSources = xr?.session && typeof xr.session === 'object'
615
- ? xr.session.inputSources ?? []
616
- : [];
617
- if (!xrFrame || !referenceSpace || typeof xrFrame.getPose !== 'function') return null;
618
- for (const inputSource of inputSources) {
619
- if (inputSource?.handedness !== handedness || !inputSource[spaceKey]) continue;
620
- const pose = xrFrame.getPose(inputSource[spaceKey], referenceSpace);
621
- const transform = pose?.transform;
622
- if (!transform) continue;
623
- return {
624
- position: {
625
- x: Number(transform.position?.x ?? 0),
626
- y: Number(transform.position?.y ?? 0),
627
- z: Number(transform.position?.z ?? 0),
628
- },
629
- orientation: {
630
- x: Number(transform.orientation?.x ?? 0),
631
- y: Number(transform.orientation?.y ?? 0),
632
- z: Number(transform.orientation?.z ?? 0),
633
- w: Number(transform.orientation?.w ?? 1),
634
- },
635
- };
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);
636
1047
  }
637
- return null;
638
- }
639
-
640
- function transformPoseByObject(pose, object) {
641
- object.updateMatrixWorld(true);
642
- const position = new THREE.Vector3(pose.position.x, pose.position.y, pose.position.z)
643
- .applyMatrix4(object.matrixWorld);
644
- const objectQuaternion = object.getWorldQuaternion(new THREE.Quaternion());
645
- const orientation = new THREE.Quaternion(
646
- pose.orientation.x,
647
- pose.orientation.y,
648
- pose.orientation.z,
649
- pose.orientation.w,
650
- ).premultiply(objectQuaternion).normalize();
651
- return {
652
- position: { x: position.x, y: position.y, z: position.z },
653
- orientation: { x: orientation.x, y: orientation.y, z: orientation.z, w: orientation.w },
654
- };
1048
+ mesh.translateX(offset.x ?? 0);
1049
+ mesh.translateY(offset.y ?? 0);
1050
+ mesh.translateZ(offset.z ?? 0);
655
1051
  }
656
1052
 
657
1053
  function createSelectedStarTarget() {
@@ -661,20 +1057,12 @@ function createSelectedStarTarget() {
661
1057
  const context = canvas.getContext('2d');
662
1058
  const center = canvas.width / 2;
663
1059
  context.clearRect(0, 0, canvas.width, canvas.height);
664
- context.strokeStyle = 'rgba(255, 218, 136, 0.98)';
665
- context.lineWidth = 5;
666
- context.beginPath();
667
- context.arc(center, center, 34, 0, Math.PI * 2);
668
- context.stroke();
1060
+ context.strokeStyle = 'rgba(84, 255, 172, 0.96)';
1061
+ context.lineWidth = 7;
1062
+ context.shadowColor = 'rgba(84, 255, 172, 0.48)';
1063
+ context.shadowBlur = 10;
669
1064
  context.beginPath();
670
- context.moveTo(center - 52, center);
671
- context.lineTo(center - 22, center);
672
- context.moveTo(center + 22, center);
673
- context.lineTo(center + 52, center);
674
- context.moveTo(center, center - 52);
675
- context.lineTo(center, center - 22);
676
- context.moveTo(center, center + 22);
677
- context.lineTo(center, center + 52);
1065
+ context.arc(center, center, 42, 0, Math.PI * 2);
678
1066
  context.stroke();
679
1067
 
680
1068
  const texture = new THREE.CanvasTexture(canvas);
@@ -683,13 +1071,15 @@ function createSelectedStarTarget() {
683
1071
  transparent: true,
684
1072
  depthTest: false,
685
1073
  depthWrite: false,
686
- sizeAttenuation: false,
1074
+ sizeAttenuation: true,
687
1075
  });
688
1076
  const object3d = new THREE.Sprite(material);
689
1077
  object3d.name = 'xr-selected-star-target';
690
1078
  object3d.visible = false;
691
1079
  object3d.renderOrder = 10_000;
692
- object3d.scale.set(0.085, 0.085, 1);
1080
+ object3d.scale.set(1, 1, 1);
1081
+ const worldPosition = new THREE.Vector3();
1082
+ const cameraPosition = new THREE.Vector3();
693
1083
 
694
1084
  return {
695
1085
  object3d,
@@ -697,6 +1087,17 @@ function createSelectedStarTarget() {
697
1087
  object3d.position.set(position.x, position.y, position.z);
698
1088
  object3d.visible = true;
699
1089
  },
1090
+ clear() {
1091
+ object3d.visible = false;
1092
+ },
1093
+ update(camera) {
1094
+ if (!object3d.visible) return;
1095
+ object3d.getWorldPosition(worldPosition);
1096
+ camera.getWorldPosition(cameraPosition);
1097
+ const distance = Math.max(worldPosition.distanceTo(cameraPosition), 0.001);
1098
+ const diameter = Math.max(distance * 0.032, 0.035);
1099
+ object3d.scale.set(diameter, diameter, 1);
1100
+ },
700
1101
  dispose() {
701
1102
  object3d.parent?.remove(object3d);
702
1103
  material.dispose();