@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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/web-spatial/JSBCommand.swift +1 -0
  3. package/web-spatial/WebMsgCommand.swift +26 -2
  4. package/web-spatial/WebSpatialApp.swift +26 -8
  5. package/web-spatial/manifest.swift +1 -1
  6. package/web-spatial/model/SpatialScene.swift +178 -39
  7. package/web-spatial/model/Spatialized2DElement.swift +2 -11
  8. package/web-spatial/model/SpatializedElement.swift +38 -0
  9. package/web-spatial/model/SpatializedStatic3DElement.swift +27 -8
  10. package/web-spatial/model/dynamic3d/EntityAnimationManager.swift +356 -0
  11. package/web-spatial/model/dynamic3d/EntityAnimationSession.swift +162 -0
  12. package/web-spatial/model/element-motion/SpatializedElementAnimationCommand.swift +15 -0
  13. package/web-spatial/model/element-motion/SpatializedElementAnimationManager.swift +263 -0
  14. package/web-spatial/model/element-motion/SpatializedElementAnimationObject.swift +399 -0
  15. package/web-spatial/model/element-motion/SpatializedElementAnimationTypes.swift +45 -0
  16. package/web-spatial/model/element-motion/SpatializedElementAnimationWriteAdapter.swift +65 -0
  17. package/web-spatial/model/element-motion/SpatializedElementMotionBridgeTypes.swift +43 -0
  18. package/web-spatial/model/element-motion/SpatializedElementMotionTimelineSampler.swift +99 -0
  19. package/web-spatial/model/element-motion/SpatializedElementMotionTiming.swift +99 -0
  20. package/web-spatial/model/element-motion/SpatializedElementMotionTransformTypes.swift +22 -0
  21. package/web-spatial/protocol/SpatializedElementContainer.swift +1 -1
  22. package/web-spatial/view/SpatialNavView.swift +37 -12
  23. package/web-spatial/view/SpatialSceneContentView.swift +9 -12
  24. package/web-spatial/view/Spatialized2DElementView.swift +10 -17
  25. package/web-spatial/view/SpatializedDynamic3DView.swift +2 -6
  26. package/web-spatial/view/SpatializedElementView.swift +3 -2
  27. package/web-spatial/view/SpatializedStatic3DView.swift +32 -33
  28. package/web-spatial/view/view-modifier/OrbitModifier.swift +84 -0
  29. package/web-spatial/webview/SpatialWebController.swift +26 -18
  30. package/web-spatial/webview/SpatialWebViewModel.swift +12 -7
  31. package/web-spatial.xcodeproj/project.pbxproj +29 -8
  32. package/web-spatial.xcodeproj/xcshareddata/xcschemes/web-spatial.xcscheme +1 -1
  33. package/web-spatialTests/NavigationCleanupTests.swift +2 -1
  34. package/web-spatialTests/SpatializedElementAnimationManagerTests.swift +1466 -0
  35. package/web-spatialTests/SpatializedElementMotionTimelineSamplerTests.swift +117 -0
@@ -0,0 +1,1466 @@
1
+ import Spatial
2
+ @testable import WebSpatial
3
+ import XCTest
4
+
5
+ final class SpatializedElementAnimationManagerTests: XCTestCase {
6
+ /// Verifies compound rotation, translation, and non-uniform scale survive transform decomposition.
7
+ func test_compoundTransformRoundTripsThroughDecomposition() {
8
+ let source = SpatializedMotionTransformComponents(
9
+ translateX: 12, translateY: -8, translateZ: 24,
10
+ rotateX: 30, rotateY: 40, rotateZ: 15,
11
+ scaleX: 1.25, scaleY: 0.75, scaleZ: 2
12
+ )
13
+
14
+ let original = SpatializedElementAnimationManager.composeTransform(source)
15
+ let decomposed = SpatializedElementAnimationManager.decomposeTransform(from: original)
16
+ let roundTrip = SpatializedElementAnimationManager.composeTransform(decomposed)
17
+
18
+ for column in 0 ..< 4 {
19
+ for row in 0 ..< 3 {
20
+ XCTAssertEqual(
21
+ original.matrix[column][row],
22
+ roundTrip.matrix[column][row],
23
+ accuracy: 1e-9,
24
+ "Mismatch at matrix column \(column), row \(row)"
25
+ )
26
+ }
27
+ }
28
+ }
29
+
30
+ /// Verifies an odd negative scale keeps the source transform's reflection.
31
+ func test_reflectedTransformRoundTripsThroughDecomposition() {
32
+ let source = SpatializedMotionTransformComponents(
33
+ translateX: 3, translateY: 5, translateZ: -7,
34
+ rotateX: 20, rotateY: -35, rotateZ: 10,
35
+ scaleX: -1.5, scaleY: 0.5, scaleZ: 2
36
+ )
37
+
38
+ let original = SpatializedElementAnimationManager.composeTransform(source)
39
+ let decomposed = SpatializedElementAnimationManager.decomposeTransform(from: original)
40
+ let roundTrip = SpatializedElementAnimationManager.composeTransform(decomposed)
41
+
42
+ for column in 0 ..< 4 {
43
+ for row in 0 ..< 3 {
44
+ XCTAssertEqual(
45
+ original.matrix[column][row],
46
+ roundTrip.matrix[column][row],
47
+ accuracy: 1e-9,
48
+ "Mismatch at matrix column \(column), row \(row)"
49
+ )
50
+ }
51
+ }
52
+ }
53
+
54
+ /// Verifies both Y-axis gimbal-lock limits preserve the represented transform matrix.
55
+ func test_gimbalLockTransformsRoundTripThroughDecomposition() {
56
+ for rotateY in [-90.0, 90.0] {
57
+ let source = SpatializedMotionTransformComponents(
58
+ translateX: 4, translateY: -2, translateZ: 6,
59
+ rotateX: 20, rotateY: rotateY, rotateZ: -35,
60
+ scaleX: 1, scaleY: 1.5, scaleZ: 0.75
61
+ )
62
+
63
+ let original = SpatializedElementAnimationManager.composeTransform(source)
64
+ let decomposed = SpatializedElementAnimationManager.decomposeTransform(from: original)
65
+ let roundTrip = SpatializedElementAnimationManager.composeTransform(decomposed)
66
+
67
+ for column in 0 ..< 4 {
68
+ for row in 0 ..< 3 {
69
+ XCTAssertEqual(
70
+ original.matrix[column][row],
71
+ roundTrip.matrix[column][row],
72
+ accuracy: 1e-9,
73
+ "Mismatch at Y=\(rotateY), matrix column \(column), row \(row)"
74
+ )
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ func test_createAnimationReturnsNativeUuidAndRegistersObject() throws {
81
+ let element = Spatialized2DElement()
82
+ let manager = SpatializedElementAnimationManager()
83
+ let timeline = SpatializedMotionTimelinePayload(
84
+ duration: 2,
85
+ delay: nil,
86
+ playbackRate: nil,
87
+ loop: nil,
88
+ tracks: [
89
+ SpatializedMotionTrackPayload(
90
+ property: "opacity",
91
+ keyframes: [
92
+ SpatializedMotionKeyframePayload(at: 0, value: 1, timingFunction: "linear"),
93
+ SpatializedMotionKeyframePayload(at: 2, value: 0.2, timingFunction: nil),
94
+ ],
95
+ timingFunction: "linear"
96
+ ),
97
+ ]
98
+ )
99
+
100
+ let animation = try manager.createAnimation(
101
+ command: CreateSpatializedElementAnimationCommand(
102
+ elementId: element.id,
103
+ timeline: timeline
104
+ ),
105
+ target: element
106
+ )
107
+
108
+ XCTAssertFalse(animation.uuid.isEmpty)
109
+ XCTAssertNotEqual(animation.uuid, element.id)
110
+ XCTAssertTrue(manager.getAnimation(animation.uuid) === animation)
111
+ }
112
+
113
+ func test_controlCommandsReuseSameAnimationObjectAndUpdatePlayState() throws {
114
+ let element = Spatialized2DElement()
115
+ let manager = SpatializedElementAnimationManager()
116
+ let timeline = SpatializedMotionTimelinePayload(
117
+ duration: 2,
118
+ delay: nil,
119
+ playbackRate: nil,
120
+ loop: nil,
121
+ tracks: [
122
+ SpatializedMotionTrackPayload(
123
+ property: "opacity",
124
+ keyframes: [
125
+ SpatializedMotionKeyframePayload(at: 0, value: 1, timingFunction: "linear"),
126
+ SpatializedMotionKeyframePayload(at: 2, value: 0.2, timingFunction: nil),
127
+ ],
128
+ timingFunction: "linear"
129
+ ),
130
+ ]
131
+ )
132
+
133
+ let animation = try manager.createAnimation(
134
+ command: CreateSpatializedElementAnimationCommand(
135
+ elementId: element.id,
136
+ timeline: timeline
137
+ ),
138
+ target: element
139
+ )
140
+
141
+ let created = animation
142
+ try manager.controlAnimation(ControlSpatializedElementAnimationCommand(animationId: animation.uuid, type: "play"))
143
+ XCTAssertEqual(created.playState, .running)
144
+ XCTAssertTrue(created.isAnimating)
145
+
146
+ try manager.controlAnimation(ControlSpatializedElementAnimationCommand(animationId: animation.uuid, type: "pause"))
147
+ XCTAssertEqual(created.playState, .paused)
148
+ XCTAssertTrue(created.isPaused)
149
+ XCTAssertEqual(element.animatingMask.opacityAnimationId, created.uuid)
150
+
151
+ try manager.controlAnimation(ControlSpatializedElementAnimationCommand(animationId: animation.uuid, type: "resume"))
152
+ XCTAssertEqual(created.playState, .running)
153
+
154
+ try manager.controlAnimation(ControlSpatializedElementAnimationCommand(animationId: animation.uuid, type: "stop"))
155
+ XCTAssertEqual(created.playState, .idle)
156
+ XCTAssertNil(element.animatingMask.opacityAnimationId)
157
+ XCTAssertTrue(manager.getAnimation(animation.uuid) === created)
158
+
159
+ try manager.controlAnimation(ControlSpatializedElementAnimationCommand(animationId: animation.uuid, type: "reset"))
160
+ XCTAssertEqual(created.playState, .idle)
161
+ XCTAssertTrue(manager.getAnimation(animation.uuid) === created)
162
+
163
+ try manager.controlAnimation(ControlSpatializedElementAnimationCommand(animationId: animation.uuid, type: "finish"))
164
+ XCTAssertEqual(created.playState, .finished)
165
+ XCTAssertTrue(created.finished)
166
+ XCTAssertTrue(manager.getAnimation(animation.uuid) === created)
167
+ }
168
+
169
+ func test_pauseKeepsMaskAndFreezesCurrentValues() throws {
170
+ let element = Spatialized2DElement()
171
+ let manager = SpatializedElementAnimationManager()
172
+ let timeline = SpatializedMotionTimelinePayload(
173
+ duration: 2,
174
+ delay: nil,
175
+ playbackRate: nil,
176
+ loop: nil,
177
+ tracks: [
178
+ SpatializedMotionTrackPayload(
179
+ property: "transform.translate.x",
180
+ keyframes: [
181
+ SpatializedMotionKeyframePayload(at: 0, value: 0, timingFunction: "linear"),
182
+ SpatializedMotionKeyframePayload(at: 2, value: 10, timingFunction: nil),
183
+ ],
184
+ timingFunction: "linear"
185
+ ),
186
+ SpatializedMotionTrackPayload(
187
+ property: "opacity",
188
+ keyframes: [
189
+ SpatializedMotionKeyframePayload(at: 0, value: 1, timingFunction: "linear"),
190
+ SpatializedMotionKeyframePayload(at: 2, value: 0.25, timingFunction: nil),
191
+ ],
192
+ timingFunction: "linear"
193
+ ),
194
+ ]
195
+ )
196
+
197
+ let animation = try manager.createAnimation(
198
+ command: CreateSpatializedElementAnimationCommand(
199
+ elementId: element.id,
200
+ timeline: timeline
201
+ ),
202
+ target: element
203
+ )
204
+
205
+ animation.play(at: 0)
206
+ animation.tick(at: 1)
207
+
208
+ animation.pause(at: 1.1)
209
+ let frozenTransformX = element.transform.matrix.columns.3.x
210
+ let frozenOpacity = element.opacity
211
+ animation.tick(at: 1.6)
212
+
213
+ XCTAssertEqual(element.animatingMask.transformAnimationId, animation.uuid)
214
+ XCTAssertEqual(element.animatingMask.opacityAnimationId, animation.uuid)
215
+ XCTAssertEqual(frozenTransformX, 5.5, accuracy: 0.0001)
216
+ XCTAssertEqual(frozenOpacity, 0.5875, accuracy: 0.0001)
217
+ XCTAssertEqual(element.transform.matrix.columns.3.x, frozenTransformX, accuracy: 0.0001)
218
+ XCTAssertEqual(element.opacity, frozenOpacity, accuracy: 0.0001)
219
+ }
220
+
221
+ func test_playWhileRunningIsNoOp() throws {
222
+ let element = Spatialized2DElement()
223
+ var events: [SpatialAnimationStateChanged] = []
224
+ let manager = SpatializedElementAnimationManager(sendWebMsg: { _, msg in
225
+ if let event = msg as? SpatialAnimationStateChanged {
226
+ events.append(event)
227
+ }
228
+ })
229
+ let timeline = SpatializedMotionTimelinePayload(
230
+ duration: 2,
231
+ delay: nil,
232
+ playbackRate: nil,
233
+ loop: nil,
234
+ tracks: [
235
+ SpatializedMotionTrackPayload(
236
+ property: "opacity",
237
+ keyframes: [
238
+ SpatializedMotionKeyframePayload(at: 0, value: 1, timingFunction: "linear"),
239
+ SpatializedMotionKeyframePayload(at: 2, value: 0.25, timingFunction: nil),
240
+ ],
241
+ timingFunction: "linear"
242
+ ),
243
+ ]
244
+ )
245
+
246
+ let animation = try manager.createAnimation(
247
+ command: CreateSpatializedElementAnimationCommand(
248
+ elementId: element.id,
249
+ timeline: timeline
250
+ ),
251
+ target: element
252
+ )
253
+
254
+ animation.play(at: 0)
255
+ animation.tick(at: 1)
256
+ let runningOpacity = element.opacity
257
+ let playEventCountBefore = events.filter { $0.action == "play" }.count
258
+
259
+ animation.play(at: 1.1)
260
+
261
+ XCTAssertEqual(animation.playState, .running)
262
+ XCTAssertEqual(element.opacity, runningOpacity, accuracy: 0.0001)
263
+ XCTAssertEqual(events.filter { $0.action == "play" }.count, playEventCountBefore)
264
+ }
265
+
266
+ func test_playEmitsStartImmediatelyWhenDelayIsZero() throws {
267
+ let element = Spatialized2DElement()
268
+ var events: [SpatialAnimationStateChanged] = []
269
+ let manager = SpatializedElementAnimationManager(sendWebMsg: { _, msg in
270
+ if let event = msg as? SpatialAnimationStateChanged {
271
+ events.append(event)
272
+ }
273
+ })
274
+ let timeline = SpatializedMotionTimelinePayload(
275
+ duration: 2,
276
+ delay: nil,
277
+ playbackRate: nil,
278
+ loop: nil,
279
+ tracks: [
280
+ SpatializedMotionTrackPayload(
281
+ property: "opacity",
282
+ keyframes: [
283
+ SpatializedMotionKeyframePayload(at: 0, value: 1, timingFunction: "linear"),
284
+ SpatializedMotionKeyframePayload(at: 2, value: 0.25, timingFunction: nil),
285
+ ],
286
+ timingFunction: "linear"
287
+ ),
288
+ ]
289
+ )
290
+
291
+ let animation = try manager.createAnimation(
292
+ command: CreateSpatializedElementAnimationCommand(
293
+ elementId: element.id,
294
+ timeline: timeline
295
+ ),
296
+ target: element
297
+ )
298
+
299
+ animation.play(at: 0)
300
+
301
+ XCTAssertEqual(events.map(\.action), ["play", "start"])
302
+ XCTAssertEqual(events.last?.playState, .running)
303
+ XCTAssertEqual(events.last?.finished, false)
304
+ }
305
+
306
+ func test_delayDefersStartEventUntilFirstPlaybackFrame() throws {
307
+ let element = Spatialized2DElement()
308
+ var events: [SpatialAnimationStateChanged] = []
309
+ let manager = SpatializedElementAnimationManager(sendWebMsg: { _, msg in
310
+ if let event = msg as? SpatialAnimationStateChanged {
311
+ events.append(event)
312
+ }
313
+ })
314
+ let timeline = SpatializedMotionTimelinePayload(
315
+ duration: 2,
316
+ delay: 1,
317
+ playbackRate: nil,
318
+ loop: nil,
319
+ tracks: [
320
+ SpatializedMotionTrackPayload(
321
+ property: "opacity",
322
+ keyframes: [
323
+ SpatializedMotionKeyframePayload(at: 0, value: 1, timingFunction: "linear"),
324
+ SpatializedMotionKeyframePayload(at: 2, value: 0.25, timingFunction: nil),
325
+ ],
326
+ timingFunction: "linear"
327
+ ),
328
+ ]
329
+ )
330
+
331
+ let animation = try manager.createAnimation(
332
+ command: CreateSpatializedElementAnimationCommand(
333
+ elementId: element.id,
334
+ timeline: timeline
335
+ ),
336
+ target: element
337
+ )
338
+
339
+ animation.play(at: 0)
340
+ XCTAssertEqual(events.map(\.action), ["play"])
341
+
342
+ animation.tick(at: 0.5)
343
+ XCTAssertEqual(events.map(\.action), ["play"])
344
+
345
+ animation.tick(at: 1)
346
+ XCTAssertEqual(events.map(\.action), ["play", "start"])
347
+ XCTAssertEqual(events.last?.playState, .running)
348
+ XCTAssertEqual(events.last?.finished, false)
349
+ }
350
+
351
+ func test_playbackRateDoesNotScaleDelayDuration() throws {
352
+ let element = Spatialized2DElement()
353
+ var events: [SpatialAnimationStateChanged] = []
354
+ let manager = SpatializedElementAnimationManager(sendWebMsg: { _, msg in
355
+ if let event = msg as? SpatialAnimationStateChanged {
356
+ events.append(event)
357
+ }
358
+ })
359
+ let timeline = SpatializedMotionTimelinePayload(
360
+ duration: 2,
361
+ delay: 2,
362
+ playbackRate: 2,
363
+ loop: nil,
364
+ tracks: [
365
+ SpatializedMotionTrackPayload(
366
+ property: "transform.translate.x",
367
+ keyframes: [
368
+ SpatializedMotionKeyframePayload(at: 0, value: 0, timingFunction: "linear"),
369
+ SpatializedMotionKeyframePayload(at: 2, value: 20, timingFunction: nil),
370
+ ],
371
+ timingFunction: "linear"
372
+ ),
373
+ ]
374
+ )
375
+
376
+ let animation = try manager.createAnimation(
377
+ command: CreateSpatializedElementAnimationCommand(
378
+ elementId: element.id,
379
+ timeline: timeline
380
+ ),
381
+ target: element
382
+ )
383
+
384
+ animation.play(at: 0)
385
+ animation.tick(at: 1)
386
+ XCTAssertEqual(events.map(\.action), ["play"])
387
+
388
+ animation.tick(at: 2)
389
+ XCTAssertEqual(events.map(\.action), ["play", "start"])
390
+
391
+ animation.tick(at: 2.5)
392
+ XCTAssertEqual(element.transform.matrix.columns.3.x, 10, accuracy: 0.0001)
393
+ }
394
+
395
+ func test_pauseDuringDelayDoesNotDelayActiveProgress() throws {
396
+ let element = Spatialized2DElement()
397
+ var events: [SpatialAnimationStateChanged] = []
398
+ let manager = SpatializedElementAnimationManager(sendWebMsg: { _, msg in
399
+ if let event = msg as? SpatialAnimationStateChanged {
400
+ events.append(event)
401
+ }
402
+ })
403
+ let timeline = SpatializedMotionTimelinePayload(
404
+ duration: 2,
405
+ delay: 1,
406
+ playbackRate: nil,
407
+ loop: nil,
408
+ tracks: [
409
+ SpatializedMotionTrackPayload(
410
+ property: "transform.translate.x",
411
+ keyframes: [
412
+ SpatializedMotionKeyframePayload(at: 0, value: 0, timingFunction: "linear"),
413
+ SpatializedMotionKeyframePayload(at: 2, value: 20, timingFunction: nil),
414
+ ],
415
+ timingFunction: "linear"
416
+ ),
417
+ ]
418
+ )
419
+
420
+ let animation = try manager.createAnimation(
421
+ command: CreateSpatializedElementAnimationCommand(
422
+ elementId: element.id,
423
+ timeline: timeline
424
+ ),
425
+ target: element
426
+ )
427
+
428
+ animation.play(at: 0)
429
+ animation.pause(at: 0.4)
430
+ animation.resume(at: 0.8)
431
+ animation.tick(at: 1.41)
432
+
433
+ XCTAssertEqual(events.map(\.action), ["play", "pause", "resume", "start"])
434
+
435
+ animation.tick(at: 1.91)
436
+ XCTAssertEqual(element.transform.matrix.columns.3.x, 5, accuracy: 0.0001)
437
+
438
+ animation.tick(at: 3.42)
439
+ XCTAssertEqual(animation.playState, .finished)
440
+ XCTAssertEqual(element.transform.matrix.columns.3.x, 20, accuracy: 0.0001)
441
+ }
442
+
443
+ func test_stopResetAndFinishReleaseMask() throws {
444
+ let element = Spatialized2DElement()
445
+ let manager = SpatializedElementAnimationManager()
446
+ let timeline = SpatializedMotionTimelinePayload(
447
+ duration: 2,
448
+ delay: nil,
449
+ playbackRate: nil,
450
+ loop: nil,
451
+ tracks: [
452
+ SpatializedMotionTrackPayload(
453
+ property: "transform.translate.x",
454
+ keyframes: [
455
+ SpatializedMotionKeyframePayload(at: 0, value: 0, timingFunction: "linear"),
456
+ SpatializedMotionKeyframePayload(at: 2, value: 10, timingFunction: nil),
457
+ ],
458
+ timingFunction: "linear"
459
+ ),
460
+ ]
461
+ )
462
+
463
+ let animation = try manager.createAnimation(
464
+ command: CreateSpatializedElementAnimationCommand(
465
+ elementId: element.id,
466
+ timeline: timeline
467
+ ),
468
+ target: element
469
+ )
470
+
471
+ animation.play(at: 0)
472
+ XCTAssertEqual(element.animatingMask.transformAnimationId, animation.uuid)
473
+
474
+ animation.stop(at: 0.5)
475
+ XCTAssertNil(element.animatingMask.transformAnimationId)
476
+ XCTAssertEqual(animation.playState, .idle)
477
+ XCTAssertFalse(animation.finished)
478
+
479
+ animation.play(at: 1)
480
+ XCTAssertEqual(element.animatingMask.transformAnimationId, animation.uuid)
481
+
482
+ animation.reset(at: 1.5)
483
+ XCTAssertNil(element.animatingMask.transformAnimationId)
484
+ XCTAssertEqual(animation.playState, .idle)
485
+ XCTAssertFalse(animation.finished)
486
+
487
+ animation.play(at: 2)
488
+ XCTAssertEqual(element.animatingMask.transformAnimationId, animation.uuid)
489
+
490
+ animation.finish(at: 2.5)
491
+ XCTAssertNil(element.animatingMask.transformAnimationId)
492
+ XCTAssertEqual(animation.playState, .finished)
493
+ XCTAssertTrue(animation.finished)
494
+ }
495
+
496
+ /// Verifies finish emits once per session and remains available after replay.
497
+ func test_finishIsIdempotentWithinEachPlaybackSession() throws {
498
+ let element = Spatialized2DElement()
499
+ var events: [SpatialAnimationStateChanged] = []
500
+ let manager = SpatializedElementAnimationManager(sendWebMsg: { _, msg in
501
+ if let event = msg as? SpatialAnimationStateChanged {
502
+ events.append(event)
503
+ }
504
+ })
505
+ let timeline = SpatializedMotionTimelinePayload(
506
+ duration: 1,
507
+ delay: nil,
508
+ playbackRate: nil,
509
+ loop: nil,
510
+ tracks: [
511
+ SpatializedMotionTrackPayload(
512
+ property: "opacity",
513
+ keyframes: [
514
+ SpatializedMotionKeyframePayload(at: 0, value: 0, timingFunction: "linear"),
515
+ SpatializedMotionKeyframePayload(at: 1, value: 1, timingFunction: nil),
516
+ ],
517
+ timingFunction: "linear"
518
+ ),
519
+ ]
520
+ )
521
+ let animation = try manager.createAnimation(
522
+ command: CreateSpatializedElementAnimationCommand(
523
+ elementId: element.id,
524
+ timeline: timeline
525
+ ),
526
+ target: element
527
+ )
528
+
529
+ animation.finish(at: 0)
530
+ animation.finish(at: 0.1)
531
+ XCTAssertEqual(events.filter { $0.action == "finish" }.count, 1)
532
+
533
+ animation.play(at: 1)
534
+ animation.finish(at: 1.5)
535
+ XCTAssertEqual(events.filter { $0.action == "finish" }.count, 2)
536
+
537
+ animation.play(at: 2)
538
+ animation.tick(at: 3.1)
539
+ XCTAssertEqual(events.filter { $0.action == "complete" }.count, 1)
540
+
541
+ animation.finish(at: 3.2)
542
+ XCTAssertEqual(events.filter { $0.action == "finish" }.count, 2)
543
+ XCTAssertEqual(events.filter { $0.action == "complete" }.count, 1)
544
+ }
545
+
546
+ /// Verifies a mask-conflict error leaves native state aligned with its emitted idle state.
547
+ func test_maskConflictResetsFinishedNativeStateToIdle() throws {
548
+ let element = Spatialized2DElement()
549
+ var events: [SpatialAnimationStateChanged] = []
550
+ let manager = SpatializedElementAnimationManager(sendWebMsg: { _, msg in
551
+ if let event = msg as? SpatialAnimationStateChanged {
552
+ events.append(event)
553
+ }
554
+ })
555
+ let timeline = SpatializedMotionTimelinePayload(
556
+ duration: 1,
557
+ delay: nil,
558
+ playbackRate: nil,
559
+ loop: nil,
560
+ tracks: [
561
+ SpatializedMotionTrackPayload(
562
+ property: "opacity",
563
+ keyframes: [
564
+ SpatializedMotionKeyframePayload(at: 0, value: 0, timingFunction: "linear"),
565
+ SpatializedMotionKeyframePayload(at: 1, value: 1, timingFunction: nil),
566
+ ],
567
+ timingFunction: "linear"
568
+ ),
569
+ ]
570
+ )
571
+ let firstAnimation = try manager.createAnimation(
572
+ command: CreateSpatializedElementAnimationCommand(
573
+ elementId: element.id,
574
+ timeline: timeline
575
+ ),
576
+ target: element
577
+ )
578
+ let secondAnimation = try manager.createAnimation(
579
+ command: CreateSpatializedElementAnimationCommand(
580
+ elementId: element.id,
581
+ timeline: timeline
582
+ ),
583
+ target: element
584
+ )
585
+
586
+ firstAnimation.finish(at: 0)
587
+ secondAnimation.play(at: 1)
588
+ firstAnimation.play(at: 2)
589
+
590
+ let firstAnimationEvents = events.filter { $0.animationId == firstAnimation.uuid }
591
+ XCTAssertEqual(firstAnimationEvents.map(\.action), ["finish", "error"])
592
+ XCTAssertEqual(firstAnimationEvents.last?.playState, .idle)
593
+ XCTAssertEqual(firstAnimationEvents.last?.finished, false)
594
+ XCTAssertEqual(firstAnimation.playState, .idle)
595
+ XCTAssertFalse(firstAnimation.finished)
596
+ XCTAssertEqual(element.animatingMask.opacityAnimationId, secondAnimation.uuid)
597
+ }
598
+
599
+ func test_stopIsIdempotentAfterAnimationBecomesIdle() throws {
600
+ let element = Spatialized2DElement()
601
+ let manager = SpatializedElementAnimationManager()
602
+ let timeline = SpatializedMotionTimelinePayload(
603
+ duration: 2,
604
+ delay: nil,
605
+ playbackRate: nil,
606
+ loop: nil,
607
+ tracks: [
608
+ SpatializedMotionTrackPayload(
609
+ property: "transform.translate.x",
610
+ keyframes: [
611
+ SpatializedMotionKeyframePayload(at: 0, value: 0, timingFunction: "linear"),
612
+ SpatializedMotionKeyframePayload(at: 2, value: 10, timingFunction: nil),
613
+ ],
614
+ timingFunction: "linear"
615
+ ),
616
+ ]
617
+ )
618
+
619
+ let animation = try manager.createAnimation(
620
+ command: CreateSpatializedElementAnimationCommand(
621
+ elementId: element.id,
622
+ timeline: timeline
623
+ ),
624
+ target: element
625
+ )
626
+
627
+ animation.play(at: 0)
628
+ animation.stop(at: 0.5)
629
+ let stoppedTransformX = element.transform.matrix.columns.3.x
630
+
631
+ animation.stop(at: 1.5)
632
+
633
+ XCTAssertEqual(animation.playState, .idle)
634
+ XCTAssertEqual(element.transform.matrix.columns.3.x, stoppedTransformX, accuracy: 0.0001)
635
+ }
636
+
637
+ func test_opacityKeepsAnimatedTerminalValueOnComplete() throws {
638
+ let element = Spatialized2DElement()
639
+ let manager = SpatializedElementAnimationManager()
640
+ let timeline = SpatializedMotionTimelinePayload(
641
+ duration: 1,
642
+ delay: nil,
643
+ playbackRate: nil,
644
+ loop: nil,
645
+ tracks: [
646
+ SpatializedMotionTrackPayload(
647
+ property: "opacity",
648
+ keyframes: [
649
+ SpatializedMotionKeyframePayload(at: 0, value: 1, timingFunction: "linear"),
650
+ SpatializedMotionKeyframePayload(at: 1, value: 0.25, timingFunction: nil),
651
+ ],
652
+ timingFunction: "linear"
653
+ ),
654
+ ]
655
+ )
656
+
657
+ let animation = try manager.createAnimation(
658
+ command: CreateSpatializedElementAnimationCommand(
659
+ elementId: element.id,
660
+ timeline: timeline
661
+ ),
662
+ target: element
663
+ )
664
+
665
+ animation.play(at: 0)
666
+ XCTAssertEqual(element.animatingMask.opacityAnimationId, animation.uuid)
667
+
668
+ animation.tick(at: 1.1)
669
+
670
+ XCTAssertNil(element.animatingMask.opacityAnimationId)
671
+ XCTAssertEqual(element.opacity, 0.25, accuracy: 0.0001)
672
+ }
673
+
674
+ func test_completePreservesAnimatedOpacityTerminalValue() throws {
675
+ let element = Spatialized2DElement()
676
+ let manager = SpatializedElementAnimationManager()
677
+ let timeline = SpatializedMotionTimelinePayload(
678
+ duration: 1,
679
+ delay: nil,
680
+ playbackRate: nil,
681
+ loop: nil,
682
+ tracks: [
683
+ SpatializedMotionTrackPayload(
684
+ property: "opacity",
685
+ keyframes: [
686
+ SpatializedMotionKeyframePayload(at: 0, value: 1, timingFunction: "linear"),
687
+ SpatializedMotionKeyframePayload(at: 1, value: 0.25, timingFunction: nil),
688
+ ],
689
+ timingFunction: "linear"
690
+ ),
691
+ ]
692
+ )
693
+
694
+ let animation = try manager.createAnimation(
695
+ command: CreateSpatializedElementAnimationCommand(
696
+ elementId: element.id,
697
+ timeline: timeline
698
+ ),
699
+ target: element
700
+ )
701
+
702
+ animation.play(at: 0)
703
+ animation.tick(at: 1.1)
704
+
705
+ XCTAssertEqual(element.opacity, 0.25, accuracy: 0.0001)
706
+ }
707
+
708
+ func test_transformKeepsAnimatedTerminalValueOnComplete() throws {
709
+ let element = Spatialized2DElement()
710
+ let manager = SpatializedElementAnimationManager()
711
+ let timeline = SpatializedMotionTimelinePayload(
712
+ duration: 1,
713
+ delay: nil,
714
+ playbackRate: nil,
715
+ loop: nil,
716
+ tracks: [
717
+ SpatializedMotionTrackPayload(
718
+ property: "transform.translate.x",
719
+ keyframes: [
720
+ SpatializedMotionKeyframePayload(at: 0, value: 0, timingFunction: "linear"),
721
+ SpatializedMotionKeyframePayload(at: 1, value: 10, timingFunction: nil),
722
+ ],
723
+ timingFunction: "linear"
724
+ ),
725
+ ]
726
+ )
727
+
728
+ let animation = try manager.createAnimation(
729
+ command: CreateSpatializedElementAnimationCommand(
730
+ elementId: element.id,
731
+ timeline: timeline
732
+ ),
733
+ target: element
734
+ )
735
+
736
+ animation.play(at: 0)
737
+ animation.tick(at: 1.1)
738
+
739
+ XCTAssertNil(element.animatingMask.transformAnimationId)
740
+ XCTAssertEqual(element.transform.matrix.columns.3.x, 10, accuracy: 0.0001)
741
+ }
742
+
743
+ func test_static3DRootTransformKeepsAnimatedTerminalValueOnComplete() throws {
744
+ let element = SpatializedStatic3DElement()
745
+ let manager = SpatializedElementAnimationManager()
746
+ let timeline = SpatializedMotionTimelinePayload(
747
+ duration: 1,
748
+ delay: nil,
749
+ playbackRate: nil,
750
+ loop: nil,
751
+ tracks: [
752
+ SpatializedMotionTrackPayload(
753
+ property: "transform.translate.x",
754
+ keyframes: [
755
+ SpatializedMotionKeyframePayload(at: 0, value: 0, timingFunction: "linear"),
756
+ SpatializedMotionKeyframePayload(at: 1, value: 10, timingFunction: nil),
757
+ ],
758
+ timingFunction: "linear"
759
+ ),
760
+ ]
761
+ )
762
+
763
+ let animation = try manager.createAnimation(
764
+ command: CreateSpatializedElementAnimationCommand(
765
+ elementId: element.id,
766
+ timeline: timeline
767
+ ),
768
+ target: element
769
+ )
770
+
771
+ animation.play(at: 0)
772
+ animation.tick(at: 1.1)
773
+
774
+ XCTAssertNil(element.animatingMask.transformAnimationId)
775
+ XCTAssertEqual(element.transform.matrix.columns.3.x, 10, accuracy: 0.0001)
776
+ XCTAssertEqual(element.entityTransform.matrix.columns.3.x, 0, accuracy: 0.0001)
777
+ }
778
+
779
+ func test_stopResetFinishAndCompleteDoNotReplayIgnoredWrites() throws {
780
+ let makeTimeline = {
781
+ SpatializedMotionTimelinePayload(
782
+ duration: 1,
783
+ delay: nil,
784
+ playbackRate: nil,
785
+ loop: nil,
786
+ tracks: [
787
+ SpatializedMotionTrackPayload(
788
+ property: "transform.translate.x",
789
+ keyframes: [
790
+ SpatializedMotionKeyframePayload(at: 0, value: 0, timingFunction: "linear"),
791
+ SpatializedMotionKeyframePayload(at: 1, value: 10, timingFunction: nil),
792
+ ],
793
+ timingFunction: "linear"
794
+ ),
795
+ SpatializedMotionTrackPayload(
796
+ property: "opacity",
797
+ keyframes: [
798
+ SpatializedMotionKeyframePayload(at: 0, value: 1, timingFunction: "linear"),
799
+ SpatializedMotionKeyframePayload(at: 1, value: 0.25, timingFunction: nil),
800
+ ],
801
+ timingFunction: "linear"
802
+ ),
803
+ ]
804
+ )
805
+ }
806
+
807
+ func makeAnimation() throws -> (Spatialized2DElement, SpatializedElementAnimationObject) {
808
+ let element = Spatialized2DElement()
809
+ let manager = SpatializedElementAnimationManager()
810
+ let animation = try manager.createAnimation(
811
+ command: CreateSpatializedElementAnimationCommand(
812
+ elementId: element.id,
813
+ timeline: makeTimeline()
814
+ ),
815
+ target: element
816
+ )
817
+ animation.play(at: 0)
818
+ return (element, animation)
819
+ }
820
+
821
+ do {
822
+ let (element, animation) = try makeAnimation()
823
+ animation.stop(at: 0.5)
824
+ XCTAssertEqual(element.transform.matrix.columns.3.x, 5, accuracy: 0.0001)
825
+ XCTAssertEqual(element.opacity, 0.625, accuracy: 0.0001)
826
+ }
827
+
828
+ do {
829
+ let (element, animation) = try makeAnimation()
830
+ animation.reset(at: 0.5)
831
+ XCTAssertEqual(element.transform.matrix.columns.3.x, 0, accuracy: 0.0001)
832
+ XCTAssertEqual(element.opacity, 1, accuracy: 0.0001)
833
+ }
834
+
835
+ do {
836
+ let (element, animation) = try makeAnimation()
837
+ animation.finish(at: 0.5)
838
+ XCTAssertEqual(element.transform.matrix.columns.3.x, 10, accuracy: 0.0001)
839
+ XCTAssertEqual(element.opacity, 0.25, accuracy: 0.0001)
840
+ }
841
+
842
+ do {
843
+ let (element, animation) = try makeAnimation()
844
+ animation.tick(at: 1.1)
845
+ XCTAssertEqual(element.transform.matrix.columns.3.x, 10, accuracy: 0.0001)
846
+ XCTAssertEqual(element.opacity, 0.25, accuracy: 0.0001)
847
+ }
848
+ }
849
+
850
+ func test_releaseMaskIgnoresMismatchedAnimationOwnerForStatic3DRootTransformAdapter() {
851
+ let element = SpatializedStatic3DElement()
852
+ let adapter = SpatializedElementAnimationWriteAdapter()
853
+
854
+ element.animatingMask.acquire(transform: "owner-a")
855
+
856
+ adapter.releaseMaskAndApplyPending(on: element, animationId: "owner-b")
857
+
858
+ XCTAssertEqual(element.animatingMask.transformAnimationId, "owner-a")
859
+ XCTAssertEqual(element.transform.matrix.columns.3.x, 0, accuracy: 0.0001)
860
+ }
861
+
862
+ func test_destroyAnimationsForElementDestroysRelatedAnimation() throws {
863
+ let element = Spatialized2DElement()
864
+ let manager = SpatializedElementAnimationManager()
865
+ let timeline = SpatializedMotionTimelinePayload(
866
+ duration: 1,
867
+ delay: nil,
868
+ playbackRate: nil,
869
+ loop: nil,
870
+ tracks: [
871
+ SpatializedMotionTrackPayload(
872
+ property: "transform.translate.x",
873
+ keyframes: [
874
+ SpatializedMotionKeyframePayload(at: 0, value: 0, timingFunction: "linear"),
875
+ SpatializedMotionKeyframePayload(at: 1, value: 10, timingFunction: nil),
876
+ ],
877
+ timingFunction: "linear"
878
+ ),
879
+ ]
880
+ )
881
+
882
+ let animation = try manager.createAnimation(
883
+ command: CreateSpatializedElementAnimationCommand(
884
+ elementId: element.id,
885
+ timeline: timeline
886
+ ),
887
+ target: element
888
+ )
889
+
890
+ animation.play()
891
+ XCTAssertEqual(element.animatingMask.transformAnimationId, animation.uuid)
892
+
893
+ manager.destroyAnimationsForElement(element.id)
894
+ XCTAssertNil(manager.getAnimation(animation.uuid))
895
+ XCTAssertNil(element.animatingMask.transformAnimationId)
896
+ XCTAssertTrue(animation.isDestroyed)
897
+ }
898
+
899
+ func test_static3DAnimationWritesContainerRootTransformNotEntityTransform() throws {
900
+ let element = SpatializedStatic3DElement()
901
+ let manager = SpatializedElementAnimationManager()
902
+ let timeline = SpatializedMotionTimelinePayload(
903
+ duration: 2,
904
+ delay: nil,
905
+ playbackRate: nil,
906
+ loop: nil,
907
+ tracks: [
908
+ SpatializedMotionTrackPayload(
909
+ property: "transform.scale.x",
910
+ keyframes: [
911
+ SpatializedMotionKeyframePayload(at: 0, value: 0.75, timingFunction: "linear"),
912
+ SpatializedMotionKeyframePayload(at: 2, value: 2, timingFunction: nil),
913
+ ],
914
+ timingFunction: "linear"
915
+ ),
916
+ ]
917
+ )
918
+
919
+ let animation = try manager.createAnimation(
920
+ command: CreateSpatializedElementAnimationCommand(
921
+ elementId: element.id,
922
+ timeline: timeline
923
+ ),
924
+ target: element
925
+ )
926
+
927
+ animation.play(at: 0)
928
+ animation.tick(at: 3)
929
+
930
+ XCTAssertEqual(element.transform.matrix.columns.0.x, 2, accuracy: 0.0001)
931
+ XCTAssertEqual(element.entityTransform.matrix.columns.0.x, 1, accuracy: 0.0001)
932
+ XCTAssertEqual(element.opacity, 1.0)
933
+ }
934
+
935
+ func test_static3DAnimationResetAndReplayUseRootTransformBaseline() throws {
936
+ let element = SpatializedStatic3DElement()
937
+ let manager = SpatializedElementAnimationManager()
938
+ let timeline = SpatializedMotionTimelinePayload(
939
+ duration: 2,
940
+ delay: nil,
941
+ playbackRate: nil,
942
+ loop: nil,
943
+ tracks: [
944
+ SpatializedMotionTrackPayload(
945
+ property: "transform.scale.x",
946
+ keyframes: [
947
+ SpatializedMotionKeyframePayload(at: 0, value: 0.75, timingFunction: "linear"),
948
+ SpatializedMotionKeyframePayload(at: 2, value: 2, timingFunction: nil),
949
+ ],
950
+ timingFunction: "linear"
951
+ ),
952
+ ]
953
+ )
954
+
955
+ let animation = try manager.createAnimation(
956
+ command: CreateSpatializedElementAnimationCommand(
957
+ elementId: element.id,
958
+ timeline: timeline
959
+ ),
960
+ target: element
961
+ )
962
+
963
+ animation.play(at: 0)
964
+ animation.tick(at: 3)
965
+ XCTAssertEqual(element.transform.matrix.columns.0.x, 2, accuracy: 0.0001)
966
+ XCTAssertEqual(element.entityTransform.matrix.columns.0.x, 1, accuracy: 0.0001)
967
+
968
+ animation.reset(at: 4)
969
+ XCTAssertEqual(element.transform.matrix.columns.0.x, 0.75, accuracy: 0.0001)
970
+ XCTAssertEqual(element.entityTransform.matrix.columns.0.x, 1, accuracy: 0.0001)
971
+
972
+ animation.play(at: 5)
973
+ XCTAssertEqual(element.transform.matrix.columns.0.x, 0.75, accuracy: 0.0001)
974
+ animation.tick(at: 8)
975
+ XCTAssertEqual(element.transform.matrix.columns.0.x, 2, accuracy: 0.0001)
976
+ XCTAssertEqual(element.entityTransform.matrix.columns.0.x, 1, accuracy: 0.0001)
977
+ }
978
+
979
+ func test_activeAnimationMaskBlocksRegularWritesFromOverridingCurrentValue() throws {
980
+ let element = Spatialized2DElement()
981
+ let manager = SpatializedElementAnimationManager()
982
+ let timeline = SpatializedMotionTimelinePayload(
983
+ duration: 2,
984
+ delay: nil,
985
+ playbackRate: nil,
986
+ loop: nil,
987
+ tracks: [
988
+ SpatializedMotionTrackPayload(
989
+ property: "transform.translate.x",
990
+ keyframes: [
991
+ SpatializedMotionKeyframePayload(at: 0, value: 0, timingFunction: "linear"),
992
+ SpatializedMotionKeyframePayload(at: 2, value: 12, timingFunction: nil),
993
+ ],
994
+ timingFunction: "linear"
995
+ ),
996
+ SpatializedMotionTrackPayload(
997
+ property: "opacity",
998
+ keyframes: [
999
+ SpatializedMotionKeyframePayload(at: 0, value: 1, timingFunction: "linear"),
1000
+ SpatializedMotionKeyframePayload(at: 2, value: 0.25, timingFunction: nil),
1001
+ ],
1002
+ timingFunction: "linear"
1003
+ ),
1004
+ ]
1005
+ )
1006
+
1007
+ let animation = try manager.createAnimation(
1008
+ command: CreateSpatializedElementAnimationCommand(
1009
+ elementId: element.id,
1010
+ timeline: timeline
1011
+ ),
1012
+ target: element
1013
+ )
1014
+
1015
+ animation.play(at: 0)
1016
+ animation.tick(at: 1)
1017
+ let animatedTransformX = element.transform.matrix.columns.3.x
1018
+ let animatedOpacity = element.opacity
1019
+
1020
+ XCTAssertFalse(animation.writeAdapter.shouldAllowTransformWrite(on: element, animationId: "other-animation"))
1021
+ XCTAssertFalse(animation.writeAdapter.shouldAllowOpacityWrite(on: element, animationId: "other-animation"))
1022
+
1023
+ if !element.animatingMask.locksOpacity {
1024
+ element.opacity = 0.9
1025
+ }
1026
+ if !element.animatingMask.locksTransform {
1027
+ element.transform = .init(translation: Vector3D(x: 99, y: 0, z: 0))
1028
+ }
1029
+
1030
+ XCTAssertEqual(element.transform.matrix.columns.3.x, animatedTransformX, accuracy: 0.0001)
1031
+ XCTAssertEqual(element.opacity, animatedOpacity, accuracy: 0.0001)
1032
+
1033
+ animation.stop(at: 1.2)
1034
+ XCTAssertTrue(animation.writeAdapter.shouldAllowTransformWrite(on: element, animationId: "other-animation"))
1035
+ XCTAssertTrue(animation.writeAdapter.shouldAllowOpacityWrite(on: element, animationId: "other-animation"))
1036
+ if !element.animatingMask.locksOpacity {
1037
+ element.opacity = 0.9
1038
+ }
1039
+ if !element.animatingMask.locksTransform {
1040
+ element.transform = .init(translation: Vector3D(x: 99, y: 0, z: 0))
1041
+ }
1042
+
1043
+ XCTAssertEqual(element.transform.matrix.columns.3.x, 99, accuracy: 0.0001)
1044
+ XCTAssertEqual(element.opacity, 0.9, accuracy: 0.0001)
1045
+ }
1046
+
1047
+ func test_static3DOpacityTracksAnimateElementOpacity() throws {
1048
+ let element = SpatializedStatic3DElement()
1049
+ let manager = SpatializedElementAnimationManager()
1050
+ let timeline = SpatializedMotionTimelinePayload(
1051
+ duration: 2,
1052
+ delay: nil,
1053
+ playbackRate: nil,
1054
+ loop: nil,
1055
+ tracks: [
1056
+ SpatializedMotionTrackPayload(
1057
+ property: "opacity",
1058
+ keyframes: [
1059
+ SpatializedMotionKeyframePayload(at: 0, value: 1, timingFunction: "linear"),
1060
+ SpatializedMotionKeyframePayload(at: 2, value: 0.2, timingFunction: nil),
1061
+ ],
1062
+ timingFunction: "linear"
1063
+ ),
1064
+ ]
1065
+ )
1066
+
1067
+ let animation = try manager.createAnimation(
1068
+ command: CreateSpatializedElementAnimationCommand(
1069
+ elementId: element.id,
1070
+ timeline: timeline
1071
+ ),
1072
+ target: element
1073
+ )
1074
+
1075
+ animation.play(at: 0)
1076
+ XCTAssertEqual(element.animatingMask.opacityAnimationId, animation.uuid)
1077
+
1078
+ animation.tick(at: 1)
1079
+
1080
+ XCTAssertEqual(element.opacity, 0.6, accuracy: 0.0001)
1081
+ XCTAssertEqual(element.entityTransform.matrix.columns.3.x, 0, accuracy: 0.0001)
1082
+
1083
+ animation.finish(at: 1.5)
1084
+
1085
+ XCTAssertNil(element.animatingMask.opacityAnimationId)
1086
+ XCTAssertEqual(element.opacity, 0.2, accuracy: 0.0001)
1087
+ }
1088
+
1089
+ func test_animationStateChangedEmitsMatchingAnimationId() throws {
1090
+ let element = Spatialized2DElement()
1091
+ var events: [SpatialAnimationStateChanged] = []
1092
+ let manager = SpatializedElementAnimationManager(sendWebMsg: { _, msg in
1093
+ if let event = msg as? SpatialAnimationStateChanged {
1094
+ events.append(event)
1095
+ }
1096
+ })
1097
+ let timeline = SpatializedMotionTimelinePayload(
1098
+ duration: 1,
1099
+ delay: nil,
1100
+ playbackRate: nil,
1101
+ loop: nil,
1102
+ tracks: [
1103
+ SpatializedMotionTrackPayload(
1104
+ property: "opacity",
1105
+ keyframes: [
1106
+ SpatializedMotionKeyframePayload(at: 0, value: 1, timingFunction: "linear"),
1107
+ SpatializedMotionKeyframePayload(at: 1, value: 0.25, timingFunction: nil),
1108
+ ],
1109
+ timingFunction: "linear"
1110
+ ),
1111
+ ]
1112
+ )
1113
+
1114
+ let animation = try manager.createAnimation(
1115
+ command: CreateSpatializedElementAnimationCommand(
1116
+ elementId: element.id,
1117
+ timeline: timeline
1118
+ ),
1119
+ target: element
1120
+ )
1121
+
1122
+ try manager.controlAnimation(ControlSpatializedElementAnimationCommand(animationId: animation.uuid, type: "play"))
1123
+ try manager.controlAnimation(ControlSpatializedElementAnimationCommand(animationId: animation.uuid, type: "pause"))
1124
+
1125
+ XCTAssertEqual(events.last?.animationId, animation.uuid)
1126
+ XCTAssertEqual(events.last?.action, "pause")
1127
+ XCTAssertEqual(events.last?.playState, .paused)
1128
+ XCTAssertEqual(events.last?.finished, false)
1129
+ XCTAssertNotNil(events.last?.values)
1130
+ XCTAssertNotNil(events.last?.values?.opacity)
1131
+ }
1132
+
1133
+ func test_animationStateChangedIncludesFinishedForAllTerminalAndControlEvents() throws {
1134
+ let element = Spatialized2DElement()
1135
+ var events: [SpatialAnimationStateChanged] = []
1136
+ let manager = SpatializedElementAnimationManager(sendWebMsg: { _, msg in
1137
+ if let event = msg as? SpatialAnimationStateChanged {
1138
+ events.append(event)
1139
+ }
1140
+ })
1141
+ let timeline = SpatializedMotionTimelinePayload(
1142
+ duration: 1,
1143
+ delay: nil,
1144
+ playbackRate: nil,
1145
+ loop: nil,
1146
+ tracks: [
1147
+ SpatializedMotionTrackPayload(
1148
+ property: "opacity",
1149
+ keyframes: [
1150
+ SpatializedMotionKeyframePayload(at: 0, value: 1, timingFunction: "linear"),
1151
+ SpatializedMotionKeyframePayload(at: 1, value: 0.25, timingFunction: nil),
1152
+ ],
1153
+ timingFunction: "linear"
1154
+ ),
1155
+ ]
1156
+ )
1157
+
1158
+ let animation = try manager.createAnimation(
1159
+ command: CreateSpatializedElementAnimationCommand(
1160
+ elementId: element.id,
1161
+ timeline: timeline
1162
+ ),
1163
+ target: element
1164
+ )
1165
+
1166
+ animation.play(at: 0)
1167
+ animation.pause(at: 0.25)
1168
+ animation.resume(at: 0.5)
1169
+ animation.stop(at: 0.75)
1170
+ animation.reset(at: 1)
1171
+ animation.finish(at: 1.25)
1172
+ animation.play(at: 2)
1173
+ animation.tick(at: 2)
1174
+ animation.tick(at: 3.1)
1175
+ animation.destroy()
1176
+
1177
+ let finishedByAction = events.reduce(into: [String: Bool]()) { result, event in
1178
+ result[event.action] = event.finished
1179
+ }
1180
+ XCTAssertEqual(finishedByAction["play"], false)
1181
+ XCTAssertEqual(finishedByAction["pause"], false)
1182
+ XCTAssertEqual(finishedByAction["resume"], false)
1183
+ XCTAssertEqual(finishedByAction["stop"], false)
1184
+ XCTAssertEqual(finishedByAction["reset"], false)
1185
+ XCTAssertEqual(finishedByAction["finish"], true)
1186
+ XCTAssertEqual(finishedByAction["complete"], true)
1187
+ XCTAssertEqual(finishedByAction["destroy"], false)
1188
+ }
1189
+
1190
+ func test_destroyAnimationDirectPathDestroysObjectReleasesMaskAndStopsFrameDriver() throws {
1191
+ let element = Spatialized2DElement()
1192
+ var events: [SpatialAnimationStateChanged] = []
1193
+ let manager = SpatializedElementAnimationManager(sendWebMsg: { _, msg in
1194
+ if let event = msg as? SpatialAnimationStateChanged {
1195
+ events.append(event)
1196
+ }
1197
+ })
1198
+ let timeline = SpatializedMotionTimelinePayload(
1199
+ duration: 2,
1200
+ delay: nil,
1201
+ playbackRate: nil,
1202
+ loop: nil,
1203
+ tracks: [
1204
+ SpatializedMotionTrackPayload(
1205
+ property: "transform.translate.x",
1206
+ keyframes: [
1207
+ SpatializedMotionKeyframePayload(at: 0, value: 0, timingFunction: "linear"),
1208
+ SpatializedMotionKeyframePayload(at: 2, value: 10, timingFunction: nil),
1209
+ ],
1210
+ timingFunction: "linear"
1211
+ ),
1212
+ ]
1213
+ )
1214
+
1215
+ let animation = try manager.createAnimation(
1216
+ command: CreateSpatializedElementAnimationCommand(
1217
+ elementId: element.id,
1218
+ timeline: timeline
1219
+ ),
1220
+ target: element
1221
+ )
1222
+
1223
+ animation.play(at: 0)
1224
+ manager.tickAll(timestamp: 1)
1225
+ XCTAssertEqual(element.animatingMask.transformAnimationId, animation.uuid)
1226
+ XCTAssertTrue(manager.hasActiveFrameDriver)
1227
+
1228
+ manager.destroyAnimation(animation.uuid)
1229
+
1230
+ XCTAssertNil(manager.getAnimation(animation.uuid))
1231
+ XCTAssertNil(SpatialObject.get(animation.uuid))
1232
+ XCTAssertNil(element.animatingMask.transformAnimationId)
1233
+ XCTAssertTrue(animation.isDestroyed)
1234
+ XCTAssertFalse(manager.hasActiveFrameDriver)
1235
+ XCTAssertEqual(events.last?.action, "destroy")
1236
+ XCTAssertEqual(events.last?.finished, false)
1237
+ }
1238
+
1239
+ /// Verifies destroy does not replace an explicitly finished value with a resampled start value.
1240
+ func test_destroyAfterFinishDoesNotEmitResampledValues() throws {
1241
+ let element = Spatialized2DElement()
1242
+ var events: [SpatialAnimationStateChanged] = []
1243
+ let manager = SpatializedElementAnimationManager(sendWebMsg: { _, msg in
1244
+ if let event = msg as? SpatialAnimationStateChanged {
1245
+ events.append(event)
1246
+ }
1247
+ })
1248
+ let timeline = SpatializedMotionTimelinePayload(
1249
+ duration: 1,
1250
+ delay: nil,
1251
+ playbackRate: nil,
1252
+ loop: nil,
1253
+ tracks: [
1254
+ SpatializedMotionTrackPayload(
1255
+ property: "opacity",
1256
+ keyframes: [
1257
+ SpatializedMotionKeyframePayload(at: 0, value: 0, timingFunction: "linear"),
1258
+ SpatializedMotionKeyframePayload(at: 1, value: 1, timingFunction: nil),
1259
+ ],
1260
+ timingFunction: "linear"
1261
+ ),
1262
+ ]
1263
+ )
1264
+ let animation = try manager.createAnimation(
1265
+ command: CreateSpatializedElementAnimationCommand(
1266
+ elementId: element.id,
1267
+ timeline: timeline
1268
+ ),
1269
+ target: element
1270
+ )
1271
+
1272
+ animation.finish(at: 1)
1273
+ XCTAssertEqual(element.opacity, 1, accuracy: 0.0001)
1274
+
1275
+ animation.destroy()
1276
+
1277
+ XCTAssertEqual(element.opacity, 1, accuracy: 0.0001)
1278
+ XCTAssertEqual(events.last?.action, "destroy")
1279
+ XCTAssertNil(events.last?.values)
1280
+ }
1281
+
1282
+ /// Verifies destroy does not resample a paused animation after changing its state to idle.
1283
+ func test_destroyWhilePausedDoesNotEmitResampledValues() throws {
1284
+ let element = Spatialized2DElement()
1285
+ var events: [SpatialAnimationStateChanged] = []
1286
+ let manager = SpatializedElementAnimationManager(sendWebMsg: { _, msg in
1287
+ if let event = msg as? SpatialAnimationStateChanged {
1288
+ events.append(event)
1289
+ }
1290
+ })
1291
+ let timeline = SpatializedMotionTimelinePayload(
1292
+ duration: 2,
1293
+ delay: nil,
1294
+ playbackRate: nil,
1295
+ loop: nil,
1296
+ tracks: [
1297
+ SpatializedMotionTrackPayload(
1298
+ property: "opacity",
1299
+ keyframes: [
1300
+ SpatializedMotionKeyframePayload(at: 0, value: 0, timingFunction: "linear"),
1301
+ SpatializedMotionKeyframePayload(at: 2, value: 1, timingFunction: nil),
1302
+ ],
1303
+ timingFunction: "linear"
1304
+ ),
1305
+ ]
1306
+ )
1307
+ let animation = try manager.createAnimation(
1308
+ command: CreateSpatializedElementAnimationCommand(
1309
+ elementId: element.id,
1310
+ timeline: timeline
1311
+ ),
1312
+ target: element
1313
+ )
1314
+
1315
+ animation.play(at: 0)
1316
+ animation.pause(at: 1)
1317
+ XCTAssertEqual(element.opacity, 0.5, accuracy: 0.0001)
1318
+
1319
+ animation.destroy()
1320
+
1321
+ XCTAssertEqual(element.opacity, 0.5, accuracy: 0.0001)
1322
+ XCTAssertEqual(events.last?.action, "destroy")
1323
+ XCTAssertNil(events.last?.values)
1324
+ }
1325
+
1326
+ func test_tickAllAllowsAnimationRemovalFromStateCallback() throws {
1327
+ let firstElement = Spatialized2DElement()
1328
+ let secondElement = Spatialized2DElement()
1329
+ var manager: SpatializedElementAnimationManager!
1330
+ var triggeringAnimationId: String?
1331
+ var animationToDestroyId: String?
1332
+
1333
+ manager = SpatializedElementAnimationManager(sendWebMsg: { _, msg in
1334
+ guard let event = msg as? SpatialAnimationStateChanged,
1335
+ event.action == "start",
1336
+ event.animationId == triggeringAnimationId,
1337
+ let animationToDestroyId
1338
+ else {
1339
+ return
1340
+ }
1341
+ manager.destroyAnimation(animationToDestroyId)
1342
+ })
1343
+
1344
+ let timeline = SpatializedMotionTimelinePayload(
1345
+ duration: 2,
1346
+ delay: nil,
1347
+ playbackRate: nil,
1348
+ loop: nil,
1349
+ tracks: [
1350
+ SpatializedMotionTrackPayload(
1351
+ property: "opacity",
1352
+ keyframes: [
1353
+ SpatializedMotionKeyframePayload(at: 0, value: 1, timingFunction: "linear"),
1354
+ SpatializedMotionKeyframePayload(at: 2, value: 0, timingFunction: nil),
1355
+ ],
1356
+ timingFunction: "linear"
1357
+ ),
1358
+ ]
1359
+ )
1360
+
1361
+ let firstAnimation = try manager.createAnimation(
1362
+ command: CreateSpatializedElementAnimationCommand(
1363
+ elementId: firstElement.id,
1364
+ timeline: timeline
1365
+ ),
1366
+ target: firstElement
1367
+ )
1368
+ let secondAnimation = try manager.createAnimation(
1369
+ command: CreateSpatializedElementAnimationCommand(
1370
+ elementId: secondElement.id,
1371
+ timeline: timeline
1372
+ ),
1373
+ target: secondElement
1374
+ )
1375
+ triggeringAnimationId = firstAnimation.uuid
1376
+ animationToDestroyId = secondAnimation.uuid
1377
+
1378
+ firstAnimation.play(at: 0)
1379
+ secondAnimation.play(at: 0)
1380
+ manager.tickAll(timestamp: 1)
1381
+
1382
+ XCTAssertNil(manager.getAnimation(secondAnimation.uuid))
1383
+ }
1384
+
1385
+ func test_createAnimationRejectsInvalidPlaybackRate() {
1386
+ let invalidRates: [Double] = [0, -1, .infinity]
1387
+
1388
+ for playbackRate in invalidRates {
1389
+ let element = Spatialized2DElement()
1390
+ let manager = SpatializedElementAnimationManager()
1391
+ let timeline = SpatializedMotionTimelinePayload(
1392
+ duration: 1,
1393
+ delay: nil,
1394
+ playbackRate: playbackRate,
1395
+ loop: nil,
1396
+ tracks: [
1397
+ SpatializedMotionTrackPayload(
1398
+ property: "opacity",
1399
+ keyframes: [
1400
+ SpatializedMotionKeyframePayload(at: 0, value: 1, timingFunction: "linear"),
1401
+ SpatializedMotionKeyframePayload(at: 1, value: 0, timingFunction: nil),
1402
+ ],
1403
+ timingFunction: "linear"
1404
+ ),
1405
+ ]
1406
+ )
1407
+
1408
+ XCTAssertThrowsError(try manager.createAnimation(
1409
+ command: CreateSpatializedElementAnimationCommand(
1410
+ elementId: element.id,
1411
+ timeline: timeline
1412
+ ),
1413
+ target: element
1414
+ ))
1415
+ }
1416
+ }
1417
+
1418
+ func test_playDoesNotStealActiveAnimationMask() throws {
1419
+ let element = Spatialized2DElement()
1420
+ var events: [SpatialAnimationStateChanged] = []
1421
+ let manager = SpatializedElementAnimationManager(sendWebMsg: { _, msg in
1422
+ if let event = msg as? SpatialAnimationStateChanged {
1423
+ events.append(event)
1424
+ }
1425
+ })
1426
+ let timeline = SpatializedMotionTimelinePayload(
1427
+ duration: 2,
1428
+ delay: nil,
1429
+ playbackRate: nil,
1430
+ loop: nil,
1431
+ tracks: [
1432
+ SpatializedMotionTrackPayload(
1433
+ property: "transform.translate.x",
1434
+ keyframes: [
1435
+ SpatializedMotionKeyframePayload(at: 0, value: 0, timingFunction: "linear"),
1436
+ SpatializedMotionKeyframePayload(at: 2, value: 10, timingFunction: nil),
1437
+ ],
1438
+ timingFunction: "linear"
1439
+ ),
1440
+ ]
1441
+ )
1442
+ let firstAnimation = try manager.createAnimation(
1443
+ command: CreateSpatializedElementAnimationCommand(
1444
+ elementId: element.id,
1445
+ timeline: timeline
1446
+ ),
1447
+ target: element
1448
+ )
1449
+ let secondAnimation = try manager.createAnimation(
1450
+ command: CreateSpatializedElementAnimationCommand(
1451
+ elementId: element.id,
1452
+ timeline: timeline
1453
+ ),
1454
+ target: element
1455
+ )
1456
+
1457
+ firstAnimation.play(at: 0)
1458
+ secondAnimation.play(at: 0)
1459
+
1460
+ XCTAssertEqual(element.animatingMask.transformAnimationId, firstAnimation.uuid)
1461
+ XCTAssertTrue(firstAnimation.isAnimating)
1462
+ XCTAssertFalse(secondAnimation.isAnimating)
1463
+ XCTAssertEqual(events.last?.animationId, secondAnimation.uuid)
1464
+ XCTAssertNotNil(events.last?.error)
1465
+ }
1466
+ }