@webspatial/platform-visionos 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webspatial/platform-visionos",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Used to publish WebSpatial projects to Apple Vision Pro",
5
5
  "type": "commonjs",
6
6
  "main": "package.json",
@@ -14,7 +14,8 @@ struct AddSpatializedElementToSpatialScene: CommandDataProtocol {
14
14
 
15
15
  struct CreateSpatializedStatic3DElement: CommandDataProtocol {
16
16
  static let commandType: String = "CreateSpatializedStatic3DElement"
17
- let modelURL: String
17
+ let modelURL: String?
18
+ let sources: [ModelSource]?
18
19
  }
19
20
 
20
21
  struct CreateSpatializedDynamic3DElement: CommandDataProtocol {
@@ -145,6 +146,26 @@ protocol SpatialObjectCommand: CommandDataProtocol {
145
146
  var id: String { get }
146
147
  }
147
148
 
149
+ struct UpdateUnlitMaterialProperties: CommandDataProtocol {
150
+ static let commandType: String = "UpdateUnlitMaterialProperties"
151
+ let id: String
152
+ let color: String?
153
+ let transparent: Bool?
154
+ let opacity: Float?
155
+ }
156
+
157
+ struct RemoveComponentFromEntity: CommandDataProtocol {
158
+ static let commandType: String = "RemoveComponentFromEntity"
159
+ let entityId: String
160
+ let componentId: String
161
+ }
162
+
163
+ struct SetMaterialsOnEntity: CommandDataProtocol {
164
+ static let commandType: String = "SetMaterialsOnEntity"
165
+ let entityId: String
166
+ let materialIds: [String]
167
+ }
168
+
148
169
  struct DestroyCommand: CommandDataProtocol {
149
170
  static let commandType: String = "Destroy"
150
171
  var id: String
@@ -240,7 +261,12 @@ struct UpdateSpatializedStatic3DElementProperties: SpatializedElementProperties
240
261
  let rotateConstrainedToAxis: Vec3?
241
262
 
242
263
  let modelURL: String?
264
+ let sources: [ModelSource]?
243
265
  let modelTransform: [Double]?
266
+ let autoplay: Bool?
267
+ let loop: Bool?
268
+ let animationPaused: Bool?
269
+ let playbackRate: Double?
244
270
  }
245
271
 
246
272
  struct UpdateSpatializedDynamic3DElementProperties: SpatializedElementProperties {
@@ -25,6 +25,8 @@ enum SpatialWebMsgType: String, Encodable {
25
25
  case spatialmagnify
26
26
  case spatialmagnifyend
27
27
 
28
+ case animationstatechange
29
+
28
30
  case objectdestroy
29
31
  }
30
32
 
@@ -97,14 +99,33 @@ struct WebSpatialMagnifyEndGuestureEvent: Encodable {
97
99
  let type: SpatialWebMsgType = .spatialmagnifyend
98
100
  }
99
101
 
102
+ struct ModelLoadSuccessDetail: Encodable {
103
+ let src: String
104
+ }
105
+
100
106
  struct ModelLoadSuccess: Encodable {
101
107
  let type: SpatialWebMsgType = .modelloaded
108
+ let detail: ModelLoadSuccessDetail
109
+
110
+ init(src: String) {
111
+ detail = ModelLoadSuccessDetail(src: src)
112
+ }
102
113
  }
103
114
 
104
115
  struct ModelLoadFailure: Encodable {
105
116
  let type: SpatialWebMsgType = .modelloadfailed
106
117
  }
107
118
 
119
+ struct AnimationStateChangeDetail: Encodable {
120
+ let paused: Bool
121
+ let duration: Double
122
+ }
123
+
124
+ struct AnimationStateChangeEvent: Encodable {
125
+ let type: SpatialWebMsgType = .animationstatechange
126
+ let detail: AnimationStateChangeDetail
127
+ }
128
+
108
129
  struct SpatialObjectDestroiedEvent: Encodable {
109
130
  let type: SpatialWebMsgType = .objectdestroy
110
131
  }
@@ -6,7 +6,7 @@ var pwaManager = PWAManager()
6
6
  struct PWAManager: Codable {
7
7
  var isLocal: Bool = false
8
8
 
9
- var start_url: String = "http://localhost:5173/#/geometry-verify"
9
+ var start_url: String = "http://localhost:5173/"
10
10
 
11
11
  // var start_url: String = "http://localhost:5173/#/spatial-drag-gesture"
12
12
 
@@ -85,6 +85,8 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
85
85
 
86
86
  // TOPIC end
87
87
 
88
+ var isSpatialElementGestureActive = false
89
+
88
90
  var spatialWebViewModel: SpatialWebViewModel
89
91
 
90
92
  private var meterToPtUnscaled: Double?
@@ -321,6 +323,10 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
321
323
  spatialWebViewModel.addJSBListener(InitializeAttachmentCommand.self, onInitializeAttachment)
322
324
  spatialWebViewModel.addJSBListener(ConvertCoordinate.self, onConvertCoordinate)
323
325
 
326
+ spatialWebViewModel.addJSBListener(UpdateUnlitMaterialProperties.self, onUpdateUnlitMaterialProperties)
327
+ spatialWebViewModel.addJSBListener(RemoveComponentFromEntity.self, onRemoveComponentFromEntity)
328
+ spatialWebViewModel.addJSBListener(SetMaterialsOnEntity.self, onSetMaterialsOnEntity)
329
+
324
330
  spatialWebViewModel.addJSBListener(UpdateAttachmentEntityCommand.self, onUpdateAttachmentEntity)
325
331
 
326
332
  spatialWebViewModel.addOpenWindowListener(protocal: "webspatial", onOpenWindowHandler)
@@ -425,7 +431,7 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
425
431
  }
426
432
  }
427
433
 
428
- // Temporary storage for webview models awaiting JSB initialization
434
+ /// Temporary storage for webview models awaiting JSB initialization
429
435
  private var pendingAttachmentWebViewModels = [String: SpatialWebViewModel]()
430
436
 
431
437
  private func handleCreateAttachment(_ url: URL) -> WebViewElementInfo? {
@@ -539,6 +545,9 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
539
545
  private func onCreateSpatializedStatic3DElement(command: CreateSpatializedStatic3DElement, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
540
546
  let spatialObject: SpatializedStatic3DElement = createSpatializedElement(.SpatializedStatic3DElement)
541
547
  spatialObject.modelURL = command.modelURL
548
+ if let sources = command.sources {
549
+ spatialObject.sources = sources
550
+ }
542
551
 
543
552
  resolve(.success(AddSpatializedElementReply(id: spatialObject.id)))
544
553
  }
@@ -581,6 +590,10 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
581
590
 
582
591
  updateSpatializedElementProperties(spatializedElement, command)
583
592
 
593
+ if let sources = command.sources {
594
+ spatializedElement.sources = sources
595
+ }
596
+
584
597
  if let modelURL = command.modelURL {
585
598
  spatializedElement.modelURL = modelURL
586
599
  }
@@ -600,6 +613,22 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
600
613
  spatializedElement.modelTransform = affineTransform3D
601
614
  }
602
615
 
616
+ if let autoplay = command.autoplay {
617
+ spatializedElement.autoplay = autoplay
618
+ }
619
+
620
+ if let loop = command.loop {
621
+ spatializedElement.loop = loop
622
+ }
623
+
624
+ if let animationPaused = command.animationPaused {
625
+ spatializedElement.animationPaused = animationPaused
626
+ }
627
+
628
+ if let playbackRate = command.playbackRate {
629
+ spatializedElement.playbackRate = playbackRate
630
+ }
631
+
603
632
  resolve(.success(baseReplyData))
604
633
  }
605
634
 
@@ -1107,22 +1136,22 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
1107
1136
  resolve(.success(ConvertReply(id: command.entityId, position: point)))
1108
1137
  }
1109
1138
 
1110
- /// Input: command.position, command.fromId, command.toId
1111
- /// fromId/toId can reference either the scene (window) or an entity.
1112
- /// Step 1: Convert position to window coordinates (view global, px)
1113
- /// - If from is window (scene), position is already in view global (px). Go to Step 2.
1114
- /// - If from is 2d frame(SpatializedElement), position is in view local (px).
1115
- /// - view local → window (view global, px) using SpatializedElement.convertToScene
1116
- /// - If from is an entity, position is in reality entity local (meters):
1117
- /// - entity local → reality world (scene)
1118
- /// - reality world → window (view global, px)
1119
- /// Step 2: Convert window coordinates (view global, px) to target output
1120
- /// - If to is window, output directly.
1121
- /// - If to is 2d frame(SpatializedElement), output in view local (px).
1122
- /// - window (view global, px) → view local using SpatializedElement.convertFromScene
1123
- /// - If to is an entity, output in reality entity local (meters):
1124
- /// - window (view global, px) → reality world (scene)
1125
- /// - reality world → reality entity local (meters)
1139
+ // Input: command.position, command.fromId, command.toId
1140
+ // fromId/toId can reference either the scene (window) or an entity.
1141
+ // Step 1: Convert position to window coordinates (view global, px)
1142
+ // - If from is window (scene), position is already in view global (px). Go to Step 2.
1143
+ // - If from is 2d frame(SpatializedElement), position is in view local (px).
1144
+ // - view local → window (view global, px) using SpatializedElement.convertToScene
1145
+ // - If from is an entity, position is in reality entity local (meters):
1146
+ // - entity local → reality world (scene)
1147
+ // - reality world → window (view global, px)
1148
+ // Step 2: Convert window coordinates (view global, px) to target output
1149
+ // - If to is window, output directly.
1150
+ // - If to is 2d frame(SpatializedElement), output in view local (px).
1151
+ // - window (view global, px) → view local using SpatializedElement.convertFromScene
1152
+ // - If to is an entity, output in reality entity local (meters):
1153
+ // - window (view global, px) → reality world (scene)
1154
+ // - reality world → reality entity local (meters)
1126
1155
 
1127
1156
  private func onConvertCoordinate(command: ConvertCoordinate, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
1128
1157
  func isSceneId(_ id: String) -> Bool {
@@ -1196,6 +1225,52 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
1196
1225
  resolve(.success(baseReplyData))
1197
1226
  }
1198
1227
 
1228
+ private func onUpdateUnlitMaterialProperties(command: UpdateUnlitMaterialProperties, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
1229
+ guard let material = spatialObjects[command.id] as? SpatialUnlitMaterial else {
1230
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "Material \(command.id) not found")))
1231
+ return
1232
+ }
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()
1240
+ }
1241
+ }
1242
+ resolve(.success(baseReplyData))
1243
+ }
1244
+
1245
+ private func onRemoveComponentFromEntity(command: RemoveComponentFromEntity, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
1246
+ guard let entity = spatialObjects[command.entityId] as? SpatialEntity,
1247
+ let component = spatialObjects[command.componentId] as? SpatialComponent
1248
+ else {
1249
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "Remove component failed")))
1250
+ return
1251
+ }
1252
+ entity.removeComponent(component)
1253
+ resolve(.success(baseReplyData))
1254
+ }
1255
+
1256
+ private func onSetMaterialsOnEntity(command: SetMaterialsOnEntity, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
1257
+ guard let entity = spatialObjects[command.entityId] as? SpatialModelEntity else {
1258
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "ModelEntity \(command.entityId) not found")))
1259
+ return
1260
+ }
1261
+ var materials: [SpatialMaterial] = []
1262
+ for mid in command.materialIds {
1263
+ if let material = spatialObjects[mid] as? SpatialMaterial {
1264
+ materials.append(material)
1265
+ } else {
1266
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "Material \(mid) not found")))
1267
+ return
1268
+ }
1269
+ }
1270
+ entity.setMaterials(materials)
1271
+ resolve(.success(baseReplyData))
1272
+ }
1273
+
1199
1274
  private func addSpatialObject(_ object: any SpatialObjectProtocol) {
1200
1275
  var spatialObject = object
1201
1276
  spatialObjects[spatialObject.spatialId] = spatialObject
@@ -1227,19 +1302,12 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
1227
1302
  return nil
1228
1303
  }
1229
1304
 
1230
- for (_, object) in spatialObjects {
1231
- guard let dynamic3dElement = object as? SpatializedDynamic3DElement else {
1232
- continue
1233
- }
1234
-
1235
- let root = dynamic3dElement.getRoot()
1236
- var current: Entity? = entity
1237
- while let node = current {
1238
- if node === root {
1239
- return dynamic3dElement
1240
- }
1241
- current = node.parent
1305
+ var current: Entity? = entity
1306
+ while let node = current {
1307
+ if let rootEntity = node as? SpatialRootEntity {
1308
+ return rootEntity.root
1242
1309
  }
1310
+ current = node.parent
1243
1311
  }
1244
1312
 
1245
1313
  return nil
@@ -4,9 +4,14 @@ import RealityKit
4
4
 
5
5
  @Observable
6
6
  class SpatializedDynamic3DElement: SpatializedElement {
7
- private var rootEntity = SpatialEntity()
7
+ private var rootEntity = SpatialRootEntity()
8
8
  private var viewContent: RealityViewContent? = nil
9
9
 
10
+ override init() {
11
+ super.init()
12
+ rootEntity.root = self
13
+ }
14
+
10
15
  func getRoot() -> SpatialEntity {
11
16
  return rootEntity
12
17
  }
@@ -1,10 +1,25 @@
1
1
  import Foundation
2
2
  import SwiftUI
3
3
 
4
+ struct ModelSource: Codable, Equatable {
5
+ let src: String
6
+ let type: String?
7
+ }
8
+
4
9
  @Observable
5
10
  class SpatializedStatic3DElement: SpatializedElement {
6
- var modelURL: String = ""
11
+ var modelURL: String?
12
+ var sources: [ModelSource] = []
7
13
  var modelTransform: AffineTransform3D = .identity
14
+ var autoplay: Bool = false
15
+ var loop: Bool = false
16
+ var animationPaused: Bool = true
17
+ var playbackRate: Double = 1.0
18
+ var allSources: [ModelSource] {
19
+ return if let modelURL {
20
+ [ModelSource(src: modelURL, type: nil)] + sources
21
+ } else { sources }
22
+ }
8
23
 
9
24
  enum CodingKeys: String, CodingKey {
10
25
  case modelURL, type
@@ -43,7 +43,12 @@ class SpatialComponent: SpatialObject {
43
43
 
44
44
  @Observable
45
45
  class SpatialModelComponent: SpatialComponent {
46
+ private(set) var spatialMaterials: [SpatialMaterial] = []
47
+ private(set) var mesh: Geometry?
48
+
46
49
  init(mesh: Geometry, mats: [SpatialMaterial]) {
50
+ spatialMaterials = mats
51
+ self.mesh = mesh
47
52
  super.init(.ModelComponent)
48
53
  var materials: [any RealityKit.Material] = []
49
54
  for item in mats {
@@ -52,6 +57,27 @@ class SpatialModelComponent: SpatialComponent {
52
57
  _resource = ModelComponent(mesh: mesh.resource!, materials: materials)
53
58
  }
54
59
 
60
+ /// Rebuild the ModelComponent with current material resources (called after material properties change)
61
+ func refreshMaterials() {
62
+ guard let mesh = mesh else { return }
63
+ var materials: [any RealityKit.Material] = []
64
+ for item in spatialMaterials {
65
+ if let res = item.resource {
66
+ materials.append(res)
67
+ }
68
+ }
69
+ _resource = ModelComponent(mesh: mesh.resource!, materials: materials)
70
+ if let entity = _entity {
71
+ entity.components.set(_resource!)
72
+ entity.generateCollisionShapes(recursive: true)
73
+ }
74
+ }
75
+
76
+ /// Check if this component uses the given material
77
+ func usesMaterial(_ materialId: String) -> Bool {
78
+ return spatialMaterials.contains { $0.id == materialId }
79
+ }
80
+
55
81
  override func addToEntity(entity: SpatialEntity) {
56
82
  super.addToEntity(entity: entity)
57
83
  entity.generateCollisionShapes(recursive: true)
@@ -63,7 +89,12 @@ class SpatialModelComponent: SpatialComponent {
63
89
  }
64
90
 
65
91
  override func onDestroy() {
92
+ // TODO(P2): `mesh` is a registered `Geometry` spatial object; clearing the reference does not
93
+ // run `Geometry.destroy()`, so dynamic mesh rebuilds can leak mesh/registry entries until the
94
+ // scene ends. Call `mesh?.destroy()` (or equivalent) before nil-ing when ownership is exclusive.
66
95
  _resource = nil
96
+ spatialMaterials = []
97
+ mesh = nil
67
98
  }
68
99
  }
69
100
 
@@ -22,16 +22,38 @@ class SpatialMaterial: SpatialObject {
22
22
 
23
23
  @Observable
24
24
  class SpatialUnlitMaterial: SpatialMaterial {
25
- let color: UIColor
25
+ private(set) var currentColor: UIColor
26
+ private(set) var currentTexture: TextureResource?
27
+ private(set) var currentTransparent: Bool
28
+ private(set) var currentOpacity: Float
26
29
 
27
30
  init(_ color: String, _ texture: TextureResource? = nil, _ transparent: Bool = true, _ opacity: Float = 1) {
28
- self.color = UIColor(Color(hex: color))
31
+ currentColor = UIColor(Color(hex: color))
32
+ currentTexture = texture
33
+ currentTransparent = transparent
34
+ currentOpacity = opacity
29
35
  super.init(.UnlitMaterial)
30
36
  var mat = UnlitMaterial()
31
- mat.color = .init(tint: UIColor(Color(hex: color)), texture: texture != nil ? .init(texture!) : nil)
37
+ mat.color = .init(tint: currentColor, texture: texture != nil ? .init(texture!) : nil)
32
38
  mat.blending = transparent ? .transparent(opacity: .init(scale: opacity)) : .opaque
33
39
  _resource = mat
34
40
  }
41
+
42
+ func updateProperties(color: String?, transparent: Bool?, opacity: Float?) {
43
+ if let color = color {
44
+ currentColor = UIColor(Color(hex: color))
45
+ }
46
+ if let transparent = transparent {
47
+ currentTransparent = transparent
48
+ }
49
+ if let opacity = opacity {
50
+ currentOpacity = opacity
51
+ }
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
56
+ }
35
57
  }
36
58
 
37
59
  enum SpatialMaterialType: String {
@@ -4,6 +4,9 @@ import SwiftUI
4
4
  @Observable
5
5
  class SpatialModelEntity: SpatialEntity {
6
6
  private var modelEntity: Entity?
7
+ /// Retained so `UpdateUnlitMaterialProperties` can re-apply current `SpatialMaterial.resource` after native material updates.
8
+ private(set) var overrideSpatialMaterials: [SpatialMaterial] = []
9
+
7
10
  required init(_ modelResource: SpatialModelResource, _ _name: String = "") {
8
11
  super.init(_name)
9
12
  modelEntity = modelResource.resource
@@ -15,12 +18,44 @@ class SpatialModelEntity: SpatialEntity {
15
18
  super.init()
16
19
  }
17
20
 
21
+ func setMaterials(_ materials: [SpatialMaterial]) {
22
+ overrideSpatialMaterials = materials
23
+ applyOverrideMaterials()
24
+ }
25
+
26
+ /// Re-apply stored override materials using each `SpatialMaterial`'s current `resource` (e.g. after unlit property updates).
27
+ func refreshMaterials() {
28
+ applyOverrideMaterials()
29
+ }
30
+
31
+ func usesMaterial(_ materialId: String) -> Bool {
32
+ overrideSpatialMaterials.contains { $0.id == materialId }
33
+ }
34
+
35
+ private func applyOverrideMaterials() {
36
+ guard let modelEntity = modelEntity else { return }
37
+ // TODO(P1): Clearing overrides (`setMaterials([])`) assigns an empty material list here; there is
38
+ // no baseline of the model asset’s authored materials to restore. Persist per-component defaults
39
+ // at load (or skip writing when overrides are empty) so clears return to the authored look.
40
+ func applyMaterials(to entity: Entity) {
41
+ if var modelComp = entity.components[ModelComponent.self] {
42
+ modelComp.materials = overrideSpatialMaterials.compactMap { $0.resource }
43
+ entity.components.set(modelComp)
44
+ }
45
+ for child in entity.children {
46
+ applyMaterials(to: child)
47
+ }
48
+ }
49
+ applyMaterials(to: modelEntity)
50
+ }
51
+
18
52
  override func onDestroy() {
19
53
  super.onDestroy()
20
54
  if let modelEntity = modelEntity {
21
55
  removeChild(modelEntity)
22
56
  }
23
57
  modelEntity = nil
58
+ overrideSpatialMaterials = []
24
59
  }
25
60
 
26
61
  enum CodingKeys: String, CodingKey {
@@ -0,0 +1,12 @@
1
+ import RealityKit
2
+ import SwiftUI
3
+
4
+ @Observable
5
+ class SpatialRootEntity: SpatialEntity {
6
+ weak var root: SpatializedDynamic3DElement?
7
+
8
+ convenience init(root: SpatializedDynamic3DElement) {
9
+ self.init()
10
+ self.root = root
11
+ }
12
+ }
@@ -72,6 +72,9 @@ struct Spatialized2DElementView: View {
72
72
  DragGesture()
73
73
  .onChanged { gesture in
74
74
  // print("\(spatialized2DElement.name) dragWebGesture")
75
+ if spatialScene.isSpatialElementGestureActive {
76
+ return
77
+ }
75
78
  if spatialized2DElement.scrollPageEnabled {
76
79
  if !gestureData.dragStarted {
77
80
  gestureData.dragStarted = true
@@ -86,6 +89,9 @@ struct Spatialized2DElementView: View {
86
89
  }
87
90
  .onEnded { _ in
88
91
  print("\(spatialized2DElement.name) dragWebGestureEnd")
92
+ if spatialScene.isSpatialElementGestureActive {
93
+ return
94
+ }
89
95
  if spatialized2DElement.scrollPageEnabled {
90
96
  gestureData.dragStarted = false
91
97
  gestureData.dragStart = 0
@@ -22,7 +22,7 @@ struct SpatializedElementView<Content: View>: View {
22
22
 
23
23
  /// Begin Interaction
24
24
  var gesture: some Gesture {
25
- DragGesture(minimumDistance: 10)
25
+ DragGesture(minimumDistance: 10, coordinateSpace: .named("SpatialScene"))
26
26
  .onChanged(onDragging)
27
27
  .onEnded(onDraggingEnded)
28
28
  .simultaneously(with:
@@ -74,14 +74,13 @@ struct SpatializedElementView<Content: View>: View {
74
74
  }
75
75
 
76
76
  private func onDragging(_ event: DragGesture.Value) {
77
+ if !gestureState.isDrag {
78
+ spatialScene.isSpatialElementGestureActive = true
79
+ }
80
+
77
81
  if spatializedElement.enableDragStartGesture, !gestureState.isDrag {
78
- let frameZ = localFrameOffsetZ()
79
- let startLocal = Point3D(
80
- x: event.startLocation3D.x,
81
- y: event.startLocation3D.y,
82
- z: event.startLocation3D.z - frameZ
83
- )
84
- let globalPoint3D = localToScene(event.startLocation3D)
82
+ let startLocal = sceneToLocal(event.startLocation3D)
83
+ let globalPoint3D = event.startLocation3D
85
84
  let gestureEvent = WebSpatialDragStartGuestureEvent(detail: .init(
86
85
  startLocation3D: startLocal,
87
86
  globalLocation3D: globalPoint3D
@@ -102,6 +101,7 @@ struct SpatializedElementView<Content: View>: View {
102
101
 
103
102
  private func onDraggingEnded(_ event: DragGesture.Value) {
104
103
  gestureState.isDrag = false
104
+ spatialScene.isSpatialElementGestureActive = false
105
105
  if spatializedElement.enableDragEndGesture {
106
106
  let gestureEvent = WebSpatialDragEndGuestureEvent()
107
107
  spatialScene.sendWebMsg(spatializedElement.id, gestureEvent)
@@ -121,6 +121,11 @@ struct SpatializedElementView<Content: View>: View {
121
121
  return Point3D(x: scene.x, y: scene.y, z: scene.z)
122
122
  }
123
123
 
124
+ private func sceneToLocal(_ scenePoint: Point3D) -> Point3D {
125
+ let local = spatializedElement.convertFromScene(SIMD3<Double>(scenePoint.x, scenePoint.y, scenePoint.z))
126
+ return Point3D(x: local.x, y: local.y, z: local.z)
127
+ }
128
+
124
129
  private func onTapEnded(_ event: SpatialTapGesture.Value) {
125
130
  if spatializedElement.enableTapGesture {
126
131
  let frameZ = localFrameOffsetZ()
@@ -198,6 +203,9 @@ struct SpatializedElementView<Content: View>: View {
198
203
  // Gesture before .position(): event.location3D is in the element's local space
199
204
  // (top-left origin), and does not include visual transforms.
200
205
  .simultaneousGesture(enableGesture ? gesture : nil)
206
+ .onDisappear {
207
+ spatialScene.isSpatialElementGestureActive = false
208
+ }
201
209
  .onGeometryChange3D(for: AffineTransform3D.self) { proxy in
202
210
  proxy.transform(in: .named("SpatialScene"))!
203
211
  } action: { new in
@@ -5,12 +5,16 @@ 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
11
+
8
12
  private var spatializedStatic3DElement: SpatializedStatic3DElement {
9
13
  return spatializedElement as! SpatializedStatic3DElement
10
14
  }
11
15
 
12
- func onLoadSuccess() {
13
- spatialScene.sendWebMsg(spatializedElement.id, ModelLoadSuccess())
16
+ func onLoadSuccess(src: String) {
17
+ spatialScene.sendWebMsg(spatializedElement.id, ModelLoadSuccess(src: src))
14
18
  }
15
19
 
16
20
  func onLoadFailure() {
@@ -28,33 +32,28 @@ struct SpatializedStatic3DView: View {
28
32
  let z = translation.z
29
33
 
30
34
  let enableGesture = spatializedElement.enableGesture
31
- let rawModelURL = spatializedStatic3DElement.modelURL
32
- let modelURL = rawModelURL.hasPrefix("file://")
33
- ? pwaManager.getLocalResourceURL(url: rawModelURL)
34
- : rawModelURL
35
- if let url = URL(string: modelURL) {
36
- Model3D(url: url) { newPhase in
37
- switch newPhase {
38
- case .empty:
35
+ if !spatializedStatic3DElement.allSources.isEmpty {
36
+ Group {
37
+ if isLoading {
39
38
  ProgressView()
40
- case let .success(resolvedModel3D):
41
- resolvedModel3D
42
- .resizable(true)
43
- .aspectRatio(
44
- nil,
45
- contentMode: .fit
46
- )
47
- .if(!depth.isZero) { view in view.scaledToFit3D() }
48
- .onAppear {
49
- self.onLoadSuccess()
50
- }
51
- .if(enableGesture) { view in view.hoverEffect() }
52
- case .failure:
39
+ } else if let asset, let source {
40
+ Model3D(asset: asset) { resolvedModel3D in
41
+ resolvedModel3D
42
+ .resizable(true)
43
+ .aspectRatio(
44
+ nil,
45
+ contentMode: .fit
46
+ )
47
+ .if(!depth.isZero) { view in view.scaledToFit3D() }
48
+ .onAppear {
49
+ self.onLoadSuccess(src: source)
50
+ }
51
+ .if(enableGesture) { view in view.hoverEffect() }
52
+ }
53
+ } else {
53
54
  Text("").onAppear {
54
55
  self.onLoadFailure()
55
56
  }
56
- @unknown default:
57
- EmptyView()
58
57
  }
59
58
  }
60
59
  .scaleEffect(
@@ -67,8 +66,95 @@ struct SpatializedStatic3DView: View {
67
66
  )
68
67
  .offset(x: x, y: y)
69
68
  .offset(z: z)
69
+ .onChange(of: asset?.animationPlaybackController?.isComplete) { _, isComplete in
70
+ guard isComplete == true else { return }
71
+ if spatializedStatic3DElement.loop,
72
+ let asset,
73
+ let animation = asset.availableAnimations.first
74
+ {
75
+ asset.selectedAnimation = animation
76
+ asset.animationPlaybackController?.speed = Float(spatializedStatic3DElement.playbackRate)
77
+ asset.animationPlaybackController?.resume()
78
+ } else {
79
+ // Non-looping animation completed naturally; sync paused state to JS
80
+ spatializedStatic3DElement.animationPaused = true
81
+ }
82
+ }
83
+ .onChange(of: spatializedStatic3DElement.animationPaused) { onPlayback(isPaused: $1) }
84
+ .onChange(of: spatializedStatic3DElement.playbackRate) { asset?.animationPlaybackController?.speed = Float($1) }
85
+ .task(id: spatializedStatic3DElement.allSources) { await loadSources() }
70
86
  } else {
71
87
  EmptyView()
72
88
  }
73
89
  }
90
+
91
+ /// Plays or pauses the model animation and sends an animation state to the web code
92
+ private func onPlayback(isPaused: Bool) {
93
+ guard let asset else {
94
+ // If entity has not loaded yet and play is called then autoplay after load
95
+ if !isPaused { spatializedStatic3DElement.autoplay = true }
96
+ return
97
+ }
98
+ // Setting selectedAnimation resets the animation and autoplays on first load
99
+ if asset.selectedAnimation == nil || asset.animationPlaybackController?.isComplete == true {
100
+ asset.selectedAnimation = asset.availableAnimations.first
101
+ }
102
+ let controller = asset.animationPlaybackController
103
+ controller?.speed = Float(spatializedStatic3DElement.playbackRate)
104
+ isPaused ? controller?.pause() : controller?.resume()
105
+ let duration = controller?.duration ?? 0
106
+ spatialScene.sendWebMsg(
107
+ spatializedElement.id,
108
+ AnimationStateChangeEvent(
109
+ detail: AnimationStateChangeDetail(paused: isPaused, duration: duration)
110
+ )
111
+ )
112
+ }
113
+
114
+ /// Downloads a remote model file and loads it as a Model3DAsset.
115
+ /// Model3DAsset(url:) requires a local file URL, so remote
116
+ /// resources must be downloaded first.
117
+ private func loadAsset(from url: URL) async throws -> Model3DAsset {
118
+ if url.isFileURL {
119
+ return try await Model3DAsset(url: url)
120
+ }
121
+ let (tempURL, _) = try await URLSession.shared.download(from: url)
122
+ // TODO: Use FileManager.temporaryDirectory and FileManager.removeItem for auto cleanup
123
+ let localURL = tempURL.deletingPathExtension()
124
+ .appendingPathExtension(url.pathExtension)
125
+ try FileManager.default.moveItem(at: tempURL, to: localURL)
126
+ return try await Model3DAsset(url: localURL)
127
+ }
128
+
129
+ private func loadSources() async {
130
+ isLoading = true
131
+ 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) }
140
+ }
141
+ isLoading = false
142
+ }
143
+
144
+ /// Attempts to load from each source in order, returning the first success.
145
+ private func loadSources(_ sources: [ModelSource]) async -> (url: URL, asset: Model3DAsset)? {
146
+ for source in sources {
147
+ guard let url = localOrRemoteURL(url: source.src) else { continue }
148
+ do {
149
+ return try (url, await loadAsset(from: url))
150
+ } catch {
151
+ continue
152
+ }
153
+ }
154
+ return nil
155
+ }
156
+ }
157
+
158
+ private func localOrRemoteURL(url: String) -> URL? {
159
+ URL(string: url.hasPrefix("file://") ? pwaManager.getLocalResourceURL(url: url) : url)
74
160
  }
@@ -70,6 +70,7 @@
70
70
  dynamic3d/SpatialMaterial.swift,
71
71
  dynamic3d/SpatialModelEntity.swift,
72
72
  dynamic3d/SpatialModelResource.swift,
73
+ dynamic3d/SpatialRootEntity.swift,
73
74
  dynamic3d/SpatialTextureResource.swift,
74
75
  SpatialApp.swift,
75
76
  Spatialized2DElement.swift,