@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,263 @@
1
+ import Foundation
2
+ import QuartzCore
3
+ import simd
4
+ import Spatial
5
+
6
+ enum SpatializedElementAnimationManagerError: Error {
7
+ case animationNotFound(String)
8
+ case invalidTarget(String)
9
+ case invalidTimeline(String)
10
+ }
11
+
12
+ final class SpatializedElementAnimationManager: NSObject {
13
+ private var animations: [String: SpatializedElementAnimationObject] = [:]
14
+ private var displayLink: CADisplayLink?
15
+ private let sendWebMsg: ((String, Encodable) -> Void)?
16
+
17
+ init(sendWebMsg: ((String, Encodable) -> Void)? = nil) {
18
+ self.sendWebMsg = sendWebMsg
19
+ super.init()
20
+ }
21
+
22
+ func getAnimation(_ animationId: String) -> SpatializedElementAnimationObject? {
23
+ animations[animationId]
24
+ }
25
+
26
+ var hasActiveFrameDriver: Bool {
27
+ displayLink != nil
28
+ }
29
+
30
+ func createAnimation(
31
+ command: CreateSpatializedElementAnimationCommand,
32
+ target: SpatializedElement,
33
+ explicitAnimationId: String? = nil
34
+ ) throws -> SpatializedElementAnimationObject {
35
+ try Self.validateTimeline(command.timeline)
36
+ let targetKind = try Self.resolveTargetKind(for: target)
37
+ let writeAdapter = SpatializedElementAnimationWriteAdapter()
38
+ let sampler = SpatializedElementMotionTimelineSampler(
39
+ timeline: command.timeline,
40
+ baselineTransform: Self.decomposeTransform(from: writeAdapter.currentAffineTransform(for: target)),
41
+ baselineOpacity: writeAdapter.baselineOpacity(for: target)
42
+ )
43
+
44
+ let animation = SpatializedElementAnimationObject(
45
+ id: explicitAnimationId,
46
+ targetElement: target,
47
+ targetKind: targetKind,
48
+ timelineSampler: sampler,
49
+ duration: command.timeline.duration,
50
+ timingFunction: "linear",
51
+ delay: command.timeline.delay ?? 0,
52
+ speed: command.timeline.playbackRate ?? 1.0,
53
+ loopConfig: command.timeline.loop ?? .none,
54
+ sendWebMsg: sendWebMsg
55
+ )
56
+ register(animation)
57
+ return animation
58
+ }
59
+
60
+ /// Validates native timeline fields that affect frame-driver lifetime.
61
+ private static func validateTimeline(_ timeline: SpatializedMotionTimelinePayload) throws {
62
+ if let playbackRate = timeline.playbackRate,
63
+ !playbackRate.isFinite || playbackRate <= 0
64
+ {
65
+ throw SpatializedElementAnimationManagerError.invalidTimeline("playbackRate must be > 0 and finite")
66
+ }
67
+ }
68
+
69
+ /// Resolves the native animation target kind from the concrete element subtype.
70
+ private static func resolveTargetKind(for target: SpatializedElement) throws -> SpatializedElementAnimationTargetKind {
71
+ if target is SpatializedStatic3DElement {
72
+ return .static3d
73
+ }
74
+ if target is SpatializedDynamic3DElement {
75
+ return .dynamic3d
76
+ }
77
+ if target is Spatialized2DElement {
78
+ return .spatialized2d
79
+ }
80
+ throw SpatializedElementAnimationManagerError.invalidTarget("unsupported element type \(String(describing: type(of: target)))")
81
+ }
82
+
83
+ func controlAnimation(_ command: ControlSpatializedElementAnimationCommand) throws {
84
+ guard let animation = animations[command.animationId] else {
85
+ throw SpatializedElementAnimationManagerError.animationNotFound(command.animationId)
86
+ }
87
+
88
+ let timestamp = CACurrentMediaTime()
89
+ switch command.type {
90
+ case "play":
91
+ animation.play(at: timestamp)
92
+ case "pause":
93
+ animation.pause(at: timestamp)
94
+ case "resume":
95
+ animation.resume(at: timestamp)
96
+ case "stop":
97
+ animation.stop(at: timestamp)
98
+ case "reset":
99
+ animation.reset(at: timestamp)
100
+ case "finish":
101
+ animation.finish(at: timestamp)
102
+ case "destroy":
103
+ animation.destroy()
104
+ default:
105
+ throw SpatializedElementAnimationManagerError.invalidTarget("unknown control type \(command.type)")
106
+ }
107
+
108
+ refreshDisplayLink()
109
+ }
110
+
111
+ func destroyAnimation(_ animationId: String) {
112
+ animations[animationId]?.destroy()
113
+ refreshDisplayLink()
114
+ }
115
+
116
+ func destroyAnimationsForElement(_ elementId: String) {
117
+ let ids = animations.values.filter { $0.targetElementId == elementId }.map { $0.uuid }
118
+ for id in ids {
119
+ animations[id]?.destroy()
120
+ }
121
+ refreshDisplayLink()
122
+ }
123
+
124
+ func removeAll() {
125
+ let current = Array(animations.values)
126
+ for animation in current {
127
+ animation.destroy()
128
+ }
129
+ animations.removeAll()
130
+ stopDisplayLink()
131
+ }
132
+
133
+ func tickAll(timestamp: CFTimeInterval) {
134
+ let current = Array(animations.values)
135
+ for animation in current {
136
+ animation.tick(at: timestamp)
137
+ }
138
+ refreshDisplayLink()
139
+ }
140
+
141
+ private func register(_ animation: SpatializedElementAnimationObject) {
142
+ var animation = animation
143
+ animations[animation.uuid] = animation
144
+ animation.on(event: SpatialObject.Events.BeforeDestroyed.rawValue) { [weak self] object, _ in
145
+ guard let self = self, let animation = object as? SpatializedElementAnimationObject else { return }
146
+ self.animations.removeValue(forKey: animation.uuid)
147
+ self.refreshDisplayLink()
148
+ }
149
+ refreshDisplayLink()
150
+ }
151
+
152
+ private func refreshDisplayLink() {
153
+ if animations.values.contains(where: { $0.isAnimating }) {
154
+ ensureDisplayLinkRunning()
155
+ } else {
156
+ stopDisplayLink()
157
+ }
158
+ }
159
+
160
+ private func ensureDisplayLinkRunning() {
161
+ guard displayLink == nil else { return }
162
+ let link = CADisplayLink(target: self, selector: #selector(onFrame(_:)))
163
+ link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 90, preferred: 90)
164
+ link.add(to: .main, forMode: .common)
165
+ displayLink = link
166
+ }
167
+
168
+ private func stopDisplayLink() {
169
+ displayLink?.invalidate()
170
+ displayLink = nil
171
+ }
172
+
173
+ @objc private func onFrame(_ link: CADisplayLink) {
174
+ tickAll(timestamp: CACurrentMediaTime())
175
+ }
176
+
177
+ static func decomposeTransform(from transform: AffineTransform3D) -> SpatializedMotionTransformComponents {
178
+ if transform == .identity {
179
+ return .identity
180
+ }
181
+
182
+ let m = transform.matrix
183
+ let tx = m.columns.3.x
184
+ let ty = m.columns.3.y
185
+ let tz = m.columns.3.z
186
+
187
+ let col0 = SIMD3<Double>(m.columns.0.x, m.columns.0.y, m.columns.0.z)
188
+ let col1 = SIMD3<Double>(m.columns.1.x, m.columns.1.y, m.columns.1.z)
189
+ let col2 = SIMD3<Double>(m.columns.2.x, m.columns.2.y, m.columns.2.z)
190
+
191
+ var sx = simd_length(col0)
192
+ let sy = simd_length(col1)
193
+ let sz = simd_length(col2)
194
+
195
+ guard sx > 1e-10, sy > 1e-10, sz > 1e-10 else {
196
+ return SpatializedMotionTransformComponents(
197
+ translateX: tx, translateY: ty, translateZ: tz,
198
+ rotateX: 0, rotateY: 0, rotateZ: 0,
199
+ scaleX: sx, scaleY: sy, scaleZ: sz
200
+ )
201
+ }
202
+
203
+ // Preserve an odd reflection on the X scale so the normalized rotation remains proper.
204
+ let determinant = simd_determinant(simd_double3x3(columns: (col0, col1, col2)))
205
+ if determinant < 0 {
206
+ sx = -sx
207
+ }
208
+
209
+ let r00 = col0.x / sx; let r10 = col0.y / sx; let r20 = col0.z / sx
210
+ let r11 = col1.y / sy; let r21 = col1.z / sy
211
+ let r12 = col2.y / sz; let r22 = col2.z / sz
212
+
213
+ let cosY = sqrt(r00 * r00 + r10 * r10)
214
+ let rotY = atan2(-r20, cosY)
215
+ let rotX: Double
216
+ let rotZ: Double
217
+
218
+ if cosY > 1e-6 {
219
+ rotX = atan2(r21, r22)
220
+ rotZ = atan2(r10, r00)
221
+ } else {
222
+ rotX = atan2(-r12, r11)
223
+ rotZ = 0
224
+ }
225
+
226
+ let rad2deg = 180.0 / Double.pi
227
+ return SpatializedMotionTransformComponents(
228
+ translateX: tx, translateY: ty, translateZ: tz,
229
+ rotateX: rotX * rad2deg,
230
+ rotateY: rotY * rad2deg,
231
+ rotateZ: rotZ * rad2deg,
232
+ scaleX: sx, scaleY: sy, scaleZ: sz
233
+ )
234
+ }
235
+
236
+ static func composeTransform(_ transform: SpatializedMotionTransformComponents) -> AffineTransform3D {
237
+ let deg2rad = Double.pi / 180.0
238
+
239
+ let cosX = cos(transform.rotateX * deg2rad)
240
+ let sinX = sin(transform.rotateX * deg2rad)
241
+ let cosY = cos(transform.rotateY * deg2rad)
242
+ let sinY = sin(transform.rotateY * deg2rad)
243
+ let cosZ = cos(transform.rotateZ * deg2rad)
244
+ let sinZ = sin(transform.rotateZ * deg2rad)
245
+
246
+ let r00 = cosY * cosZ
247
+ let r01 = sinX * sinY * cosZ - cosX * sinZ
248
+ let r02 = cosX * sinY * cosZ + sinX * sinZ
249
+ let r10 = cosY * sinZ
250
+ let r11 = sinX * sinY * sinZ + cosX * cosZ
251
+ let r12 = cosX * sinY * sinZ - sinX * cosZ
252
+ let r20 = -sinY
253
+ let r21 = sinX * cosY
254
+ let r22 = cosX * cosY
255
+
256
+ let col0 = simd_double4(r00 * transform.scaleX, r10 * transform.scaleX, r20 * transform.scaleX, 0)
257
+ let col1 = simd_double4(r01 * transform.scaleY, r11 * transform.scaleY, r21 * transform.scaleY, 0)
258
+ let col2 = simd_double4(r02 * transform.scaleZ, r12 * transform.scaleZ, r22 * transform.scaleZ, 0)
259
+ let col3 = simd_double4(transform.translateX, transform.translateY, transform.translateZ, 1)
260
+
261
+ return AffineTransform3D(truncating: simd_double4x4(columns: (col0, col1, col2, col3)))
262
+ }
263
+ }
@@ -0,0 +1,399 @@
1
+ import Foundation
2
+ import QuartzCore
3
+ import Spatial
4
+
5
+ final class SpatializedElementAnimationObject: SpatialObject {
6
+ let targetElementId: String
7
+ weak var targetElement: SpatializedElement?
8
+ let targetKind: SpatializedElementAnimationTargetKind
9
+ let timelineSampler: SpatializedElementMotionTimelineSampler
10
+ let writeAdapter: SpatializedElementAnimationWriteAdapter
11
+ private let sendWebMsg: ((String, Encodable) -> Void)?
12
+
13
+ private let duration: TimeInterval
14
+ private let delay: TimeInterval
15
+ private let timingFunction: SpatializedMotionTimingFunction
16
+ private let speed: Double
17
+ private let loopConfig: SpatializedMotionLoopConfig
18
+
19
+ private(set) var animatesTransform: Bool
20
+ private(set) var animatesOpacity: Bool
21
+
22
+ private var isReversed = false
23
+
24
+ private(set) var playState: SpatializedElementAnimationPlayState = .idle
25
+ private(set) var finished = false
26
+
27
+ private var startTime: CFTimeInterval = 0
28
+ private var pausedDuration: CFTimeInterval = 0
29
+ private var pauseStartTime: CFTimeInterval = 0
30
+ private var delayCompleted = false
31
+ private var createdTime: CFTimeInterval = 0
32
+ /// Tracks whether the current playback session has emitted its first-frame lifecycle event.
33
+ private var hasEmittedStartEvent = false
34
+
35
+ var isAnimating: Bool {
36
+ playState == .running
37
+ }
38
+
39
+ var isPaused: Bool {
40
+ playState == .paused
41
+ }
42
+
43
+ var uuid: String {
44
+ spatialId
45
+ }
46
+
47
+ init(
48
+ id: String? = nil,
49
+ targetElement: SpatializedElement,
50
+ targetKind: SpatializedElementAnimationTargetKind,
51
+ timelineSampler: SpatializedElementMotionTimelineSampler,
52
+ duration: Double,
53
+ timingFunction: String,
54
+ delay: Double,
55
+ speed: Double = 1.0,
56
+ loopConfig: SpatializedMotionLoopConfig = .none,
57
+ sendWebMsg: ((String, Encodable) -> Void)? = nil
58
+ ) {
59
+ targetElementId = targetElement.spatialId
60
+ self.targetElement = targetElement
61
+ self.targetKind = targetKind
62
+ self.timelineSampler = timelineSampler
63
+ writeAdapter = SpatializedElementAnimationWriteAdapter()
64
+ self.duration = duration
65
+ self.delay = delay
66
+ self.timingFunction = SpatializedMotionTimingFunction.from(name: timingFunction)
67
+ self.speed = speed
68
+ self.loopConfig = loopConfig
69
+ self.sendWebMsg = sendWebMsg
70
+ animatesTransform = timelineSampler.animatesTransform
71
+ animatesOpacity = timelineSampler.animatesOpacity
72
+
73
+ if let id {
74
+ super.init(id)
75
+ } else {
76
+ super.init()
77
+ }
78
+ }
79
+
80
+ func play(at timestamp: CFTimeInterval = CACurrentMediaTime()) {
81
+ if isPaused {
82
+ resume(at: timestamp)
83
+ return
84
+ }
85
+
86
+ // Preserve session semantics: play() while already running is a no-op.
87
+ guard playState != .running else {
88
+ return
89
+ }
90
+
91
+ startFreshPlayback(at: timestamp)
92
+ }
93
+
94
+ func pause(at timestamp: CFTimeInterval = CACurrentMediaTime()) {
95
+ guard playState == .running else {
96
+ return
97
+ }
98
+
99
+ let values = currentValues(at: timestamp)
100
+ applySample(values)
101
+ playState = .paused
102
+ pauseStartTime = timestamp
103
+ emitStateChanged(action: "pause", playState: .paused, values: values)
104
+ }
105
+
106
+ func resume(at timestamp: CFTimeInterval = CACurrentMediaTime()) {
107
+ guard playState == .paused else {
108
+ return
109
+ }
110
+
111
+ playState = .running
112
+ pausedDuration += timestamp - pauseStartTime
113
+ emitStateChanged(action: "resume", playState: .running, values: currentValues(at: timestamp))
114
+ }
115
+
116
+ func stop(at timestamp: CFTimeInterval = CACurrentMediaTime()) {
117
+ guard playState == .running || playState == .paused else {
118
+ return
119
+ }
120
+
121
+ let values = currentValues(at: timestamp)
122
+ playState = .idle
123
+ finished = false
124
+ applySample(values)
125
+ releaseMask()
126
+ emitStateChanged(action: "stop", playState: .idle, values: values)
127
+ }
128
+
129
+ func reset(at timestamp: CFTimeInterval = CACurrentMediaTime()) {
130
+ let values = sampleValues(at: 0)
131
+ resetTimingState()
132
+ playState = .idle
133
+ finished = false
134
+ applySample(values)
135
+ releaseMask()
136
+ emitStateChanged(action: "reset", playState: .idle, values: values)
137
+ }
138
+
139
+ func finish(at timestamp: CFTimeInterval = CACurrentMediaTime()) {
140
+ // A finished session has already emitted its terminal event.
141
+ guard playState != .finished else {
142
+ return
143
+ }
144
+
145
+ let values = sampleValues(at: duration)
146
+ resetTimingState()
147
+ playState = .finished
148
+ finished = true
149
+ applySample(values)
150
+ releaseMask()
151
+ emitStateChanged(action: "finish", playState: .finished, values: values)
152
+ }
153
+
154
+ func tick(at timestamp: CFTimeInterval) {
155
+ guard playState == .running else {
156
+ return
157
+ }
158
+
159
+ guard shouldStartAnimation(at: timestamp) else {
160
+ return
161
+ }
162
+
163
+ if !hasEmittedStartEvent {
164
+ let values = currentValues(at: timestamp)
165
+ applySample(values)
166
+ emitStartEventIfNeeded(values: values)
167
+ if isIterationComplete(at: timestamp) {
168
+ let completedValues = sampleValues(at: duration)
169
+ applySample(completedValues)
170
+ playState = .finished
171
+ finished = true
172
+ releaseMask()
173
+ emitStateChanged(action: "complete", playState: .finished, values: completedValues)
174
+ }
175
+ return
176
+ }
177
+
178
+ if isIterationComplete(at: timestamp) {
179
+ switch loopConfig {
180
+ case .none:
181
+ let values = sampleValues(at: duration)
182
+ applySample(values)
183
+ playState = .finished
184
+ finished = true
185
+ releaseMask()
186
+ emitStateChanged(action: "complete", playState: .finished, values: values)
187
+ case .resetLoop:
188
+ applySample(sampleValues(at: 0))
189
+ resetForNextIteration(at: timestamp)
190
+ case .reverseLoop:
191
+ isReversed.toggle()
192
+ resetForNextIteration(at: timestamp)
193
+ }
194
+ return
195
+ }
196
+
197
+ applySample(currentValues(at: timestamp))
198
+ }
199
+
200
+ override func onDestroy() {
201
+ playState = .idle
202
+ finished = false
203
+ releaseMask()
204
+ emitStateChanged(action: "destroy", playState: .idle)
205
+ }
206
+
207
+ private func startFreshPlayback(at timestamp: CFTimeInterval) {
208
+ createdTime = timestamp
209
+ startTime = timestamp
210
+ pausedDuration = 0
211
+ pauseStartTime = 0
212
+ delayCompleted = delay <= 0
213
+ isReversed = false
214
+ finished = false
215
+ hasEmittedStartEvent = false
216
+ guard lockMask() else {
217
+ playState = .idle
218
+ emitStateChanged(
219
+ action: "error",
220
+ playState: .idle,
221
+ values: currentValues(at: timestamp),
222
+ error: SpatializedElementAnimationErrorPayload(
223
+ code: "mask-conflict",
224
+ message: "Another animation is already writing this target"
225
+ )
226
+ )
227
+ return
228
+ }
229
+ playState = .running
230
+ let startValues = sampleValues(at: 0)
231
+ applySample(startValues)
232
+ emitStateChanged(action: "play", playState: .running, values: startValues)
233
+ if delay <= 0 {
234
+ emitStartEventIfNeeded(values: startValues)
235
+ }
236
+ }
237
+
238
+ private func lockMask() -> Bool {
239
+ guard let element = targetElement else { return false }
240
+ return writeAdapter.acquireMask(
241
+ on: element,
242
+ animationId: uuid,
243
+ animatesTransform: animatesTransform,
244
+ animatesOpacity: animatesOpacity
245
+ )
246
+ }
247
+
248
+ private func releaseMask() {
249
+ guard let element = targetElement else { return }
250
+ writeAdapter.releaseMaskAndApplyPending(on: element, animationId: uuid)
251
+ }
252
+
253
+ private func currentValues(at timestamp: CFTimeInterval) -> SpatializedMotionValuesPayload {
254
+ let progress = currentProgress(at: timestamp)
255
+ return sampleValues(at: progress * duration)
256
+ }
257
+
258
+ private func sampleValues(at timeSec: Double) -> SpatializedMotionValuesPayload {
259
+ let sample = timelineSampler.sampleTransformAndOpacity(at: timeSec)
260
+ return buildValuesPayload(
261
+ transform: sample.transform,
262
+ opacity: sample.opacity,
263
+ animatesTransform: animatesTransform,
264
+ animatesOpacity: animatesOpacity
265
+ )
266
+ }
267
+
268
+ private func applySample(_ values: SpatializedMotionValuesPayload) {
269
+ guard let element = targetElement else { return }
270
+
271
+ if let transform = values.transform,
272
+ writeAdapter.shouldAllowTransformWrite(on: element, animationId: uuid)
273
+ {
274
+ let components = SpatializedMotionTransformComponents(
275
+ translateX: transform.translate?.x ?? 0,
276
+ translateY: transform.translate?.y ?? 0,
277
+ translateZ: transform.translate?.z ?? 0,
278
+ rotateX: transform.rotate?.x ?? 0,
279
+ rotateY: transform.rotate?.y ?? 0,
280
+ rotateZ: transform.rotate?.z ?? 0,
281
+ scaleX: transform.scale?.x ?? 1,
282
+ scaleY: transform.scale?.y ?? 1,
283
+ scaleZ: transform.scale?.z ?? 1
284
+ )
285
+ writeAdapter.applyAffine(SpatializedElementAnimationManager.composeTransform(components), to: element)
286
+ }
287
+
288
+ if let opacity = values.opacity,
289
+ writeAdapter.shouldAllowOpacityWrite(on: element, animationId: uuid)
290
+ {
291
+ writeAdapter.applyOpacity(opacity, to: element)
292
+ }
293
+ }
294
+
295
+ private func buildValuesPayload(
296
+ transform: SpatializedMotionTransformComponents,
297
+ opacity: Double,
298
+ animatesTransform: Bool,
299
+ animatesOpacity: Bool
300
+ ) -> SpatializedMotionValuesPayload {
301
+ var transformPayload: SpatializedMotionTransformPayload? = nil
302
+ var opacityPayload: Double? = nil
303
+
304
+ if animatesTransform {
305
+ transformPayload = SpatializedMotionTransformPayload(
306
+ translate: Vec3Payload(x: transform.translateX, y: transform.translateY, z: transform.translateZ),
307
+ rotate: Vec3Payload(x: transform.rotateX, y: transform.rotateY, z: transform.rotateZ),
308
+ scale: Vec3Payload(x: transform.scaleX, y: transform.scaleY, z: transform.scaleZ)
309
+ )
310
+ }
311
+
312
+ if animatesOpacity {
313
+ opacityPayload = opacity
314
+ }
315
+
316
+ return SpatializedMotionValuesPayload(
317
+ transform: transformPayload,
318
+ opacity: opacityPayload
319
+ )
320
+ }
321
+
322
+ private func emitStateChanged(
323
+ action: String,
324
+ playState: SpatializedElementAnimationPlayState,
325
+ values: SpatializedMotionValuesPayload? = nil,
326
+ error: SpatializedElementAnimationErrorPayload? = nil
327
+ ) {
328
+ guard let sendWebMsg else { return }
329
+ sendWebMsg(uuid, SpatialAnimationStateChanged(
330
+ animationId: uuid,
331
+ action: action,
332
+ playState: playState,
333
+ finished: finished,
334
+ values: values,
335
+ error: error
336
+ ))
337
+ }
338
+
339
+ private func shouldStartAnimation(at timestamp: CFTimeInterval) -> Bool {
340
+ if delayCompleted {
341
+ return true
342
+ }
343
+ let elapsed = timestamp - createdTime - pausedDuration
344
+ if elapsed >= delay {
345
+ delayCompleted = true
346
+ startTime = timestamp
347
+ // Delay-window pauses are already accounted for above; active playback starts with a fresh pause clock.
348
+ pausedDuration = 0
349
+ pauseStartTime = 0
350
+ return true
351
+ }
352
+ return false
353
+ }
354
+
355
+ /// Emits a one-time start lifecycle event when playback reaches its first sampled frame.
356
+ private func emitStartEventIfNeeded(values: SpatializedMotionValuesPayload) {
357
+ guard !hasEmittedStartEvent else {
358
+ return
359
+ }
360
+ hasEmittedStartEvent = true
361
+ emitStateChanged(action: "start", playState: .running, values: values)
362
+ }
363
+
364
+ private func currentProgress(at timestamp: CFTimeInterval) -> Double {
365
+ let raw = rawProgress(at: timestamp)
366
+ let eased = timingFunction.evaluate(raw)
367
+ return isReversed ? (1.0 - eased) : eased
368
+ }
369
+
370
+ private func rawProgress(at timestamp: CFTimeInterval) -> Double {
371
+ guard delayCompleted else { return 0 }
372
+ let elapsed = (samplingTimestamp(for: timestamp) - startTime - pausedDuration) * speed
373
+ return min(max(elapsed / duration, 0.0), 1.0)
374
+ }
375
+
376
+ private func isIterationComplete(at timestamp: CFTimeInterval) -> Bool {
377
+ guard delayCompleted else { return false }
378
+ let elapsed = (timestamp - startTime - pausedDuration) * speed
379
+ return elapsed >= duration
380
+ }
381
+
382
+ private func resetForNextIteration(at timestamp: CFTimeInterval) {
383
+ startTime = timestamp
384
+ pausedDuration = 0
385
+ }
386
+
387
+ private func resetTimingState() {
388
+ startTime = 0
389
+ pausedDuration = 0
390
+ pauseStartTime = 0
391
+ createdTime = 0
392
+ delayCompleted = false
393
+ isReversed = false
394
+ }
395
+
396
+ private func samplingTimestamp(for timestamp: CFTimeInterval) -> CFTimeInterval {
397
+ isPaused ? pauseStartTime : timestamp
398
+ }
399
+ }
@@ -0,0 +1,45 @@
1
+ import Foundation
2
+
3
+ enum SpatializedElementAnimationTargetKind: String, Codable {
4
+ case spatialized2d
5
+ case static3d
6
+ case dynamic3d
7
+ }
8
+
9
+ enum SpatializedElementAnimationPlayState: String, Codable {
10
+ case idle
11
+ case running
12
+ case paused
13
+ case finished
14
+ }
15
+
16
+ struct SpatializedElementAnimationErrorPayload: Codable {
17
+ let code: String
18
+ let message: String
19
+ }
20
+
21
+ struct SpatialAnimationStateChanged: Encodable {
22
+ let type: SpatialWebMsgType = .animationstatechange
23
+ let animationId: String
24
+ let action: String
25
+ let playState: SpatializedElementAnimationPlayState
26
+ let finished: Bool
27
+ let values: SpatializedMotionValuesPayload?
28
+ let error: SpatializedElementAnimationErrorPayload?
29
+
30
+ init(
31
+ animationId: String,
32
+ action: String,
33
+ playState: SpatializedElementAnimationPlayState,
34
+ finished: Bool,
35
+ values: SpatializedMotionValuesPayload? = nil,
36
+ error: SpatializedElementAnimationErrorPayload? = nil
37
+ ) {
38
+ self.animationId = animationId
39
+ self.action = action
40
+ self.playState = playState
41
+ self.finished = finished
42
+ self.values = values
43
+ self.error = error
44
+ }
45
+ }