@found-in-space/skykit 0.2.0-alpha.0 → 0.2.0-dev.20260527.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +223 -8
  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 +644 -217
  6. package/package.json +46 -5
  7. package/src/__tests__/skykit-anchored-images.test.js +32 -4
  8. package/src/__tests__/skykit-browser.test.js +442 -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 +123 -2
  13. package/src/__tests__/skykit.test.js +138 -1
  14. package/src/anchored-images.js +14 -15
  15. package/src/browser-addons.d.ts +16 -0
  16. package/src/browser-addons.js +155 -0
  17. package/src/browser-constellations.d.ts +13 -0
  18. package/src/browser-constellations.js +387 -0
  19. package/src/browser-journey.d.ts +8 -0
  20. package/src/browser-journey.js +240 -0
  21. package/src/browser.d.ts +170 -0
  22. package/src/browser.js +369 -0
  23. package/src/data.d.ts +133 -0
  24. package/src/data.js +447 -0
  25. package/src/embed.d.ts +6 -0
  26. package/src/embed.js +119 -0
  27. package/src/hr-diagram.js +23 -5
  28. package/src/index.d.ts +32 -7
  29. package/src/plugins.js +87 -43
  30. package/src/story.d.ts +57 -0
  31. package/src/story.js +396 -0
  32. package/src/three-shim.d.ts +32 -0
  33. package/src/touch-os.d.ts +70 -0
  34. package/src/touch-os.js +275 -0
  35. package/src/utils.js +96 -6
  36. package/src/viewer-entry.d.ts +10 -0
  37. package/src/viewer-entry.js +4 -0
  38. package/src/viewer.js +110 -12
  39. package/src/xr/plugins.js +224 -13
  40. package/src/xr/session.js +60 -14
  41. package/src/xr.d.ts +22 -0
  42. package/src/xr.js +1 -0
@@ -0,0 +1,131 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ formatStarLabel,
6
+ loadStarLabels,
7
+ loadStarRows,
8
+ rowsFromStarCells,
9
+ streamStarRows,
10
+ } from '../data.js';
11
+
12
+ test('rowsFromStarCells converts cells to plain app rows', () => {
13
+ const rows = rowsFromStarCells([createCell()], {
14
+ observerPc: { x: 0, y: 0, z: 0 },
15
+ });
16
+
17
+ assert.equal(rows.length, 2);
18
+ assert.deepEqual(rows[0].positionPc, { x: 1, y: 2, z: 2 });
19
+ assert.equal(rows[0].distancePc, 3);
20
+ assert.equal(rows[0].apparentMagnitude, 1 + 5 * (Math.log10(3) - 1));
21
+ assert.equal(rows[0].temperatureK, 5800);
22
+ assert.deepEqual(rows[0].ref, {
23
+ datasetId: 'dataset-a',
24
+ level: 1,
25
+ mortonCode: '2',
26
+ ordinal: 0,
27
+ });
28
+ });
29
+
30
+ test('loadStarRows streams provider cells, filters visible rows, and respects maxStars', async () => {
31
+ const provider = createProvider();
32
+ const rows = await loadStarRows({
33
+ provider,
34
+ limitingMagnitude: 4,
35
+ maxStars: 1,
36
+ sortBy: 'distancePc',
37
+ });
38
+
39
+ assert.equal(provider.disposed, false);
40
+ assert.equal(provider.streamOptions.length, 1);
41
+ assert.deepEqual(provider.streamOptions[0].view.observerPc, { x: 0, y: 0, z: 0 });
42
+ assert.deepEqual(provider.streamOptions[0].attributes, ['position', 'magAbs', 'teffLog8', 'objectRef']);
43
+ assert.equal(rows.length, 1);
44
+ assert.equal(rows[0].ordinal, 0);
45
+ });
46
+
47
+ test('streamStarRows yields row batches without exposing cell deltas', async () => {
48
+ const batches = [];
49
+ for await (const rows of streamStarRows({
50
+ provider: createProvider(),
51
+ limitingMagnitude: 99,
52
+ })) {
53
+ batches.push(rows);
54
+ }
55
+
56
+ assert.equal(batches.length, 1);
57
+ assert.equal(batches[0].length, 2);
58
+ });
59
+
60
+ test('loadStarLabels formats metadata labels for rows', async () => {
61
+ const [row] = rowsFromStarCells([createCell()]);
62
+ const labels = await loadStarLabels([row], {
63
+ metaProvider: {
64
+ async getMeta(ref) {
65
+ assert.equal(ref.ordinal, 0);
66
+ return { proper_name: 'Sol', hip_id: 0 };
67
+ },
68
+ dispose() {
69
+ throw new Error('caller-owned meta providers are not disposed');
70
+ },
71
+ },
72
+ });
73
+
74
+ assert.equal(labels.length, 1);
75
+ assert.equal(labels[0].label, 'Sol');
76
+ assert.equal(formatStarLabel(null, 'Fallback'), 'Fallback');
77
+ });
78
+
79
+ function createCell() {
80
+ return {
81
+ cellKey: '1:2',
82
+ cell: { level: 1, mortonCode: '2' },
83
+ bounds: {
84
+ centerPc: { x: 0, y: 0, z: 0 },
85
+ halfSizePc: 1,
86
+ gridX: 0,
87
+ gridY: 0,
88
+ gridZ: 0,
89
+ },
90
+ count: 2,
91
+ coordinates: {
92
+ name: 'position',
93
+ frame: 'icrs',
94
+ units: ['pc', 'pc', 'pc'],
95
+ components: new Float32Array([
96
+ 1, 2, 2,
97
+ 20, 0, 0,
98
+ ]),
99
+ },
100
+ attributes: {
101
+ magAbs: new Float32Array([1, 9]),
102
+ teffLog8: new Uint8Array([255, 0]),
103
+ },
104
+ refs: [
105
+ { datasetId: 'dataset-a', level: 1, mortonCode: '2', ordinal: 0 },
106
+ { datasetId: 'dataset-a', level: 1, mortonCode: '2', ordinal: 1 },
107
+ ],
108
+ };
109
+ }
110
+
111
+ function createProvider() {
112
+ return {
113
+ disposed: false,
114
+ streamOptions: [],
115
+ async *streamCells(options) {
116
+ this.streamOptions.push(options);
117
+ yield {
118
+ type: 'stars/cells-upsert',
119
+ cells: [createCell()],
120
+ };
121
+ yield {
122
+ type: 'stars/current',
123
+ cellKeys: ['1:2'],
124
+ starCount: 2,
125
+ };
126
+ },
127
+ dispose() {
128
+ this.disposed = true;
129
+ },
130
+ };
131
+ }
@@ -185,7 +185,7 @@ test('parallax observer moves in the target-relative plane without accumulating
185
185
  const viewer = await createSkykitViewer({
186
186
  view: {
187
187
  observerPc: { x: 0, y: 0, z: 0 },
188
- targetPc: { x: 0, y: 0, z: -10 },
188
+ lookAt: { targetPc: { x: 0, y: 0, z: -10 } },
189
189
  },
190
190
  plugins: [
191
191
  createParallaxObserverPlugin({
@@ -223,7 +223,7 @@ test('parallax observer smoothing approaches the requested offset', async () =>
223
223
  const viewer = await createSkykitViewer({
224
224
  view: {
225
225
  observerPc: { x: 0, y: 0, z: 0 },
226
- targetPc: { x: 0, y: 0, z: -10 },
226
+ lookAt: { targetPc: { x: 0, y: 0, z: -10 } },
227
227
  },
228
228
  plugins: [
229
229
  createParallaxObserverPlugin({
@@ -253,7 +253,7 @@ test('parallax observer static upIcrs controls the target-relative up plane', as
253
253
  const viewer = await createSkykitViewer({
254
254
  view: {
255
255
  observerPc: { x: 0, y: 0, z: 0 },
256
- targetPc: { x: 0, y: -10, z: 0 },
256
+ lookAt: { targetPc: { x: 0, y: -10, z: 0 } },
257
257
  },
258
258
  plugins: [
259
259
  createParallaxObserverPlugin({
@@ -285,7 +285,7 @@ test('parallax observer resolveUpIcrs overrides static up and can change at runt
285
285
  const viewer = await createSkykitViewer({
286
286
  view: {
287
287
  observerPc: { x: 0, y: 0, z: 0 },
288
- targetPc: { x: 0, y: -10, z: 0 },
288
+ lookAt: { targetPc: { x: 0, y: -10, z: 0 } },
289
289
  },
290
290
  plugins: [
291
291
  createParallaxObserverPlugin({
@@ -1,6 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
  import * as THREE from 'three';
4
+ import { createRuntime } from '@found-in-space/touch-os';
4
5
 
5
6
  import {
6
7
  SKYKIT_ACTIONS,
@@ -8,6 +9,8 @@ import {
8
9
  } from '../index.js';
9
10
  import {
10
11
  createSkykitShipControlsRoot,
12
+ createSkykitSurfaceApp,
13
+ createSkykitTabletRoot,
11
14
  createTouchOsHudPlugin,
12
15
  createTouchOsPanelPlugin,
13
16
  dispatchTouchOsActionOutputs,
@@ -72,6 +75,74 @@ test('createSkykitShipControlsRoot builds reusable pseudo-key controls and statu
72
75
  );
73
76
  });
74
77
 
78
+ test('createSkykitTabletRoot builds a tablet app shell from touch apps', () => {
79
+ const app = createSkykitSurfaceApp({
80
+ id: 'app.surface',
81
+ name: 'Surface',
82
+ node: createSkykitShipControlsRoot({ id: 'surface-child', movePad: false, verticalControls: false }),
83
+ });
84
+ const root = createSkykitTabletRoot({
85
+ id: 'test-tablet',
86
+ apps: [app],
87
+ appStates: { 'app.surface': { ready: true } },
88
+ });
89
+
90
+ assert.equal(root.id, 'test-tablet');
91
+ assert.equal(root.component.kind, 'app-shell');
92
+ assert.equal(root.props.presentation.kind, 'tablet-home');
93
+ assert.equal(root.props.appHostMode, 'same-runtime');
94
+ assert.equal(root.props.homeKey, true);
95
+ assert.deepEqual(root.props.registry.list().map((manifest) => manifest.id), ['app.surface']);
96
+
97
+ const runtime = createRuntime({
98
+ root,
99
+ surface: { width: 320, height: 240 },
100
+ });
101
+ const snapshot = runtime.render();
102
+ assert.equal(snapshot.commands.some((command) => command.role === 'tablet-home-button'), true);
103
+ assert.equal(snapshot.commands.some((command) => command.role === 'tablet-home-bar'), false);
104
+ });
105
+
106
+ test('createSkykitSurfaceApp wraps display nodes and emits app events', () => {
107
+ const emitted = [];
108
+ const app = createSkykitSurfaceApp({
109
+ id: 'app.hr',
110
+ name: 'HR',
111
+ node: () => createSkykitShipControlsRoot({ id: 'hr-child', movePad: false, verticalControls: false }),
112
+ });
113
+ const instance = app.createApp({
114
+ appId: 'app.hr',
115
+ instanceId: 'app-1',
116
+ windowId: 'app-1-window',
117
+ surface: { width: 420, height: 300, pixelDensity: 1, safeArea: { top: 0, right: 0, bottom: 0, left: 0 } },
118
+ theme: { getTokens() { return {}; } },
119
+ actions: { emit(event) { emitted.push(event); } },
120
+ windows: {
121
+ setTitle() {},
122
+ requestClose() {},
123
+ requestResize() {},
124
+ openApp() {},
125
+ },
126
+ });
127
+
128
+ const root = instance.render({});
129
+ assert.equal(app.manifest.id, 'app.hr');
130
+ assert.deepEqual(app.manifest.capabilities, ['surfaces']);
131
+ assert.equal(root.component.kind, 'skykit-surface-app-frame');
132
+ assert.equal(root.props.child.id, 'hr-child');
133
+
134
+ instance.handleOutput({ type: 'action', actionId: 'app.fly', componentId: 'fly', payload: { target: 'sun' } });
135
+ assert.deepEqual(emitted, [{
136
+ type: 'app-action',
137
+ appId: 'app.hr',
138
+ instanceId: 'app-1',
139
+ windowId: 'app-1-window',
140
+ name: 'app.fly',
141
+ payload: { target: 'sun' },
142
+ componentId: 'fly',
143
+ }]);
144
+ });
145
+
75
146
  test('touch-os pointer helpers resolve screen input and surface metrics', () => {
76
147
  const target = createTarget({ width: 640, height: 360, pixelRatio: 3 });
77
148
  const metrics = resolveTouchOsSurfaceMetrics(target, { pixelDensity: 1.5 });
@@ -1,4 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
2
3
  import test from 'node:test';
3
4
  import * as THREE from 'three';
4
5
 
@@ -11,6 +12,7 @@ import {
11
12
  createSkykitXrObserverRig,
12
13
  createSkykitXrPickRouter,
13
14
  createSkykitXrRaySource,
15
+ createSkykitXrRayVisualPlugin,
14
16
  createSkykitXrRig,
15
17
  createSkykitXrSessionPlugin,
16
18
  createSkykitXrStarPickingPlugin,
@@ -20,6 +22,32 @@ import {
20
22
  } from '../xr.js';
21
23
  import { createSkykitActionRegistry } from '../index.js';
22
24
 
25
+ test('xr free-roam demo uses restored alpha XR regressions defaults', () => {
26
+ const source = readFileSync(new URL('../../examples/xr-free-roam/xr-free-roam.js', import.meta.url), 'utf8');
27
+
28
+ assert.match(source, /createDefaultThreeStarFieldMaterialProfile/);
29
+ assert.doesNotMatch(source, /createVrThreeStarFieldMaterialProfile/);
30
+ assert.match(source, /createSkykitXrRayVisualPlugin/);
31
+ assert.match(source, /createSurfaceShell/);
32
+ assert.match(source, /createMetaSidecarProviderService/);
33
+ assert.match(source, /deriveMetaSidecarUrlFromRenderUrl/);
34
+ assert.match(source, /metaSidecarEntryDisplayFields/);
35
+ assert.match(source, /datasetId:\s*DATASET_ID_c56103/);
36
+ assert.match(source, /attributes:\s*\[\s*'objectRef'\s*,\s*'pickMeta'\s*\]/);
37
+ assert.match(source, /selectSun:\s*'xr-demo:selected\.sun'/);
38
+ assert.match(source, /primaryActionId:\s*XR_DEMO_ACTIONS\.goSelected/);
39
+ assert.match(source, /primaryActionLabel:\s*'Fly to'/);
40
+ assert.match(source, /homeControl:\s*'button'/);
41
+ assert.match(source, /pointerType:\s*'ray'/);
42
+ assert.doesNotMatch(source, /dragThreshold/);
43
+ assert.doesNotMatch(source, /createChoiceGroup/);
44
+ assert.doesNotMatch(source, /createSlider/);
45
+ assert.doesNotMatch(source, /createToggle/);
46
+ assert.doesNotMatch(source, /createStack/);
47
+ assert.doesNotMatch(source, /waypointPrefix/);
48
+ assert.doesNotMatch(source, /panelState\.page/);
49
+ });
50
+
23
51
  test('skykit/xr rig builds multi-root hierarchy', () => {
24
52
  const camera = new THREE.PerspectiveCamera();
25
53
  const rig = createSkykitXrRig({
@@ -102,6 +130,32 @@ test('skykit/xr depth helpers compute and apply render state', () => {
102
130
  assert.deepEqual(state, { depthNear: range.depthNear, depthFar: range.depthFar });
103
131
  });
104
132
 
133
+ test('skykit/xr depth helpers include distant visible star bounds', () => {
134
+ const range = computeSkykitXrDepthRange({
135
+ observer: { x: 0, y: 0, z: 0 },
136
+ visibleBounds: {
137
+ min: { x: 62, y: 602, z: -13 },
138
+ max: { x: 64, y: 604, z: -11 },
139
+ },
140
+ observerCentricSpheres: [{ radiusNavigationUnits: 16 }],
141
+ scale: {
142
+ navigationUnits: 'pc',
143
+ metersPerNavigationUnit: 1,
144
+ worldUnitsPerNavigationUnit: 1,
145
+ },
146
+ policy: {
147
+ near: 0.03,
148
+ minFar: 100,
149
+ maxFar: 2000000,
150
+ marginFactor: 1.2,
151
+ },
152
+ });
153
+
154
+ assert.ok(range.far > 720);
155
+ assert.ok(range.telemetry.farthestVisibleBoundsDistance > 600);
156
+ assert.equal(range.telemetry.farthestObserverCentricSphereDistance, 16);
157
+ });
158
+
105
159
  test('skykit/xr session helpers use injected navigator', async () => {
106
160
  let ended = false;
107
161
  const session = {
@@ -118,7 +172,8 @@ test('skykit/xr session helpers use injected navigator', async () => {
118
172
  async isSessionSupported(mode) {
119
173
  return mode === 'immersive-vr';
120
174
  },
121
- async requestSession() {
175
+ async requestSession(_mode, init) {
176
+ this.lastInit = init;
122
177
  return session;
123
178
  },
124
179
  },
@@ -126,6 +181,7 @@ test('skykit/xr session helpers use injected navigator', async () => {
126
181
  assert.equal(await isSkykitXrModeSupported('immersive-vr', { navigator }), true);
127
182
  const handle = await enterSkykitXrSession({ navigator, mode: 'immersive-vr' });
128
183
  assert.equal(handle.presenting, true);
184
+ assert.deepEqual(navigator.xr.lastInit.optionalFeatures, ['local-floor']);
129
185
  await exitSkykitXrSession(handle);
130
186
  assert.equal(ended, true);
131
187
  });
@@ -147,15 +203,20 @@ test('skykit/xr observer rig bridges viewer state to an XR rig without camera re
147
203
 
148
204
  test('skykit/xr session plugin registers enter/exit actions and syncs snapshot state', async () => {
149
205
  let activeSession = null;
206
+ let requestedReferenceSpaceCount = 0;
150
207
  const session = {
151
208
  async requestReferenceSpace(type) {
209
+ requestedReferenceSpaceCount += 1;
152
210
  return { type };
153
211
  },
154
212
  async end() {
155
213
  this.ended = true;
214
+ activeSession = null;
215
+ renderer.xr.isPresenting = false;
156
216
  },
157
217
  addEventListener() {},
158
218
  };
219
+ let rendererReferenceSpaceType = null;
159
220
  const renderer = {
160
221
  xr: {
161
222
  enabled: false,
@@ -163,6 +224,9 @@ test('skykit/xr session plugin registers enter/exit actions and syncs snapshot s
163
224
  getSession() {
164
225
  return activeSession;
165
226
  },
227
+ setReferenceSpaceType(type) {
228
+ rendererReferenceSpaceType = type;
229
+ },
166
230
  async setSession(nextSession) {
167
231
  activeSession = nextSession;
168
232
  this.isPresenting = Boolean(nextSession);
@@ -174,7 +238,8 @@ test('skykit/xr session plugin registers enter/exit actions and syncs snapshot s
174
238
  async isSessionSupported() {
175
239
  return true;
176
240
  },
177
- async requestSession() {
241
+ async requestSession(_mode, init) {
242
+ this.lastInit = init;
178
243
  return session;
179
244
  },
180
245
  },
@@ -195,8 +260,12 @@ test('skykit/xr session plugin registers enter/exit actions and syncs snapshot s
195
260
 
196
261
  await actions.invoke('skykit:xr.enter');
197
262
  assert.equal(renderer.xr.enabled, true);
263
+ assert.equal(rendererReferenceSpaceType, 'local-floor');
264
+ assert.deepEqual(navigator.xr.lastInit.optionalFeatures, ['local-floor']);
265
+ assert.equal(requestedReferenceSpaceCount, 0);
198
266
  assert.equal(activeSession, session);
199
267
  assert.equal(plugin.getSnapshot().presenting, true);
268
+ assert.equal(plugin.getSnapshot().enterStage, 'presenting');
200
269
 
201
270
  const frame = createXrFrame({ renderer });
202
271
  part.update(frame);
@@ -240,6 +309,58 @@ test('skykit/xr navigation plugin updates viewer state from controller axes', ()
240
309
  assert.deepEqual(actions.getControlValue('skykit:ship.control.move'), { x: 0, y: 0, z: -10 });
241
310
  });
242
311
 
312
+ test('skykit/xr ray visual shows the controller ray and shortens at blockers', () => {
313
+ let part = null;
314
+ let disposedRaySource = false;
315
+ const plugin = createSkykitXrRayVisualPlugin({
316
+ raySource: {
317
+ getRay() {
318
+ return {
319
+ id: 'ray',
320
+ kind: 'target-ray',
321
+ handedness: 'right',
322
+ origin: { x: 1, y: 2, z: 3 },
323
+ direction: { x: 0, y: 0, z: -1 },
324
+ length: 10,
325
+ };
326
+ },
327
+ getSnapshot() {
328
+ return { id: 'ray-source' };
329
+ },
330
+ dispose() {
331
+ disposedRaySource = true;
332
+ },
333
+ },
334
+ blockers: [{
335
+ blockRay() {
336
+ return { blocked: true, distance: 3, hit: { componentId: 'panel' } };
337
+ },
338
+ }],
339
+ });
340
+ const context = createPluginContext({
341
+ addPart(nextPart) {
342
+ part = nextPart;
343
+ },
344
+ });
345
+ plugin.setup(context);
346
+ part.attach(context);
347
+
348
+ part.update(createXrFrame());
349
+
350
+ assert.equal(part.object3d.visible, true);
351
+ assert.equal(plugin.getSnapshot().blocked, true);
352
+ assert.equal(plugin.getSnapshot().lastLength, 3);
353
+ assert.deepEqual(
354
+ Array.from(part.object3d.children[0].geometry.getAttribute('position').array),
355
+ [1, 2, 3, 1, 2, 0],
356
+ );
357
+
358
+ part.update({ ...createXrFrame(), xr: { presenting: false } });
359
+ assert.equal(part.object3d.visible, false);
360
+ part.dispose();
361
+ assert.equal(disposedRaySource, true);
362
+ });
363
+
243
364
  test('skykit/xr star picking fires only on trigger edge and registers attribute-only demand', () => {
244
365
  const actions = createSkykitActionRegistry();
245
366
  let part = null;
@@ -77,6 +77,57 @@ function createRenderer() {
77
77
  };
78
78
  }
79
79
 
80
+ function createTextureRenderer() {
81
+ const renderer = createRenderer();
82
+ return {
83
+ ...renderer,
84
+ xr: { enabled: true },
85
+ currentTarget: null,
86
+ viewport: new THREE.Vector4(0, 0, 1, 1),
87
+ scissor: new THREE.Vector4(0, 0, 1, 1),
88
+ scissorTest: false,
89
+ getRenderTarget() {
90
+ return this.currentTarget;
91
+ },
92
+ setRenderTarget(target) {
93
+ this.currentTarget = target;
94
+ },
95
+ getViewport(target) {
96
+ return target.copy(this.viewport);
97
+ },
98
+ setViewport(value) {
99
+ if (value?.isVector4) this.viewport.copy(value);
100
+ },
101
+ getScissor(target) {
102
+ return target.copy(this.scissor);
103
+ },
104
+ setScissor(value) {
105
+ if (value?.isVector4) this.scissor.copy(value);
106
+ },
107
+ getScissorTest() {
108
+ return this.scissorTest;
109
+ },
110
+ setScissorTest(value) {
111
+ this.scissorTest = Boolean(value);
112
+ },
113
+ };
114
+ }
115
+
116
+ function createEmbeddedSurfaceSpy() {
117
+ const publishCalls = [];
118
+ const unpublishCalls = [];
119
+ return {
120
+ publishCalls,
121
+ unpublishCalls,
122
+ publish(sourceId, update) {
123
+ publishCalls.push({ sourceId, update });
124
+ },
125
+ unpublish(sourceId) {
126
+ unpublishCalls.push(sourceId);
127
+ },
128
+ };
129
+ }
130
+
80
131
  async function flushMicrotasks(count = 10) {
81
132
  for (let index = 0; index < count; index += 1) {
82
133
  await Promise.resolve();
@@ -242,6 +293,51 @@ test('viewer exposes action registry, emits action events, and resets to initial
242
293
  await viewer.dispose();
243
294
  });
244
295
 
296
+ test('viewer derives camera orientation from lookAt targets, sky coordinates, and stars', async () => {
297
+ const targetViewer = await createSkykitViewer({
298
+ renderer: createRenderer(),
299
+ view: {
300
+ observerPc: { x: 0, y: 0, z: 0 },
301
+ lookAt: { targetPc: { x: 10, y: 0, z: 0 }, positionAngleDeg: 0 },
302
+ },
303
+ });
304
+ let view = targetViewer.getViewState();
305
+ assert.deepEqual(view.targetPc, { x: 10, y: 0, z: 0 });
306
+ assertVectorApprox(localVectorFromView(view, { x: 0, y: 0, z: -1 }), { x: 1, y: 0, z: 0 });
307
+ assertVectorApprox(localVectorFromView(view, { x: 0, y: 1, z: 0 }), { x: 0, y: 0, z: 1 });
308
+ await targetViewer.dispose();
309
+
310
+ const skyViewer = await createSkykitViewer({
311
+ renderer: createRenderer(),
312
+ view: {
313
+ lookAt: { raDeg: 0, decDeg: 0, positionAngleDeg: 90 },
314
+ },
315
+ });
316
+ view = skyViewer.getViewState();
317
+ assert.equal(view.targetPc, null);
318
+ assertVectorApprox(localVectorFromView(view, { x: 0, y: 0, z: -1 }), { x: 1, y: 0, z: 0 });
319
+ assertVectorApprox(localVectorFromView(view, { x: 0, y: 1, z: 0 }), { x: 0, y: 1, z: 0 });
320
+ await skyViewer.dispose();
321
+
322
+ const starViewer = await createSkykitViewer({
323
+ renderer: createRenderer(),
324
+ view: { lookAt: { star: 'hyades' } },
325
+ resolveLookAtStar: async (star) => star === 'hyades'
326
+ ? { targetPc: { x: 4, y: 5, z: 6 } }
327
+ : null,
328
+ });
329
+ view = starViewer.getViewState();
330
+ assert.equal(view.lookAt?.star, 'hyades');
331
+ assert.deepEqual(view.targetPc, { x: 4, y: 5, z: 6 });
332
+ assert.ok(view.orientationIcrs);
333
+
334
+ starViewer.requestViewState({ lookAt: { star: 'orion' } }, 'test-star-look');
335
+ await new Promise((resolve) => setTimeout(resolve, 0));
336
+ starViewer.update(0);
337
+ assert.deepEqual(starViewer.getViewState().targetPc, null);
338
+ await starViewer.dispose();
339
+ });
340
+
245
341
  test('requestViewState batches patches and observer-centric root follows translation without rotation', async () => {
246
342
  const viewer = await createSkykitViewer({
247
343
  renderer: createRenderer(),
@@ -682,6 +778,41 @@ test('HR diagram mode changes refresh shared demand and update the renderer view
682
778
  await viewer.dispose();
683
779
  });
684
780
 
781
+ test('HR diagram touch-os surfaces can be resolved lazily from panel runtimes', async () => {
782
+ const session = createFakeSession();
783
+ const provider = {
784
+ id: 'provider',
785
+ createSession() {
786
+ return session;
787
+ },
788
+ };
789
+ const source = createSkykitStarSourcePlugin({ provider });
790
+ const surfaces = createEmbeddedSurfaceSpy();
791
+ let activeSurfaces = null;
792
+ const hr = createSkykitHrDiagramPlugin({
793
+ id: 'hr',
794
+ source,
795
+ touchOs: {
796
+ sourceId: 'hr:surface',
797
+ surfaces: () => activeSurfaces,
798
+ },
799
+ });
800
+ const viewer = await createSkykitViewer({
801
+ renderer: createTextureRenderer(),
802
+ plugins: [source, hr],
803
+ });
804
+
805
+ activeSurfaces = surfaces;
806
+ viewer.frame(0.016);
807
+
808
+ assert.equal(surfaces.publishCalls.length, 1);
809
+ assert.equal(surfaces.publishCalls[0].sourceId, 'hr:surface');
810
+ assert.equal(surfaces.publishCalls[0].update.available, true);
811
+
812
+ await viewer.dispose();
813
+ assert.deepEqual(surfaces.unpublishCalls, ['hr:surface']);
814
+ });
815
+
685
816
  test('HR diagram demand strategy override can be supplied and restored at runtime', async () => {
686
817
  const sessions = [];
687
818
  const provider = {
@@ -1698,7 +1829,7 @@ test('journey plugin can route to non-orbit scenes before applying the arrival t
1698
1829
  },
1699
1830
  },
1700
1831
  local: {
1701
- view: { targetPc: { x: 1, y: 0, z: 0 } },
1832
+ view: { lookAt: { targetPc: { x: 1, y: 0, z: 0 } } },
1702
1833
  navigation: {
1703
1834
  transitionTo: {
1704
1835
  observerPc: { x: 0, y: 0, z: 0 },
@@ -2323,6 +2454,12 @@ function localVectorFromView(view, vector) {
2323
2454
  return { x: result.x, y: result.y, z: result.z };
2324
2455
  }
2325
2456
 
2457
+ function assertVectorApprox(actual, expected, epsilon = 1e-9) {
2458
+ assert.ok(Math.abs(actual.x - expected.x) < epsilon, `x ${actual.x} !== ${expected.x}`);
2459
+ assert.ok(Math.abs(actual.y - expected.y) < epsilon, `y ${actual.y} !== ${expected.y}`);
2460
+ assert.ok(Math.abs(actual.z - expected.z) < epsilon, `z ${actual.z} !== ${expected.z}`);
2461
+ }
2462
+
2326
2463
  function createPointerTarget() {
2327
2464
  const target = createEventTarget();
2328
2465
  target.clientWidth = 800;