@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
@@ -107,16 +107,16 @@ export async function createAnchoredImageCatalog(options = {}) {
107
107
  }),
108
108
  };
109
109
  },
110
- resolveNearest(directionIcrs, nearestOptions = {}) {
111
- const matches = scoreEntries(directionIcrs, selectEntries(entries, nearestOptions.selection, catalog));
110
+ resolveNearest(lookDirection, nearestOptions = {}) {
111
+ const matches = scoreEntries(lookDirection, selectEntries(entries, nearestOptions.selection, catalog));
112
112
  if (matches.length === 0) return null;
113
113
  const maxAngleRad = optionalAngleRad(nearestOptions.maxAngleDeg);
114
114
  return matches.find((match) => maxAngleRad == null || match.viewDistanceRad <= maxAngleRad) ?? null;
115
115
  },
116
- resolveWithinAngle(directionIcrs, withinOptions) {
116
+ resolveWithinAngle(lookDirection, withinOptions) {
117
117
  const maxAngleRad = requiredAngleRad(withinOptions?.maxAngleDeg);
118
118
  if (!Number.isFinite(maxAngleRad)) return [];
119
- return scoreEntries(directionIcrs, selectEntries(entries, withinOptions?.selection, catalog))
119
+ return scoreEntries(lookDirection, selectEntries(entries, withinOptions?.selection, catalog))
120
120
  .filter((match) => match.viewDistanceRad <= maxAngleRad);
121
121
  },
122
122
  };
@@ -308,7 +308,8 @@ export function createAnchoredImageSkyPlugin(options) {
308
308
  id,
309
309
  priority: options.priority,
310
310
  object3d: root,
311
- anchorMode: fixedAtInfinity ? 'observer-centric' : 'world-space',
311
+ anchorMode: options.anchorMode ?? (fixedAtInfinity ? 'observer-centric' : 'world-space'),
312
+ scaleBandId: options.scaleBandId,
312
313
  });
313
314
  pluginContext.addPart({
314
315
  ...layer,
@@ -661,26 +662,24 @@ function targetDirectionAt(solved, x, y) {
661
662
 
662
663
  /** @param {SkykitViewState} view @returns {Vector3Like} */
663
664
  function resolveViewDirection(view) {
664
- const explicitDirection = normalizeVector3(view.directionIcrs, null);
665
- if (explicitDirection) return explicitDirection;
666
- const orientation = normalizeQuaternion(view.orientationIcrs);
667
- if (orientation) return normalizeVector3(rotateVectorByQuaternion(LOCAL_FORWARD, orientation), LOCAL_FORWARD) ?? { ...LOCAL_FORWARD };
668
665
  const targetPc = normalizeVector3(view.targetPc, null);
669
666
  const observerPc = normalizeVector3(view.observerPc, { x: 0, y: 0, z: 0 }) ?? { x: 0, y: 0, z: 0 };
670
667
  if (targetPc) {
671
668
  const direction = normalizeVector3(subtractVectors(targetPc, observerPc), null);
672
669
  if (direction) return direction;
673
670
  }
671
+ const orientation = normalizeQuaternion(view.orientationIcrs);
672
+ if (orientation) return normalizeVector3(rotateVectorByQuaternion(LOCAL_FORWARD, orientation), LOCAL_FORWARD) ?? { ...LOCAL_FORWARD };
674
673
  return { ...LOCAL_FORWARD };
675
674
  }
676
675
 
677
676
  /**
678
- * @param {Vector3Like | [number, number, number]} directionIcrs
677
+ * @param {Vector3Like | [number, number, number]} lookDirection
679
678
  * @param {AnchoredImageCatalogEntry[]} entries
680
679
  * @returns {AnchoredImageMatch[]}
681
680
  */
682
- function scoreEntries(directionIcrs, entries) {
683
- const direction = vector3FromArray(normalizeDirection(directionIcrs));
681
+ function scoreEntries(lookDirection, entries) {
682
+ const direction = vector3FromArray(normalizeDirection(lookDirection));
684
683
  if (!direction) return [];
685
684
  return entries
686
685
  .map((entry) => scoreEntry(direction, entry))
@@ -689,13 +688,13 @@ function scoreEntries(directionIcrs, entries) {
689
688
  }
690
689
 
691
690
  /**
692
- * @param {Vector3Like | [number, number, number]} directionIcrs
691
+ * @param {Vector3Like | [number, number, number]} lookDirection
693
692
  * @param {AnchoredImageCatalogEntry | null | undefined} entry
694
693
  * @returns {AnchoredImageMatch | null}
695
694
  */
696
- function scoreEntry(directionIcrs, entry) {
695
+ function scoreEntry(lookDirection, entry) {
697
696
  if (!entry) return null;
698
- const direction = vector3FromArray(normalizeDirection(directionIcrs));
697
+ const direction = vector3FromArray(normalizeDirection(lookDirection));
699
698
  if (!direction) return null;
700
699
  const angleRad = angularDistance(direction, entry.centroidIcrs);
701
700
  const viewDistanceRad = Math.max(0, angleRad - entry.boundsConeRadiusRad);
@@ -0,0 +1,16 @@
1
+ import type {
2
+ SkykitBrowser,
3
+ SkykitBrowserAddon,
4
+ SkykitBrowserGlobal,
5
+ } from './browser.js';
6
+
7
+ export declare function installSkykitBrowserGlobal(target?: typeof globalThis): SkykitBrowserGlobal;
8
+ export declare function registerBrowserAddon(
9
+ service: SkykitBrowserGlobal,
10
+ addon: SkykitBrowserAddon
11
+ ): () => void;
12
+ export declare function registerBrowserInstance(
13
+ service: SkykitBrowserGlobal,
14
+ host: unknown,
15
+ browser: SkykitBrowser
16
+ ): () => void;
@@ -0,0 +1,155 @@
1
+ const STATE = Symbol.for('found-in-space.skykit.browserAddons');
2
+ const GLOBAL_NAME = 'Skykit';
3
+
4
+ /**
5
+ * Install or upgrade the small global service used by the noob embed path.
6
+ *
7
+ * @param {typeof globalThis} [target]
8
+ * @returns {import('./browser.d.ts').SkykitBrowserGlobal}
9
+ */
10
+ export function installSkykitBrowserGlobal(target = globalThis) {
11
+ const globalTarget = /** @type {typeof globalThis & { Skykit?: Partial<import('./browser.d.ts').SkykitBrowserGlobal> & Record<PropertyKey, unknown> }} */ (target);
12
+ const service = /** @type {Partial<import('./browser.d.ts').SkykitBrowserGlobal> & Record<PropertyKey, unknown>} */ (
13
+ globalTarget[GLOBAL_NAME] && typeof globalTarget[GLOBAL_NAME] === 'object'
14
+ ? globalTarget[GLOBAL_NAME]
15
+ : {}
16
+ );
17
+ const state = getState(service);
18
+ const queuedAddons = Array.isArray(service.browserAddons)
19
+ ? service.browserAddons.splice(0)
20
+ : [];
21
+
22
+ service.browserAddons = state.addons;
23
+ service.registerBrowserAddon = (addon) => registerBrowserAddon(service, addon);
24
+ service.whenReady = (targetOrSelector) => whenReady(service, targetOrSelector);
25
+ service.getBrowsers = () => state.records.map((record) => record.browser);
26
+ globalTarget[GLOBAL_NAME] = service;
27
+
28
+ for (const addon of queuedAddons) {
29
+ registerBrowserAddon(service, addon);
30
+ }
31
+
32
+ return /** @type {import('./browser.d.ts').SkykitBrowserGlobal} */ (service);
33
+ }
34
+
35
+ /**
36
+ * @param {import('./browser.d.ts').SkykitBrowserGlobal} service
37
+ * @param {import('./browser.d.ts').SkykitBrowserAddon} addon
38
+ */
39
+ export function registerBrowserAddon(service, addon) {
40
+ if (!addon || typeof addon.install !== 'function') {
41
+ throw new TypeError('SkyKit browser add-ons must provide install(context).');
42
+ }
43
+ const state = getState(service);
44
+ if (state.addons.includes(addon)) return () => {};
45
+ state.addons.push(addon);
46
+ for (const record of state.records) {
47
+ void installAddonOnRecord(record, addon);
48
+ }
49
+ return () => {
50
+ const index = state.addons.indexOf(addon);
51
+ if (index >= 0) state.addons.splice(index, 1);
52
+ };
53
+ }
54
+
55
+ /**
56
+ * @param {import('./browser.d.ts').SkykitBrowserGlobal} service
57
+ * @param {unknown} host
58
+ * @param {import('./browser.d.ts').SkykitBrowser} browser
59
+ * @returns {() => void}
60
+ */
61
+ export function registerBrowserInstance(service, host, browser) {
62
+ const state = getState(service);
63
+ const record = {
64
+ host,
65
+ browser,
66
+ installedAddonIds: new Set(),
67
+ };
68
+ state.records.push(record);
69
+ for (const addon of state.addons) {
70
+ void installAddonOnRecord(record, addon);
71
+ }
72
+ resolveWaiters(state, record);
73
+ return () => {
74
+ const index = state.records.indexOf(record);
75
+ if (index >= 0) state.records.splice(index, 1);
76
+ };
77
+ }
78
+
79
+ /**
80
+ * @param {Partial<import('./browser.d.ts').SkykitBrowserGlobal> & Record<PropertyKey, unknown>} service
81
+ * @returns {{
82
+ * addons: import('./browser.d.ts').SkykitBrowserAddon[];
83
+ * records: Array<{ host: unknown; browser: import('./browser.d.ts').SkykitBrowser; installedAddonIds: Set<string> }>;
84
+ * waiters: Array<{ target: unknown; resolve: (browser: import('./browser.d.ts').SkykitBrowser) => void; reject: (error: unknown) => void }>;
85
+ * }}
86
+ */
87
+ function getState(service) {
88
+ if (!service[STATE]) {
89
+ Object.defineProperty(service, STATE, {
90
+ configurable: false,
91
+ enumerable: false,
92
+ value: {
93
+ addons: [],
94
+ records: [],
95
+ waiters: [],
96
+ },
97
+ });
98
+ }
99
+ return /** @type {ReturnType<typeof getState>} */ (service[STATE]);
100
+ }
101
+
102
+ /**
103
+ * @param {ReturnType<typeof getState>['records'][number]} record
104
+ * @param {import('./browser.d.ts').SkykitBrowserAddon} addon
105
+ */
106
+ async function installAddonOnRecord(record, addon) {
107
+ const addonId = addon.id ?? addon.install;
108
+ const dedupeId = typeof addonId === 'string' ? addonId : String(record.installedAddonIds.size + 1);
109
+ if (record.installedAddonIds.has(dedupeId)) return;
110
+ record.installedAddonIds.add(dedupeId);
111
+ await record.browser.install(addon);
112
+ }
113
+
114
+ /**
115
+ * @param {import('./browser.d.ts').SkykitBrowserGlobal} service
116
+ * @param {unknown} target
117
+ * @returns {Promise<import('./browser.d.ts').SkykitBrowser>}
118
+ */
119
+ function whenReady(service, target) {
120
+ const state = getState(service);
121
+ const existing = state.records.find((record) => matchesTarget(record.host, target));
122
+ if (existing) return Promise.resolve(existing.browser);
123
+ return new Promise((resolve, reject) => {
124
+ state.waiters.push({ target, resolve, reject });
125
+ });
126
+ }
127
+
128
+ /**
129
+ * @param {ReturnType<typeof getState>} state
130
+ * @param {ReturnType<typeof getState>['records'][number]} record
131
+ */
132
+ function resolveWaiters(state, record) {
133
+ for (const waiter of [...state.waiters]) {
134
+ if (!matchesTarget(record.host, waiter.target)) continue;
135
+ const index = state.waiters.indexOf(waiter);
136
+ if (index >= 0) state.waiters.splice(index, 1);
137
+ waiter.resolve(record.browser);
138
+ }
139
+ }
140
+
141
+ /** @param {unknown} host @param {unknown} target */
142
+ function matchesTarget(host, target) {
143
+ if (target == null) return true;
144
+ if (target === host) return true;
145
+ if (typeof target !== 'string') return false;
146
+ const element = /** @type {{ matches?: (selector: string) => boolean }} */ (host);
147
+ if (typeof element.matches === 'function') {
148
+ try {
149
+ if (element.matches(target)) return true;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+ return false;
155
+ }
@@ -0,0 +1,13 @@
1
+ import type {
2
+ SkykitBrowser,
3
+ SkykitBrowserConstellationsFacade,
4
+ SkykitBrowserConstellationsOptions,
5
+ } from './browser.js';
6
+
7
+ export declare function installSkykitConstellationsBrowserCapability(
8
+ context: {
9
+ browser: SkykitBrowser;
10
+ host?: unknown;
11
+ options?: SkykitBrowserConstellationsOptions;
12
+ }
13
+ ): Promise<SkykitBrowserConstellationsFacade>;
@@ -0,0 +1,387 @@
1
+ import * as THREE from 'three';
2
+
3
+ import { raDecToIcrsDirection } from '@found-in-space/spatial';
4
+
5
+ import {
6
+ createAnchoredImageCatalog,
7
+ createAnchoredImageSkyPlugin,
8
+ createViewAnchoredImageController,
9
+ } from './anchored-images.js';
10
+ import { createObject3dLayer } from './layers.js';
11
+
12
+ const CONSTELLATIONS_CAPABILITY = 'skykit:browser.constellations';
13
+ const WESTERN_SKYCULTURE_VERSION = '0.3.0';
14
+ const WESTERN_SKYCULTURE_BASE =
15
+ `https://cdn.jsdelivr.net/npm/@found-in-space/stellarium-skycultures-western@${WESTERN_SKYCULTURE_VERSION}/dist/`;
16
+ const WESTERN_SKYCULTURE_MANIFEST = `${WESTERN_SKYCULTURE_BASE}manifest.json`;
17
+ const DEFAULT_BOUNDARY_RADIUS = 8;
18
+
19
+ /**
20
+ * @param {{
21
+ * browser: import('./browser.d.ts').SkykitBrowser;
22
+ * host?: unknown;
23
+ * options?: import('./browser.d.ts').SkykitBrowserConstellationsOptions;
24
+ * }} context
25
+ * @returns {Promise<import('./browser.d.ts').SkykitBrowserConstellationsFacade>}
26
+ */
27
+ export async function installSkykitConstellationsBrowserCapability({ browser, host, options = {} }) {
28
+ const settings = normalizeConstellationOptions(host, options);
29
+ const loaded = await loadSkycultureManifest(settings);
30
+ const manifest = loaded.manifest;
31
+ const assetBaseUrl = settings.assetBaseUrl ?? loaded.assetBaseUrl;
32
+ const root = createConstellationBoundaryObject(manifest, settings);
33
+ let visible = settings.visible !== false;
34
+ let artMode = normalizeArtMode(settings.art);
35
+ /** @type {import('./index.d.ts').SkykitPluginTeardown | null} */
36
+ let boundaryTeardown = null;
37
+ /** @type {import('./index.d.ts').SkykitPluginTeardown | null} */
38
+ let artTeardown = null;
39
+ /** @type {import('./index.d.ts').AnchoredImageSkyPlugin | null} */
40
+ let artPlugin = null;
41
+
42
+ root.visible = visible;
43
+ boundaryTeardown = await browser.install({
44
+ id: 'constellation-boundaries',
45
+ setup(context) {
46
+ const layer = createObject3dLayer({
47
+ id: 'constellation-boundaries',
48
+ object3d: root,
49
+ anchorMode: 'observer-centric',
50
+ priority: settings.priority,
51
+ });
52
+ const remove = context.addPart({
53
+ ...layer,
54
+ dispose() {
55
+ layer.dispose?.();
56
+ disposeObject(root);
57
+ },
58
+ getSnapshot() {
59
+ return {
60
+ id: 'constellation-boundaries',
61
+ visible: root.visible,
62
+ lineCount: root.userData.lineCount ?? 0,
63
+ };
64
+ },
65
+ });
66
+ return remove;
67
+ },
68
+ });
69
+ browser.capabilities.add(CONSTELLATIONS_CAPABILITY);
70
+
71
+ if (artMode !== 'off') {
72
+ await installArt();
73
+ }
74
+
75
+ const handle = {
76
+ async load() {
77
+ return handle;
78
+ },
79
+ show() {
80
+ visible = true;
81
+ syncVisibility(browser, root, visible);
82
+ return visible;
83
+ },
84
+ hide() {
85
+ visible = false;
86
+ syncVisibility(browser, root, visible);
87
+ return visible;
88
+ },
89
+ toggle(force) {
90
+ visible = typeof force === 'boolean' ? force : !visible;
91
+ syncVisibility(browser, root, visible);
92
+ return visible;
93
+ },
94
+ async setArt(mode) {
95
+ artMode = normalizeArtMode(mode);
96
+ if (artMode === 'off') {
97
+ artTeardown?.();
98
+ artTeardown = null;
99
+ artPlugin = null;
100
+ return artMode;
101
+ }
102
+ await installArt();
103
+ return artMode;
104
+ },
105
+ getSnapshot() {
106
+ return {
107
+ capability: CONSTELLATIONS_CAPABILITY,
108
+ visible,
109
+ art: artMode,
110
+ manifestId: manifest.id ?? null,
111
+ lineCount: root.userData.lineCount ?? 0,
112
+ artPlugin: artPlugin?.getSnapshot?.() ?? null,
113
+ };
114
+ },
115
+ dispose() {
116
+ artTeardown?.();
117
+ boundaryTeardown?.();
118
+ artTeardown = null;
119
+ boundaryTeardown = null;
120
+ artPlugin = null;
121
+ },
122
+ };
123
+
124
+ return handle;
125
+
126
+ async function installArt() {
127
+ if (artPlugin) return;
128
+ const catalog = await createAnchoredImageCatalog({
129
+ manifest: toAnchoredImageManifest(manifest, assetBaseUrl),
130
+ });
131
+ artPlugin = createAnchoredImageSkyPlugin({
132
+ id: 'constellation-art',
133
+ catalog,
134
+ controller: createViewAnchoredImageController({
135
+ strategy: 'nearest',
136
+ maxAngleDeg: finiteNumber(settings.artMaxAngleDeg, 60),
137
+ }),
138
+ loading: artMode === 'preload' ? 'preload' : 'lazy',
139
+ opacity: finiteNumber(settings.artOpacity, 0.22),
140
+ skipTextureErrors: settings.skipTextureErrors !== false,
141
+ });
142
+ artTeardown = await browser.install(artPlugin);
143
+ syncVisibility(browser, root, visible);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * @param {unknown} host
149
+ * @param {import('./browser.d.ts').SkykitBrowserConstellationsOptions} options
150
+ * @returns {import('./browser.d.ts').SkykitBrowserConstellationsOptions}
151
+ */
152
+ function normalizeConstellationOptions(host, options) {
153
+ const data = host && typeof host === 'object' && 'dataset' in host
154
+ ? /** @type {{ dataset?: Record<string, string | undefined> }} */ (host).dataset ?? {}
155
+ : {};
156
+ const requested = options.skyculture ?? data.skykitConstellations ?? 'western';
157
+ const manifestUrl = options.manifestUrl
158
+ ?? data.skykitConstellationManifest
159
+ ?? (looksLikeUrl(requested) ? String(requested) : undefined)
160
+ ?? (String(requested || 'western').toLowerCase() === 'western' ? WESTERN_SKYCULTURE_MANIFEST : undefined);
161
+ const assetBaseUrl = options.assetBaseUrl
162
+ ?? data.skykitConstellationAssets
163
+ ?? (manifestUrl ? new URL('./', manifestUrl).href : undefined)
164
+ ?? WESTERN_SKYCULTURE_BASE;
165
+ return {
166
+ ...options,
167
+ skyculture: requested,
168
+ manifestUrl,
169
+ assetBaseUrl,
170
+ art: options.art ?? data.skykitConstellationArt ?? 'off',
171
+ };
172
+ }
173
+
174
+ /**
175
+ * @param {import('./browser.d.ts').SkykitBrowserConstellationsOptions} options
176
+ * @returns {Promise<{ manifest: Record<string, unknown>; assetBaseUrl?: string }>}
177
+ */
178
+ async function loadSkycultureManifest(options) {
179
+ if (options.manifest && typeof options.manifest === 'object') {
180
+ return {
181
+ manifest: /** @type {Record<string, unknown>} */ (options.manifest),
182
+ assetBaseUrl: options.assetBaseUrl,
183
+ };
184
+ }
185
+ const manifestUrl = options.manifestUrl ?? WESTERN_SKYCULTURE_MANIFEST;
186
+ const response = await fetch(manifestUrl);
187
+ if (!response.ok) throw new Error(`Failed to load SkyKit constellation manifest: ${response.status} ${response.statusText}`);
188
+ return {
189
+ manifest: /** @type {Record<string, unknown>} */ (await response.json()),
190
+ assetBaseUrl: options.assetBaseUrl ?? new URL('./', manifestUrl).href,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * @param {Record<string, unknown>} manifest
196
+ * @param {import('./browser.d.ts').SkykitBrowserConstellationsOptions} options
197
+ */
198
+ function createConstellationBoundaryObject(manifest, options) {
199
+ const root = new THREE.Group();
200
+ root.name = 'constellation-boundaries';
201
+ const radius = finiteNumber(options.boundaryRadius, DEFAULT_BOUNDARY_RADIUS);
202
+ const positions = boundaryPositions(manifest, radius);
203
+ const geometry = new THREE.BufferGeometry();
204
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
205
+ const material = new THREE.LineBasicMaterial({
206
+ color: options.boundaryColor ?? 0x7aa7ff,
207
+ transparent: true,
208
+ opacity: finiteNumber(options.boundaryOpacity, 0.35),
209
+ depthWrite: false,
210
+ });
211
+ const lines = new THREE.LineSegments(geometry, material);
212
+ lines.name = 'constellation-boundary-lines';
213
+ lines.renderOrder = finiteNumber(options.renderOrder, -2);
214
+ root.add(lines);
215
+ root.userData.lineCount = positions.length / 6;
216
+ return root;
217
+ }
218
+
219
+ /** @param {Record<string, unknown>} manifest @param {number} radius */
220
+ function boundaryPositions(manifest, radius) {
221
+ const boundaries = /** @type {{ edges?: unknown }} */ (manifest.boundaries ?? {});
222
+ const edges = Array.isArray(boundaries.edges) ? boundaries.edges : [];
223
+ /** @type {number[]} */
224
+ const positions = [];
225
+ for (const edge of edges) {
226
+ if (typeof edge !== 'string') continue;
227
+ const parts = edge.trim().split(/\s+/);
228
+ if (parts.length < 6) continue;
229
+ const start = directionFromRaDecText(parts[2], parts[3]);
230
+ const end = directionFromRaDecText(parts[4], parts[5]);
231
+ if (!start || !end) continue;
232
+ positions.push(
233
+ start.x * radius, start.y * radius, start.z * radius,
234
+ end.x * radius, end.y * radius, end.z * radius,
235
+ );
236
+ }
237
+ return positions;
238
+ }
239
+
240
+ /** @param {string} raText @param {string} decText */
241
+ function directionFromRaDecText(raText, decText) {
242
+ const raHours = parseSexagesimal(raText);
243
+ const decDeg = parseSexagesimal(decText);
244
+ if (!Number.isFinite(raHours) || !Number.isFinite(decDeg)) return null;
245
+ return raDecToIcrsDirection({ raHours, decDeg });
246
+ }
247
+
248
+ /** @param {string} value */
249
+ function parseSexagesimal(value) {
250
+ const sign = value.trim().startsWith('-') ? -1 : 1;
251
+ const clean = value.trim().replace(/^[+-]/, '');
252
+ const parts = clean.split(':').map(Number);
253
+ if (parts.some((part) => !Number.isFinite(part))) return Number.NaN;
254
+ return sign * (parts[0] + (parts[1] ?? 0) / 60 + (parts[2] ?? 0) / 3600);
255
+ }
256
+
257
+ /** @param {Record<string, unknown>} manifest @param {string | undefined} assetBaseUrl */
258
+ function toAnchoredImageManifest(manifest, assetBaseUrl) {
259
+ const constellations = Array.isArray(manifest.constellations) ? manifest.constellations : [];
260
+ return {
261
+ format: 'found-in-space/anchored-image-manifest@1',
262
+ id: String(manifest.id ?? 'constellations'),
263
+ label: String(manifest.id ?? 'Constellations'),
264
+ assetBaseUrl: assetBaseUrl ?? null,
265
+ images: constellations.map((constellation, index) => toAnchoredImage(constellation, assetBaseUrl, index)).filter(Boolean),
266
+ attribution: manifest.license,
267
+ metadata: {
268
+ sourceFormat: manifest.format,
269
+ sourcePackage: manifest.source,
270
+ astrometry: manifest.astrometry,
271
+ },
272
+ };
273
+ }
274
+
275
+ /** @param {unknown} input @param {string | undefined} assetBaseUrl @param {number} index */
276
+ function toAnchoredImage(input, assetBaseUrl, index) {
277
+ const constellation = /** @type {Record<string, unknown>} */ (input && typeof input === 'object' ? input : {});
278
+ const image = /** @type {Record<string, unknown>} */ (constellation.image && typeof constellation.image === 'object' ? constellation.image : {});
279
+ const imageFile = typeof image.file === 'string' ? image.file : '';
280
+ const anchors = Array.isArray(image.anchors) ? image.anchors.map(toAnchoredImageAnchor).filter(Boolean) : [];
281
+ if (!imageFile || anchors.length < 2) return null;
282
+ return {
283
+ id: String(constellation.id ?? constellation.iau ?? `constellation-${index}`),
284
+ label: resolveConstellationLabel(constellation),
285
+ groupId: typeof constellation.iau === 'string' ? constellation.iau : undefined,
286
+ image: {
287
+ src: typeof image.url === 'string'
288
+ ? image.url
289
+ : assetBaseUrl
290
+ ? new URL(imageFile, assetBaseUrl).href
291
+ : imageFile,
292
+ width: Array.isArray(image.size) ? Number(image.size[0]) || 512 : 512,
293
+ height: Array.isArray(image.size) ? Number(image.size[1]) || 512 : 512,
294
+ anchors,
295
+ },
296
+ metadata: {
297
+ sourceConstellationId: constellation.id ?? null,
298
+ iau: constellation.iau ?? null,
299
+ common_name: constellation.common_name ?? null,
300
+ },
301
+ };
302
+ }
303
+
304
+ /** @param {unknown} input */
305
+ function toAnchoredImageAnchor(input) {
306
+ const anchor = /** @type {Record<string, unknown>} */ (input && typeof input === 'object' ? input : {});
307
+ const icrs = /** @type {Record<string, unknown>} */ (anchor.icrs && typeof anchor.icrs === 'object' ? anchor.icrs : {});
308
+ const pixel = /** @type {Record<string, unknown>} */ (anchor.pixel && typeof anchor.pixel === 'object' ? anchor.pixel : {});
309
+ const target = {
310
+ x: Number(icrs.x),
311
+ y: Number(icrs.y),
312
+ z: Number(icrs.z),
313
+ };
314
+ const point = {
315
+ x: Number(pixel.x),
316
+ y: Number(pixel.y),
317
+ };
318
+ if (!Number.isFinite(target.x) || !Number.isFinite(target.y) || !Number.isFinite(target.z)) return null;
319
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return null;
320
+ return {
321
+ pixel: point,
322
+ target: {
323
+ kind: 'direction',
324
+ frame: 'icrs',
325
+ ...target,
326
+ },
327
+ metadata: {
328
+ hip: anchor.hip,
329
+ raDeg: anchor.raDeg,
330
+ decDeg: anchor.decDeg,
331
+ },
332
+ };
333
+ }
334
+
335
+ /** @param {Record<string, unknown>} constellation */
336
+ function resolveConstellationLabel(constellation) {
337
+ const commonName = constellation.common_name;
338
+ if (commonName && typeof commonName === 'object') {
339
+ const source = /** @type {Record<string, unknown>} */ (commonName);
340
+ if (typeof source.native === 'string' && source.native.trim()) return source.native.trim();
341
+ if (typeof source.english === 'string' && source.english.trim()) return source.english.trim();
342
+ }
343
+ return String(constellation.iau ?? constellation.id ?? 'Constellation');
344
+ }
345
+
346
+ /**
347
+ * @param {import('./browser.d.ts').SkykitBrowser} browser
348
+ * @param {THREE.Object3D} boundaryRoot
349
+ * @param {boolean} visible
350
+ */
351
+ function syncVisibility(browser, boundaryRoot, visible) {
352
+ boundaryRoot.visible = visible;
353
+ const artRoot = browser.viewer.roots.observerContentRoot.children.find((child) => child.name === 'constellation-art');
354
+ if (artRoot) artRoot.visible = visible;
355
+ }
356
+
357
+ /** @param {unknown} value */
358
+ function normalizeArtMode(value) {
359
+ const mode = String(value ?? 'off').trim().toLowerCase();
360
+ if (mode === 'preload') return 'preload';
361
+ if (mode === 'lazy' || mode === 'on' || mode === 'true') return 'lazy';
362
+ return 'off';
363
+ }
364
+
365
+ /** @param {unknown} value */
366
+ function looksLikeUrl(value) {
367
+ return typeof value === 'string' && (/^https?:\/\//.test(value) || value.endsWith('.json'));
368
+ }
369
+
370
+ /** @param {unknown} value @param {number} fallback */
371
+ function finiteNumber(value, fallback) {
372
+ const number = Number(value);
373
+ return Number.isFinite(number) ? number : fallback;
374
+ }
375
+
376
+ /** @param {THREE.Object3D} object */
377
+ function disposeObject(object) {
378
+ object.traverse((child) => {
379
+ const mesh = /** @type {THREE.Object3D & { geometry?: { dispose?: () => void }; material?: { dispose?: () => void } | Array<{ dispose?: () => void }> }} */ (child);
380
+ mesh.geometry?.dispose?.();
381
+ if (Array.isArray(mesh.material)) {
382
+ for (const material of mesh.material) material.dispose?.();
383
+ } else {
384
+ mesh.material?.dispose?.();
385
+ }
386
+ });
387
+ }
@@ -0,0 +1,8 @@
1
+ import type {
2
+ SkykitBrowser,
3
+ SkykitBrowserJourneyFacade,
4
+ } from './browser.js';
5
+
6
+ export declare function installSkykitJourneyBrowserCapability(
7
+ context: { browser: SkykitBrowser }
8
+ ): Promise<SkykitBrowserJourneyFacade>;