@webspatial/core-sdk 1.6.1 → 1.7.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 (53) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/iife/index.d.ts +386 -236
  3. package/dist/iife/index.global.js +7 -7
  4. package/dist/iife/index.global.js.map +1 -1
  5. package/dist/index.d.ts +386 -236
  6. package/dist/index.js +1467 -1210
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/JSBCommand.ts +30 -100
  10. package/src/Spatial.ts +0 -21
  11. package/src/SpatialScene.ts +1 -5
  12. package/src/SpatialSession.ts +17 -3
  13. package/src/SpatializedDynamic3DElement.ts +0 -1
  14. package/src/SpatializedElementCreator.ts +6 -4
  15. package/src/SpatializedStatic3DElement.test.ts +99 -0
  16. package/src/SpatializedStatic3DElement.ts +99 -1
  17. package/src/WebMsgCommand.ts +10 -0
  18. package/src/coverage-boost.test.ts +38 -119
  19. package/src/index.ts +3 -1
  20. package/src/jsbcommand.coverage.test.ts +19 -48
  21. package/src/platform-adapter/CommandResultUtils.ts +2 -2
  22. package/src/platform-adapter/createPlatformSync.ts +34 -0
  23. package/src/platform-adapter/index.ts +5 -51
  24. package/src/platform-adapter/interface.ts +35 -23
  25. package/src/platform-adapter/pico-os/PicoOSPlatform.ts +84 -52
  26. package/src/platform-adapter/puppeteer/PuppeteerPlatform.ts +37 -11
  27. package/src/platform-adapter/spatialSceneQuery.ts +17 -0
  28. package/src/platform-adapter/ssr/SSRPlatform.ts +24 -15
  29. package/src/platform-adapter/vision-os/VisionOSPlatform.ts +55 -24
  30. package/src/platform-runtime.ts +13 -0
  31. package/src/reality/Attachment.ts +2 -2
  32. package/src/reality/entity/SpatialEntity.ts +0 -2
  33. package/src/reality/realityCreator.ts +15 -1
  34. package/src/reality/resource/SpatialTextureResource.ts +16 -0
  35. package/src/reality/resource/index.ts +1 -0
  36. package/src/runtime/WebSpatialRuntimeError.ts +16 -0
  37. package/src/runtime/capability-data.ts +113 -0
  38. package/src/runtime/contract-review.test.ts +44 -0
  39. package/src/runtime/index.ts +28 -0
  40. package/src/runtime/jsbAdapterPlatform.test.ts +36 -0
  41. package/src/runtime/jsbAdapterPlatform.ts +51 -0
  42. package/src/runtime/keys.ts +129 -0
  43. package/src/runtime/semver.ts +33 -0
  44. package/src/runtime/supports.test.ts +207 -0
  45. package/src/runtime/supports.ts +110 -0
  46. package/src/runtime/types.ts +11 -0
  47. package/src/runtime/userAgent.ts +64 -0
  48. package/src/scene-polyfill.manifest.test.ts +7 -5
  49. package/src/scene-polyfill.ts +8 -5
  50. package/src/spatial-host.ts +25 -0
  51. package/src/types/{global.d.ts → global.ts} +10 -5
  52. package/src/types/types.ts +9 -0
  53. package/src/platform-adapter/android/AndroidPlatform.ts +0 -133
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webspatial/core-sdk",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "this is the core js API for webspatial",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
package/src/JSBCommand.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { createPlatform } from './platform-adapter'
2
- import { WebSpatialProtocolResult } from './platform-adapter/interface'
1
+ import { getPlatform } from './platform-runtime'
3
2
  import { SpatialComponent } from './reality/component/SpatialComponent'
4
3
  import { SpatialEntity } from './reality/entity/SpatialEntity'
5
4
  import { SpatialMaterial } from './reality/material/SpatialMaterial'
@@ -24,13 +23,12 @@ import {
24
23
  Vec3,
25
24
  AttachmentEntityOptions,
26
25
  AttachmentEntityUpdateOptions,
26
+ ModelLoadingMode,
27
27
  ModelSource,
28
+ SpatialTextureResourceOptions,
28
29
  } from './types/types'
29
- import { SpatialSceneCreationOptionsInternal } from './types/internal'
30
30
  import { composeSRT } from './utils'
31
31
 
32
- const platform = createPlatform()
33
-
34
32
  abstract class JSBCommand {
35
33
  commandType: string = ''
36
34
  protected abstract getParams(): Record<string, any> | undefined
@@ -38,6 +36,7 @@ abstract class JSBCommand {
38
36
  async execute() {
39
37
  const param = this.getParams()
40
38
  const msg = param ? JSON.stringify(param) : ''
39
+ const platform = await getPlatform()
41
40
  return platform.callJSB(this.commandType, msg)
42
41
  }
43
42
  }
@@ -293,14 +292,20 @@ export class CreateSpatializedStatic3DElementCommand extends JSBCommand {
293
292
  constructor(
294
293
  readonly modelURL?: string,
295
294
  readonly sources?: ModelSource[],
295
+ readonly loading: ModelLoadingMode = 'eager',
296
296
  ) {
297
297
  super()
298
298
  this.modelURL = modelURL
299
299
  this.sources = sources
300
+ this.loading = loading
300
301
  }
301
302
 
302
303
  protected getParams() {
303
- return { modelURL: this.modelURL, sources: this.sources }
304
+ return {
305
+ modelURL: this.modelURL,
306
+ sources: this.sources,
307
+ loading: this.loading,
308
+ }
304
309
  }
305
310
  }
306
311
 
@@ -558,7 +563,7 @@ export class ConvertCoordinateCommand extends JSBCommand {
558
563
  commandType = 'ConvertCoordinate'
559
564
  }
560
565
 
561
- export class CreateTextureResourceCommand extends JSBCommand {
566
+ export class CreateTextureCommand extends JSBCommand {
562
567
  constructor(private url: string) {
563
568
  super()
564
569
  }
@@ -567,7 +572,24 @@ export class CreateTextureResourceCommand extends JSBCommand {
567
572
  url: this.url,
568
573
  }
569
574
  }
570
- commandType = 'CreateTextureResource'
575
+ commandType = 'CreateTexture'
576
+ }
577
+
578
+ export class UpdateTexturePropertiesCommand extends SpatializedElementCommand {
579
+ properties: Partial<SpatialTextureResourceOptions>
580
+ commandType = 'UpdateTextureProperties'
581
+
582
+ constructor(
583
+ spatialObject: SpatialObject,
584
+ properties: Partial<SpatialTextureResourceOptions>,
585
+ ) {
586
+ super(spatialObject)
587
+ this.properties = properties
588
+ }
589
+
590
+ protected getExtraParams() {
591
+ return this.properties
592
+ }
571
593
  }
572
594
 
573
595
  export class InspectCommand extends JSBCommand {
@@ -606,88 +628,6 @@ export class CheckWebViewCanCreateCommand extends JSBCommand {
606
628
  }
607
629
  }
608
630
 
609
- /* WebSpatial Protocol Begin */
610
- abstract class WebSpatialProtocolCommand extends JSBCommand {
611
- target?: string
612
- features?: string
613
-
614
- async execute(): Promise<WebSpatialProtocolResult> {
615
- const query = this.getQuery()
616
- return platform.callWebSpatialProtocol(
617
- this.commandType,
618
- query,
619
- this.target,
620
- this.features,
621
- )
622
- }
623
-
624
- executeSync(): WebSpatialProtocolResult {
625
- const query = this.getQuery()
626
- return platform.callWebSpatialProtocolSync(
627
- this.commandType,
628
- query,
629
- this.target,
630
- this.features,
631
- )
632
- }
633
-
634
- private getQuery() {
635
- let query = undefined
636
- const params = this.getParams()
637
- if (params) {
638
- query = Object.keys(params)
639
- .map(key => {
640
- const value = params[key]
641
- const finalValue =
642
- typeof value === 'object' ? JSON.stringify(value) : value
643
- return `${key}=${encodeURIComponent(finalValue)}`
644
- })
645
- .join('&')
646
- }
647
-
648
- return query
649
- }
650
- }
651
-
652
- export class createSpatialized2DElementCommand extends WebSpatialProtocolCommand {
653
- commandType = 'createSpatialized2DElement'
654
- constructor() {
655
- super()
656
- }
657
- protected getParams() {
658
- return {}
659
- }
660
- }
661
-
662
- export class createSpatialSceneCommand extends WebSpatialProtocolCommand {
663
- commandType = 'createSpatialScene'
664
-
665
- constructor(
666
- private url: string,
667
- private config: SpatialSceneCreationOptionsInternal | undefined,
668
- public target?: string,
669
- public features?: string,
670
- ) {
671
- super()
672
- }
673
- protected getParams() {
674
- return {
675
- url: this.url,
676
- config: this.config,
677
- }
678
- }
679
- }
680
-
681
- export class CreateAttachmentEntityCommand extends WebSpatialProtocolCommand {
682
- commandType = 'createAttachment'
683
- constructor(private options: AttachmentEntityOptions) {
684
- super()
685
- }
686
- protected getParams() {
687
- return {} // No metadata — just trigger engine/webview creation
688
- }
689
- }
690
-
691
631
  export class InitializeAttachmentCommand extends JSBCommand {
692
632
  commandType = 'InitializeAttachment'
693
633
  constructor(
@@ -722,13 +662,3 @@ export class UpdateAttachmentEntityCommand extends JSBCommand {
722
662
  }
723
663
  }
724
664
  }
725
-
726
- // TODO: Can crypto.randomUUID be used instead including in dev environments without https
727
- function uuid(): string {
728
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
729
- const r = (Math.random() * 16) | 0
730
- return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
731
- })
732
- }
733
-
734
- /* WebSpatial Protocol End */
package/src/Spatial.ts CHANGED
@@ -6,8 +6,6 @@ import { SpatialWebEvent } from './SpatialWebEvent'
6
6
  * This is the main entry point for the WebSpatial SDK, providing access to spatial capabilities.
7
7
  */
8
8
  export class Spatial {
9
- private wsAppShellVersionFromUA: string | null | undefined
10
-
11
9
  /**
12
10
  * Requests a spatial session object from the browser.
13
11
  * This is the primary method to initialize spatial functionality.
@@ -35,25 +33,6 @@ export class Spatial {
35
33
  return false
36
34
  }
37
35
 
38
- getShellVersionFromUA(): string | null {
39
- if (this.wsAppShellVersionFromUA !== undefined) {
40
- return this.wsAppShellVersionFromUA
41
- }
42
- if (
43
- typeof navigator === 'undefined' ||
44
- typeof navigator.userAgent !== 'string'
45
- ) {
46
- this.wsAppShellVersionFromUA = null
47
- return null
48
- }
49
-
50
- const match = navigator.userAgent.match(
51
- /WSAppShell\/(\d+(?:\.\d+){2}(?:[-+][0-9A-Za-z.-]+)*)/,
52
- )
53
- this.wsAppShellVersionFromUA = match ? match[1] : '1.3.0'
54
- return this.wsAppShellVersionFromUA
55
- }
56
-
57
36
  /** @deprecated
58
37
  * Checks if WebSpatial is supported in the current environment.
59
38
  * Verifies compatibility between native and client versions.
@@ -1,8 +1,4 @@
1
- import {
2
- SpatialSceneCreationOptions,
3
- SpatialSceneProperties,
4
- Vec3,
5
- } from './types/types'
1
+ import { SpatialSceneProperties, Vec3 } from './types/types'
6
2
  import { SpatialSceneCreationOptionsInternal } from './types/internal'
7
3
  import {
8
4
  AddSpatializedElementToSpatialScene,
@@ -14,14 +14,14 @@ import {
14
14
  SpatialBoxGeometryOptions,
15
15
  SpatialConeGeometryOptions,
16
16
  SpatialCylinderGeometryOptions,
17
- SpatialGeometryOptions,
18
17
  SpatialModelEntityCreationOptions,
19
18
  SpatialPlaneGeometryOptions,
20
- SpatialSceneCreationOptions,
21
19
  SpatialSphereGeometryOptions,
22
20
  SpatialUnlitMaterialOptions,
21
+ SpatialTextureResourceOptions,
23
22
  SpatialEntityUserData,
24
23
  AttachmentEntityOptions,
24
+ ModelLoadingMode,
25
25
  ModelSource,
26
26
  } from './types/types'
27
27
  import { SpatializedDynamic3DElement } from './SpatializedDynamic3DElement'
@@ -32,6 +32,7 @@ import {
32
32
  createSpatialEntity,
33
33
  createSpatialGeometry,
34
34
  createSpatialModelEntity,
35
+ createSpatialTexture,
35
36
  createSpatialUnlitMaterial,
36
37
  } from './reality/realityCreator'
37
38
  import {
@@ -70,13 +71,16 @@ export class SpatialSession {
70
71
  * Creates a new static 3D element with an optional model URL.
71
72
  * Static 3D elements represent pre-built 3D models that can be loaded from a URL.
72
73
  * @param modelURL Optional URL to the 3D model to load
74
+ * @param sources Optional list of fallback model sources
75
+ * @param loading Whether the asset should fetch eagerly or be deferred (`'lazy'`)
73
76
  * @returns Promise resolving to a new SpatializedStatic3DElement instance
74
77
  */
75
78
  createSpatializedStatic3DElement(
76
79
  modelURL?: string,
77
80
  sources?: ModelSource[],
81
+ loading: ModelLoadingMode = 'eager',
78
82
  ): Promise<SpatializedStatic3DElement> {
79
- return createSpatializedStatic3DElement(modelURL, sources)
83
+ return createSpatializedStatic3DElement(modelURL, sources, loading)
80
84
  }
81
85
 
82
86
  /**
@@ -169,6 +173,16 @@ export class SpatialSession {
169
173
  return createSpatialUnlitMaterial(options)
170
174
  }
171
175
 
176
+ /**
177
+ * Creates a texture resource from the specified image URL.
178
+ * Texture resources can be referenced by materials and other spatial content.
179
+ * @param options Configuration options for the texture resource
180
+ * @returns Promise resolving to a new SpatialTextureResource instance
181
+ */
182
+ createTexture(options: SpatialTextureResourceOptions) {
183
+ return createSpatialTexture(options)
184
+ }
185
+
172
186
  /**
173
187
  * Creates a model asset with the specified configuration.
174
188
  * Model assets represent 3D model resources that can be used by entities.
@@ -1,5 +1,4 @@
1
1
  import {
2
- AddEntityToDynamic3DCommand,
3
2
  SetParentForEntityCommand,
4
3
  UpdateSpatializedDynamic3DElementProperties,
5
4
  } from './JSBCommand'
@@ -1,15 +1,15 @@
1
1
  import {
2
- createSpatialized2DElementCommand,
3
2
  CreateSpatializedDynamic3DElementCommand,
4
3
  CreateSpatializedStatic3DElementCommand,
5
4
  } from './JSBCommand'
6
5
  import { Spatialized2DElement } from './Spatialized2DElement'
7
6
  import { SpatializedStatic3DElement } from './SpatializedStatic3DElement'
8
7
  import { SpatializedDynamic3DElement } from './SpatializedDynamic3DElement'
9
- import { ModelSource } from './types/types'
8
+ import { createNativeSpatialDiv } from './spatial-host'
9
+ import { ModelLoadingMode, ModelSource } from './types/types'
10
10
 
11
11
  export async function createSpatialized2DElement(): Promise<Spatialized2DElement> {
12
- const result = await new createSpatialized2DElementCommand().execute()
12
+ const result = await createNativeSpatialDiv()
13
13
  if (!result.success) {
14
14
  throw new Error('createSpatialized2DElement failed')
15
15
  } else {
@@ -24,16 +24,18 @@ export async function createSpatialized2DElement(): Promise<Spatialized2DElement
24
24
  export async function createSpatializedStatic3DElement(
25
25
  modelURL?: string,
26
26
  sources?: ModelSource[],
27
+ loading: ModelLoadingMode = 'eager',
27
28
  ): Promise<SpatializedStatic3DElement> {
28
29
  const result = await new CreateSpatializedStatic3DElementCommand(
29
30
  modelURL,
30
31
  sources,
32
+ loading,
31
33
  ).execute()
32
34
  if (!result.success) {
33
35
  throw new Error('createSpatializedStatic3DElement failed')
34
36
  } else {
35
37
  const { id } = result.data
36
- return new SpatializedStatic3DElement(id, modelURL, sources)
38
+ return new SpatializedStatic3DElement(id, modelURL, sources, loading)
37
39
  }
38
40
  }
39
41
 
@@ -136,4 +136,103 @@ describe('SpatializedStatic3DElement', () => {
136
136
  el.onReceiveEvent({ type: SpatialWebMsgType.modelloaded })
137
137
  await expect(p3).resolves.toBe(true)
138
138
  })
139
+
140
+ it('currentTime defaults to 0 before any sample', () => {
141
+ const el = new SpatializedStatic3DElement('ct1', 'a.glb')
142
+ expect(el.currentTime).toBe(0)
143
+ })
144
+
145
+ it('currentTime returns the anchor while paused', () => {
146
+ const el = new SpatializedStatic3DElement('ct2', 'a.glb')
147
+ el.onReceiveEvent({
148
+ type: SpatialWebMsgType.animationstatechange,
149
+ detail: {
150
+ paused: true,
151
+ duration: 10,
152
+ currentTime: 4,
153
+ timestamp: Date.now(),
154
+ },
155
+ })
156
+ expect(el.currentTime).toBe(4)
157
+ })
158
+
159
+ it('currentTime extrapolates while playing using playbackRate', () => {
160
+ const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_000)
161
+ const el = new SpatializedStatic3DElement('ct3', 'a.glb')
162
+ el.onReceiveEvent({
163
+ type: SpatialWebMsgType.animationstatechange,
164
+ detail: {
165
+ paused: false,
166
+ duration: 10,
167
+ currentTime: 2,
168
+ timestamp: 1_000,
169
+ },
170
+ })
171
+ nowSpy.mockReturnValue(2_000) // +1s real time
172
+ // default rate 1 → +1s animation time
173
+ expect(el.currentTime).toBe(3)
174
+ nowSpy.mockRestore()
175
+ })
176
+
177
+ it('currentTime clamps extrapolation to duration', () => {
178
+ const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_000)
179
+ const el = new SpatializedStatic3DElement('ct4', 'a.glb')
180
+ el.onReceiveEvent({
181
+ type: SpatialWebMsgType.animationstatechange,
182
+ detail: {
183
+ paused: false,
184
+ duration: 5,
185
+ currentTime: 4,
186
+ timestamp: 1_000,
187
+ },
188
+ })
189
+ nowSpy.mockReturnValue(11_000) // +10s real time → would extrapolate to 14
190
+ expect(el.currentTime).toBe(5)
191
+ nowSpy.mockRestore()
192
+ })
193
+
194
+ it('setting currentTime optimistically updates the anchor', async () => {
195
+ const el = new SpatializedStatic3DElement('ct5', 'a.glb')
196
+ el.onReceiveEvent({
197
+ type: SpatialWebMsgType.animationstatechange,
198
+ detail: {
199
+ paused: true,
200
+ duration: 10,
201
+ currentTime: 0,
202
+ timestamp: Date.now(),
203
+ },
204
+ })
205
+ el.currentTime = 7
206
+ expect(el.currentTime).toBe(7)
207
+ })
208
+
209
+ it('setting currentTime clamps negative values to 0', async () => {
210
+ const el = new SpatializedStatic3DElement('ct6', 'a.glb')
211
+ el.onReceiveEvent({
212
+ type: SpatialWebMsgType.animationstatechange,
213
+ detail: {
214
+ paused: true,
215
+ duration: 10,
216
+ currentTime: 5,
217
+ timestamp: Date.now(),
218
+ },
219
+ })
220
+ el.currentTime = -3
221
+ expect(el.currentTime).toBe(0)
222
+ })
223
+
224
+ it('setting currentTime clamps values above duration', async () => {
225
+ const el = new SpatializedStatic3DElement('ct7', 'a.glb')
226
+ el.onReceiveEvent({
227
+ type: SpatialWebMsgType.animationstatechange,
228
+ detail: {
229
+ paused: true,
230
+ duration: 8,
231
+ currentTime: 0,
232
+ timestamp: Date.now(),
233
+ },
234
+ })
235
+ el.currentTime = 100
236
+ expect(el.currentTime).toBe(8)
237
+ })
139
238
  })
@@ -1,6 +1,7 @@
1
1
  import { UpdateSpatializedStatic3DElementProperties } from './JSBCommand'
2
2
  import { ReceiveEventData, SpatializedElement } from './SpatializedElement'
3
3
  import {
4
+ ModelLoadingMode,
4
5
  ModelSource,
5
6
  SpatializedStatic3DElementProperties,
6
7
  } from './types/types'
@@ -24,11 +25,18 @@ export class SpatializedStatic3DElement extends SpatializedElement {
24
25
  * @param id Unique identifier for this element
25
26
  * @param modelURL URL of the 3D model
26
27
  * @param sources Optional fallback model sources
28
+ * @param loading Initial loading mode (`'eager'` by default)
27
29
  */
28
- constructor(id: string, modelURL?: string, sources?: ModelSource[]) {
30
+ constructor(
31
+ id: string,
32
+ modelURL?: string,
33
+ sources?: ModelSource[],
34
+ loading: ModelLoadingMode = 'eager',
35
+ ) {
29
36
  super(id)
30
37
  this.modelURL = modelURL
31
38
  this.sources = sources
39
+ this._loading = loading
32
40
  }
33
41
 
34
42
  /**
@@ -42,6 +50,10 @@ export class SpatializedStatic3DElement extends SpatializedElement {
42
50
  * Used to reset the ready promise when the model URL changes.
43
51
  */
44
52
  private modelURL?: string
53
+ // @TODO: Deprecate modelURL property from Web and Swift code
54
+ get modelUrl(): string | undefined {
55
+ return this.modelURL
56
+ }
45
57
 
46
58
  /**
47
59
  * Caches the last sources array to detect changes.
@@ -109,8 +121,23 @@ export class SpatializedStatic3DElement extends SpatializedElement {
109
121
  this._loop = properties.loop
110
122
  }
111
123
  if (properties.playbackRate !== undefined) {
124
+ // Re-anchor so extrapolation uses the new rate only for future elapsed
125
+ // time, not the window between the last sample and this change.
126
+ if (!this._paused) {
127
+ this._currentTime = this.currentTime
128
+ this._anchorTimestamp = Date.now()
129
+ }
112
130
  this._playbackRate = properties.playbackRate
113
131
  }
132
+ if (properties.currentTime !== undefined) {
133
+ // Optimistically update the local anchor so subsequent reads reflect
134
+ // the requested seek without waiting for the next native sample.
135
+ this._currentTime = properties.currentTime
136
+ this._anchorTimestamp = Date.now()
137
+ }
138
+ if (properties.loading !== undefined) {
139
+ this._loading = properties.loading
140
+ }
114
141
  return new UpdateSpatializedStatic3DElementProperties(
115
142
  this,
116
143
  properties,
@@ -148,6 +175,49 @@ export class SpatializedStatic3DElement extends SpatializedElement {
148
175
  this.updateProperties({ playbackRate: value })
149
176
  }
150
177
 
178
+ /**
179
+ * Last playback position sampled from native (seconds).
180
+ */
181
+ private _currentTime: number = 0
182
+
183
+ /**
184
+ * Unix epoch time (ms) corresponding to `_currentTime`. Sourced from the
185
+ * native `timestamp` on samples, or `Date.now()` on local seeks/transitions.
186
+ */
187
+ private _anchorTimestamp: number = 0
188
+
189
+ private clampTime(time: number): number {
190
+ if (!Number.isFinite(time) || time < 0) return 0
191
+ // When looping the current time is modulo the duration
192
+ if (time > this._duration) {
193
+ return this.loop && this.duration > 0
194
+ ? time % this._duration
195
+ : this.duration
196
+ }
197
+ return time
198
+ }
199
+
200
+ /**
201
+ * Returns the current (un-scaled) playback position in seconds. While
202
+ * playing, the value is extrapolated from the last anchor using
203
+ * `playbackRate` and clamped to `[0, duration]`; while paused it returns
204
+ * the anchor directly.
205
+ */
206
+ get currentTime(): number {
207
+ if (this._paused) return this._currentTime
208
+ const elapsed = (Date.now() - this._anchorTimestamp) / 1000
209
+ return this.clampTime(this._currentTime + elapsed * this._playbackRate)
210
+ }
211
+
212
+ /**
213
+ * Seeks the animation to `value` seconds (clamped to `[0, duration]`) and
214
+ * forwards the request to native.
215
+ */
216
+ set currentTime(value: number) {
217
+ const time = Number.isNaN(value) ? 0 : clamp(value, 0, this.duration)
218
+ this.updateProperties({ currentTime: time })
219
+ }
220
+
151
221
  /**
152
222
  * Whether the animation is currently paused.
153
223
  */
@@ -181,6 +251,10 @@ export class SpatializedStatic3DElement extends SpatializedElement {
181
251
  * @returns Promise resolving when the command is sent
182
252
  */
183
253
  async play(): Promise<void> {
254
+ if (this._paused) {
255
+ // Start extrapolating from the last known position on resume.
256
+ this._anchorTimestamp = Date.now()
257
+ }
184
258
  this._paused = false
185
259
  await this.updateProperties({ animationPaused: false })
186
260
  }
@@ -190,6 +264,12 @@ export class SpatializedStatic3DElement extends SpatializedElement {
190
264
  * @returns Promise resolving when the command is sent
191
265
  */
192
266
  async pause(): Promise<void> {
267
+ if (!this._paused) {
268
+ // Freeze the extrapolated position so reads remain stable until the
269
+ // next native sample arrives.
270
+ this._currentTime = this.currentTime
271
+ this._anchorTimestamp = Date.now()
272
+ }
193
273
  this._paused = true
194
274
  await this.updateProperties({ animationPaused: true })
195
275
  }
@@ -213,6 +293,9 @@ export class SpatializedStatic3DElement extends SpatializedElement {
213
293
  } else if (data.type === SpatialWebMsgType.animationstatechange) {
214
294
  this._paused = data.detail.paused
215
295
  this._duration = data.detail.duration
296
+ // In unsupported environments currentTime is invalid
297
+ this._currentTime = data.detail.currentTime ?? 0
298
+ this._anchorTimestamp = data.detail.timestamp ?? Date.now()
216
299
  this._onAnimationStateChangeCallback?.(data.detail)
217
300
  } else {
218
301
  // Handle other spatial events using the base class implementation
@@ -232,6 +315,16 @@ export class SpatializedStatic3DElement extends SpatializedElement {
232
315
  return this._autoplay
233
316
  }
234
317
 
318
+ /**
319
+ * Asset fetch policy. `'lazy'` defers fetching until the host signals the
320
+ * element is in view; `'eager'` fetches immediately.
321
+ */
322
+ private _loading: ModelLoadingMode = 'eager'
323
+
324
+ get loading(): ModelLoadingMode {
325
+ return this._loading
326
+ }
327
+
235
328
  /**
236
329
  * Whether the model animation should loop continuously.
237
330
  */
@@ -276,6 +369,11 @@ export class SpatializedStatic3DElement extends SpatializedElement {
276
369
  }
277
370
  }
278
371
 
372
+ // Equivalent of proposed Math.clamp
373
+ function clamp(num: number, min: number, max: number) {
374
+ return num <= min ? min : num >= max ? max : num
375
+ }
376
+
279
377
  type Static3DReceiveEventData =
280
378
  | ModelLoadSuccess
281
379
  | ModelLoadFailure
@@ -81,6 +81,16 @@ export interface ModelLoadFailure {
81
81
  export interface AnimationStateChangeDetail {
82
82
  paused: boolean
83
83
  duration: number
84
+ /**
85
+ * Sampled animation playback position in seconds at `timestamp`.
86
+ * Optional for compatibility with older native runtimes.
87
+ */
88
+ currentTime?: number
89
+ /**
90
+ * Unix epoch time in milliseconds at which `currentTime` was sampled.
91
+ * Used to extrapolate `currentTime` between samples while playing.
92
+ */
93
+ timestamp?: number
84
94
  }
85
95
 
86
96
  export interface AnimationStateChangeMsg {