@webspatial/platform-visionos 1.7.0 → 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.
- package/package.json +1 -1
- package/web-spatial/JSBCommand.swift +1 -0
- package/web-spatial/WebMsgCommand.swift +26 -2
- package/web-spatial/WebSpatialApp.swift +26 -8
- package/web-spatial/manifest.swift +1 -1
- package/web-spatial/model/SpatialScene.swift +178 -39
- package/web-spatial/model/Spatialized2DElement.swift +2 -11
- package/web-spatial/model/SpatializedElement.swift +38 -0
- package/web-spatial/model/SpatializedStatic3DElement.swift +27 -8
- package/web-spatial/model/dynamic3d/EntityAnimationManager.swift +356 -0
- package/web-spatial/model/dynamic3d/EntityAnimationSession.swift +162 -0
- package/web-spatial/model/element-motion/SpatializedElementAnimationCommand.swift +15 -0
- package/web-spatial/model/element-motion/SpatializedElementAnimationManager.swift +263 -0
- package/web-spatial/model/element-motion/SpatializedElementAnimationObject.swift +399 -0
- package/web-spatial/model/element-motion/SpatializedElementAnimationTypes.swift +45 -0
- package/web-spatial/model/element-motion/SpatializedElementAnimationWriteAdapter.swift +65 -0
- package/web-spatial/model/element-motion/SpatializedElementMotionBridgeTypes.swift +43 -0
- package/web-spatial/model/element-motion/SpatializedElementMotionTimelineSampler.swift +99 -0
- package/web-spatial/model/element-motion/SpatializedElementMotionTiming.swift +99 -0
- package/web-spatial/model/element-motion/SpatializedElementMotionTransformTypes.swift +22 -0
- package/web-spatial/protocol/SpatializedElementContainer.swift +1 -1
- package/web-spatial/view/SpatialNavView.swift +37 -12
- package/web-spatial/view/SpatialSceneContentView.swift +9 -12
- package/web-spatial/view/Spatialized2DElementView.swift +10 -17
- package/web-spatial/view/SpatializedDynamic3DView.swift +2 -6
- package/web-spatial/view/SpatializedElementView.swift +3 -2
- package/web-spatial/view/SpatializedStatic3DView.swift +32 -33
- package/web-spatial/view/view-modifier/OrbitModifier.swift +84 -0
- package/web-spatial/webview/SpatialWebController.swift +26 -18
- package/web-spatial/webview/SpatialWebViewModel.swift +12 -7
- package/web-spatial.xcodeproj/project.pbxproj +29 -8
- package/web-spatial.xcodeproj/xcshareddata/xcschemes/web-spatial.xcscheme +1 -1
- package/web-spatialTests/NavigationCleanupTests.swift +2 -1
- package/web-spatialTests/SpatializedElementAnimationManagerTests.swift +1466 -0
- 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
|
+
}
|
|
@@ -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
|
+
}
|