@webspatial/platform-visionos 1.6.0 → 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.
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@webspatial/platform-visionos",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Used to publish WebSpatial projects to Apple Vision Pro",
5
5
  "type": "commonjs",
6
6
  "main": "package.json",
7
7
  "engines": {
8
- "node": ">=14.15.0"
8
+ "node": ">=22"
9
9
  },
10
10
  "repository": {
11
11
  "type": "git",
@@ -16,6 +16,7 @@ struct CreateSpatializedStatic3DElement: CommandDataProtocol {
16
16
  static let commandType: String = "CreateSpatializedStatic3DElement"
17
17
  let modelURL: String?
18
18
  let sources: [ModelSource]?
19
+ let loading: String?
19
20
  }
20
21
 
21
22
  struct CreateSpatializedDynamic3DElement: CommandDataProtocol {
@@ -52,6 +53,12 @@ struct CreateTexture: CommandDataProtocol {
52
53
  let url: String
53
54
  }
54
55
 
56
+ struct UpdateTextureProperties: CommandDataProtocol {
57
+ static let commandType: String = "UpdateTextureProperties"
58
+ let id: String
59
+ let url: String?
60
+ }
61
+
55
62
  struct CreateModelAsset: CommandDataProtocol {
56
63
  static let commandType: String = "CreateModelAsset"
57
64
  let url: String
@@ -150,6 +157,7 @@ struct UpdateUnlitMaterialProperties: CommandDataProtocol {
150
157
  static let commandType: String = "UpdateUnlitMaterialProperties"
151
158
  let id: String
152
159
  let color: String?
160
+ let textureId: String?
153
161
  let transparent: Bool?
154
162
  let opacity: Float?
155
163
  }
@@ -267,6 +275,9 @@ struct UpdateSpatializedStatic3DElementProperties: SpatializedElementProperties
267
275
  let loop: Bool?
268
276
  let animationPaused: Bool?
269
277
  let playbackRate: Double?
278
+ let currentTime: Double?
279
+ let posterURL: String?
280
+ let loading: String?
270
281
  }
271
282
 
272
283
  struct UpdateSpatializedDynamic3DElementProperties: SpatializedElementProperties {
@@ -119,6 +119,9 @@ struct ModelLoadFailure: Encodable {
119
119
  struct AnimationStateChangeDetail: Encodable {
120
120
  let paused: Bool
121
121
  let duration: Double
122
+ let currentTime: Double
123
+ /// Unix epoch time in milliseconds
124
+ let timestamp: Double
122
125
  }
123
126
 
124
127
  struct AnimationStateChangeEvent: Encodable {
@@ -1,6 +1,85 @@
1
+ import CryptoKit
1
2
  import Foundation
2
3
  import RealityKit
3
4
 
5
+ /// Serializes remote URL → local file for each distinct URL string and uses a unique on-disk name
6
+ /// (hash + basename) so concurrent loads never `removeItem` a path another `TextureResource` read
7
+ /// is using (the old `Documents/lastPathComponent` scheme collided across scenes / retries).
8
+ private actor RemoteResourceLoadCache {
9
+ static let shared = RemoteResourceLoadCache()
10
+
11
+ private var inFlight: [String: Task<URL, Error>] = [:]
12
+
13
+ func localFileURL(forRemote urlString: String) async throws -> URL {
14
+ if let existing = inFlight[urlString] {
15
+ return try await existing.value
16
+ }
17
+ let task = Task {
18
+ try await Self.downloadInstallIfNeeded(urlString: urlString)
19
+ }
20
+ inFlight[urlString] = task
21
+ do {
22
+ let url = try await task.value
23
+ inFlight[urlString] = nil
24
+ return url
25
+ } catch {
26
+ inFlight[urlString] = nil
27
+ throw error
28
+ }
29
+ }
30
+
31
+ private static func downloadInstallIfNeeded(urlString: String) async throws -> URL {
32
+ guard let remote = URL(string: urlString) else {
33
+ throw NSError(
34
+ domain: "Invalid URL",
35
+ code: 0,
36
+ userInfo: [NSLocalizedDescriptionKey: "Failed to create URL from string: \(urlString)"]
37
+ )
38
+ }
39
+ guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
40
+ throw NSError(
41
+ domain: "Download Error",
42
+ code: 0,
43
+ userInfo: [NSLocalizedDescriptionKey: "Documents directory is unavailable"]
44
+ )
45
+ }
46
+ let destination = cacheFileURL(documents: documents, urlString: urlString)
47
+ let fm = FileManager.default
48
+ if fm.fileExists(atPath: destination.path) {
49
+ return destination
50
+ }
51
+
52
+ print("start load")
53
+ let (tempURL, response) = try await URLSession.shared.download(from: remote)
54
+ guard let httpResponse = response as? HTTPURLResponse else {
55
+ throw NSError(
56
+ domain: "HTTP Error",
57
+ code: 0,
58
+ userInfo: [NSLocalizedDescriptionKey: "Missing HTTP response"]
59
+ )
60
+ }
61
+ guard (200 ... 299).contains(httpResponse.statusCode) else {
62
+ throw NSError(
63
+ domain: "HTTP Error",
64
+ code: httpResponse.statusCode,
65
+ userInfo: [NSLocalizedDescriptionKey: "HTTP Error \(httpResponse.statusCode)"]
66
+ )
67
+ }
68
+ try await FileCoordinator.shared.moveReplacingIfExists(from: tempURL, to: destination)
69
+ print("load complete")
70
+ return destination
71
+ }
72
+
73
+ private static func cacheFileURL(documents: URL, urlString: String) -> URL {
74
+ let digest = SHA256.hash(data: Data(urlString.utf8))
75
+ let hex = digest.prefix(8).reduce(into: "") { $0.append(String(format: "%02x", $1)) }
76
+ var baseName = URL(string: urlString)?.lastPathComponent ?? "asset"
77
+ if baseName.isEmpty { baseName = "asset" }
78
+ let safe = baseName.replacingOccurrences(of: "/", with: "_")
79
+ return documents.appendingPathComponent("\(hex)_\(safe)")
80
+ }
81
+ }
82
+
4
83
  enum GeometryCreationError: LocalizedError {
5
84
  case invalidType(String)
6
85
  case missingFields(String, [String])
@@ -67,7 +146,17 @@ class Dynamic3DManager {
67
146
  // Error messages are thrown from createGeometry using GeometryCreationError
68
147
 
69
148
  static func createUnlitMaterial(_ props: CreateUnlitMaterial, _ tex: TextureResource? = nil) -> SpatialUnlitMaterial {
70
- return SpatialUnlitMaterial(props.color ?? "#FFFFFF", tex, props.transparent ?? true, props.opacity ?? 1)
149
+ let textureSpatialId: String? = {
150
+ guard let tid = props.textureId, !tid.isEmpty else { return nil }
151
+ return tid
152
+ }()
153
+ return SpatialUnlitMaterial(
154
+ props.color ?? "#FFFFFF",
155
+ tex,
156
+ props.transparent ?? true,
157
+ props.opacity ?? 1,
158
+ textureSpatialId: textureSpatialId
159
+ )
71
160
  }
72
161
 
73
162
  static func loadResourceToLocal(_ urlString: String, loadComplete: @escaping (Result<URL, Error>) -> Void) {
@@ -80,43 +169,14 @@ class Dynamic3DManager {
80
169
  loadComplete(.success(localUrl))
81
170
  return
82
171
  }
83
- // load net file
84
- guard let url = URL(string: urlString) else {
85
- loadComplete(.failure(NSError(domain: "Invalid URL", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to create URL from string: \(urlString)"])))
86
- return
87
- }
88
- // Use an immutable URL to avoid capturing a mutable var in concurrent code
89
- let documentsUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(url.lastPathComponent)
90
- let session = URLSession(configuration: URLSessionConfiguration.default)
91
- var request = URLRequest(url: url)
92
- request.httpMethod = "GET"
93
- print("start load")
94
- let task = session.downloadTask(with: request, completionHandler: { location, response, error in
95
- if let error = error {
172
+ // load net file — coalesced per URL + unique cache filename (see RemoteResourceLoadCache).
173
+ Task {
174
+ do {
175
+ let url = try await RemoteResourceLoadCache.shared.localFileURL(forRemote: urlString)
176
+ loadComplete(.success(url))
177
+ } catch {
96
178
  loadComplete(.failure(error))
97
- return
98
179
  }
99
- if let httpResponse = response as? HTTPURLResponse, !(200 ... 299).contains(httpResponse.statusCode) {
100
- let error = NSError(domain: "HTTP Error", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP Error \(httpResponse.statusCode)"])
101
- loadComplete(.failure(error))
102
- return
103
- }
104
- guard let location = location else {
105
- loadComplete(.failure(NSError(domain: "Download Error", code: 0, userInfo: [NSLocalizedDescriptionKey: "Download location is nil"])))
106
- return
107
- }
108
- Task {
109
- do {
110
- try await FileCoordinator.shared.moveReplacingIfExists(from: location, to: documentsUrl)
111
- print("load complete")
112
- loadComplete(.success(documentsUrl))
113
- } catch {
114
- print("File operation error: \(error)")
115
- loadComplete(.failure(error))
116
- }
117
- }
118
-
119
- })
120
- task.resume()
180
+ }
121
181
  }
122
182
  }
@@ -6,7 +6,8 @@ var pwaManager = PWAManager()
6
6
  struct PWAManager: Codable {
7
7
  var isLocal: Bool = false
8
8
 
9
- var start_url: String = "http://localhost:5173/"
9
+ // var start_url: String = "http://localhost:5173/#/"
10
+ var start_url: String = "http://localhost:5173/#/runtime-capabilities"
10
11
 
11
12
  // var start_url: String = "http://localhost:5173/#/spatial-drag-gesture"
12
13
 
@@ -0,0 +1,41 @@
1
+ import Foundation
2
+
3
+ /// Scene-level helpers for keeping RealityKit model components in sync after material / texture changes.
4
+ /// Per-material mutation lives on `SpatialUnlitMaterial` in `dynamic3d/SpatialMaterial.swift`; this type only
5
+ /// orchestrates lookups across `spatialObjects`.
6
+ enum MaterialSceneRefresh {
7
+ /// After `SpatialUnlitMaterial.updateProperties`, `ModelComponent` may still hold a stale material copy —
8
+ /// refresh every component and entity that references `materialId`.
9
+ static func refreshComponentsUsingMaterial(
10
+ _ materialId: String,
11
+ spatialObjects: [String: any SpatialObjectProtocol]
12
+ ) {
13
+ for (_, obj) in spatialObjects {
14
+ if let comp = obj as? SpatialModelComponent, comp.usesMaterial(materialId) {
15
+ comp.refreshMaterials()
16
+ }
17
+ }
18
+ for (_, obj) in spatialObjects {
19
+ if let entity = obj as? SpatialModelEntity, entity.usesMaterial(materialId) {
20
+ entity.refreshMaterials()
21
+ }
22
+ }
23
+ }
24
+
25
+ /// After `SpatialTextureResource` reloads from a new URL, push `texture.resource` into every bound unlit material and return their ids for `refreshComponentsUsingMaterial`.
26
+ static func pushReloadedTextureToBoundUnlitMaterials(
27
+ texture: SpatialTextureResource,
28
+ textureSpatialId: String,
29
+ spatialObjects: [String: any SpatialObjectProtocol]
30
+ ) -> Set<String> {
31
+ var refreshedMaterialIds = Set<String>()
32
+ for (_, obj) in spatialObjects {
33
+ guard let material = obj as? SpatialUnlitMaterial,
34
+ material.textureSpatialId == textureSpatialId
35
+ else { continue }
36
+ material.updateProperties(color: nil, texture: .some(texture.resource), transparent: nil, opacity: nil)
37
+ refreshedMaterialIds.insert(material.spatialId)
38
+ }
39
+ return refreshedMaterialIds
40
+ }
41
+ }
@@ -307,6 +307,8 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
307
307
  spatialWebViewModel.addJSBListener(CreateSpatialEntity.self, onCreateEntity)
308
308
  spatialWebViewModel.addJSBListener(CreateGeometryProperties.self, onCreateGeometry)
309
309
  spatialWebViewModel.addJSBListener(CreateUnlitMaterial.self, onCreateUnlitMaterial)
310
+ spatialWebViewModel.addJSBListener(CreateTexture.self, onCreateTexture)
311
+ spatialWebViewModel.addJSBListener(UpdateTextureProperties.self, onUpdateTextureProperties)
310
312
  spatialWebViewModel.addJSBListener(CreateModelComponent.self, onCreateModelComponent)
311
313
  spatialWebViewModel.addJSBListener(AddComponentToEntity.self, onAddComponentToEntity)
312
314
  spatialWebViewModel.addJSBListener(AddEntityToDynamic3D.self, onAddEntityToDynamic3D)
@@ -548,6 +550,9 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
548
550
  if let sources = command.sources {
549
551
  spatialObject.sources = sources
550
552
  }
553
+ if let loading = command.loading {
554
+ spatialObject.loading = loading
555
+ }
551
556
 
552
557
  resolve(.success(AddSpatializedElementReply(id: spatialObject.id)))
553
558
  }
@@ -629,6 +634,18 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
629
634
  spatializedElement.playbackRate = playbackRate
630
635
  }
631
636
 
637
+ if let currentTime = command.currentTime {
638
+ spatializedElement.pendingSeekTime = currentTime
639
+ }
640
+
641
+ if let posterURL = command.posterURL {
642
+ spatializedElement.posterURL = posterURL.isEmpty ? nil : posterURL
643
+ }
644
+
645
+ if let loading = command.loading {
646
+ spatializedElement.loading = loading
647
+ }
648
+
632
649
  resolve(.success(baseReplyData))
633
650
  }
634
651
 
@@ -964,8 +981,28 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
964
981
  // @fukang: add Component here
965
982
  }
966
983
 
984
+ private func onCreateTexture(command: CreateTexture, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
985
+ let texture = SpatialTextureResource(command.url)
986
+ addSpatialObject(texture)
987
+ Task {
988
+ do {
989
+ try await texture.load()
990
+ resolve(.success(AddSpatializedElementReply(id: texture.id)))
991
+ } catch {
992
+ texture.destroy()
993
+ resolve(.failure(JsbError(code: .CommandError, message: "Failed to load texture from \(command.url): \(error.localizedDescription)")))
994
+ }
995
+ }
996
+ }
997
+
967
998
  private func onCreateUnlitMaterial(command: CreateUnlitMaterial, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
968
- let material = Dynamic3DManager.createUnlitMaterial(command, nil)
999
+ var tex: TextureResource? = nil
1000
+ if let textureId = command.textureId,
1001
+ let texObj = spatialObjects[textureId] as? SpatialTextureResource
1002
+ {
1003
+ tex = texObj.resource
1004
+ }
1005
+ let material = Dynamic3DManager.createUnlitMaterial(command, tex)
969
1006
  addSpatialObject(material)
970
1007
  resolve(.success(AddSpatializedElementReply(id: material.id)))
971
1008
  }
@@ -1225,20 +1262,54 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
1225
1262
  resolve(.success(baseReplyData))
1226
1263
  }
1227
1264
 
1265
+ private func onUpdateTextureProperties(command: UpdateTextureProperties, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
1266
+ guard let texture = spatialObjects[command.id] as? SpatialTextureResource else {
1267
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "Texture \(command.id) not found")))
1268
+ return
1269
+ }
1270
+ guard let newURL = command.url else {
1271
+ resolve(.success(baseReplyData))
1272
+ return
1273
+ }
1274
+ Task {
1275
+ do {
1276
+ try await texture.updateURL(newURL)
1277
+ let refreshedMaterialIds = MaterialSceneRefresh.pushReloadedTextureToBoundUnlitMaterials(
1278
+ texture: texture,
1279
+ textureSpatialId: command.id,
1280
+ spatialObjects: spatialObjects
1281
+ )
1282
+ for mid in refreshedMaterialIds {
1283
+ MaterialSceneRefresh.refreshComponentsUsingMaterial(mid, spatialObjects: spatialObjects)
1284
+ }
1285
+ resolve(.success(baseReplyData))
1286
+ } catch {
1287
+ resolve(.failure(JsbError(code: .CommandError, message: error.localizedDescription)))
1288
+ }
1289
+ }
1290
+ }
1291
+
1228
1292
  private func onUpdateUnlitMaterialProperties(command: UpdateUnlitMaterialProperties, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
1229
1293
  guard let material = spatialObjects[command.id] as? SpatialUnlitMaterial else {
1230
1294
  resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "Material \(command.id) not found")))
1231
1295
  return
1232
1296
  }
1233
- material.updateProperties(color: command.color, transparent: command.transparent, opacity: command.opacity)
1234
- // Re-apply material to any ModelComponent or ModelEntity override that references it
1235
- for (_, obj) in spatialObjects {
1236
- if let comp = obj as? SpatialModelComponent, comp.usesMaterial(command.id) {
1237
- comp.refreshMaterials()
1238
- } else if let modelEntity = obj as? SpatialModelEntity, modelEntity.usesMaterial(command.id) {
1239
- modelEntity.refreshMaterials()
1297
+ var texture: TextureResource?? = nil
1298
+ if let textureId = command.textureId {
1299
+ if textureId.isEmpty {
1300
+ texture = .some(nil)
1301
+ } else if let texObj = spatialObjects[textureId] as? SpatialTextureResource {
1302
+ texture = .some(texObj.resource)
1303
+ } else {
1304
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "Texture \(textureId) not found")))
1305
+ return
1240
1306
  }
1241
1307
  }
1308
+ material.updateProperties(color: command.color, texture: texture, transparent: command.transparent, opacity: command.opacity)
1309
+ if let tid = command.textureId {
1310
+ material.textureSpatialId = tid.isEmpty ? nil : tid
1311
+ }
1312
+ MaterialSceneRefresh.refreshComponentsUsingMaterial(command.id, spatialObjects: spatialObjects)
1242
1313
  resolve(.success(baseReplyData))
1243
1314
  }
1244
1315
 
@@ -15,7 +15,15 @@ class SpatializedStatic3DElement: SpatializedElement {
15
15
  var loop: Bool = false
16
16
  var animationPaused: Bool = true
17
17
  var playbackRate: Double = 1.0
18
+ /// Requested seek position in seconds. Setting it triggers a seek in
19
+ /// `SpatializedStatic3DView`, which clears it back to `nil`.
20
+ var pendingSeekTime: Double?
21
+ var posterURL: String?
22
+ /// `"eager"` (default) fetches the model immediately; `"lazy"` defers
23
+ /// fetching until the web layer flips it back to `"eager"`.
24
+ var loading: String = "eager"
18
25
  var allSources: [ModelSource] {
26
+ guard loading == "eager" else { return [] }
19
27
  return if let modelURL {
20
28
  [ModelSource(src: modelURL, type: nil)] + sources
21
29
  } else { sources }
@@ -22,37 +22,53 @@ class SpatialMaterial: SpatialObject {
22
22
 
23
23
  @Observable
24
24
  class SpatialUnlitMaterial: SpatialMaterial {
25
+ /// Single RealityKit unlit instance we mutate in place; avoids allocating a new `UnlitMaterial()` on every property update.
26
+ private var _mat: UnlitMaterial
25
27
  private(set) var currentColor: UIColor
26
28
  private(set) var currentTexture: TextureResource?
27
29
  private(set) var currentTransparent: Bool
28
30
  private(set) var currentOpacity: Float
31
+ /// Spatial id of the bound `SpatialTextureResource`, when this material displays a texture. Used to push `TextureResource` updates after `UpdateTextureProperties`.
32
+ var textureSpatialId: String?
29
33
 
30
- init(_ color: String, _ texture: TextureResource? = nil, _ transparent: Bool = true, _ opacity: Float = 1) {
34
+ init(_ color: String, _ texture: TextureResource? = nil, _ transparent: Bool = true, _ opacity: Float = 1, textureSpatialId: String? = nil) {
31
35
  currentColor = UIColor(Color(hex: color))
32
36
  currentTexture = texture
33
37
  currentTransparent = transparent
34
38
  currentOpacity = opacity
39
+ self.textureSpatialId = textureSpatialId
40
+ _mat = UnlitMaterial()
35
41
  super.init(.UnlitMaterial)
36
- var mat = UnlitMaterial()
37
- mat.color = .init(tint: currentColor, texture: texture != nil ? .init(texture!) : nil)
38
- mat.blending = transparent ? .transparent(opacity: .init(scale: opacity)) : .opaque
39
- _resource = mat
42
+ applyProperties()
40
43
  }
41
44
 
42
- func updateProperties(color: String?, transparent: Bool?, opacity: Float?) {
45
+ /// Pushes `currentColor` / `currentTexture` / blending into `_mat` and exposes it as `resource`.
46
+ /// ModelComponent still holds its own copy, so callers must `refreshMaterials()` on affected components after this.
47
+ private func applyProperties() {
48
+ _mat.color = .init(
49
+ tint: currentColor,
50
+ texture: currentTexture.map { .init($0) }
51
+ )
52
+ _mat.blending = currentTransparent
53
+ ? .transparent(opacity: .init(scale: currentOpacity))
54
+ : .opaque
55
+ _resource = _mat
56
+ }
57
+
58
+ func updateProperties(color: String?, texture: TextureResource?? = nil, transparent: Bool?, opacity: Float?) {
43
59
  if let color = color {
44
60
  currentColor = UIColor(Color(hex: color))
45
61
  }
62
+ if let tex = texture {
63
+ currentTexture = tex
64
+ }
46
65
  if let transparent = transparent {
47
66
  currentTransparent = transparent
48
67
  }
49
68
  if let opacity = opacity {
50
69
  currentOpacity = opacity
51
70
  }
52
- var mat = UnlitMaterial()
53
- mat.color = .init(tint: currentColor, texture: currentTexture != nil ? .init(currentTexture!) : nil)
54
- mat.blending = currentTransparent ? .transparent(opacity: .init(scale: currentOpacity)) : .opaque
55
- _resource = mat
71
+ applyProperties()
56
72
  }
57
73
  }
58
74
 
@@ -9,9 +9,11 @@ class SpatialModelEntity: SpatialEntity {
9
9
 
10
10
  required init(_ modelResource: SpatialModelResource, _ _name: String = "") {
11
11
  super.init(_name)
12
- modelEntity = modelResource.resource
13
- addChild(modelEntity!)
14
- generateCollisionShapes(recursive: true)
12
+ modelEntity = modelResource.resource?.clone(recursive: true)
13
+ if let modelEntity = modelEntity {
14
+ addChild(modelEntity)
15
+ generateCollisionShapes(recursive: true)
16
+ }
15
17
  }
16
18
 
17
19
  required init() {
@@ -3,15 +3,40 @@ import SwiftUI
3
3
 
4
4
  @Observable
5
5
  class SpatialTextureResource: SpatialObject {
6
+ private(set) var url: String
6
7
  var _resource: TextureResource?
7
8
  var resource: TextureResource? {
8
9
  _resource
9
10
  }
10
11
 
11
12
  override init(_ url: String) {
13
+ self.url = url
12
14
  super.init()
13
15
  }
14
16
 
17
+ func load() async throws {
18
+ let localURL = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
19
+ Dynamic3DManager.loadResourceToLocal(url) { result in
20
+ continuation.resume(with: result)
21
+ }
22
+ }
23
+ _resource = try await Task { @MainActor in
24
+ try await TextureResource(contentsOf: localURL)
25
+ }.value
26
+ }
27
+
28
+ func updateURL(_ newURL: String) async throws {
29
+ url = newURL
30
+ let localURL = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
31
+ Dynamic3DManager.loadResourceToLocal(url) { result in
32
+ continuation.resume(with: result)
33
+ }
34
+ }
35
+ _resource = try await Task { @MainActor in
36
+ try await TextureResource(contentsOf: localURL)
37
+ }.value
38
+ }
39
+
15
40
  override func onDestroy() {
16
41
  _resource = nil
17
42
  }
@@ -5,14 +5,17 @@ struct SpatializedStatic3DView: View {
5
5
  @Environment(SpatializedElement.self) var spatializedElement: SpatializedElement
6
6
  @Environment(SpatialScene.self) var spatialScene: SpatialScene
7
7
 
8
- @State private var asset: Model3DAsset?
9
- @State private var source: String?
10
- @State private var isLoading = false
8
+ @State private var loadState: LoadState = .idle
11
9
 
12
10
  private var spatializedStatic3DElement: SpatializedStatic3DElement {
13
11
  return spatializedElement as! SpatializedStatic3DElement
14
12
  }
15
13
 
14
+ private var asset: Model3DAsset? {
15
+ if case let .loaded(asset, _) = loadState { return asset }
16
+ return nil
17
+ }
18
+
16
19
  func onLoadSuccess(src: String) {
17
20
  spatialScene.sendWebMsg(spatializedElement.id, ModelLoadSuccess(src: src))
18
21
  }
@@ -34,9 +37,10 @@ struct SpatializedStatic3DView: View {
34
37
  let enableGesture = spatializedElement.enableGesture
35
38
  if !spatializedStatic3DElement.allSources.isEmpty {
36
39
  Group {
37
- if isLoading {
38
- ProgressView()
39
- } else if let asset, let source {
40
+ switch loadState {
41
+ case .idle, .loading:
42
+ posterView { ProgressView() }
43
+ case let .loaded(asset, _):
40
44
  Model3D(asset: asset) { resolvedModel3D in
41
45
  resolvedModel3D
42
46
  .resizable(true)
@@ -45,15 +49,10 @@ struct SpatializedStatic3DView: View {
45
49
  contentMode: .fit
46
50
  )
47
51
  .if(!depth.isZero) { view in view.scaledToFit3D() }
48
- .onAppear {
49
- self.onLoadSuccess(src: source)
50
- }
51
52
  .if(enableGesture) { view in view.hoverEffect() }
52
53
  }
53
- } else {
54
- Text("").onAppear {
55
- self.onLoadFailure()
56
- }
54
+ case .failed:
55
+ posterView {}
57
56
  }
58
57
  }
59
58
  .scaleEffect(
@@ -82,12 +81,35 @@ struct SpatializedStatic3DView: View {
82
81
  }
83
82
  .onChange(of: spatializedStatic3DElement.animationPaused) { onPlayback(isPaused: $1) }
84
83
  .onChange(of: spatializedStatic3DElement.playbackRate) { asset?.animationPlaybackController?.speed = Float($1) }
84
+ .onChange(of: spatializedStatic3DElement.pendingSeekTime) { _, time in onSeek(time: time) }
85
85
  .task(id: spatializedStatic3DElement.allSources) { await loadSources() }
86
86
  } else {
87
87
  EmptyView()
88
88
  }
89
89
  }
90
90
 
91
+ /// Renders the poster image while the 3D model is loading or after all
92
+ /// sources fail. When no poster URL is provided, or the poster itself is
93
+ /// still loading/failed, the supplied `fallback` view is shown instead.
94
+ @ViewBuilder
95
+ private func posterView<Fallback: View>(
96
+ @ViewBuilder fallback: @escaping () -> Fallback
97
+ ) -> some View {
98
+ if let posterURL = spatializedStatic3DElement.posterURL,
99
+ let url = localOrRemoteURL(url: posterURL)
100
+ {
101
+ AsyncImage(url: url) { phase in
102
+ if case let .success(image) = phase {
103
+ image.resizable().aspectRatio(contentMode: .fit)
104
+ } else {
105
+ fallback()
106
+ }
107
+ }
108
+ } else {
109
+ fallback()
110
+ }
111
+ }
112
+
91
113
  /// Plays or pauses the model animation and sends an animation state to the web code
92
114
  private func onPlayback(isPaused: Bool) {
93
115
  guard let asset else {
@@ -101,12 +123,40 @@ struct SpatializedStatic3DView: View {
101
123
  }
102
124
  let controller = asset.animationPlaybackController
103
125
  controller?.speed = Float(spatializedStatic3DElement.playbackRate)
126
+ if let time = spatializedStatic3DElement.pendingSeekTime, let controller {
127
+ controller.time = time
128
+ spatializedStatic3DElement.pendingSeekTime = nil
129
+ }
104
130
  isPaused ? controller?.pause() : controller?.resume()
131
+ sendAnimationStateChange(isPaused: isPaused)
132
+ }
133
+
134
+ /// Seeks the underlying animation controller when a non-nil `time` is
135
+ /// requested, then clears `pendingSeekTime` so subsequent identical
136
+ /// requests still trigger a fresh seek.
137
+ private func onSeek(time: Double?) {
138
+ guard let controller = asset?.animationPlaybackController, let time else { return }
139
+ controller.time = time
140
+ spatializedStatic3DElement.pendingSeekTime = nil
141
+ sendAnimationStateChange(isPaused: spatializedStatic3DElement.animationPaused)
142
+ }
143
+
144
+ /// Emits the current animation state to the web layer, sampling the
145
+ /// controller's position and the wall clock together so the web side can
146
+ /// extrapolate between samples.
147
+ private func sendAnimationStateChange(isPaused: Bool) {
148
+ let controller = asset?.animationPlaybackController
105
149
  let duration = controller?.duration ?? 0
150
+ let currentTime = controller?.time ?? 0
106
151
  spatialScene.sendWebMsg(
107
152
  spatializedElement.id,
108
153
  AnimationStateChangeEvent(
109
- detail: AnimationStateChangeDetail(paused: isPaused, duration: duration)
154
+ detail: AnimationStateChangeDetail(
155
+ paused: isPaused,
156
+ duration: duration,
157
+ currentTime: currentTime,
158
+ timestamp: Date().timeIntervalSince1970 * 1000
159
+ )
110
160
  )
111
161
  )
112
162
  }
@@ -127,18 +177,23 @@ struct SpatializedStatic3DView: View {
127
177
  }
128
178
 
129
179
  private func loadSources() async {
130
- isLoading = true
180
+ loadState = .loading
131
181
  let result = await loadSources(spatializedStatic3DElement.allSources)
132
- asset = result?.asset
133
- source = result?.url.absoluteString
134
- if spatializedStatic3DElement.autoplay {
135
- // If animationPaused didn't change then SwiftUI will not trigger onChange so manually trigger playback
136
- // This happens when play is called before load and autoplay is enabled
137
- if spatializedStatic3DElement.animationPaused {
138
- spatializedStatic3DElement.animationPaused = false
139
- } else { onPlayback(isPaused: false) }
182
+ guard !Task.isCancelled else { return }
183
+ if let result {
184
+ loadState = .loaded(result.asset, result.url.absoluteString)
185
+ onLoadSuccess(src: result.url.absoluteString)
186
+ if spatializedStatic3DElement.autoplay {
187
+ // If animationPaused didn't change then SwiftUI will not trigger onChange so manually trigger playback
188
+ // This happens when play is called before load and autoplay is enabled
189
+ if spatializedStatic3DElement.animationPaused {
190
+ spatializedStatic3DElement.animationPaused = false
191
+ } else { onPlayback(isPaused: false) }
192
+ }
193
+ } else {
194
+ loadState = .failed
195
+ onLoadFailure()
140
196
  }
141
- isLoading = false
142
197
  }
143
198
 
144
199
  /// Attempts to load from each source in order, returning the first success.
@@ -158,3 +213,10 @@ struct SpatializedStatic3DView: View {
158
213
  private func localOrRemoteURL(url: String) -> URL? {
159
214
  URL(string: url.hasPrefix("file://") ? pwaManager.getLocalResourceURL(url: url) : url)
160
215
  }
216
+
217
+ private enum LoadState {
218
+ case idle
219
+ case loading
220
+ case loaded(Model3DAsset, String)
221
+ case failed
222
+ }
@@ -14,7 +14,6 @@
14
14
  2B06AEE62E4C1AE8000327E9 /* JSBCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B06AEC02E4C1AE8000327E9 /* JSBCommand.swift */; };
15
15
  2B06AEE92E4C1AE8000327E9 /* EventEmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B06AEBF2E4C1AE8000327E9 /* EventEmitter.swift */; };
16
16
  2B1008A22F0B000800000002 /* NavigationCleanupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B1008A12F0B000800000001 /* NavigationCleanupTests.swift */; };
17
- 2B2F1D692BEBFAAA006897EE /* RealityKitContent in Frameworks */ = {isa = PBXBuildFile; productRef = 2B2F1D682BEBFAAA006897EE /* RealityKitContent */; };
18
17
  2B2F1D712BEBFAAC006897EE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2B2F1D702BEBFAAC006897EE /* Assets.xcassets */; };
19
18
  2B2F1D742BEBFAAC006897EE /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2B2F1D732BEBFAAC006897EE /* Preview Assets.xcassets */; };
20
19
  2B89E38C2E699104004079AA /* WebMsgCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B89E38B2E699104004079AA /* WebMsgCommand.swift */; };
@@ -72,6 +71,7 @@
72
71
  dynamic3d/SpatialModelResource.swift,
73
72
  dynamic3d/SpatialRootEntity.swift,
74
73
  dynamic3d/SpatialTextureResource.swift,
74
+ MaterialSceneRefresh.swift,
75
75
  SpatialApp.swift,
76
76
  Spatialized2DElement.swift,
77
77
  SpatializedDynamic3DElement.swift,
@@ -142,7 +142,6 @@
142
142
  isa = PBXFrameworksBuildPhase;
143
143
  buildActionMask = 2147483647;
144
144
  files = (
145
- 2B2F1D692BEBFAAA006897EE /* RealityKitContent in Frameworks */,
146
145
  );
147
146
  runOnlyForDeploymentPostprocessing = 0;
148
147
  };
@@ -244,7 +243,6 @@
244
243
  );
245
244
  name = "web-spatial";
246
245
  packageProductDependencies = (
247
- 2B2F1D682BEBFAAA006897EE /* RealityKitContent */,
248
246
  );
249
247
  productName = "web-spatial";
250
248
  productReference = 2B2F1D632BEBFAAA006897EE /* WebSpatial.app */;
@@ -605,12 +603,6 @@
605
603
  };
606
604
  /* End XCConfigurationList section */
607
605
 
608
- /* Begin XCSwiftPackageProductDependency section */
609
- 2B2F1D682BEBFAAA006897EE /* RealityKitContent */ = {
610
- isa = XCSwiftPackageProductDependency;
611
- productName = RealityKitContent;
612
- };
613
- /* End XCSwiftPackageProductDependency section */
614
606
  };
615
607
  rootObject = 2B2F1D5B2BEBFAAA006897EE /* Project object */;
616
608
  }