@webspatial/platform-visionos 1.5.0 → 1.6.1

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.5.0",
3
+ "version": "1.6.1",
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 {
@@ -260,7 +261,12 @@ struct UpdateSpatializedStatic3DElementProperties: SpatializedElementProperties
260
261
  let rotateConstrainedToAxis: Vec3?
261
262
 
262
263
  let modelURL: String?
264
+ let sources: [ModelSource]?
263
265
  let modelTransform: [Double]?
266
+ let autoplay: Bool?
267
+ let loop: Bool?
268
+ let animationPaused: Bool?
269
+ let playbackRate: Double?
264
270
  }
265
271
 
266
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
  }
@@ -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?
@@ -429,7 +431,7 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
429
431
  }
430
432
  }
431
433
 
432
- // Temporary storage for webview models awaiting JSB initialization
434
+ /// Temporary storage for webview models awaiting JSB initialization
433
435
  private var pendingAttachmentWebViewModels = [String: SpatialWebViewModel]()
434
436
 
435
437
  private func handleCreateAttachment(_ url: URL) -> WebViewElementInfo? {
@@ -543,6 +545,9 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
543
545
  private func onCreateSpatializedStatic3DElement(command: CreateSpatializedStatic3DElement, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
544
546
  let spatialObject: SpatializedStatic3DElement = createSpatializedElement(.SpatializedStatic3DElement)
545
547
  spatialObject.modelURL = command.modelURL
548
+ if let sources = command.sources {
549
+ spatialObject.sources = sources
550
+ }
546
551
 
547
552
  resolve(.success(AddSpatializedElementReply(id: spatialObject.id)))
548
553
  }
@@ -585,6 +590,10 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
585
590
 
586
591
  updateSpatializedElementProperties(spatializedElement, command)
587
592
 
593
+ if let sources = command.sources {
594
+ spatializedElement.sources = sources
595
+ }
596
+
588
597
  if let modelURL = command.modelURL {
589
598
  spatializedElement.modelURL = modelURL
590
599
  }
@@ -604,6 +613,22 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
604
613
  spatializedElement.modelTransform = affineTransform3D
605
614
  }
606
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
+
607
632
  resolve(.success(baseReplyData))
608
633
  }
609
634
 
@@ -1111,22 +1136,22 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
1111
1136
  resolve(.success(ConvertReply(id: command.entityId, position: point)))
1112
1137
  }
1113
1138
 
1114
- /// Input: command.position, command.fromId, command.toId
1115
- /// fromId/toId can reference either the scene (window) or an entity.
1116
- /// Step 1: Convert position to window coordinates (view global, px)
1117
- /// - If from is window (scene), position is already in view global (px). Go to Step 2.
1118
- /// - If from is 2d frame(SpatializedElement), position is in view local (px).
1119
- /// - view local → window (view global, px) using SpatializedElement.convertToScene
1120
- /// - If from is an entity, position is in reality entity local (meters):
1121
- /// - entity local → reality world (scene)
1122
- /// - reality world → window (view global, px)
1123
- /// Step 2: Convert window coordinates (view global, px) to target output
1124
- /// - If to is window, output directly.
1125
- /// - If to is 2d frame(SpatializedElement), output in view local (px).
1126
- /// - window (view global, px) → view local using SpatializedElement.convertFromScene
1127
- /// - If to is an entity, output in reality entity local (meters):
1128
- /// - window (view global, px) → reality world (scene)
1129
- /// - 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)
1130
1155
 
1131
1156
  private func onConvertCoordinate(command: ConvertCoordinate, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
1132
1157
  func isSceneId(_ id: String) -> Bool {
@@ -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
@@ -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
  }