@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 +1 -1
- package/web-spatial/JSBCommand.swift +27 -1
- package/web-spatial/WebMsgCommand.swift +21 -0
- package/web-spatial/manifest.swift +1 -1
- package/web-spatial/model/SpatialScene.swift +97 -29
- package/web-spatial/model/SpatializedDynamic3DElement.swift +6 -1
- package/web-spatial/model/SpatializedStatic3DElement.swift +16 -1
- package/web-spatial/model/dynamic3d/SpatialComponent.swift +31 -0
- package/web-spatial/model/dynamic3d/SpatialMaterial.swift +25 -3
- package/web-spatial/model/dynamic3d/SpatialModelEntity.swift +35 -0
- package/web-spatial/model/dynamic3d/SpatialRootEntity.swift +12 -0
- 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/web-spatial.xcodeproj/project.pbxproj +1 -0
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 {
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
@@ -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
|
}
|