@webspatial/platform-visionos 1.6.1 → 2.0.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 (40) hide show
  1. package/package.json +2 -2
  2. package/web-spatial/JSBCommand.swift +12 -0
  3. package/web-spatial/WebMsgCommand.swift +29 -2
  4. package/web-spatial/WebSpatialApp.swift +26 -8
  5. package/web-spatial/manager/Dynamic3DManager.swift +97 -37
  6. package/web-spatial/manifest.swift +2 -1
  7. package/web-spatial/model/MaterialSceneRefresh.swift +41 -0
  8. package/web-spatial/model/SpatialScene.swift +255 -45
  9. package/web-spatial/model/Spatialized2DElement.swift +2 -11
  10. package/web-spatial/model/SpatializedElement.swift +38 -0
  11. package/web-spatial/model/SpatializedStatic3DElement.swift +31 -4
  12. package/web-spatial/model/dynamic3d/EntityAnimationManager.swift +356 -0
  13. package/web-spatial/model/dynamic3d/EntityAnimationSession.swift +162 -0
  14. package/web-spatial/model/dynamic3d/SpatialMaterial.swift +26 -10
  15. package/web-spatial/model/dynamic3d/SpatialModelEntity.swift +5 -3
  16. package/web-spatial/model/dynamic3d/SpatialTextureResource.swift +25 -0
  17. package/web-spatial/model/element-motion/SpatializedElementAnimationCommand.swift +15 -0
  18. package/web-spatial/model/element-motion/SpatializedElementAnimationManager.swift +263 -0
  19. package/web-spatial/model/element-motion/SpatializedElementAnimationObject.swift +399 -0
  20. package/web-spatial/model/element-motion/SpatializedElementAnimationTypes.swift +45 -0
  21. package/web-spatial/model/element-motion/SpatializedElementAnimationWriteAdapter.swift +65 -0
  22. package/web-spatial/model/element-motion/SpatializedElementMotionBridgeTypes.swift +43 -0
  23. package/web-spatial/model/element-motion/SpatializedElementMotionTimelineSampler.swift +99 -0
  24. package/web-spatial/model/element-motion/SpatializedElementMotionTiming.swift +99 -0
  25. package/web-spatial/model/element-motion/SpatializedElementMotionTransformTypes.swift +22 -0
  26. package/web-spatial/protocol/SpatializedElementContainer.swift +1 -1
  27. package/web-spatial/view/SpatialNavView.swift +37 -12
  28. package/web-spatial/view/SpatialSceneContentView.swift +9 -12
  29. package/web-spatial/view/Spatialized2DElementView.swift +10 -17
  30. package/web-spatial/view/SpatializedDynamic3DView.swift +2 -6
  31. package/web-spatial/view/SpatializedElementView.swift +3 -2
  32. package/web-spatial/view/SpatializedStatic3DView.swift +104 -43
  33. package/web-spatial/view/view-modifier/OrbitModifier.swift +84 -0
  34. package/web-spatial/webview/SpatialWebController.swift +26 -18
  35. package/web-spatial/webview/SpatialWebViewModel.swift +12 -7
  36. package/web-spatial.xcodeproj/project.pbxproj +30 -17
  37. package/web-spatial.xcodeproj/xcshareddata/xcschemes/web-spatial.xcscheme +1 -1
  38. package/web-spatialTests/NavigationCleanupTests.swift +2 -1
  39. package/web-spatialTests/SpatializedElementAnimationManagerTests.swift +1466 -0
  40. package/web-spatialTests/SpatializedElementMotionTimelineSamplerTests.swift +117 -0
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@webspatial/platform-visionos",
3
- "version": "1.6.1",
3
+ "version": "2.0.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,10 @@ 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?
281
+ let stagemode: String?
270
282
  }
271
283
 
272
284
  struct UpdateSpatializedDynamic3DElementProperties: SpatializedElementProperties {
@@ -24,9 +24,8 @@ enum SpatialWebMsgType: String, Encodable {
24
24
  case spatialrotateend
25
25
  case spatialmagnify
26
26
  case spatialmagnifyend
27
-
28
27
  case animationstatechange
29
-
28
+ case entitytransformchange
30
29
  case objectdestroy
31
30
  }
32
31
 
@@ -119,6 +118,9 @@ struct ModelLoadFailure: Encodable {
119
118
  struct AnimationStateChangeDetail: Encodable {
120
119
  let paused: Bool
121
120
  let duration: Double
121
+ let currentTime: Double
122
+ /// Unix epoch time in milliseconds
123
+ let timestamp: Double
122
124
  }
123
125
 
124
126
  struct AnimationStateChangeEvent: Encodable {
@@ -126,6 +128,31 @@ struct AnimationStateChangeEvent: Encodable {
126
128
  let detail: AnimationStateChangeDetail
127
129
  }
128
130
 
131
+ /// 16-element column-major representation of the new `entityTransform`,
132
+ /// matching the wire format that JS already uses when sending the matrix to
133
+ /// native via `UpdateSpatializedStatic3DElementProperties.modelTransform`.
134
+ struct EntityTransformChangeDetail: Encodable {
135
+ let transform: [Double]
136
+ }
137
+
138
+ struct EntityTransformChangeEvent: Encodable {
139
+ let type: SpatialWebMsgType = .entitytransformchange
140
+ let detail: EntityTransformChangeDetail
141
+
142
+ /// Emits the transform to the web layer as a 16-element column-major matrix.
143
+ init(_ transform: AffineTransform3D) {
144
+ let m = transform.matrix
145
+ let c0 = m.columns.0, c1 = m.columns.1, c2 = m.columns.2, c3 = m.columns.3
146
+ let array: [Double] = [
147
+ c0.x, c0.y, c0.z, 0,
148
+ c1.x, c1.y, c1.z, 0,
149
+ c2.x, c2.y, c2.z, 0,
150
+ c3.x, c3.y, c3.z, 1,
151
+ ]
152
+ detail = EntityTransformChangeDetail(transform: array)
153
+ }
154
+ }
155
+
129
156
  struct SpatialObjectDestroiedEvent: Encodable {
130
157
  let type: SpatialWebMsgType = .objectdestroy
131
158
  }
@@ -40,13 +40,31 @@ struct WebSpatialApp: App {
40
40
  )
41
41
  }
42
42
 
43
+ func getSceneOrCreate(_ sceneID: String?, _ style: SpatialScene.WindowStyle) -> SpatialScene {
44
+ if let sceneID, let spatialScene = SpatialApp.Instance.getScene(sceneID) {
45
+ return spatialScene
46
+ }
47
+
48
+ logger.debug("Missing scene for WindowGroup data. Recreating scene.")
49
+ return SpatialApp.Instance.createScene(
50
+ startURL,
51
+ style,
52
+ .visible,
53
+ app.getSceneOptions()
54
+ )
55
+ }
56
+
57
+ func getSceneOrCreate(_ sceneID: String, _ style: SpatialScene.WindowStyle) -> SpatialScene {
58
+ return getSceneOrCreate(Optional(sceneID), style)
59
+ }
60
+
43
61
  var body: some Scene {
44
62
  WindowGroup(
45
63
  id: SpatialScene.WindowStyle.window.rawValue,
46
64
  for: String.self
47
65
  ) { $windowData in
48
- let spatialScene = SpatialApp.Instance.getScene(windowData)
49
- SpatialSceneView(spatialScene: spatialScene!)
66
+ let spatialScene = getSceneOrCreate(windowData, .window)
67
+ SpatialSceneView(spatialScene: spatialScene)
50
68
  }
51
69
  defaultValue: {
52
70
  let scene = SpatialApp.Instance.createScene(
@@ -66,20 +84,20 @@ struct WebSpatialApp: App {
66
84
  )
67
85
 
68
86
  WindowGroup(id: SpatialScene.WindowStyle.volume.rawValue, for: String.self) { $windowData in
69
- let spatialScene = SpatialApp.Instance.getScene(windowData)
70
- SpatialSceneView(spatialScene: spatialScene!)
87
+ let spatialScene = getSceneOrCreate(windowData, .volume)
88
+ SpatialSceneView(spatialScene: spatialScene)
71
89
  .frame(
72
90
  minWidth: getCGFloat(
73
- app.getSceneOptions(windowData)?.resizeRange?.minWidth
91
+ spatialScene.sceneConfig?.resizeRange?.minWidth
74
92
  ),
75
93
  maxWidth: getCGFloat(
76
- app.getSceneOptions(windowData)?.resizeRange?.maxWidth
94
+ spatialScene.sceneConfig?.resizeRange?.maxWidth
77
95
  ),
78
96
  minHeight: getCGFloat(
79
- app.getSceneOptions(windowData)?.resizeRange?.minHeight
97
+ spatialScene.sceneConfig?.resizeRange?.minHeight
80
98
  ),
81
99
  maxHeight: getCGFloat(
82
- app.getSceneOptions(windowData)?.resizeRange?.maxHeight
100
+ spatialScene.sceneConfig?.resizeRange?.maxHeight
83
101
  )
84
102
  )
85
103
  }
@@ -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
+ }