@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.
Files changed (42) hide show
  1. package/README.md +143 -6
  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 +31 -5
  7. package/src/__tests__/skykit-anchored-images.test.js +32 -4
  8. package/src/__tests__/skykit-browser.test.js +217 -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 +98 -0
  22. package/src/browser.js +215 -13
  23. package/src/data.d.ts +133 -0
  24. package/src/data.js +447 -0
  25. package/src/embed.d.ts +5 -0
  26. package/src/embed.js +52 -2
  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
package/src/touch-os.js CHANGED
@@ -1,12 +1,19 @@
1
1
  import {
2
+ createAppShell,
2
3
  createButton,
3
4
  createColumn,
4
5
  createDPad,
5
6
  createDockLayout,
6
7
  createHoldButton,
8
+ createNode,
9
+ createRect,
7
10
  createRuntime,
11
+ createTabletHomePresentation,
8
12
  createTextLabel,
13
+ createTouchAppRegistry,
9
14
  createValueReadout,
15
+ defineTouchApp,
16
+ rectContainsPoint,
10
17
  } from '@found-in-space/touch-os';
11
18
  import {
12
19
  createHudPanelDriver,
@@ -31,6 +38,185 @@ const DEFAULT_PANEL_DRIVER_OPTIONS = Object.freeze({
31
38
  pointerClaimPolicy: 'block-on-hit',
32
39
  transparent: true,
33
40
  });
41
+ const DEFAULT_TABLET_LAUNCHER_LAYOUT = Object.freeze({
42
+ tileWidth: 84,
43
+ tileHeight: 88,
44
+ gap: 10,
45
+ bodyPadding: 10,
46
+ iconMinSize: 38,
47
+ iconMaxSize: 46,
48
+ iconScale: 0.55,
49
+ iconTop: 2,
50
+ labelGap: 5,
51
+ });
52
+
53
+ const SkykitSurfaceAppFrameComponent = {
54
+ kind: 'skykit-surface-app-frame',
55
+ getChildren(ctx) {
56
+ return ctx.props.child ? [ctx.props.child] : [];
57
+ },
58
+ measure(ctx) {
59
+ const padding = positiveFinite(ctx.props.padding, 0);
60
+ const width = Math.max(0, ctx.constraints.maxWidth - padding * 2);
61
+ const height = Math.max(0, ctx.constraints.maxHeight - padding * 2);
62
+ if (ctx.props.child) {
63
+ ctx.measureChild(ctx.props.child.id, {
64
+ minWidth: 0,
65
+ minHeight: 0,
66
+ maxWidth: width,
67
+ maxHeight: height,
68
+ });
69
+ }
70
+ return {
71
+ width: ctx.constraints.maxWidth,
72
+ height: ctx.constraints.maxHeight,
73
+ };
74
+ },
75
+ layout(ctx) {
76
+ const padding = positiveFinite(ctx.props.padding, 0);
77
+ const content = createRect(
78
+ ctx.bounds.x + padding,
79
+ ctx.bounds.y + padding,
80
+ Math.max(0, ctx.bounds.width - padding * 2),
81
+ Math.max(0, ctx.bounds.height - padding * 2),
82
+ );
83
+ if (ctx.props.child) {
84
+ ctx.setChildBounds(ctx.props.child.id, content);
85
+ }
86
+ ctx.setContentBounds(content);
87
+ },
88
+ render(ctx) {
89
+ const theme = ctx.services.theme.getTokens();
90
+ return [{
91
+ type: 'rect',
92
+ componentId: ctx.id,
93
+ role: 'skykit-surface-app-frame',
94
+ rect: ctx.bounds,
95
+ fill: ctx.props.backgroundColor ?? theme.backgroundColor,
96
+ strokeWidth: 0,
97
+ radius: 0,
98
+ }];
99
+ },
100
+ hitTest(ctx) {
101
+ if (ctx.props.pointerOpaque === false || !rectContainsPoint(ctx.bounds, ctx.point)) {
102
+ return null;
103
+ }
104
+ return {
105
+ targetId: `${ctx.id}:background`,
106
+ role: 'surface-app-background',
107
+ };
108
+ },
109
+ };
110
+
111
+ /**
112
+ * Build a SkyKit-flavored touch-os tablet shell from ordinary touch apps.
113
+ *
114
+ * @param {import('./touch-os.d.ts').SkykitTabletRootOptions} [options]
115
+ * @returns {import('@found-in-space/touch-os').DisplayNode}
116
+ */
117
+ export function createSkykitTabletRoot(options = {}) {
118
+ if (!options || typeof options !== 'object') {
119
+ throw new TypeError('createSkykitTabletRoot requires an options object.');
120
+ }
121
+
122
+ const id = options.id ?? 'skykit-tablet';
123
+ const registry = options.registry ?? createTouchAppRegistry(options.apps ?? []);
124
+ const presentation = options.presentation ?? createTabletHomePresentation({
125
+ homeControl: options.homeControl ?? 'button',
126
+ taskSwitcher: options.taskSwitcher ?? 'cards',
127
+ taskCloseControl: options.taskCloseControl ?? 'button',
128
+ launcherLayout: {
129
+ ...DEFAULT_TABLET_LAUNCHER_LAYOUT,
130
+ ...(options.launcherLayout ?? {}),
131
+ },
132
+ });
133
+
134
+ return createAppShell(id, {
135
+ registry,
136
+ presentation,
137
+ appHostMode: options.appHostMode ?? 'same-runtime',
138
+ homeKey: options.homeKey ?? true,
139
+ keepAlive: options.keepAlive ?? true,
140
+ ...(options.initialSessions === undefined ? {} : { initialSessions: options.initialSessions }),
141
+ ...(options.appStates === undefined ? {} : { appStates: options.appStates }),
142
+ ...(options.getAppState === undefined ? {} : { getAppState: options.getAppState }),
143
+ ...(options.forwardAppOutputs === undefined ? {} : { forwardAppOutputs: options.forwardAppOutputs }),
144
+ ...(options.storage === undefined ? {} : { storage: options.storage }),
145
+ ...(options.surfaces === undefined ? {} : { surfaces: options.surfaces }),
146
+ ...(options.onAppEvent === undefined ? {} : { onAppEvent: options.onAppEvent }),
147
+ ...(options.onShellChange === undefined ? {} : { onShellChange: options.onShellChange }),
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Wrap a display node as a full-screen tablet app. This is useful for surface
153
+ * consumers such as HR diagrams, camera mirrors, or other panel-hosted views.
154
+ *
155
+ * @template TState
156
+ * @param {import('./touch-os.d.ts').SkykitSurfaceAppOptions<TState>} options
157
+ * @returns {import('@found-in-space/touch-os').TouchAppModule<TState>}
158
+ */
159
+ export function createSkykitSurfaceApp(options) {
160
+ if (!options || typeof options !== 'object') {
161
+ throw new TypeError('createSkykitSurfaceApp requires options.');
162
+ }
163
+ const id = requiredString(options.id, 'createSkykitSurfaceApp id');
164
+ const name = requiredString(options.name, 'createSkykitSurfaceApp name');
165
+ const rootId = options.rootId ?? `${id}:root`;
166
+
167
+ return defineTouchApp({
168
+ manifest: {
169
+ id,
170
+ name,
171
+ version: options.version ?? '1.0.0',
172
+ icon: options.icon ?? createSymbolIcon(name),
173
+ capabilities: options.capabilities ?? ['surfaces'],
174
+ preferredWindow: {
175
+ width: options.preferredWindow?.width ?? 360,
176
+ height: options.preferredWindow?.height ?? 300,
177
+ minWidth: options.preferredWindow?.minWidth ?? 260,
178
+ minHeight: options.preferredWindow?.minHeight ?? 180,
179
+ resizable: options.preferredWindow?.resizable ?? false,
180
+ },
181
+ },
182
+ createApp(ctx) {
183
+ return {
184
+ render(state) {
185
+ const child = resolveSurfaceAppNode(options.node, {
186
+ context: ctx,
187
+ state,
188
+ });
189
+ if (!child) {
190
+ return createTextLabel(`${rootId}:empty`, {
191
+ text: options.emptyLabel ?? `${name} unavailable`,
192
+ tone: 'muted',
193
+ align: 'center',
194
+ });
195
+ }
196
+ return createSkykitSurfaceAppFrame(rootId, {
197
+ child,
198
+ padding: options.padding ?? 0,
199
+ pointerOpaque: options.pointerOpaque !== false,
200
+ backgroundColor: options.backgroundColor,
201
+ });
202
+ },
203
+ handleOutput(output) {
204
+ options.onOutput?.(output, { context: ctx });
205
+ emitSkykitSurfaceAppOutput(ctx, output);
206
+ },
207
+ };
208
+ },
209
+ });
210
+ }
211
+
212
+ /**
213
+ * @param {string} id
214
+ * @param {{ child: import('@found-in-space/touch-os').DisplayNode; padding?: number; pointerOpaque?: boolean; backgroundColor?: string }} props
215
+ * @returns {import('@found-in-space/touch-os').DisplayNode}
216
+ */
217
+ function createSkykitSurfaceAppFrame(id, props) {
218
+ return createNode(id, SkykitSurfaceAppFrameComponent, props);
219
+ }
34
220
 
35
221
  /**
36
222
  * Create a SkyKit plugin that mounts a touch-os HUD and routes action outputs
@@ -901,6 +1087,95 @@ function finiteNumber(value, fallback) {
901
1087
  return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
902
1088
  }
903
1089
 
1090
+ /**
1091
+ * @param {unknown} value
1092
+ * @param {string} context
1093
+ * @returns {string}
1094
+ */
1095
+ function requiredString(value, context) {
1096
+ if (typeof value !== 'string' || value.trim().length === 0) {
1097
+ throw new TypeError(`${context} must be a non-empty string.`);
1098
+ }
1099
+ return value;
1100
+ }
1101
+
1102
+ /**
1103
+ * @param {import('./touch-os.d.ts').SkykitSurfaceAppOptions['node']} node
1104
+ * @param {import('./touch-os.d.ts').SkykitSurfaceAppRenderContext} context
1105
+ * @returns {import('@found-in-space/touch-os').DisplayNode | null}
1106
+ */
1107
+ function resolveSurfaceAppNode(node, context) {
1108
+ const resolved = typeof node === 'function' ? node(context) : node;
1109
+ if (resolved == null) return null;
1110
+ if (!resolved || typeof resolved !== 'object' || typeof resolved.id !== 'string') {
1111
+ throw new TypeError('SkyKit surface app nodes must be display nodes.');
1112
+ }
1113
+ return resolved;
1114
+ }
1115
+
1116
+ /**
1117
+ * @param {import('@found-in-space/touch-os').TouchAppContext} context
1118
+ * @param {unknown} output
1119
+ */
1120
+ function emitSkykitSurfaceAppOutput(context, output) {
1121
+ if (isTouchOsActionOutput(output)) {
1122
+ context.actions.emit({
1123
+ type: 'app-action',
1124
+ appId: context.appId,
1125
+ instanceId: context.instanceId,
1126
+ windowId: context.windowId,
1127
+ name: output.actionId,
1128
+ ...(output.payload === undefined ? {} : { payload: output.payload }),
1129
+ componentId: output.componentId,
1130
+ });
1131
+ return;
1132
+ }
1133
+ if (isTouchOsChangeOutput(output)) {
1134
+ context.actions.emit({
1135
+ type: 'app-change',
1136
+ appId: context.appId,
1137
+ instanceId: context.instanceId,
1138
+ windowId: context.windowId,
1139
+ name: `${output.field}.change`,
1140
+ payload: {
1141
+ field: output.field,
1142
+ value: output.value,
1143
+ },
1144
+ componentId: output.componentId,
1145
+ });
1146
+ }
1147
+ }
1148
+
1149
+ /**
1150
+ * @param {unknown} output
1151
+ * @returns {output is { type: 'change-request'; componentId: string; field: string; value: unknown }}
1152
+ */
1153
+ function isTouchOsChangeOutput(output) {
1154
+ return Boolean(output)
1155
+ && typeof output === 'object'
1156
+ && output.type === 'change-request'
1157
+ && typeof output.componentId === 'string'
1158
+ && typeof output.field === 'string';
1159
+ }
1160
+
1161
+ /**
1162
+ * @param {string} name
1163
+ * @returns {{ kind: 'symbol'; value: string }}
1164
+ */
1165
+ function createSymbolIcon(name) {
1166
+ const letters = name
1167
+ .split(/\s+/)
1168
+ .filter(Boolean)
1169
+ .map((part) => part[0])
1170
+ .join('')
1171
+ .slice(0, 2)
1172
+ .toUpperCase();
1173
+ return {
1174
+ kind: 'symbol',
1175
+ value: letters || 'SK',
1176
+ };
1177
+ }
1178
+
904
1179
  function now() {
905
1180
  return typeof performance !== 'undefined' && typeof performance.now === 'function'
906
1181
  ? performance.now()
package/src/utils.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import * as THREE from 'three';
2
+ import {
3
+ resolveSpatialLookAt,
4
+ } from '@found-in-space/spatial';
2
5
 
3
6
  export const DEFAULT_MAG_LIMIT = 6.5;
4
7
  export const IDENTITY_QUATERNION = Object.freeze({ x: 0, y: 0, z: 0, w: 1 });
@@ -86,16 +89,17 @@ export function resolveAnchorRoot(roots, anchorMode, scaleBandId) {
86
89
  * @param {number} revision
87
90
  * @returns {SkykitViewState}
88
91
  */
89
- export function normalizeViewState(input = {}, revision = 0) {
92
+ export function normalizeViewState(input = {}, revision = 0, options = {}) {
90
93
  const observerPc = normalizeVector3(input.observerPc, { x: 0, y: 0, z: 0 });
91
94
  const coordinateUnitsPerParsec = positiveFinite(input.coordinateUnitsPerParsec, 1);
95
+ const look = resolveViewLook(input, observerPc, options);
92
96
  return {
93
97
  revision,
94
98
  observerPc,
95
99
  renderObserverPosition: normalizeVector3(input.renderObserverPosition, observerPc),
96
- targetPc: input.targetPc == null ? null : normalizeVector3(input.targetPc, { x: 0, y: 0, z: 0 }),
97
- directionIcrs: input.directionIcrs == null ? null : normalizeVector3(input.directionIcrs, { x: 0, y: 0, z: -1 }),
98
- orientationIcrs: input.orientationIcrs == null ? null : normalizeQuaternion(input.orientationIcrs, IDENTITY_QUATERNION),
100
+ lookAt: look.lookAt,
101
+ targetPc: look.targetPc,
102
+ orientationIcrs: look.orientationIcrs,
99
103
  limitingMagnitude: finiteNumber(input.limitingMagnitude, DEFAULT_MAG_LIMIT),
100
104
  ...(input.verticalFovDeg !== undefined ? { verticalFovDeg: positiveFinite(input.verticalFovDeg, 40) } : {}),
101
105
  ...(input.aspectRatio !== undefined ? { aspectRatio: positiveFinite(input.aspectRatio, 1) } : {}),
@@ -110,8 +114,8 @@ export function cloneViewState(view) {
110
114
  ...view,
111
115
  observerPc: cloneVector3(view.observerPc),
112
116
  renderObserverPosition: cloneVector3(view.renderObserverPosition),
117
+ lookAt: cloneLookAt(view.lookAt),
113
118
  targetPc: view.targetPc ? cloneVector3(view.targetPc) : null,
114
- directionIcrs: view.directionIcrs ? cloneVector3(view.directionIcrs) : null,
115
119
  orientationIcrs: view.orientationIcrs ? cloneQuaternion(view.orientationIcrs) : null,
116
120
  motion: view.motion ? cloneMotion(view.motion) : null,
117
121
  };
@@ -125,7 +129,6 @@ export function toStarOctreeViewPatch(view) {
125
129
  limitingMagnitude: view.limitingMagnitude,
126
130
  mDesired: view.limitingMagnitude,
127
131
  ...(view.targetPc ? { targetPc: view.targetPc } : {}),
128
- ...(view.directionIcrs ? { directionIcrs: view.directionIcrs } : {}),
129
132
  ...(view.orientationIcrs ? { orientationIcrs: view.orientationIcrs } : {}),
130
133
  ...(view.verticalFovDeg !== undefined ? { verticalFovDeg: view.verticalFovDeg } : {}),
131
134
  ...(view.aspectRatio !== undefined ? { aspectRatio: view.aspectRatio } : {}),
@@ -134,6 +137,93 @@ export function toStarOctreeViewPatch(view) {
134
137
  return patch;
135
138
  }
136
139
 
140
+ /**
141
+ * @param {Partial<SkykitViewState>} input
142
+ * @param {Record<string, unknown>} [options]
143
+ * @returns {Promise<Partial<SkykitViewState>>}
144
+ */
145
+ export async function resolveViewLookAtInput(input = {}, options = {}) {
146
+ const observerPc = normalizeVector3(
147
+ input.observerPc ?? /** @type {{ observerPc?: unknown }} */ (options).observerPc,
148
+ { x: 0, y: 0, z: 0 },
149
+ );
150
+ const lookInput = input.lookAt
151
+ ?? (input.orientationIcrs ? { orientationIcrs: input.orientationIcrs } : null);
152
+ if (!lookInput) return input;
153
+ const resolved = await resolveSpatialLookAt(lookInput, {
154
+ observerPc,
155
+ resolveStar: typeof options.resolveStar === 'function'
156
+ ? /** @type {import('@found-in-space/spatial').ResolveSpatialLookAtOptions['resolveStar']} */ (options.resolveStar)
157
+ : undefined,
158
+ resolveBookmark: typeof options.resolveBookmark === 'function'
159
+ ? /** @type {import('@found-in-space/spatial').ResolveSpatialLookAtOptions['resolveBookmark']} */ (options.resolveBookmark)
160
+ : undefined,
161
+ });
162
+ const resolvedLook = /** @type {import('@found-in-space/spatial').SpatialResolvedLookAt} */ (resolved);
163
+ return {
164
+ ...input,
165
+ lookAt: /** @type {import('./index.d.ts').SkykitLookAtInput | null} */ (resolvedLook.lookAt),
166
+ targetPc: resolvedLook.targetPc,
167
+ orientationIcrs: resolvedLook.orientationIcrs,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * @param {Partial<SkykitViewState>} input
173
+ * @param {Vector3Like} observerPc
174
+ * @param {Record<string, unknown>} [options]
175
+ * @returns {{ lookAt: import('./index.d.ts').SkykitLookAtInput | null; targetPc: Vector3Like | null; orientationIcrs: QuaternionLike | null }}
176
+ */
177
+ function resolveViewLook(input, observerPc, options = {}) {
178
+ const lookInput = input.lookAt
179
+ ?? (input.orientationIcrs ? { orientationIcrs: input.orientationIcrs } : null);
180
+ if (!lookInput) {
181
+ return {
182
+ lookAt: null,
183
+ targetPc: null,
184
+ orientationIcrs: null,
185
+ };
186
+ }
187
+ const resolved = resolveSpatialLookAt(lookInput, {
188
+ observerPc,
189
+ resolveStar: typeof options.resolveStar === 'function'
190
+ ? /** @type {import('@found-in-space/spatial').ResolveSpatialLookAtOptions['resolveStar']} */ (options.resolveStar)
191
+ : undefined,
192
+ resolveBookmark: typeof options.resolveBookmark === 'function'
193
+ ? /** @type {import('@found-in-space/spatial').ResolveSpatialLookAtOptions['resolveBookmark']} */ (options.resolveBookmark)
194
+ : undefined,
195
+ });
196
+ if (resolved && typeof /** @type {Promise<unknown>} */ (resolved).then === 'function') {
197
+ throw new TypeError('normalizeViewState() received an async lookAt resolver result.');
198
+ }
199
+ const resolvedLook = /** @type {import('@found-in-space/spatial').SpatialResolvedLookAt} */ (resolved);
200
+ return {
201
+ lookAt: /** @type {import('./index.d.ts').SkykitLookAtInput | null} */ (cloneLookAt(resolvedLook.lookAt)),
202
+ targetPc: resolvedLook.targetPc
203
+ ? cloneVector3(resolvedLook.targetPc)
204
+ : (input.targetPc == null ? null : normalizeVector3(input.targetPc, { x: 0, y: 0, z: 0 })),
205
+ orientationIcrs: resolvedLook.orientationIcrs ? cloneQuaternion(resolvedLook.orientationIcrs) : null,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * @param {unknown} lookAt
211
+ * @returns {import('./index.d.ts').SkykitLookAtInput | null}
212
+ */
213
+ function cloneLookAt(lookAt) {
214
+ if (!lookAt || typeof lookAt !== 'object') return null;
215
+ const source = /** @type {Record<string, unknown>} */ (lookAt);
216
+ return {
217
+ ...source,
218
+ ...(source.targetPc && typeof source.targetPc === 'object'
219
+ ? { targetPc: cloneVector3(/** @type {Vector3Like} */ (source.targetPc)) }
220
+ : {}),
221
+ ...(source.orientationIcrs && typeof source.orientationIcrs === 'object'
222
+ ? { orientationIcrs: cloneQuaternion(/** @type {QuaternionLike} */ (source.orientationIcrs)) }
223
+ : {}),
224
+ };
225
+ }
226
+
137
227
  /**
138
228
  * @param {SkykitPluginInput} plugin
139
229
  * @param {SkykitThreePluginContext} context
@@ -0,0 +1,10 @@
1
+ import type * as ThreeNamespace from 'three';
2
+
3
+ export declare const THREE: typeof ThreeNamespace;
4
+ export {
5
+ createSkykitBrowser,
6
+ type SkykitBrowser,
7
+ type SkykitBrowserObjectHandle,
8
+ type SkykitBrowserObjectOptions,
9
+ type SkykitBrowserOptions,
10
+ } from './browser.js';
@@ -0,0 +1,4 @@
1
+ import * as THREE from 'three';
2
+
3
+ export { THREE };
4
+ export { createSkykitBrowser } from './browser.js';
package/src/viewer.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  mountRenderer,
16
16
  normalizeViewState,
17
17
  positiveFinite,
18
+ resolveViewLookAtInput,
18
19
  setupPlugin,
19
20
  snapshotPart,
20
21
  syncRootsFromView,
@@ -71,7 +72,7 @@ export async function createSkykitViewer(options = {}) {
71
72
  let started = false;
72
73
  let elapsedSeconds = 0;
73
74
  const initialProjectionView = resolveCameraProjectionView(camera);
74
- let view = normalizeViewState({
75
+ const initialViewInput = await resolveViewLookAtInput({
75
76
  ...options.view,
76
77
  ...(options.view?.verticalFovDeg === undefined && initialProjectionView.verticalFovDeg !== undefined
77
78
  ? { verticalFovDeg: initialProjectionView.verticalFovDeg }
@@ -83,8 +84,11 @@ export async function createSkykitViewer(options = {}) {
83
84
  renderObserverPosition: options.view?.renderObserverPosition ?? observerRig.getRenderObserverPosition(),
84
85
  orientationIcrs: options.view?.orientationIcrs ?? observerRig.getOrientationIcrs?.() ?? null,
85
86
  motion: options.view?.motion ?? observerRig.getMotion?.() ?? null,
86
- }, 0);
87
+ }, viewLookResolverOptions(options.view?.observerPc ?? observerRig.getObserverPc()));
88
+ let view = normalizeViewState(initialViewInput, 0, viewLookResolverOptions(initialViewInput.observerPc));
87
89
  const initialView = cloneViewState(view);
90
+ observerRig.setObserverPc?.(view.observerPc);
91
+ if (view.orientationIcrs) observerRig.setOrientationIcrs?.(view.orientationIcrs);
88
92
 
89
93
  addRootToScene(scene, roots.originContentRoot);
90
94
  addRootToScene(scene, roots.observerContentRoot);
@@ -112,6 +116,7 @@ export async function createSkykitViewer(options = {}) {
112
116
  observerRig,
113
117
  actions,
114
118
  addPart,
119
+ addPlugin,
115
120
  getViewState,
116
121
  requestViewState,
117
122
  update,
@@ -139,10 +144,7 @@ export async function createSkykitViewer(options = {}) {
139
144
  }
140
145
 
141
146
  for (const plugin of pluginInputs) {
142
- const teardown = await setupPlugin(plugin, context);
143
- if (typeof teardown === 'function') {
144
- disposables.push(teardown);
145
- }
147
+ await installPlugin(plugin);
146
148
  }
147
149
 
148
150
  for (const part of orderedParts()) {
@@ -176,6 +178,32 @@ export async function createSkykitViewer(options = {}) {
176
178
  };
177
179
  }
178
180
 
181
+ /**
182
+ * @param {import('./index.d.ts').SkykitPluginInput} plugin
183
+ * @returns {Promise<SkykitPluginTeardown>}
184
+ */
185
+ async function addPlugin(plugin) {
186
+ assertActive();
187
+ return installPlugin(plugin, true);
188
+ }
189
+
190
+ /**
191
+ * @param {import('./index.d.ts').SkykitPluginInput} plugin
192
+ * @param {boolean} [record]
193
+ * @returns {Promise<SkykitPluginTeardown>}
194
+ */
195
+ async function installPlugin(plugin, record = false) {
196
+ const teardown = await setupPlugin(plugin, context);
197
+ if (record) pluginInputs.push(plugin);
198
+ if (typeof teardown !== 'function') return () => {};
199
+ disposables.push(teardown);
200
+ return () => {
201
+ const index = disposables.indexOf(teardown);
202
+ if (index >= 0) disposables.splice(index, 1);
203
+ void teardown();
204
+ };
205
+ }
206
+
179
207
  /**
180
208
  * @param {SkykitThreePart} part
181
209
  */
@@ -192,9 +220,8 @@ export async function createSkykitViewer(options = {}) {
192
220
  */
193
221
  async function removePart(part) {
194
222
  const index = parts.indexOf(part);
195
- if (index >= 0) {
196
- parts.splice(index, 1);
197
- }
223
+ if (index < 0) return;
224
+ parts.splice(index, 1);
198
225
  emit({ type: 'part/remove', part });
199
226
  await detachAndDisposePart(part);
200
227
  }
@@ -209,11 +236,32 @@ export async function createSkykitViewer(options = {}) {
209
236
  */
210
237
  function requestViewState(patch, reason) {
211
238
  assertActive();
239
+ const normalizedPatch = normalizeRequestedViewPatch(patch);
240
+ if (lookAtNeedsAsyncResolution(normalizedPatch)) {
241
+ const observerPc = /** @type {Partial<SkykitViewState>} */ (normalizedPatch).observerPc ?? view.observerPc;
242
+ void resolveViewLookAtInput(normalizedPatch, viewLookResolverOptions(observerPc))
243
+ .then((resolvedPatch) => {
244
+ if (disposed) return;
245
+ pendingViewPatch = {
246
+ ...(pendingViewPatch ?? {}),
247
+ ...resolvedPatch,
248
+ };
249
+ })
250
+ .catch((error) => {
251
+ emit({
252
+ type: 'view/lookAt-error',
253
+ reason,
254
+ error: error instanceof Error ? error.message : String(error),
255
+ });
256
+ });
257
+ emit({ type: 'view/request', reason, patch: normalizedPatch });
258
+ return;
259
+ }
212
260
  pendingViewPatch = {
213
261
  ...(pendingViewPatch ?? {}),
214
- ...patch,
262
+ ...normalizedPatch,
215
263
  };
216
- emit({ type: 'view/request', reason, patch });
264
+ emit({ type: 'view/request', reason, patch: normalizedPatch });
217
265
  }
218
266
 
219
267
  /**
@@ -356,7 +404,7 @@ export async function createSkykitViewer(options = {}) {
356
404
  }
357
405
  }
358
406
  for (const part of [...orderedParts()].reverse()) {
359
- await detachAndDisposePart(part);
407
+ await removePart(part);
360
408
  }
361
409
  for (const disposable of [...disposables].reverse()) {
362
410
  await disposable();
@@ -454,6 +502,56 @@ export async function createSkykitViewer(options = {}) {
454
502
  return [...parts].sort(compareParts);
455
503
  }
456
504
 
505
+ /** @param {Partial<SkykitViewState>} patch */
506
+ function normalizeRequestedViewPatch(patch) {
507
+ if (!patch || typeof patch !== 'object') return patch;
508
+ if ('lookAt' in patch) return patch;
509
+ if (patch.orientationIcrs) {
510
+ return {
511
+ ...patch,
512
+ lookAt: { orientationIcrs: patch.orientationIcrs },
513
+ };
514
+ }
515
+ if (patch.targetPc) {
516
+ return {
517
+ ...patch,
518
+ lookAt: { targetPc: patch.targetPc },
519
+ };
520
+ }
521
+ return patch;
522
+ }
523
+
524
+ /** @param {Partial<SkykitViewState>} patch */
525
+ function lookAtNeedsAsyncResolution(patch) {
526
+ if (!patch || typeof patch !== 'object' || !('lookAt' in patch)) return false;
527
+ const lookAt = /** @type {Record<string, unknown> | null} */ (patch.lookAt);
528
+ return !!lookAt
529
+ && typeof lookAt === 'object'
530
+ && 'star' in lookAt
531
+ && !('targetPc' in lookAt)
532
+ && typeof options.resolveLookAtStar === 'function';
533
+ }
534
+
535
+ /** @param {unknown} observerPc */
536
+ function viewLookResolverOptions(observerPc) {
537
+ return {
538
+ observerPc,
539
+ resolveStar: typeof options.resolveLookAtStar === 'function'
540
+ ? /** @type {import('@found-in-space/spatial').ResolveSpatialLookAtOptions['resolveStar']} */ (
541
+ (star, lookAt) => options.resolveLookAtStar?.(
542
+ star,
543
+ /** @type {import('./index.d.ts').SkykitLookAtInput} */ (lookAt),
544
+ )
545
+ )
546
+ : undefined,
547
+ resolveBookmark: typeof options.resolveLookAtBookmark === 'function'
548
+ ? /** @type {import('@found-in-space/spatial').ResolveSpatialLookAtOptions['resolveBookmark']} */ (
549
+ (bookmarkId, lookAt) => options.resolveLookAtBookmark?.(bookmarkId, lookAt)
550
+ )
551
+ : undefined,
552
+ };
553
+ }
554
+
457
555
  /**
458
556
  * @param {SkykitViewer} currentViewer
459
557
  * @returns {SkykitThreePluginContext}