Blender4iOS/Sources/Animation/AnimationData.swift

423 lines
14 KiB
Swift
Raw Normal View History

Animation Data Model Mirrors Blender's internal structure exactly: Keyframe — stores frame, value, handleLeft/handleRight (absolute SIMD2 coordinates for Bézier control points), handleType (Free/Aligned/Vector/Auto/Auto Clamped), and interpolation mode (Constant/Linear/Bézier/Bounce/Elastic/Back). The autoComputeHandles() method computes smooth tangents from neighboring keyframes, matching Blender's BKE_fcurve_correct_bezpart. moveHandleLeft/moveHandleRight enforce the Aligned constraint (co-linear handles). AnimationTrack (= Blender's FCurve) — one channel like location.X. Contains a sorted keyframe array. The evaluate(at:) method performs full cubic Bézier interpolation using Newton-Raphson iteration (8 steps, matching Blender's precision) to solve for the parametric t given a frame x-coordinate, then evaluates the y-coordinate. Colors follow Blender's convention: X=red, Y=green, Z=blue. AnimAction (= Blender's bAction) — groups multiple tracks into a reusable animation data block. evaluate(at:) returns all channel values as a dictionary. AnimationSystem — owns the action library, playback state (currentFrame, fps, loop mode), and applyToObject() to push evaluated transforms onto a SceneObject. Dope Sheet (DopeSheetView.swift) Faithful to Blender's Dope Sheet layout: Left channel list with colored track indicators (red/green/blue bars), mute (speaker icon) and lock buttons per channel Summary track at top showing all keyframes merged as yellow diamonds Per-channel tracks with diamond keyframes (yellow = normal, white = selected) Frame ruler at top with major ticks every 10 frames, minor every 5, and frame number labels Blue playhead line with triangular marker — tap the ruler to scrub Keyframe interaction: tap to select, drag to move (snaps to integer frames), auto-recomputes handles after drag Pinch to zoom the frame axis, horizontal drag to scroll Graph Editor (GraphEditorView.swift) The most complex view — a full interactive F-Curve editor: Adaptive grid with auto-scaling step sizes (1/2/5/10 pattern), axis lines highlighted, frame numbers on bottom, value numbers on left F-Curve rendering as cubic Bézier paths using SwiftUI's addCurve(to:control1:control2:), with flat extensions before/after the first/last keyframes. Constant interpolation draws step functions, Linear draws straight lines. Keyframe diamonds colored per-channel (red/green/blue), white when selected Bézier handles — visible only on selected keyframes, drawn as circles connected to the diamond by lines. Supports: Drag the diamond to move the keyframe (frame snaps to integer, value is free) Drag the left handle to change the incoming tangent Drag the right handle to change the outgoing tangent Aligned mode: moving one handle mirrors the other Hit testing with 14pt touch radius, prioritizing handles over keyframes over timeline scrubbing Info overlay showing current frame/value coordinates during drag Channel list overlay with per-track visibility toggles Transport Controls (AnimationPanelView.swift) Matches Blender's timeline footer pixel-for-pixel: Editor switcher — segmented control toggling between Dope Sheet and Graph Editor Action name display with film icon Interpolation menu — set selected keyframes to Constant/Linear/Bézier/Bounce/Elastic/Back Handle type menu — set selected handles to Free/Aligned/Vector/Auto/Auto Clamped Insert keyframe button (yellow diamond icon) Transport bar: Jump to Start, Previous Keyframe, Step Back, Play/Pause (highlighted blue when playing), Step Forward, Next Keyframe, Jump to End Frame range display (Start/End) Current frame in blue monospaced bold FPS indicator and loop toggle Playback Engine (AnimationPlayer.swift) CADisplayLink-driven with preferredFrameRateRange set to 30–120fps. Delta-time accumulation advances currentFrame at the configured FPS rate. nextKeyframe()/previousKeyframe() jump between actual keyframe positions. To see it: tap the Animation workspace tab, or tap the play icon in any workspace header to toggle the timeline panel. The default "CubeAction" ships with sample keyframes on all three location channels so you'll see curves immediately in the Graph Editor. Tap a keyframe diamond, then drag its Bézier handles to reshape the easing.
2026-04-10 21:52:58 -07:00
// 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<Float>
/// Right handle (outgoing tangent), in absolute coordinates
var handleRight: SIMD2<Float>
/// 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<Float>(frame - 5, value)
self.handleRight = SIMD2<Float>(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<Float>(frame - dt, value - slope * dt)
handleRight = SIMD2<Float>(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<Float>(frame - dt, value - slope * dt)
handleRight = SIMD2<Float>(frame + dt, value)
} else if let next {
let slope = (next.value - value) / (next.frame - frame)
handleLeft = SIMD2<Float>(frame - dt, value)
handleRight = SIMD2<Float>(frame + dt, value + slope * dt)
} else {
handleLeft = SIMD2<Float>(frame - dt, value)
handleRight = SIMD2<Float>(frame + dt, value)
}
}
/// Move handle maintaining aligned constraint if needed.
func moveHandleLeft(to pos: SIMD2<Float>) {
handleLeft = pos
if handleType == .aligned {
let delta = SIMD2<Float>(frame, value) - pos
handleRight = SIMD2<Float>(frame, value) + delta
}
}
func moveHandleRight(to pos: SIMD2<Float>) {
handleRight = pos
if handleType == .aligned {
let delta = SIMD2<Float>(frame, value) - pos
handleLeft = SIMD2<Float>(frame, value) + delta
}
}
}
extension Comparable {
func clamped(to range: ClosedRange<Self>) -> 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<Float> {
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<Float>(kf0.frame, kf0.value)
let p1 = kf0.handleRight
let p2 = kf1.handleLeft
let p3 = SIMD2<Float>(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..<sorted.count {
let prev = i > 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<Float>(loc[0], loc[1], loc[2])
}
if let rot = values["rotation_euler"] {
obj.transform.rotation = SIMD3<Float>(rot[0], rot[1], rot[2])
}
if let scl = values["scale"] {
obj.transform.scale = SIMD3<Float>(scl[0], scl[1], scl[2])
}
}
}