@webspatial/platform-visionos 1.6.1 → 2.0.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.
Files changed (40) hide show
  1. package/package.json +2 -2
  2. package/web-spatial/JSBCommand.swift +12 -0
  3. package/web-spatial/WebMsgCommand.swift +29 -2
  4. package/web-spatial/WebSpatialApp.swift +26 -8
  5. package/web-spatial/manager/Dynamic3DManager.swift +97 -37
  6. package/web-spatial/manifest.swift +2 -1
  7. package/web-spatial/model/MaterialSceneRefresh.swift +41 -0
  8. package/web-spatial/model/SpatialScene.swift +255 -45
  9. package/web-spatial/model/Spatialized2DElement.swift +2 -11
  10. package/web-spatial/model/SpatializedElement.swift +38 -0
  11. package/web-spatial/model/SpatializedStatic3DElement.swift +31 -4
  12. package/web-spatial/model/dynamic3d/EntityAnimationManager.swift +356 -0
  13. package/web-spatial/model/dynamic3d/EntityAnimationSession.swift +162 -0
  14. package/web-spatial/model/dynamic3d/SpatialMaterial.swift +26 -10
  15. package/web-spatial/model/dynamic3d/SpatialModelEntity.swift +5 -3
  16. package/web-spatial/model/dynamic3d/SpatialTextureResource.swift +25 -0
  17. package/web-spatial/model/element-motion/SpatializedElementAnimationCommand.swift +15 -0
  18. package/web-spatial/model/element-motion/SpatializedElementAnimationManager.swift +263 -0
  19. package/web-spatial/model/element-motion/SpatializedElementAnimationObject.swift +399 -0
  20. package/web-spatial/model/element-motion/SpatializedElementAnimationTypes.swift +45 -0
  21. package/web-spatial/model/element-motion/SpatializedElementAnimationWriteAdapter.swift +65 -0
  22. package/web-spatial/model/element-motion/SpatializedElementMotionBridgeTypes.swift +43 -0
  23. package/web-spatial/model/element-motion/SpatializedElementMotionTimelineSampler.swift +99 -0
  24. package/web-spatial/model/element-motion/SpatializedElementMotionTiming.swift +99 -0
  25. package/web-spatial/model/element-motion/SpatializedElementMotionTransformTypes.swift +22 -0
  26. package/web-spatial/protocol/SpatializedElementContainer.swift +1 -1
  27. package/web-spatial/view/SpatialNavView.swift +37 -12
  28. package/web-spatial/view/SpatialSceneContentView.swift +9 -12
  29. package/web-spatial/view/Spatialized2DElementView.swift +10 -17
  30. package/web-spatial/view/SpatializedDynamic3DView.swift +2 -6
  31. package/web-spatial/view/SpatializedElementView.swift +3 -2
  32. package/web-spatial/view/SpatializedStatic3DView.swift +104 -43
  33. package/web-spatial/view/view-modifier/OrbitModifier.swift +84 -0
  34. package/web-spatial/webview/SpatialWebController.swift +26 -18
  35. package/web-spatial/webview/SpatialWebViewModel.swift +12 -7
  36. package/web-spatial.xcodeproj/project.pbxproj +30 -17
  37. package/web-spatial.xcodeproj/xcshareddata/xcschemes/web-spatial.xcscheme +1 -1
  38. package/web-spatialTests/NavigationCleanupTests.swift +2 -1
  39. package/web-spatialTests/SpatializedElementAnimationManagerTests.swift +1466 -0
  40. package/web-spatialTests/SpatializedElementMotionTimelineSamplerTests.swift +117 -0
@@ -0,0 +1,356 @@
1
+ import Combine
2
+ import Foundation
3
+ import RealityKit
4
+
5
+ // MARK: - Animation Session Manager
6
+
7
+ /// Manages active animation sessions for entities within a SpatialScene.
8
+ /// Each entity can have at most one active session at a time.
9
+ class EntityAnimationManager {
10
+ /// Active sessions keyed by animationId.
11
+ private var sessions: [String: EntityAnimationSession] = [:]
12
+
13
+ /// Active scene event subscriptions keyed by animationId.
14
+ private var completionSubscriptions: [String: any Cancellable] = [:]
15
+
16
+ /// Weak reference to the scene for sending events back to JS.
17
+ weak var scene: SpatialScene?
18
+
19
+ init(scene: SpatialScene? = nil) {
20
+ self.scene = scene
21
+ }
22
+
23
+ func getSession(_ animationId: String) -> EntityAnimationSession? {
24
+ return sessions[animationId]
25
+ }
26
+
27
+ func addSession(_ session: EntityAnimationSession) {
28
+ sessions[session.animationId] = session
29
+ }
30
+
31
+ func removeSession(_ animationId: String) {
32
+ completionSubscriptions.removeValue(forKey: animationId)
33
+ sessions.removeValue(forKey: animationId)
34
+ }
35
+
36
+ /// Remove all sessions for a given entity (called on entity destroy).
37
+ func removeSessionsForEntity(_ entityId: String) {
38
+ let toRemove = sessions.filter { $0.value.entityId == entityId }
39
+ for (id, session) in toRemove {
40
+ session.markCanceled()
41
+ session.playbackController?.stop()
42
+ completionSubscriptions.removeValue(forKey: id)
43
+ sessions.removeValue(forKey: id)
44
+ }
45
+ }
46
+
47
+ func removeAll() {
48
+ for (_, session) in sessions {
49
+ session.markCanceled()
50
+ session.playbackController?.stop()
51
+ }
52
+ completionSubscriptions.removeAll()
53
+ sessions.removeAll()
54
+ }
55
+
56
+ // MARK: - Play
57
+
58
+ func handlePlay(
59
+ command: AnimateTransformCommand,
60
+ entity: SpatialEntity,
61
+ resolve: @escaping JSBManager.ResolveHandler<Encodable>
62
+ ) {
63
+ let resolvedFromTransform =
64
+ command.fromTransform ?? float4x4ToArray(entity.transform.matrix)
65
+ let session = EntityAnimationSession(
66
+ animationId: command.animationId,
67
+ entityId: entity.spatialId,
68
+ toTransform: command.toTransform,
69
+ fromTransform: resolvedFromTransform,
70
+ duration: command.duration ?? 0.3,
71
+ timingFunction: command.timingFunction ?? "easeInOut",
72
+ delay: command.delay ?? 0,
73
+ loop: command.loop,
74
+ speed: command.playbackRate ?? 1.0
75
+ )
76
+ addSession(session)
77
+
78
+ // Acknowledge the play command immediately so the JS side can set up listeners
79
+ resolve(.success(nil))
80
+
81
+ // Start the RealityKit animation immediately; delay is handled natively
82
+ // by AnimationView's `delay` parameter.
83
+ startRealityKitAnimation(session: session, entity: entity)
84
+ }
85
+
86
+ // MARK: - Pause
87
+
88
+ func handlePause(
89
+ command: AnimateTransformCommand,
90
+ resolve: @escaping JSBManager.ResolveHandler<Encodable>
91
+ ) {
92
+ guard let session = getSession(command.animationId) else {
93
+ resolve(.failure(JsbError(code: .CommandError, message: "Animation session \(command.animationId) not found")))
94
+ return
95
+ }
96
+ guard !session.isTerminal else {
97
+ resolve(.failure(JsbError(code: .CommandError, message: "Animation session \(command.animationId) is already terminal")))
98
+ return
99
+ }
100
+
101
+ session.markPaused()
102
+
103
+ // RealityKit's playback controller handles pause correctly even during
104
+ // the delay phase (the animation simply won't advance until resumed).
105
+ session.playbackController?.pause()
106
+
107
+ resolve(.success(nil))
108
+ }
109
+
110
+ // MARK: - Resume
111
+
112
+ func handleResume(
113
+ command: AnimateTransformCommand,
114
+ entity: SpatialEntity,
115
+ resolve: @escaping JSBManager.ResolveHandler<Encodable>
116
+ ) {
117
+ guard let session = getSession(command.animationId) else {
118
+ resolve(.failure(JsbError(code: .CommandError, message: "Animation session \(command.animationId) not found")))
119
+ return
120
+ }
121
+ guard session.isPaused else {
122
+ resolve(.failure(JsbError(code: .CommandError, message: "Animation session \(command.animationId) is not paused")))
123
+ return
124
+ }
125
+
126
+ session.markResumed()
127
+
128
+ // Resume playback — works correctly whether paused during delay or active phase.
129
+ session.playbackController?.resume()
130
+
131
+ resolve(.success(nil))
132
+ }
133
+
134
+ // MARK: - Cancel
135
+
136
+ func handleCancel(
137
+ command: AnimateTransformCommand,
138
+ entity: SpatialEntity,
139
+ resolve: @escaping JSBManager.ResolveHandler<Encodable>
140
+ ) {
141
+ guard let session = getSession(command.animationId) else {
142
+ resolve(.failure(JsbError(code: .CommandError, message: "Animation session \(command.animationId) not found")))
143
+ return
144
+ }
145
+
146
+ guard !session.isTerminal else {
147
+ // Already terminal — acknowledge silently
148
+ resolve(.success(nil))
149
+ return
150
+ }
151
+
152
+ session.markCanceled()
153
+
154
+ // Stop the playback controller first, then remove ALL animations from
155
+ // the entity. RealityKit may retain a "fill" state from the stopped
156
+ // animation that overrides entity.transform.matrix unless the animation
157
+ // resource is fully detached via stopAllAnimations().
158
+ session.playbackController?.stop()
159
+ entity.stopAllAnimations()
160
+
161
+ // Cancel restores entity to the from-transform (or session start snapshot
162
+ // when from was omitted). This aligns with Web Animation API cancel() semantics.
163
+ //
164
+ // IMPORTANT: After stopAllAnimations(), RealityKit bind-point system may
165
+ // still hold an override on the entity transform for the current frame.
166
+ // Direct assignment (entity.transform.matrix = ...) is rejected with
167
+ // "Failed to set override status for bind point component member".
168
+ // Using move(to:relativeTo:duration:0) creates a zero-duration animation
169
+ // that properly takes over the bind-point ownership.
170
+ let restoredTransform = session.fromTransform
171
+ if let fromArray = restoredTransform, fromArray.count == 16 {
172
+ let fromMatrix = arrayToFloat4x4(fromArray)
173
+ let fromTransform = Transform(matrix: fromMatrix)
174
+ entity.move(to: fromTransform, relativeTo: entity.parent, duration: 0, timingFunction: .linear)
175
+ }
176
+
177
+ // Send canceled event to JS with the restored transform
178
+ sendCanceledEvent(session: session)
179
+ removeSession(command.animationId)
180
+
181
+ resolve(.success(nil))
182
+ }
183
+
184
+ // MARK: - RealityKit Animation
185
+
186
+ private func startRealityKitAnimation(session: EntityAnimationSession, entity: SpatialEntity) {
187
+ guard !session.isTerminal else { return }
188
+
189
+ // Build to-transform as float4x4
190
+ guard let toArray = session.toTransform, toArray.count == 16 else {
191
+ sendFailedEvent(session: session, command: "play", reason: "Missing or invalid toTransform")
192
+ session.markCanceled()
193
+ removeSession(session.animationId)
194
+ return
195
+ }
196
+
197
+ let toMatrix = arrayToFloat4x4(toArray)
198
+
199
+ // If fromTransform is provided, set entity transform to it before animating.
200
+ // Direct assignment is safe here because no animation is active on this entity
201
+ // at this point (any previous animation was already stopped in handleCancel).
202
+ // NOTE: Do NOT use move(to:duration:0) here — it creates a zero-duration animation
203
+ // that fires PlaybackCompleted, which would falsely trigger our completion observer.
204
+ if let fromArray = session.fromTransform, fromArray.count == 16 {
205
+ let fromMatrix = arrayToFloat4x4(fromArray)
206
+ entity.stopAllAnimations()
207
+ entity.transform.matrix = fromMatrix
208
+ }
209
+
210
+ // Map timing function to RealityKit animation timing
211
+ let timingFunction = mapTimingFunction(session.timingFunction)
212
+
213
+ // Build the animation
214
+ let toTransform = Transform(matrix: toMatrix)
215
+ let animation = FromToByAnimation<Transform>(
216
+ to: toTransform,
217
+ duration: session.duration,
218
+ timing: timingFunction,
219
+ bindTarget: .transform,
220
+ repeatMode: mapRepeatMode(session.loop)
221
+ )
222
+
223
+ // Wrap with AnimationView to apply the native delay.
224
+ // RealityKit handles the delay internally — no manual timer needed.
225
+ var animationView = AnimationView(source: animation, delay: session.delay)
226
+ animationView.speed = Float(session.speed)
227
+
228
+ guard let animResource = try? AnimationResource.generate(with: animationView) else {
229
+ sendFailedEvent(session: session, command: "play", reason: "Failed to generate animation resource")
230
+ session.markCanceled()
231
+ removeSession(session.animationId)
232
+ return
233
+ }
234
+
235
+ // Play the animation
236
+ let controller = entity.playAnimation(animResource, startsPaused: session.isPaused)
237
+ session.playbackController = controller
238
+
239
+ // Observe completion for non-looping animations using Scene event subscription
240
+ let isLooping = session.loop?.isEnabled ?? false
241
+ if !isLooping {
242
+ observeAnimationCompletion(session: session, entity: entity)
243
+ }
244
+ }
245
+
246
+ /// Subscribe to RealityKit's `AnimationEvents.PlaybackCompleted` on the entity
247
+ /// to detect natural animation completion without timer polling.
248
+ private func observeAnimationCompletion(session: EntityAnimationSession, entity: SpatialEntity) {
249
+ guard let rkScene = entity.scene else {
250
+ // Fallback: entity not yet in a RealityKit scene — this should not
251
+ // happen in normal flow since play is called after entity is added.
252
+ sendFailedEvent(session: session, command: "play", reason: "Entity has no RealityKit scene for event subscription")
253
+ session.markCanceled()
254
+ removeSession(session.animationId)
255
+ return
256
+ }
257
+
258
+ let subscription = rkScene.subscribe(to: AnimationEvents.PlaybackCompleted.self, on: entity) { [weak self, weak entity] _ in
259
+ guard let self = self, let entity = entity else { return }
260
+ guard !session.isTerminal else { return }
261
+
262
+ // Animation completed naturally
263
+ session.markCompleted()
264
+ self.sendCompletedEvent(session: session, entity: entity)
265
+ self.removeSession(session.animationId)
266
+ }
267
+
268
+ completionSubscriptions[session.animationId] = subscription
269
+ }
270
+
271
+ // MARK: - Event Emission
272
+
273
+ /// Send the `{animationId}_completed` event to JS with the entity's current transform.
274
+ private func sendCompletedEvent(session: EntityAnimationSession, entity: SpatialEntity) {
275
+ guard let scene = scene else { return }
276
+ let transform = float4x4ToArray(entity.transform.matrix)
277
+ let payload = AnimationCompletedPayload(type: "completed", transform: transform)
278
+ scene.sendWebMsg("\(session.animationId)_completed", payload)
279
+ }
280
+
281
+ /// Send the `{animationId}_canceled` event to JS with the resolved restore target.
282
+ private func sendCanceledEvent(session: EntityAnimationSession) {
283
+ guard let scene = scene else { return }
284
+ guard let transform = session.fromTransform, transform.count == 16 else { return }
285
+ let payload = AnimationCanceledPayload(type: "canceled", transform: transform)
286
+ scene.sendWebMsg("\(session.animationId)_canceled", payload)
287
+ }
288
+
289
+ /// Send the `{animationId}_failed` event to JS.
290
+ private func sendFailedEvent(session: EntityAnimationSession, command: String, reason: String) {
291
+ guard let scene = scene else { return }
292
+ let payload = AnimationFailedPayload(
293
+ type: "failed",
294
+ animationId: session.animationId,
295
+ command: command,
296
+ reason: reason
297
+ )
298
+ scene.sendWebMsg("\(session.animationId)_failed", payload)
299
+ }
300
+
301
+ // MARK: - Helpers
302
+
303
+ private func arrayToFloat4x4(_ array: [Double]) -> float4x4 {
304
+ // Column-major order matching DOMMatrix.toFloat64Array()
305
+ return float4x4(
306
+ SIMD4<Float>(Float(array[0]), Float(array[1]), Float(array[2]), Float(array[3])),
307
+ SIMD4<Float>(Float(array[4]), Float(array[5]), Float(array[6]), Float(array[7])),
308
+ SIMD4<Float>(Float(array[8]), Float(array[9]), Float(array[10]), Float(array[11])),
309
+ SIMD4<Float>(Float(array[12]), Float(array[13]), Float(array[14]), Float(array[15]))
310
+ )
311
+ }
312
+
313
+ private func float4x4ToArray(_ matrix: float4x4) -> [Double] {
314
+ // Column-major order
315
+ let c0 = matrix.columns.0
316
+ let c1 = matrix.columns.1
317
+ let c2 = matrix.columns.2
318
+ let c3 = matrix.columns.3
319
+ return [
320
+ Double(c0.x), Double(c0.y), Double(c0.z), Double(c0.w),
321
+ Double(c1.x), Double(c1.y), Double(c1.z), Double(c1.w),
322
+ Double(c2.x), Double(c2.y), Double(c2.z), Double(c2.w),
323
+ Double(c3.x), Double(c3.y), Double(c3.z), Double(c3.w),
324
+ ]
325
+ }
326
+
327
+ private func mapTimingFunction(_ name: String) -> AnimationTimingFunction {
328
+ switch name {
329
+ case "linear": return .linear
330
+ case "easeIn": return .easeIn
331
+ case "easeOut": return .easeOut
332
+ case "easeInOut": return .easeInOut
333
+ default: return .easeInOut
334
+ }
335
+ }
336
+
337
+ /// Maps the JS `loop` config to RealityKit repeat mode.
338
+ ///
339
+ /// OpenSpec contract (task 4.3 verified):
340
+ /// - `loop: true` -> reset loop: plays to to, instantly resets to from, repeats.
341
+ /// RealityKit .repeat replays the baked animation clip from the start -- matches spec.
342
+ /// When from is omitted, the implicit start is the entity transform at play() time,
343
+ /// baked into the AnimationResource, so it is NOT re-snapshotted each loop.
344
+ /// - `loop: { reverse: true }` -> reverse loop: smoothly plays back from to to from.
345
+ /// RealityKit .autoReverse does exactly this.
346
+ /// - No loop -> .none: plays once, fires PlaybackCompleted.
347
+ private func mapRepeatMode(_ loop: AnimateTransformLoopValue?) -> AnimationRepeatMode {
348
+ guard let loop = loop, loop.isEnabled else {
349
+ return .none
350
+ }
351
+ if loop.isReverse {
352
+ return .autoReverse
353
+ }
354
+ return .repeat
355
+ }
356
+ }
@@ -0,0 +1,162 @@
1
+ import Foundation
2
+ import RealityKit
3
+
4
+ // MARK: - JSB Command
5
+
6
+ /// Command received from the JS SDK via the bridge.
7
+ /// Matches the `commandType = "AnimateTransform"` defined in core-sdk JSBCommand.ts.
8
+ struct AnimateTransformCommand: CommandDataProtocol {
9
+ static let commandType: String = "AnimateTransform"
10
+
11
+ let animationId: String
12
+ let type: String // "play" | "pause" | "resume" | "cancel"
13
+
14
+ // Fields present only when type == "play"
15
+ let entityId: String?
16
+ let toTransform: [Double]?
17
+ let fromTransform: [Double]?
18
+ let duration: Double?
19
+ let timingFunction: String?
20
+ let delay: Double?
21
+ let loop: AnimateTransformLoopValue?
22
+ let playbackRate: Double?
23
+ }
24
+
25
+ /// Handles the polymorphic `loop` field which can be `true`, `false`, or `{ reverse: true }`.
26
+ enum AnimateTransformLoopValue: Decodable {
27
+ case boolean(Bool)
28
+ case object(AnimateTransformLoopObject)
29
+
30
+ init(from decoder: Decoder) throws {
31
+ let container = try decoder.singleValueContainer()
32
+ if let boolVal = try? container.decode(Bool.self) {
33
+ self = .boolean(boolVal)
34
+ } else if let objVal = try? container.decode(AnimateTransformLoopObject.self) {
35
+ self = .object(objVal)
36
+ } else {
37
+ self = .boolean(false)
38
+ }
39
+ }
40
+
41
+ var isEnabled: Bool {
42
+ switch self {
43
+ case let .boolean(v): return v
44
+ case .object: return true
45
+ }
46
+ }
47
+
48
+ var isReverse: Bool {
49
+ switch self {
50
+ case .boolean: return false
51
+ case let .object(obj): return obj.reverse ?? false
52
+ }
53
+ }
54
+ }
55
+
56
+ struct AnimateTransformLoopObject: Decodable {
57
+ let reverse: Bool?
58
+ }
59
+
60
+ // MARK: - Native Animation Session
61
+
62
+ /// Represents a single animation session on the native side.
63
+ /// Tracks state so pause/resume/stop commands can be fulfilled.
64
+ class EntityAnimationSession {
65
+ let animationId: String
66
+ let entityId: String
67
+
68
+ /// The RealityKit animation playback controller.
69
+ var playbackController: AnimationPlaybackController?
70
+
71
+ /// Whether the session has been canceled (terminal state).
72
+ private(set) var isCanceled: Bool = false
73
+
74
+ /// Whether the session has completed naturally (terminal state).
75
+ private(set) var isCompleted: Bool = false
76
+
77
+ /// Whether the session is paused.
78
+ private(set) var isPaused: Bool = false
79
+
80
+ /// The to-transform matrix (column-major, 16 doubles).
81
+ let toTransform: [Double]?
82
+
83
+ /// The from-transform matrix (column-major, 16 doubles).
84
+ let fromTransform: [Double]?
85
+
86
+ /// Duration in seconds.
87
+ let duration: TimeInterval
88
+
89
+ /// Delay in seconds before playback starts.
90
+ let delay: TimeInterval
91
+
92
+ /// Timing function name.
93
+ let timingFunction: String
94
+
95
+ /// Loop configuration.
96
+ let loop: AnimateTransformLoopValue?
97
+
98
+ /// Playback speed multiplier. Default: 1.0. Maps to AnimationView.speed.
99
+ let speed: Double
100
+
101
+ init(
102
+ animationId: String,
103
+ entityId: String,
104
+ toTransform: [Double]?,
105
+ fromTransform: [Double]?,
106
+ duration: Double,
107
+ timingFunction: String,
108
+ delay: Double,
109
+ loop: AnimateTransformLoopValue?,
110
+ speed: Double = 1.0
111
+ ) {
112
+ self.animationId = animationId
113
+ self.entityId = entityId
114
+ self.toTransform = toTransform
115
+ self.fromTransform = fromTransform
116
+ self.duration = duration
117
+ self.delay = delay
118
+ self.timingFunction = timingFunction
119
+ self.loop = loop
120
+ self.speed = speed
121
+ }
122
+
123
+ /// Whether this session has reached a terminal state.
124
+ var isTerminal: Bool {
125
+ return isCanceled || isCompleted
126
+ }
127
+
128
+ func markCompleted() {
129
+ isCompleted = true
130
+ }
131
+
132
+ func markCanceled() {
133
+ isCanceled = true
134
+ }
135
+
136
+ func markPaused() {
137
+ isPaused = true
138
+ }
139
+
140
+ func markResumed() {
141
+ isPaused = false
142
+ }
143
+ }
144
+
145
+ // MARK: - Event Payloads
146
+
147
+ struct AnimationCompletedPayload: Encodable {
148
+ let type: String
149
+ let transform: [Double]
150
+ }
151
+
152
+ struct AnimationCanceledPayload: Encodable {
153
+ let type: String
154
+ let transform: [Double]
155
+ }
156
+
157
+ struct AnimationFailedPayload: Encodable {
158
+ let type: String
159
+ let animationId: String
160
+ let command: String
161
+ let reason: String
162
+ }
@@ -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
- var mat = UnlitMaterial()
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
- func updateProperties(color: String?, transparent: Bool?, opacity: Float?) {
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
- 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
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
- addChild(modelEntity!)
14
- generateCollisionShapes(recursive: true)
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
  }
@@ -0,0 +1,15 @@
1
+ import Foundation
2
+
3
+ struct CreateSpatializedElementAnimationCommand: CommandDataProtocol {
4
+ static let commandType: String = "CreateSpatializedElementAnimation"
5
+
6
+ let elementId: String
7
+ let timeline: SpatializedMotionTimelinePayload
8
+ }
9
+
10
+ struct ControlSpatializedElementAnimationCommand: CommandDataProtocol {
11
+ static let commandType: String = "ControlSpatializedElementAnimation"
12
+
13
+ let animationId: String
14
+ let type: String
15
+ }