@found-in-space/skykit 0.2.0-alpha.1 → 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.
- package/README.md +143 -6
- package/examples/custom-object-layer/custom-object-layer.js +1 -24
- package/examples/xr-free-roam/index.html +62 -4
- package/examples/xr-free-roam/xr-free-roam.css +249 -18
- package/examples/xr-free-roam/xr-free-roam.js +644 -217
- package/package.json +31 -5
- package/src/__tests__/skykit-anchored-images.test.js +32 -4
- package/src/__tests__/skykit-browser.test.js +217 -0
- package/src/__tests__/skykit-data.test.js +131 -0
- package/src/__tests__/skykit-parallax.test.js +4 -4
- package/src/__tests__/skykit-touch-os.test.js +71 -0
- package/src/__tests__/skykit-xr.test.js +123 -2
- package/src/__tests__/skykit.test.js +138 -1
- package/src/anchored-images.js +14 -15
- package/src/browser-addons.d.ts +16 -0
- package/src/browser-addons.js +155 -0
- package/src/browser-constellations.d.ts +13 -0
- package/src/browser-constellations.js +387 -0
- package/src/browser-journey.d.ts +8 -0
- package/src/browser-journey.js +240 -0
- package/src/browser.d.ts +98 -0
- package/src/browser.js +215 -13
- package/src/data.d.ts +133 -0
- package/src/data.js +447 -0
- package/src/embed.d.ts +5 -0
- package/src/embed.js +52 -2
- package/src/hr-diagram.js +23 -5
- package/src/index.d.ts +32 -7
- package/src/plugins.js +87 -43
- package/src/story.d.ts +57 -0
- package/src/story.js +396 -0
- package/src/three-shim.d.ts +32 -0
- package/src/touch-os.d.ts +70 -0
- package/src/touch-os.js +275 -0
- package/src/utils.js +96 -6
- package/src/viewer-entry.d.ts +10 -0
- package/src/viewer-entry.js +4 -0
- package/src/viewer.js +110 -12
- package/src/xr/plugins.js +224 -13
- package/src/xr/session.js +60 -14
- package/src/xr.d.ts +22 -0
- package/src/xr.js +1 -0
|
@@ -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,240 @@
|
|
|
1
|
+
import { createJourney } from '@found-in-space/journey';
|
|
2
|
+
import { parseSpatialLookAtText } from '@found-in-space/spatial';
|
|
3
|
+
|
|
4
|
+
import { SKYKIT_ACTIONS } from './actions.js';
|
|
5
|
+
import {
|
|
6
|
+
createSkykitJourneyPlugin,
|
|
7
|
+
createSkykitNavigationPlugin,
|
|
8
|
+
} from './plugins.js';
|
|
9
|
+
|
|
10
|
+
const NAVIGATION_CAPABILITY = 'skykit:navigation';
|
|
11
|
+
const JOURNEY_CAPABILITY = 'skykit:browser.journey';
|
|
12
|
+
const SOURCE = 'browser.journey';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {{ browser: import('./browser.d.ts').SkykitBrowser }} context
|
|
16
|
+
* @returns {Promise<import('./browser.d.ts').SkykitBrowserJourneyFacade>}
|
|
17
|
+
*/
|
|
18
|
+
export async function installSkykitJourneyBrowserCapability({ browser }) {
|
|
19
|
+
browser.capabilities.add(JOURNEY_CAPABILITY);
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
transitionTo(viewOrScene, options) {
|
|
23
|
+
return transitionTo(browser, viewOrScene, options);
|
|
24
|
+
},
|
|
25
|
+
applyScene(sceneSpec) {
|
|
26
|
+
return applyScene(browser, sceneSpec);
|
|
27
|
+
},
|
|
28
|
+
load(input, options) {
|
|
29
|
+
return loadJourney(browser, input, options);
|
|
30
|
+
},
|
|
31
|
+
getSnapshot() {
|
|
32
|
+
return {
|
|
33
|
+
capability: JOURNEY_CAPABILITY,
|
|
34
|
+
navigationInstalled: browser.capabilities.has(NAVIGATION_CAPABILITY),
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {import('./browser.d.ts').SkykitBrowser} browser
|
|
42
|
+
*/
|
|
43
|
+
async function ensureNavigation(browser) {
|
|
44
|
+
if (browser.capabilities.has(NAVIGATION_CAPABILITY)) return;
|
|
45
|
+
await browser.install(createSkykitNavigationPlugin());
|
|
46
|
+
browser.capabilities.add(NAVIGATION_CAPABILITY);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {import('./browser.d.ts').SkykitBrowser} browser
|
|
51
|
+
* @param {unknown} viewOrScene
|
|
52
|
+
* @param {Record<string, unknown>} [options]
|
|
53
|
+
*/
|
|
54
|
+
async function transitionTo(browser, viewOrScene, options = {}) {
|
|
55
|
+
await ensureNavigation(browser);
|
|
56
|
+
const payload = normalizeTransitionPayload(viewOrScene, options);
|
|
57
|
+
return browser.viewer.actions.invoke(SKYKIT_ACTIONS.navigation.transitionTo, payload, { source: SOURCE });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {import('./browser.d.ts').SkykitBrowser} browser
|
|
62
|
+
* @param {unknown} sceneSpec
|
|
63
|
+
*/
|
|
64
|
+
async function applyScene(browser, sceneSpec) {
|
|
65
|
+
const scene = /** @type {Record<string, unknown> | null} */ (
|
|
66
|
+
sceneSpec && typeof sceneSpec === 'object' ? sceneSpec : null
|
|
67
|
+
);
|
|
68
|
+
if (!scene) return null;
|
|
69
|
+
if (scene.view && typeof scene.view === 'object') {
|
|
70
|
+
browser.viewer.requestViewState(normalizeView(/** @type {Record<string, unknown>} */ (scene.view)), SOURCE);
|
|
71
|
+
}
|
|
72
|
+
const navigation = /** @type {Record<string, unknown> | null} */ (
|
|
73
|
+
scene.navigation && typeof scene.navigation === 'object' ? scene.navigation : null
|
|
74
|
+
);
|
|
75
|
+
if (navigation?.transitionTo) {
|
|
76
|
+
return transitionTo(browser, navigation.transitionTo);
|
|
77
|
+
}
|
|
78
|
+
return scene;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {import('./browser.d.ts').SkykitBrowser} browser
|
|
83
|
+
* @param {unknown} input
|
|
84
|
+
* @param {Record<string, unknown>} [options]
|
|
85
|
+
* @returns {Promise<import('./browser.d.ts').SkykitBrowserJourneyInstance>}
|
|
86
|
+
*/
|
|
87
|
+
async function loadJourney(browser, input, options = {}) {
|
|
88
|
+
await ensureNavigation(browser);
|
|
89
|
+
const definition = await loadJourneyDefinition(input);
|
|
90
|
+
const plugin = isTimedJourney(definition)
|
|
91
|
+
? createSkykitJourneyPlugin({
|
|
92
|
+
...options,
|
|
93
|
+
timedJourney: definition,
|
|
94
|
+
})
|
|
95
|
+
: createSkykitJourneyPlugin({
|
|
96
|
+
...options,
|
|
97
|
+
journey: createJourney(/** @type {import('@found-in-space/journey').CreateJourneyOptions} */ (definition)),
|
|
98
|
+
});
|
|
99
|
+
const uninstall = await browser.install(plugin);
|
|
100
|
+
let disposed = false;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
goTo(sceneId) {
|
|
104
|
+
assertActive();
|
|
105
|
+
return plugin.goTo?.(sceneId) ?? Promise.resolve(null);
|
|
106
|
+
},
|
|
107
|
+
next() {
|
|
108
|
+
assertActive();
|
|
109
|
+
return plugin.next?.() ?? Promise.resolve(null);
|
|
110
|
+
},
|
|
111
|
+
previous() {
|
|
112
|
+
assertActive();
|
|
113
|
+
return plugin.previous?.() ?? Promise.resolve(null);
|
|
114
|
+
},
|
|
115
|
+
play(payload) {
|
|
116
|
+
assertActive();
|
|
117
|
+
return plugin.play?.(payload) ?? 0;
|
|
118
|
+
},
|
|
119
|
+
pause() {
|
|
120
|
+
assertActive();
|
|
121
|
+
return plugin.pause?.() ?? 0;
|
|
122
|
+
},
|
|
123
|
+
seek(timeSecs) {
|
|
124
|
+
assertActive();
|
|
125
|
+
return plugin.seek?.({ timeSecs }) ?? 0;
|
|
126
|
+
},
|
|
127
|
+
getSnapshot() {
|
|
128
|
+
return {
|
|
129
|
+
disposed,
|
|
130
|
+
plugin: plugin.getSnapshot?.() ?? null,
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
dispose() {
|
|
134
|
+
if (disposed) return;
|
|
135
|
+
disposed = true;
|
|
136
|
+
uninstall();
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
function assertActive() {
|
|
141
|
+
if (disposed) throw new Error('SkyKit journey instance has been disposed.');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {unknown} input
|
|
147
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
148
|
+
*/
|
|
149
|
+
async function loadJourneyDefinition(input) {
|
|
150
|
+
if (typeof input === 'string') {
|
|
151
|
+
const response = await fetch(input);
|
|
152
|
+
if (!response.ok) throw new Error(`Failed to load SkyKit journey: ${response.status} ${response.statusText}`);
|
|
153
|
+
return /** @type {Record<string, unknown>} */ (await response.json());
|
|
154
|
+
}
|
|
155
|
+
if (!input || typeof input !== 'object') {
|
|
156
|
+
throw new TypeError('browser.journey.load() requires a URL or journey definition.');
|
|
157
|
+
}
|
|
158
|
+
return /** @type {Record<string, unknown>} */ (input);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** @param {Record<string, unknown>} definition */
|
|
162
|
+
function isTimedJourney(definition) {
|
|
163
|
+
return Number.isFinite(Number(definition.durationSecs))
|
|
164
|
+
&& (Array.isArray(definition.locationWaypoints)
|
|
165
|
+
|| Array.isArray(definition.cameraLookWaypoints)
|
|
166
|
+
|| Array.isArray(definition.cameraWaypoints));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @param {unknown} input
|
|
171
|
+
* @param {Record<string, unknown>} [options]
|
|
172
|
+
*/
|
|
173
|
+
function normalizeTransitionPayload(input, options = {}) {
|
|
174
|
+
const source = /** @type {Record<string, unknown>} */ (
|
|
175
|
+
input && typeof input === 'object' ? input : { lookAt: input }
|
|
176
|
+
);
|
|
177
|
+
if (source.navigation && typeof source.navigation === 'object') {
|
|
178
|
+
const navigation = /** @type {Record<string, unknown>} */ (source.navigation);
|
|
179
|
+
if (navigation.transitionTo) return normalizeTransitionPayload(navigation.transitionTo, options);
|
|
180
|
+
}
|
|
181
|
+
const viewSource = source.view && typeof source.view === 'object'
|
|
182
|
+
? /** @type {Record<string, unknown>} */ (source.view)
|
|
183
|
+
: source.to && typeof source.to === 'object'
|
|
184
|
+
? /** @type {Record<string, unknown>} */ (source.to)
|
|
185
|
+
: stripTransitionOptions(source);
|
|
186
|
+
return {
|
|
187
|
+
...stripUndefined({
|
|
188
|
+
...source,
|
|
189
|
+
...options,
|
|
190
|
+
view: normalizeView(viewSource),
|
|
191
|
+
durationSecs: options.durationSecs ?? source.durationSecs,
|
|
192
|
+
}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** @param {Record<string, unknown>} input */
|
|
197
|
+
function normalizeView(input) {
|
|
198
|
+
const lookAt = input.lookAt ?? (
|
|
199
|
+
hasLookAtShape(input) ? input : undefined
|
|
200
|
+
);
|
|
201
|
+
return {
|
|
202
|
+
...input,
|
|
203
|
+
...(lookAt !== undefined ? { lookAt: normalizeLookAt(lookAt) } : {}),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** @param {unknown} input */
|
|
208
|
+
function normalizeLookAt(input) {
|
|
209
|
+
if (typeof input !== 'string') return input;
|
|
210
|
+
return parseSpatialLookAtText(input) ?? { star: input };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** @param {Record<string, unknown>} input */
|
|
214
|
+
function stripTransitionOptions(input) {
|
|
215
|
+
const {
|
|
216
|
+
durationSecs: _durationSecs,
|
|
217
|
+
movement: _movement,
|
|
218
|
+
orientation: _orientation,
|
|
219
|
+
orientationDurationSecs: _orientationDurationSecs,
|
|
220
|
+
movementDurationSecs: _movementDurationSecs,
|
|
221
|
+
onArrive: _onArrive,
|
|
222
|
+
...view
|
|
223
|
+
} = input;
|
|
224
|
+
return view;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** @param {Record<string, unknown>} input */
|
|
228
|
+
function hasLookAtShape(input) {
|
|
229
|
+
return 'raDeg' in input
|
|
230
|
+
|| 'raHours' in input
|
|
231
|
+
|| 'decDeg' in input
|
|
232
|
+
|| 'targetPc' in input
|
|
233
|
+
|| 'orientationIcrs' in input
|
|
234
|
+
|| 'star' in input;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** @param {Record<string, unknown>} input */
|
|
238
|
+
function stripUndefined(input) {
|
|
239
|
+
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
240
|
+
}
|