@webspatial/core-sdk 1.4.0 → 1.6.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.
@@ -9,8 +9,13 @@ import {
9
9
  isValidWorldScalingType,
10
10
  isValidWorldAlignmentType,
11
11
  isValidBaseplateVisibilityType,
12
+ PWAManifest,
13
+ XRSpatialSceneConfig,
14
+ XRSpatialSceneDefaults,
12
15
  } from './types/types'
13
16
  import { SpatialSceneCreationOptionsInternal } from './types/internal'
17
+ import { deepCloneJSON } from './utils'
18
+ import { pointToPhysical, physicalToPoint } from './physicalMetrics'
14
19
 
15
20
  const defaultSceneConfig: SpatialSceneCreationOptions = {
16
21
  defaultSize: {
@@ -21,17 +26,80 @@ const defaultSceneConfig: SpatialSceneCreationOptions = {
21
26
 
22
27
  const defaultSceneConfigVolume: SpatialSceneCreationOptions = {
23
28
  defaultSize: {
24
- width: 0.94,
25
- height: 0.94,
26
- depth: 0.94,
29
+ width: '0.94m',
30
+ height: '0.94m',
31
+ depth: '0.94m',
27
32
  },
28
33
  }
29
34
 
35
+ let xr_window_defaults: SpatialSceneCreationOptions = {
36
+ ...defaultSceneConfig,
37
+ }
38
+ let xr_volume_defaults: SpatialSceneCreationOptions = {
39
+ ...defaultSceneConfigVolume,
40
+ }
41
+
30
42
  const INTERNAL_SCHEMA_PREFIX = 'webspatial://'
31
43
 
44
+ /**
45
+ * Deep-merge two plain object trees (no arrays, no special classes).
46
+ * - Creates a shallow clone of base, then recursively merges properties from over.
47
+ * - When both sides at a key are plain objects, merges recursively; otherwise, replaces with over.
48
+ * - Ignores arrays (treated as replace).
49
+ * Intended for small configuration objects like manifest overrides.
50
+ */
51
+ function deepMergePlain<
52
+ T extends Record<string, any>,
53
+ U extends Record<string, any> | undefined,
54
+ >(base: T, over: U): T & (U extends undefined ? {} : U) {
55
+ if (!over) return { ...(base || {}) } as any
56
+ const out: any = { ...(base || {}) }
57
+ for (const k of Object.keys(over)) {
58
+ const bv = out[k]
59
+ const ov = (over as any)[k]
60
+ if (
61
+ ov &&
62
+ typeof ov === 'object' &&
63
+ !Array.isArray(ov) &&
64
+ bv &&
65
+ typeof bv === 'object' &&
66
+ !Array.isArray(bv)
67
+ ) {
68
+ out[k] = deepMergePlain(bv, ov)
69
+ } else {
70
+ out[k] = ov
71
+ }
72
+ }
73
+ return out
74
+ }
75
+
76
+ /**
77
+ * Normalize XRSpatialSceneDefaults (manifest shape) into SpatialSceneCreationOptions (runtime shape).
78
+ * - Only remap default_size -> defaultSize when present.
79
+ * - Leaves other keys (resizability, worldScaling, etc.) unchanged.
80
+ * - Units are left as-is; downstream formatting is handled by formatSceneConfig.
81
+ */
82
+ function normalizeXRDefaultsToSceneOptions(
83
+ src: XRSpatialSceneDefaults | Record<string, any>,
84
+ ): SpatialSceneCreationOptions {
85
+ const out: any = { ...(src || {}) }
86
+ const ds =
87
+ (src as any).defaultSize !== undefined
88
+ ? (src as any).defaultSize
89
+ : (src as any).default_size
90
+ if (ds !== undefined) {
91
+ out.defaultSize = ds
92
+ }
93
+ if ('default_size' in out) {
94
+ delete out.default_size
95
+ }
96
+ return out
97
+ }
98
+
32
99
  class SceneManager {
33
100
  private originalOpen: any
34
101
  private static instance: SceneManager
102
+ private manifestReady: Promise<void> | null = null
35
103
  static getInstance() {
36
104
  if (!SceneManager.instance) {
37
105
  SceneManager.instance = new SceneManager()
@@ -40,16 +108,26 @@ class SceneManager {
40
108
  }
41
109
 
42
110
  init(window: WindowProxy) {
111
+ this.manifestReady = this.setupManifest()
43
112
  this.originalOpen = window.open.bind(window)
44
113
  ;(window as any).open = this.open
45
114
  }
46
115
 
47
- private configMap: Record<string, SpatialSceneCreationOptionsInternal> = {} // name=>config
116
+ // Stores the latest formatted config used by the platform (per scene name).
117
+ // This object contains normalized values and is safe for internal consumption.
118
+ private configMap: Record<string, SpatialSceneCreationOptionsInternal> = {}
119
+ // Stores the raw callback return value (per scene name) to feed into the next initScene call as `pre`.
120
+ // We keep this unformatted so developers receive exactly what they last returned.
121
+ private callbackReturnMap: Record<string, SpatialSceneCreationOptions> = {}
48
122
  private getConfig(name?: string) {
49
123
  if (name === undefined || !this.configMap[name]) return undefined
50
124
  return this.configMap[name]
51
125
  }
52
126
 
127
+ waitManifest(): Promise<void> {
128
+ return this.manifestReady ?? Promise.resolve()
129
+ }
130
+
53
131
  // Ensure URL is absolute; only convert when a relative path is provided
54
132
  // - Keep external and special schemes untouched (http, https, data, blob, about, file, mailto, etc.)
55
133
  // - Handle protocol-relative URLs (//example.com/path)
@@ -73,9 +151,54 @@ class SceneManager {
73
151
  }
74
152
  }
75
153
 
154
+ private async setupManifest() {
155
+ const manifest = await this.getPWAManifest()
156
+ try {
157
+ const xr = manifest?.xr_spatial_scene
158
+ if (!xr || typeof xr !== 'object') return
159
+ const { overrides, ...topLevel } = xr as XRSpatialSceneConfig
160
+ // Merge top-level defaults with per-scene overrides.
161
+ const windowRaw = deepMergePlain(topLevel, overrides?.window_scene)
162
+ const volumeRaw = deepMergePlain(topLevel, overrides?.volume_scene)
163
+ const windowNext = normalizeXRDefaultsToSceneOptions(windowRaw)
164
+
165
+ const volumeNext = normalizeXRDefaultsToSceneOptions(volumeRaw)
166
+ if (windowNext && Object.keys(windowNext).length > 0) {
167
+ xr_window_defaults = windowNext
168
+ }
169
+ if (volumeNext && Object.keys(volumeNext).length > 0) {
170
+ xr_volume_defaults = volumeNext
171
+ }
172
+ } catch (error: any) {
173
+ console.warn(
174
+ 'SceneManager.setupManifest failed; using built-in defaults.',
175
+ error?.message || error,
176
+ )
177
+ }
178
+ }
179
+
76
180
  private open = (url?: string, target?: string, features?: string) => {
77
181
  // bypass internal
78
182
  if (url?.startsWith(INTERNAL_SCHEMA_PREFIX)) {
183
+ if (
184
+ url.includes('createSpatialized2DElement') ||
185
+ url.includes('createAttachment')
186
+ ) {
187
+ const token = //@ts-ignore
188
+ (window.webSpatial || window.__webspatialShell__)?.genToken?.()
189
+ if (token) {
190
+ const command = url.includes('createAttachment')
191
+ ? 'createAttachment'
192
+ : 'createSpatialized2DElement'
193
+ const host = window.location.host
194
+ const protocol = window.location.protocol
195
+ const finalURL = `${protocol}//${host}/${token}/?command=${command}`
196
+ const rid = new URL(url).searchParams.get('rid')
197
+ const final = new URL(finalURL)
198
+ if (rid) final.searchParams.set('rid', rid)
199
+ return this.originalOpen(final.toString(), target, features)
200
+ }
201
+ }
79
202
  return this.originalOpen(url, target, features)
80
203
  }
81
204
 
@@ -88,7 +211,16 @@ class SceneManager {
88
211
  return newWindow
89
212
  }
90
213
 
91
- const cfg = target ? this.getConfig(target) : undefined
214
+ let cfg = target ? this.getConfig(target) : undefined
215
+
216
+ if (cfg === undefined) {
217
+ // if no config, use default window config
218
+ const preFormatted = deepCloneJSON(getSceneDefaultConfig('window'))
219
+
220
+ const [ans] = formatSceneConfig(preFormatted, 'window')
221
+ cfg = { ...ans, type: 'window' }
222
+ }
223
+
92
224
  const cmd = new createSpatialSceneCommand(url!, cfg, target, features)
93
225
  const result = cmd.executeSync()
94
226
 
@@ -108,25 +240,137 @@ class SceneManager {
108
240
  options?: { type: SpatialSceneType },
109
241
  ) {
110
242
  const sceneType = options?.type ?? 'window'
111
- const defaultConfig = getSceneDefaultConfig(sceneType)
112
- const rawReturnVal = callback({ ...defaultConfig })
113
- const [formattedConfig, errors] = formatSceneConfig(rawReturnVal, sceneType)
243
+ const defaultConfigRaw = getSceneDefaultConfig(sceneType)
244
+ const previousOrDefault =
245
+ this.callbackReturnMap[name] ??
246
+ ((): SpatialSceneCreationOptions => {
247
+ // Clone default config to avoid mutating shared defaults during formatting.
248
+ const cloned = deepCloneJSON(defaultConfigRaw)
249
+ return cloned
250
+ })()
251
+ const rawReturnVal = callback(previousOrDefault)
252
+ const sanitizedReturnVal = sanitizeSceneOptionsUnits(
253
+ deepCloneJSON(rawReturnVal),
254
+ )
255
+ const clonedForFormat = deepCloneJSON(sanitizedReturnVal)
256
+ // Merge normalized user return with scene-type defaults before final formatting.
257
+ // This ensures missing fields fall back to the appropriate xr_window_defaults / xr_volume_defaults.
258
+ const baseDefaults = deepCloneJSON(getSceneDefaultConfig(sceneType))
259
+ const mergedForFormat = deepMergePlain(baseDefaults, clonedForFormat)
260
+
261
+ const [formattedConfig, errors] = formatSceneConfig(
262
+ mergedForFormat,
263
+ sceneType,
264
+ )
265
+
114
266
  if (errors.length > 0) {
115
267
  console.warn(`initScene ${name} with errors: ${errors.join(', ')}`)
116
268
  }
269
+ this.callbackReturnMap[name] = sanitizedReturnVal
117
270
  this.configMap[name] = {
118
271
  ...formattedConfig,
119
272
  type: sceneType,
120
273
  }
121
274
  }
275
+ /**
276
+ * Resolve and load a PWA manifest as JSON:
277
+ * 1) Determine href:
278
+ * - Prefer explicit manifestUrl if provided;
279
+ * - Fallback to <link rel="manifest">, preferring the raw attribute over computed href.
280
+ * 2) Normalize href to absolute using ensureAbsoluteUrl (respects <base href>).
281
+ * 3) Handle data URLs inline:
282
+ * - data:...;base64,... → atob then JSON.parse
283
+ * - data:...,... → decodeURIComponent then JSON.parse
284
+ * 4) Fetch with credentials same-origin first; if that fails (e.g., CORS), attempt unauthenticated fetch.
285
+ * 5) Parse as JSON; if response body is text, parse the text as JSON.
286
+ */
287
+ async getPWAManifest(manifestUrl?: string): Promise<PWAManifest | undefined> {
288
+ let href: string | undefined = manifestUrl
289
+ if (!href) {
290
+ const el = document.querySelector(
291
+ 'link[rel="manifest"]',
292
+ ) as HTMLLinkElement | null
293
+ href = el?.getAttribute('href') || el?.href
294
+ }
295
+ if (!href) return
296
+ href = this.ensureAbsoluteUrl(href)
297
+ if (!href) return
298
+ if (href.startsWith('data:')) {
299
+ // Inline data URL manifest: data:[<mediatype>][;base64],<data>
300
+ try {
301
+ const comma = href.indexOf(',')
302
+ if (comma < 0) return
303
+ const meta = href.slice(5, comma)
304
+ const data = href.slice(comma + 1)
305
+ const isBase64 = /;base64/i.test(meta)
306
+ const decoded = isBase64 ? atob(data) : decodeURIComponent(data)
307
+ return JSON.parse(decoded)
308
+ } catch {
309
+ return
310
+ }
311
+ }
312
+ try {
313
+ // Same-origin fetch with credentials first.
314
+ const res = await fetch(href, { credentials: 'same-origin' })
315
+ if (!res.ok) throw new Error(String(res.status))
316
+ try {
317
+ return await res.json()
318
+ } catch {
319
+ const t = await res.text()
320
+ return JSON.parse(t)
321
+ }
322
+ } catch {
323
+ try {
324
+ // Fallback: unauthenticated fetch (may help when same-origin credentials fail due to CORS).
325
+ const res = await fetch(href)
326
+ if (!res.ok) return
327
+ try {
328
+ return await res.json()
329
+ } catch {
330
+ const t = await res.text()
331
+ return JSON.parse(t)
332
+ }
333
+ } catch {
334
+ return
335
+ }
336
+ }
337
+ }
338
+ }
339
+
340
+ function sanitizeSceneOptionsUnits(
341
+ val: SpatialSceneCreationOptions,
342
+ ): SpatialSceneCreationOptions {
343
+ if (val?.defaultSize) {
344
+ const keys = ['width', 'height', 'depth'] as const
345
+ for (const k of keys) {
346
+ if (
347
+ k in (val.defaultSize as any) &&
348
+ !isValidSceneUnit((val.defaultSize as any)[k])
349
+ ) {
350
+ delete (val.defaultSize as any)[k]
351
+ }
352
+ }
353
+ }
354
+ if (val?.resizability) {
355
+ const keys = ['minWidth', 'minHeight', 'maxWidth', 'maxHeight'] as const
356
+ for (const k of keys) {
357
+ if (
358
+ k in (val.resizability as any) &&
359
+ !isValidSceneUnit((val.resizability as any)[k])
360
+ ) {
361
+ delete (val.resizability as any)[k]
362
+ }
363
+ }
364
+ }
365
+ return val
122
366
  }
123
367
 
124
368
  function pxToMeter(px: number): number {
125
- return px / 1360
369
+ return pointToPhysical(px)
126
370
  }
127
371
 
128
372
  function meterToPx(meter: number): number {
129
- return meter * 1360
373
+ return physicalToPoint(meter)
130
374
  }
131
375
 
132
376
  function formatToNumber(
@@ -203,12 +447,11 @@ export function formatSceneConfig(
203
447
  ;(config.defaultSize as any)[k] = formatToNumber(
204
448
  (config.defaultSize as any)[k],
205
449
  isWindow ? 'px' : 'm',
206
- isWindow ? 'px' : 'm',
450
+ 'px',
207
451
  )
208
452
  } else {
209
- ;(config.defaultSize as any)[k] = (
210
- defaultSceneConfig.defaultSize as any
211
- )[k]
453
+ // delete invalid unit
454
+ delete (config.defaultSize as any)[k]
212
455
  errors.push(`defaultSize.${k}`)
213
456
  }
214
457
  }
@@ -223,10 +466,11 @@ export function formatSceneConfig(
223
466
  ;(config.resizability as any)[k] = formatToNumber(
224
467
  (config.resizability as any)[k],
225
468
  'px',
226
- isWindow ? 'px' : 'm',
469
+ 'px',
227
470
  )
228
471
  } else {
229
- ;(config.resizability as any)[k] = undefined
472
+ // delete invalid unit
473
+ delete (config.resizability as any)[k]
230
474
  errors.push(`resizability.${k}`)
231
475
  }
232
476
  }
@@ -265,6 +509,13 @@ export function initScene(
265
509
  return SceneManager.getInstance().initScene(name, callback, options)
266
510
  }
267
511
 
512
+ export function __getSceneConfigSnapshotForTest(
513
+ name: string,
514
+ ): SpatialSceneCreationOptionsInternal | undefined {
515
+ const mgr = SceneManager.getInstance() as any
516
+ return mgr?.configMap?.[name]
517
+ }
518
+
268
519
  export function hijackWindowOpen(window: WindowProxy) {
269
520
  SceneManager.getInstance().init(window)
270
521
  }
@@ -313,7 +564,9 @@ function handleATag(event: MouseEvent) {
313
564
  }
314
565
 
315
566
  function getSceneDefaultConfig(sceneType: SpatialSceneType) {
316
- return sceneType === 'window' ? defaultSceneConfig : defaultSceneConfigVolume
567
+ return sceneType === 'window'
568
+ ? xr_window_defaults || defaultSceneConfig
569
+ : xr_volume_defaults || defaultSceneConfigVolume
317
570
  }
318
571
 
319
572
  async function injectScenePolyfill() {
@@ -336,13 +589,16 @@ async function injectScenePolyfill() {
336
589
  }
337
590
 
338
591
  onContentLoaded(async () => {
339
- let provideDefaultSceneConfig = getSceneDefaultConfig(
340
- window.xrCurrentSceneType ?? 'window',
341
- )
342
- let cfg = provideDefaultSceneConfig
592
+ await SceneManager.getInstance().waitManifest()
593
+ const sceneType = window.xrCurrentSceneType ?? 'window'
594
+ const rawDefault = getSceneDefaultConfig(sceneType)
595
+ // Provide a formatted 'pre' to the callback for consistent units and types.
596
+ const pre = deepCloneJSON(rawDefault)
597
+
598
+ let cfg = pre
343
599
  if (typeof window.xrCurrentSceneDefaults === 'function') {
344
600
  try {
345
- cfg = await window.xrCurrentSceneDefaults?.(provideDefaultSceneConfig)
601
+ cfg = await window.xrCurrentSceneDefaults?.(pre)
346
602
  } catch (error) {
347
603
  console.error(error)
348
604
  }
@@ -354,17 +610,19 @@ async function injectScenePolyfill() {
354
610
  }, 1000)
355
611
  })
356
612
 
357
- const sceneType = window.xrCurrentSceneType ?? 'window'
358
- const [formattedConfig, errors] = formatSceneConfig(cfg, sceneType)
613
+ // Merge callback return with base defaults to ensure missing fields are filled.
614
+ const mergedCfg = deepMergePlain(deepCloneJSON(rawDefault), cfg)
615
+ const [formattedConfig, errors] = formatSceneConfig(mergedCfg, sceneType)
359
616
  if (errors.length > 0) {
360
617
  console.warn(
361
618
  `window.xrCurrentSceneDefaults with errors: ${errors.join(', ')}`,
362
619
  )
363
620
  }
364
- await SpatialScene.getInstance().updateSceneCreationConfig({
621
+ const finalCfg = {
365
622
  ...formattedConfig,
366
623
  type: sceneType,
367
- })
624
+ }
625
+ await SpatialScene.getInstance().updateSceneCreationConfig(finalCfg)
368
626
  })
369
627
  }
370
628
 
@@ -22,6 +22,14 @@ declare global {
22
22
  webkit: any
23
23
  webspatialBridge: any
24
24
 
25
+ // Project Pico OS browser injects this global object to provide internal capabilities.
26
+ webSpatial?: {
27
+ genToken?: () => string
28
+ }
29
+ __webspatialShell__?: {
30
+ genToken?: () => string
31
+ }
32
+
25
33
  // Will be removed in favor of __WebSpatialData
26
34
  WebSpatailNativeVersion: string
27
35
 
@@ -95,10 +95,20 @@ export interface Spatialized2DElementProperties
95
95
  scrollEdgeInsetsMarginRight: number
96
96
  }
97
97
 
98
+ export interface ModelSource {
99
+ src: string
100
+ type?: string
101
+ }
102
+
98
103
  export interface SpatializedStatic3DElementProperties
99
104
  extends SpatializedElementProperties {
100
105
  modelURL: string
106
+ sources?: ModelSource[]
101
107
  modelTransform?: number[]
108
+ autoplay?: boolean
109
+ loop?: boolean
110
+ animationPaused?: boolean
111
+ playbackRate?: number
102
112
  }
103
113
 
104
114
  export interface SpatialSceneCreationOptions {
@@ -411,3 +421,64 @@ export interface AttachmentEntityUpdateOptions {
411
421
  position?: [number, number, number]
412
422
  size?: { width: number; height: number }
413
423
  }
424
+
425
+ // manifest
426
+
427
+ export type SceneUnitPx = `${number}px`
428
+ export type SceneUnitM = `${number}m`
429
+ export type SceneUnit = number | SceneUnitPx | SceneUnitM
430
+
431
+ export interface XRSceneSize {
432
+ width: SceneUnit
433
+ height: SceneUnit
434
+ depth?: SceneUnit
435
+ }
436
+
437
+ export interface XRSceneResizability {
438
+ minWidth?: SceneUnit
439
+ minHeight?: SceneUnit
440
+ maxWidth?: SceneUnit
441
+ maxHeight?: SceneUnit
442
+ }
443
+
444
+ export interface XRMainSceneConfig extends XRSpatialSceneDefaults {
445
+ type?: SpatialSceneType
446
+ }
447
+
448
+ export interface XRSpatialSceneDefaults {
449
+ default_size?: XRSceneSize
450
+ resizability?: XRSceneResizability
451
+ worldScaling?: WorldScalingType
452
+ worldAlignment?: WorldAlignmentType
453
+ baseplateVisibility?: BaseplateVisibilityType
454
+ }
455
+
456
+ export interface XRSpatialSceneOverrides {
457
+ window_scene?: XRSpatialSceneDefaults
458
+ volume_scene?: XRSpatialSceneDefaults
459
+ }
460
+
461
+ export interface XRSpatialSceneConfig extends XRSpatialSceneDefaults {
462
+ overrides?: XRSpatialSceneOverrides
463
+ }
464
+
465
+ export interface XRPrdConfig {
466
+ xr_main_scene?: XRMainSceneConfig
467
+ xr_spatial_scene?: XRSpatialSceneConfig
468
+ }
469
+
470
+ export interface PWAManifest extends XRPrdConfig {
471
+ name?: string
472
+ short_name?: string
473
+ start_url?: string
474
+ display?: string
475
+ icons?: Array<{
476
+ src: string
477
+ sizes?: string
478
+ type?: string
479
+ purpose?: string
480
+ }>
481
+ id?: string
482
+ scope?: string
483
+ [key: string]: any
484
+ }
package/src/utils.ts CHANGED
@@ -59,3 +59,24 @@ export function composeSRT(position: Vec3, rotation: Vec3, scale: Vec3) {
59
59
  m = m.scale(sx, sy, sz)
60
60
  return m
61
61
  }
62
+
63
+ /**
64
+ * Deep-clone a plain JSON-serializable object by value.
65
+ *
66
+ * Notes:
67
+ * - Only use for data composed of primitives, arrays, and plain objects.
68
+ * - Functions, Dates, Maps/Sets, DOM nodes, and circular structures are not supported.
69
+ */
70
+ export function deepCloneJSON<T>(value: T): T {
71
+ const sc = (globalThis as any).structuredClone as
72
+ | ((v: any) => any)
73
+ | undefined
74
+ if (typeof sc === 'function') {
75
+ try {
76
+ return sc(value)
77
+ } catch {
78
+ // fall through to JSON method
79
+ }
80
+ }
81
+ return JSON.parse(JSON.stringify(value))
82
+ }