@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.
- package/package.json +2 -2
- package/web-spatial/JSBCommand.swift +12 -0
- package/web-spatial/WebMsgCommand.swift +29 -2
- package/web-spatial/WebSpatialApp.swift +26 -8
- package/web-spatial/manager/Dynamic3DManager.swift +97 -37
- package/web-spatial/manifest.swift +2 -1
- package/web-spatial/model/MaterialSceneRefresh.swift +41 -0
- package/web-spatial/model/SpatialScene.swift +255 -45
- package/web-spatial/model/Spatialized2DElement.swift +2 -11
- package/web-spatial/model/SpatializedElement.swift +38 -0
- package/web-spatial/model/SpatializedStatic3DElement.swift +31 -4
- package/web-spatial/model/dynamic3d/EntityAnimationManager.swift +356 -0
- package/web-spatial/model/dynamic3d/EntityAnimationSession.swift +162 -0
- package/web-spatial/model/dynamic3d/SpatialMaterial.swift +26 -10
- package/web-spatial/model/dynamic3d/SpatialModelEntity.swift +5 -3
- package/web-spatial/model/dynamic3d/SpatialTextureResource.swift +25 -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 +104 -43
- 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 +30 -17
- 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,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
|
+
}
|