@webspatial/platform-visionos 1.6.1 → 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 +2 -2
- package/web-spatial/JSBCommand.swift +11 -0
- package/web-spatial/WebMsgCommand.swift +3 -0
- package/web-spatial/manager/Dynamic3DManager.swift +97 -37
- package/web-spatial/manifest.swift +2 -1
- package/web-spatial/model/MaterialSceneRefresh.swift +41 -0
- package/web-spatial/model/SpatialScene.swift +79 -8
- package/web-spatial/model/SpatializedStatic3DElement.swift +8 -0
- package/web-spatial/model/dynamic3d/SpatialMaterial.swift +26 -10
- package/web-spatial/model/dynamic3d/SpatialModelEntity.swift +5 -3
- package/web-spatial/model/dynamic3d/SpatialTextureResource.swift +25 -0
- package/web-spatial/view/SpatializedStatic3DView.swift +86 -24
- package/web-spatial.xcodeproj/project.pbxproj +1 -9
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webspatial/platform-visionos",
|
|
3
|
-
"version": "1.
|
|
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": ">=
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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(
|
|
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
|
-
|
|
180
|
+
loadState = .loading
|
|
131
181
|
let result = await loadSources(spatializedStatic3DElement.allSources)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
}
|