// AnimationData.swift // Core animation data model — mirrors Blender's FCurve / Keyframe / Action system. import Observation import simd // MARK: - Interpolation & Easing (matches Blender's enum) enum KeyframeInterpolation: String, CaseIterable, Identifiable { case constant = "Constant" // BEZT_IPO_CONST — holds value until next keyframe case linear = "Linear" // BEZT_IPO_LIN — straight line case bezier = "Bézier" // BEZT_IPO_BEZ — cubic Bézier case bounce = "Bounce" case elastic = "Elastic" case back = "Back" var id: String { rawValue } } enum HandleType: String, CaseIterable, Identifiable { case free = "Free" // independent handle control case aligned = "Aligned" // handles are co-linear (mirrored angle) case vector = "Vector" // auto-computed to point at neighbor case auto = "Auto" // auto-smooth (Blender default) case autoClamp = "Auto Clamped" // auto but clamped to prevent overshoot var id: String { rawValue } } // MARK: - Keyframe @Observable final class Keyframe: Identifiable { let id = UUID() /// Frame number (Blender uses integer frames, we allow float for sub-frame) var frame: Float /// Value at this keyframe var value: Float /// Left handle (incoming tangent), in absolute coordinates var handleLeft: SIMD2 /// Right handle (outgoing tangent), in absolute coordinates var handleRight: SIMD2 /// Handle type var handleType: HandleType = .auto /// Interpolation mode TO the next keyframe var interpolation: KeyframeInterpolation = .bezier /// Selection state var isSelected: Bool = false var isHandleLeftSelected: Bool = false var isHandleRightSelected: Bool = false init(frame: Float, value: Float) { self.frame = frame self.value = value // Default handles: horizontal, 1/3 of typical frame spacing self.handleLeft = SIMD2(frame - 5, value) self.handleRight = SIMD2(frame + 5, value) } /// Auto-compute handles for smooth interpolation (Blender's "Auto" mode). func autoComputeHandles(prev: Keyframe?, next: Keyframe?) { guard handleType == .auto || handleType == .autoClamp else { return } let dt: Float = 5.0 if let prev, let next { // Slope through neighbors let slope = (next.value - prev.value) / (next.frame - prev.frame) handleLeft = SIMD2(frame - dt, value - slope * dt) handleRight = SIMD2(frame + dt, value + slope * dt) // Auto-clamp: prevent overshoot if handleType == .autoClamp { let minV = min(prev.value, next.value) let maxV = max(prev.value, next.value) handleLeft.y = handleLeft.y.clamped(to: minV...maxV) handleRight.y = handleRight.y.clamped(to: minV...maxV) } } else if let prev { let slope = (value - prev.value) / (frame - prev.frame) handleLeft = SIMD2(frame - dt, value - slope * dt) handleRight = SIMD2(frame + dt, value) } else if let next { let slope = (next.value - value) / (next.frame - frame) handleLeft = SIMD2(frame - dt, value) handleRight = SIMD2(frame + dt, value + slope * dt) } else { handleLeft = SIMD2(frame - dt, value) handleRight = SIMD2(frame + dt, value) } } /// Move handle maintaining aligned constraint if needed. func moveHandleLeft(to pos: SIMD2) { handleLeft = pos if handleType == .aligned { let delta = SIMD2(frame, value) - pos handleRight = SIMD2(frame, value) + delta } } func moveHandleRight(to pos: SIMD2) { handleRight = pos if handleType == .aligned { let delta = SIMD2(frame, value) - pos handleLeft = SIMD2(frame, value) + delta } } } extension Comparable { func clamped(to range: ClosedRange) -> Self { min(max(self, range.lowerBound), range.upperBound) } } // MARK: - Animation Track (single F-Curve) /// Represents one animated channel, e.g. "location.x" or "rotation.z". /// Mirrors Blender's FCurve. @Observable final class AnimationTrack: Identifiable { let id = UUID() let channelPath: String // e.g. "location", "rotation_euler" let channelIndex: Int // 0=X, 1=Y, 2=Z var keyframes: [Keyframe] = [] var isMuted: Bool = false var isLocked: Bool = false var isVisible: Bool = true // visibility in graph editor /// Display color matching Blender convention var color: SIMD4 { switch channelIndex { case 0: SIMD4(0.86, 0.20, 0.27, 1) // X = red case 1: SIMD4(0.30, 0.69, 0.31, 1) // Y = green case 2: SIMD4(0.28, 0.46, 0.86, 1) // Z = blue default: SIMD4(0.7, 0.7, 0.7, 1) // W/custom = grey } } var displayName: String { let axis = ["X", "Y", "Z", "W"] let idx = channelIndex < axis.count ? axis[channelIndex] : "\(channelIndex)" return "\(channelPath).\(idx)" } init(channelPath: String, channelIndex: Int) { self.channelPath = channelPath self.channelIndex = channelIndex } /// Sorted keyframes (must be sorted for evaluation). var sortedKeyframes: [Keyframe] { keyframes.sorted { $0.frame < $1.frame } } /// Insert a keyframe, auto-computing handles. func insertKeyframe(frame: Float, value: Float) { // Remove existing keyframe at same frame keyframes.removeAll { abs($0.frame - frame) < 0.001 } let kf = Keyframe(frame: frame, value: value) keyframes.append(kf) recomputeAutoHandles() } /// Evaluate the curve at a given frame using cubic Bézier interpolation. func evaluate(at frame: Float) -> Float { let sorted = sortedKeyframes guard !sorted.isEmpty else { return 0 } // Before first keyframe if frame <= sorted.first!.frame { return sorted.first!.value } // After last keyframe if frame >= sorted.last!.frame { return sorted.last!.value } // Find surrounding keyframes for i in 0..<(sorted.count - 1) { let kf0 = sorted[i] let kf1 = sorted[i + 1] if frame >= kf0.frame && frame <= kf1.frame { return interpolate(from: kf0, to: kf1, at: frame) } } return sorted.last!.value } // MARK: - Bézier interpolation private func interpolate(from kf0: Keyframe, to kf1: Keyframe, at frame: Float) -> Float { switch kf0.interpolation { case .constant: return kf0.value case .linear: let t = (frame - kf0.frame) / (kf1.frame - kf0.frame) return kf0.value + (kf1.value - kf0.value) * t case .bezier: return bezierEvaluate(kf0: kf0, kf1: kf1, frame: frame) case .bounce: let t = (frame - kf0.frame) / (kf1.frame - kf0.frame) let bt = bounceEaseOut(t) return kf0.value + (kf1.value - kf0.value) * bt case .elastic: let t = (frame - kf0.frame) / (kf1.frame - kf0.frame) let et = elasticEaseOut(t) return kf0.value + (kf1.value - kf0.value) * et case .back: let t = (frame - kf0.frame) / (kf1.frame - kf0.frame) let bt = backEaseOut(t) return kf0.value + (kf1.value - kf0.value) * bt } } /// Evaluate cubic Bézier — solves for t given x, then evaluates y. /// Matches Blender's BKE_fcurve_correct_bezpart approach. private func bezierEvaluate(kf0: Keyframe, kf1: Keyframe, frame: Float) -> Float { let p0 = SIMD2(kf0.frame, kf0.value) let p1 = kf0.handleRight let p2 = kf1.handleLeft let p3 = SIMD2(kf1.frame, kf1.value) // Find t for the given frame (x coordinate) using Newton-Raphson let t = solveBezierT(x: frame, p0x: p0.x, p1x: p1.x, p2x: p2.x, p3x: p3.x) // Evaluate y at found t let oneMinusT = 1.0 - t let y = oneMinusT * oneMinusT * oneMinusT * p0.y + 3 * oneMinusT * oneMinusT * t * p1.y + 3 * oneMinusT * t * t * p2.y + t * t * t * p3.y return y } /// Newton-Raphson solver to find parameter t for a given x on the Bézier. private func solveBezierT(x: Float, p0x: Float, p1x: Float, p2x: Float, p3x: Float) -> Float { // Initial guess: linear var t = (x - p0x) / max(p3x - p0x, 0.0001) t = t.clamped(to: 0...1) // 8 iterations of Newton-Raphson (matches Blender's precision) for _ in 0..<8 { let omt = 1.0 - t let currentX = omt * omt * omt * p0x + 3 * omt * omt * t * p1x + 3 * omt * t * t * p2x + t * t * t * p3x let dx = 3 * omt * omt * (p1x - p0x) + 6 * omt * t * (p2x - p1x) + 3 * t * t * (p3x - p2x) if abs(dx) < 1e-8 { break } t -= (currentX - x) / dx t = t.clamped(to: 0...1) } return t } // MARK: - Easing functions private func bounceEaseOut(_ t: Float) -> Float { if t < 1.0 / 2.75 { return 7.5625 * t * t } else if t < 2.0 / 2.75 { let t2 = t - 1.5 / 2.75 return 7.5625 * t2 * t2 + 0.75 } else if t < 2.5 / 2.75 { let t2 = t - 2.25 / 2.75 return 7.5625 * t2 * t2 + 0.9375 } else { let t2 = t - 2.625 / 2.75 return 7.5625 * t2 * t2 + 0.984375 } } private func elasticEaseOut(_ t: Float) -> Float { if t <= 0 { return 0 } if t >= 1 { return 1 } return powf(2, -10 * t) * sinf((t - 0.075) * (2 * .pi) / 0.3) + 1 } private func backEaseOut(_ t: Float) -> Float { let s: Float = 1.70158 let t2 = t - 1 return t2 * t2 * ((s + 1) * t2 + s) + 1 } // MARK: - Handle computation func recomputeAutoHandles() { let sorted = sortedKeyframes for i in 0.. 0 ? sorted[i - 1] : nil let next = i < sorted.count - 1 ? sorted[i + 1] : nil sorted[i].autoComputeHandles(prev: prev, next: next) } } } // MARK: - Action (collection of tracks for one object) /// Mirrors Blender's bAction — a reusable animation data block. @Observable final class AnimAction: Identifiable { let id = UUID() var name: String var tracks: [AnimationTrack] = [] /// Frame range var frameStart: Float { tracks.flatMap { $0.sortedKeyframes }.map(\.frame).min() ?? 1 } var frameEnd: Float { tracks.flatMap { $0.sortedKeyframes }.map(\.frame).max() ?? 250 } init(name: String) { self.name = name } func track(path: String, index: Int) -> AnimationTrack { if let existing = tracks.first(where: { $0.channelPath == path && $0.channelIndex == index }) { return existing } let new = AnimationTrack(channelPath: path, channelIndex: index) tracks.append(new) return new } /// Evaluate all tracks at a given frame, returns a dictionary of values. func evaluate(at frame: Float) -> [String: [Float]] { var result: [String: [Float]] = [:] for track in tracks where !track.isMuted { var arr = result[track.channelPath] ?? [0, 0, 0] if track.channelIndex < arr.count { arr[track.channelIndex] = track.evaluate(at: frame) } result[track.channelPath] = arr } return result } } // MARK: - Animation System @Observable final class AnimationSystem { var actions: [AnimAction] = [] var activeAction: AnimAction? // Playback state var currentFrame: Float = 1 var isPlaying: Bool = false var fps: Float = 24 var frameStart: Float = 1 var frameEnd: Float = 250 var isLooping: Bool = true // Display var showDopeSheet: Bool = true // false = show graph editor init() { // Create a default action with sample keyframes let action = AnimAction(name: "CubeAction") let locX = action.track(path: "location", index: 0) locX.insertKeyframe(frame: 1, value: 0) locX.insertKeyframe(frame: 30, value: 3) locX.insertKeyframe(frame: 60, value: -2) locX.insertKeyframe(frame: 100, value: 0) let locY = action.track(path: "location", index: 1) locY.insertKeyframe(frame: 1, value: 0) locY.insertKeyframe(frame: 50, value: 4) locY.insertKeyframe(frame: 100, value: 0) let locZ = action.track(path: "location", index: 2) locZ.insertKeyframe(frame: 1, value: 0) locZ.insertKeyframe(frame: 40, value: 2) locZ.insertKeyframe(frame: 80, value: -1) locZ.insertKeyframe(frame: 100, value: 0) actions.append(action) activeAction = action } /// Advance one frame (called from CADisplayLink). func tick(dt: Float) { guard isPlaying else { return } currentFrame += dt * fps if currentFrame > frameEnd { currentFrame = isLooping ? frameStart : frameEnd if !isLooping { isPlaying = false } } } /// Apply the current frame's animation to a SceneObject. func applyToObject(_ obj: SceneObject) { guard let action = activeAction else { return } let values = action.evaluate(at: currentFrame) if let loc = values["location"] { obj.transform.position = SIMD3(loc[0], loc[1], loc[2]) } if let rot = values["rotation_euler"] { obj.transform.rotation = SIMD3(rot[0], rot[1], rot[2]) } if let scl = values["scale"] { obj.transform.scale = SIMD3(scl[0], scl[1], scl[2]) } } }