@webspatial/platform-visionos 1.5.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 +1 -1
- package/web-spatial/JSBCommand.swift +7 -1
- package/web-spatial/WebMsgCommand.swift +21 -0
- package/web-spatial/model/SpatialScene.swift +42 -17
- package/web-spatial/model/SpatializedStatic3DElement.swift +16 -1
- package/web-spatial/view/Spatialized2DElementView.swift +6 -0
- package/web-spatial/view/SpatializedElementView.swift +16 -8
- package/web-spatial/view/SpatializedStatic3DView.swift +111 -25
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
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
|
|
79
|
-
let
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
41
|
-
resolvedModel3D
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
}
|