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.
This commit is contained in:
Dallas Groot 2026-04-10 21:52:58 -07:00
parent 07d217a3d8
commit f947abd77b
8 changed files with 1598 additions and 1 deletions

View file

@ -0,0 +1,422 @@
// 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])
}
}
}

View file

@ -0,0 +1,254 @@
// AnimationPanelView.swift
// Combined animation panel: transport bar + Dope Sheet / Graph Editor toggle.
// Designed to slot into the bottom of any workspace layout.
import SwiftUI
struct AnimationPanelView: View {
let animSystem: AnimationSystem
@State private var player: AnimationPlayer?
@State private var showGraphEditor = false
var body: some View {
VStack(spacing: 0) {
// Editor switcher + Toolbar
editorToolbar
Divider().background(Color.white.opacity(0.1))
// Dope Sheet or Graph Editor
Group {
if showGraphEditor {
GraphEditorView(animSystem: animSystem)
} else {
DopeSheetView(
animSystem: animSystem,
player: player ?? AnimationPlayer(system: animSystem)
)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider().background(Color.white.opacity(0.1))
// Transport controls
transportBar
}
.background(Color(red: 0.15, green: 0.15, blue: 0.15))
.onAppear {
if player == nil {
player = AnimationPlayer(system: animSystem)
}
}
}
// MARK: - Editor toolbar (matches Blender's editor type selector)
private var editorToolbar: some View {
HStack(spacing: 8) {
// Editor type switcher
Picker("Editor", selection: $showGraphEditor) {
Label("Dope Sheet", systemImage: "rectangle.split.3x1")
.tag(false)
Label("Graph Editor", systemImage: "chart.xyaxis.line")
.tag(true)
}
.pickerStyle(.segmented)
.frame(width: 220)
Divider().frame(height: 16)
if let action = animSystem.activeAction {
// Action name
HStack(spacing: 4) {
Image(systemName: "film")
.font(.system(size: 10))
.foregroundStyle(.orange)
Text(action.name)
.font(.system(size: 11, weight: .medium))
}
}
Spacer()
// Interpolation mode for selected keyframes
Menu {
ForEach(KeyframeInterpolation.allCases) { interp in
Button {
setSelectedInterpolation(interp)
} label: {
Text(interp.rawValue)
}
}
} label: {
Label("Interpolation", systemImage: "point.topleft.down.to.point.bottomright.curvepath")
.font(.system(size: 10))
}
// Handle type for selected keyframes
Menu {
ForEach(HandleType.allCases) { ht in
Button {
setSelectedHandleType(ht)
} label: {
Text(ht.rawValue)
}
}
} label: {
Label("Handles", systemImage: "point.bottomleft.forward.to.arrowtriangle.uturn.scurvepath")
.font(.system(size: 10))
}
// Insert keyframe
Button {
insertKeyframeAtCurrent()
} label: {
Image(systemName: "diamond.fill")
.font(.system(size: 10))
.foregroundStyle(.yellow)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(Color(red: 0.17, green: 0.17, blue: 0.17))
}
// MARK: - Transport controls (Blender style)
private var transportBar: some View {
HStack(spacing: 0) {
// Left: frame range
HStack(spacing: 4) {
Text("Start:")
.font(.system(size: 9))
.foregroundStyle(.secondary)
Text(String(format: "%.0f", animSystem.frameStart))
.font(.system(size: 10, design: .monospaced))
.frame(width: 36)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 3))
Text("End:")
.font(.system(size: 9))
.foregroundStyle(.secondary)
Text(String(format: "%.0f", animSystem.frameEnd))
.font(.system(size: 10, design: .monospaced))
.frame(width: 36)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 3))
}
.padding(.horizontal, 12)
Spacer()
// Center: transport buttons
HStack(spacing: 2) {
transportButton(icon: "backward.end.fill") { player?.jumpToStart() }
transportButton(icon: "chevron.backward") { player?.previousKeyframe() }
transportButton(icon: "backward.frame.fill") { player?.stepBackward() }
// Play/Pause (larger, highlighted)
Button { player?.togglePlayback() } label: {
Image(systemName: animSystem.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 14))
.frame(width: 36, height: 28)
.background(
animSystem.isPlaying
? Color(red: 0.30, green: 0.55, blue: 0.95).opacity(0.3)
: Color.white.opacity(0.08),
in: RoundedRectangle(cornerRadius: 4)
)
.foregroundStyle(animSystem.isPlaying ? .blue : .primary)
}
.buttonStyle(.plain)
transportButton(icon: "forward.frame.fill") { player?.stepForward() }
transportButton(icon: "chevron.forward") { player?.nextKeyframe() }
transportButton(icon: "forward.end.fill") { player?.jumpToEnd() }
}
Spacer()
// Right: current frame + FPS
HStack(spacing: 8) {
// Current frame
HStack(spacing: 2) {
Image(systemName: "film")
.font(.system(size: 9))
.foregroundStyle(.secondary)
Text(String(format: "%.0f", animSystem.currentFrame))
.font(.system(size: 11, weight: .bold, design: .monospaced))
.foregroundStyle(Color(red: 0.30, green: 0.55, blue: 0.95))
.frame(width: 40)
}
Divider().frame(height: 16)
// FPS
HStack(spacing: 2) {
Text("\(Int(animSystem.fps))")
.font(.system(size: 10, design: .monospaced))
Text("fps")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
// Loop toggle
Button {
animSystem.isLooping.toggle()
} label: {
Image(systemName: "repeat")
.font(.system(size: 10))
.foregroundStyle(animSystem.isLooping ? .orange : .secondary)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 12)
}
.padding(.vertical, 6)
.background(Color(red: 0.14, green: 0.14, blue: 0.14))
}
private func transportButton(icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 10))
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
// MARK: - Actions
private func setSelectedInterpolation(_ interp: KeyframeInterpolation) {
guard let action = animSystem.activeAction else { return }
for track in action.tracks {
for kf in track.keyframes where kf.isSelected {
kf.interpolation = interp
}
}
}
private func setSelectedHandleType(_ ht: HandleType) {
guard let action = animSystem.activeAction else { return }
for track in action.tracks {
for kf in track.keyframes where kf.isSelected {
kf.handleType = ht
}
track.recomputeAutoHandles()
}
}
private func insertKeyframeAtCurrent() {
guard let action = animSystem.activeAction else { return }
let frame = animSystem.currentFrame
for track in action.tracks {
let currentVal = track.evaluate(at: frame)
track.insertKeyframe(frame: frame, value: currentVal)
}
}
}

View file

@ -0,0 +1,119 @@
// AnimationPlayer.swift
// CADisplayLink-driven playback engine matching Blender's transport controls.
import UIKit
import Observation
@Observable
final class AnimationPlayer {
var animationSystem: AnimationSystem
private var displayLink: CADisplayLink?
private var lastTimestamp: CFTimeInterval = 0
init(system: AnimationSystem) {
self.animationSystem = system
}
// MARK: - Transport controls (mirror Blender's timeline buttons)
func play() {
animationSystem.isPlaying = true
startDisplayLink()
}
func pause() {
animationSystem.isPlaying = false
stopDisplayLink()
}
func stop() {
animationSystem.isPlaying = false
animationSystem.currentFrame = animationSystem.frameStart
stopDisplayLink()
}
func togglePlayback() {
if animationSystem.isPlaying {
pause()
} else {
play()
}
}
func jumpToStart() {
animationSystem.currentFrame = animationSystem.frameStart
}
func jumpToEnd() {
animationSystem.currentFrame = animationSystem.frameEnd
}
func stepForward() {
animationSystem.currentFrame = min(
animationSystem.currentFrame + 1,
animationSystem.frameEnd
)
}
func stepBackward() {
animationSystem.currentFrame = max(
animationSystem.currentFrame - 1,
animationSystem.frameStart
)
}
func nextKeyframe() {
guard let action = animationSystem.activeAction else { return }
let allFrames = action.tracks
.flatMap { $0.sortedKeyframes }
.map(\.frame)
.sorted()
if let next = allFrames.first(where: { $0 > animationSystem.currentFrame + 0.5 }) {
animationSystem.currentFrame = next
}
}
func previousKeyframe() {
guard let action = animationSystem.activeAction else { return }
let allFrames = action.tracks
.flatMap { $0.sortedKeyframes }
.map(\.frame)
.sorted()
if let prev = allFrames.last(where: { $0 < animationSystem.currentFrame - 0.5 }) {
animationSystem.currentFrame = prev
}
}
// MARK: - Display link
private func startDisplayLink() {
guard displayLink == nil else { return }
lastTimestamp = 0
let link = CADisplayLink(target: self, selector: #selector(tick))
link.preferredFrameRateRange = CAFrameRateRange(
minimum: 30, maximum: 120, preferred: 60
)
link.add(to: .main, forMode: .common)
displayLink = link
}
private func stopDisplayLink() {
displayLink?.invalidate()
displayLink = nil
}
@objc private func tick(_ link: CADisplayLink) {
if lastTimestamp == 0 {
lastTimestamp = link.timestamp
return
}
let dt = Float(link.timestamp - lastTimestamp)
lastTimestamp = link.timestamp
animationSystem.tick(dt: dt)
}
deinit {
stopDisplayLink()
}
}

View file

@ -0,0 +1,311 @@
// DopeSheetView.swift
// Blender-style Dope Sheet with diamond keyframes on horizontal tracks.
import SwiftUI
struct DopeSheetView: View {
let animSystem: AnimationSystem
let player: AnimationPlayer
// View state
@State private var frameOffset: Float = 0 // scroll position in frames
@State private var framesPerPoint: Float = 0.25 // zoom level
@State private var channelListWidth: CGFloat = 140
@State private var draggedKeyframe: (trackID: UUID, kfID: UUID)?
// Blender colors
private let bgColor = Color(red: 0.16, green: 0.16, blue: 0.16)
private let channelBg = Color(red: 0.20, green: 0.20, blue: 0.20)
private let channelBgAlt = Color(red: 0.18, green: 0.18, blue: 0.18)
private let gridLineColor = Color.white.opacity(0.06)
private let gridTextColor = Color.white.opacity(0.35)
private let playheadColor = Color(red: 0.30, green: 0.55, blue: 0.95)
private let keyframeColor = Color(red: 0.90, green: 0.78, blue: 0.30)
private let keyframeSelected = Color.white
private let headerBg = Color(red: 0.14, green: 0.14, blue: 0.14)
private let trackHeight: CGFloat = 22
private let headerHeight: CGFloat = 28
var body: some View {
GeometryReader { geo in
let totalWidth = geo.size.width
let timelineWidth = totalWidth - channelListWidth
VStack(spacing: 0) {
// Frame number header
HStack(spacing: 0) {
// Channel list header
Text("Channels")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
.frame(width: channelListWidth, height: headerHeight)
.background(headerBg)
// Frame numbers ruler
frameRuler(width: timelineWidth)
.frame(height: headerHeight)
.background(headerBg)
}
Divider().background(Color.white.opacity(0.1))
// Tracks
ScrollView(.vertical, showsIndicators: false) {
if let action = animSystem.activeAction {
LazyVStack(spacing: 0) {
// Summary track
summaryTrack(action: action, width: timelineWidth)
// Per-channel tracks
ForEach(Array(action.tracks.enumerated()), id: \.element.id) { idx, track in
channelTrack(
track: track,
index: idx,
width: timelineWidth
)
}
}
}
}
}
.background(bgColor)
.gesture(zoomGesture)
.gesture(scrollGesture)
}
}
// MARK: - Frame ruler
private func frameRuler(width: CGFloat) -> some View {
Canvas { context, size in
let startFrame = Int(frameOffset)
let endFrame = startFrame + Int(Float(width) * framesPerPoint) + 1
// Major ticks every 10 frames, minor every 5
for f in startFrame...endFrame {
let x = CGFloat(Float(f) - frameOffset) / CGFloat(framesPerPoint)
guard x >= 0 && x <= size.width else { continue }
if f % 10 == 0 {
// Major tick + label
context.stroke(
Path { p in p.move(to: .init(x: x, y: size.height - 8)); p.addLine(to: .init(x: x, y: size.height)) },
with: .color(.white.opacity(0.3)),
lineWidth: 1
)
context.draw(
Text("\(f)").font(.system(size: 9)).foregroundStyle(gridTextColor),
at: CGPoint(x: x, y: size.height - 16)
)
} else if f % 5 == 0 {
context.stroke(
Path { p in p.move(to: .init(x: x, y: size.height - 4)); p.addLine(to: .init(x: x, y: size.height)) },
with: .color(.white.opacity(0.15)),
lineWidth: 0.5
)
}
}
// Playhead marker
let phX = CGFloat(animSystem.currentFrame - frameOffset) / CGFloat(framesPerPoint)
if phX >= 0 && phX <= size.width {
// Triangle marker
let tri = Path { p in
p.move(to: .init(x: phX - 6, y: 0))
p.addLine(to: .init(x: phX + 6, y: 0))
p.addLine(to: .init(x: phX, y: 10))
p.closeSubpath()
}
context.fill(tri, with: .color(playheadColor))
}
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let frame = frameOffset + Float(value.location.x) * framesPerPoint
animSystem.currentFrame = max(animSystem.frameStart, min(frame, animSystem.frameEnd))
}
)
}
// MARK: - Summary track (all keyframes merged)
private func summaryTrack(action: AnimAction, width: CGFloat) -> some View {
HStack(spacing: 0) {
HStack(spacing: 4) {
Image(systemName: "chevron.down")
.font(.system(size: 8))
Image(systemName: "cube.fill")
.font(.system(size: 10))
.foregroundStyle(.orange)
Text(action.name)
.font(.system(size: 11, weight: .medium))
}
.frame(width: channelListWidth, alignment: .leading)
.padding(.leading, 8)
Canvas { context, size in
drawGridLines(context: context, size: size)
drawPlayhead(context: context, size: size)
// All keyframes as white diamonds
let allFrames = Set(action.tracks.flatMap { $0.sortedKeyframes.map(\.frame) })
for frame in allFrames {
let x = CGFloat(frame - frameOffset) / CGFloat(framesPerPoint)
guard x >= -6 && x <= size.width + 6 else { continue }
drawDiamond(context: context, at: CGPoint(x: x, y: size.height / 2),
color: keyframeColor, size: 7)
}
}
.frame(width: width, height: trackHeight)
.background(channelBg)
}
.frame(height: trackHeight)
}
// MARK: - Individual channel track
private func channelTrack(track: AnimationTrack, index: Int, width: CGFloat) -> some View {
let bg = index % 2 == 0 ? channelBgAlt : channelBg
let trackColor = simdToColor(track.color)
return HStack(spacing: 0) {
// Channel name
HStack(spacing: 4) {
Rectangle()
.fill(trackColor)
.frame(width: 3, height: 14)
.cornerRadius(1)
.padding(.leading, 24)
Text(track.displayName)
.font(.system(size: 10))
.foregroundStyle(track.isMuted ? .secondary : .primary)
Spacer()
// Mute / lock buttons
Button { track.isMuted.toggle() } label: {
Image(systemName: track.isMuted ? "speaker.slash" : "speaker.wave.2")
.font(.system(size: 8))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
Button { track.isLocked.toggle() } label: {
Image(systemName: track.isLocked ? "lock" : "lock.open")
.font(.system(size: 8))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.padding(.trailing, 4)
}
.frame(width: channelListWidth)
// Keyframe track
Canvas { context, size in
drawGridLines(context: context, size: size)
drawPlayhead(context: context, size: size)
// Draw keyframes as diamonds
for kf in track.sortedKeyframes {
let x = CGFloat(kf.frame - frameOffset) / CGFloat(framesPerPoint)
guard x >= -6 && x <= size.width + 6 else { continue }
let color = kf.isSelected ? keyframeSelected : keyframeColor
drawDiamond(context: context, at: CGPoint(x: x, y: size.height / 2),
color: color, size: 5)
}
}
.frame(width: width, height: trackHeight)
.background(bg)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let frame = frameOffset + Float(value.location.x) * framesPerPoint
// Find nearest keyframe to select/drag
if let nearest = track.keyframes.min(by: {
abs($0.frame - frame) < abs($1.frame - frame)
}), abs(nearest.frame - frame) < 3 * framesPerPoint {
if draggedKeyframe == nil {
// Select
for kf in track.keyframes { kf.isSelected = false }
nearest.isSelected = true
draggedKeyframe = (track.id, nearest.id)
}
// Drag
nearest.frame = round(frame) // snap to integer frames
}
}
.onEnded { _ in
draggedKeyframe = nil
track.recomputeAutoHandles()
}
)
}
.frame(height: trackHeight)
}
// MARK: - Drawing helpers
private func drawGridLines(context: GraphicsContext, size: CGSize) {
let startFrame = Int(frameOffset)
let endFrame = startFrame + Int(Float(size.width) * framesPerPoint) + 1
for f in startFrame...endFrame where f % 10 == 0 {
let x = CGFloat(Float(f) - frameOffset) / CGFloat(framesPerPoint)
context.stroke(
Path { p in p.move(to: .init(x: x, y: 0)); p.addLine(to: .init(x: x, y: size.height)) },
with: .color(gridLineColor),
lineWidth: 0.5
)
}
}
private func drawPlayhead(context: GraphicsContext, size: CGSize) {
let x = CGFloat(animSystem.currentFrame - frameOffset) / CGFloat(framesPerPoint)
guard x >= 0 && x <= size.width else { return }
context.stroke(
Path { p in p.move(to: .init(x: x, y: 0)); p.addLine(to: .init(x: x, y: size.height)) },
with: .color(playheadColor),
lineWidth: 1.5
)
}
private func drawDiamond(context: GraphicsContext, at center: CGPoint, color: Color, size: CGFloat) {
let diamond = Path { p in
p.move(to: .init(x: center.x, y: center.y - size))
p.addLine(to: .init(x: center.x + size, y: center.y))
p.addLine(to: .init(x: center.x, y: center.y + size))
p.addLine(to: .init(x: center.x - size, y: center.y))
p.closeSubpath()
}
context.fill(diamond, with: .color(color))
context.stroke(diamond, with: .color(.black.opacity(0.5)), lineWidth: 0.5)
}
// MARK: - Gestures
private var zoomGesture: some Gesture {
MagnifyGesture()
.onChanged { value in
framesPerPoint = max(0.05, min(framesPerPoint / Float(value.magnification), 2.0))
}
}
private var scrollGesture: some Gesture {
DragGesture(minimumDistance: 10)
.onChanged { value in
if abs(value.translation.width) > abs(value.translation.height) {
frameOffset -= Float(value.velocity.width) * framesPerPoint * 0.005
frameOffset = max(0, frameOffset)
}
}
}
private func simdToColor(_ c: SIMD4<Float>) -> Color {
Color(red: Double(c.x), green: Double(c.y), blue: Double(c.z))
}
}

View file

@ -0,0 +1,483 @@
// GraphEditorView.swift
// Blender-style F-Curve editor with interactive Bézier handle manipulation.
import SwiftUI
struct GraphEditorView: View {
let animSystem: AnimationSystem
// View transform (pan/zoom in frame-value space)
@State private var viewCenter: SIMD2<Float> = SIMD2(50, 0) // center in (frame, value)
@State private var viewScale: SIMD2<Float> = SIMD2(3.0, 50.0) // pixels per (frame, value)
@State private var dragTarget: DragTarget?
@State private var hoverInfo: String = ""
// Blender graph editor colors
private let bgColor = Color(red: 0.15, green: 0.15, blue: 0.15)
private let gridMajor = Color.white.opacity(0.08)
private let gridMinor = Color.white.opacity(0.03)
private let axisColor = Color.white.opacity(0.15)
private let textColor = Color.white.opacity(0.3)
private let playheadColor = Color(red: 0.30, green: 0.55, blue: 0.95)
private let handleLineColor = Color.white.opacity(0.4)
private let curveWidth: CGFloat = 1.8
// Keyframe node sizes
private let kfNodeRadius: CGFloat = 5
private let handleNodeRadius: CGFloat = 3.5
private let hitTestRadius: CGFloat = 14
var body: some View {
GeometryReader { geo in
let size = geo.size
ZStack(alignment: .topLeading) {
// Canvas
Canvas { context, canvasSize in
drawGrid(context: context, size: canvasSize)
drawPlayhead(context: context, size: canvasSize)
if let action = animSystem.activeAction {
for track in action.tracks where track.isVisible && !track.isMuted {
drawCurve(context: context, size: canvasSize, track: track)
drawKeyframesAndHandles(context: context, size: canvasSize, track: track)
}
}
}
.background(bgColor)
// Gesture layer
Color.clear
.contentShape(Rectangle())
.gesture(tapGesture(size: size))
.gesture(dragGesture(size: size))
.gesture(panGesture)
.gesture(zoomGesture)
// Info overlay
VStack {
HStack {
Text("Graph Editor")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.white.opacity(0.5))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.black.opacity(0.3), in: RoundedRectangle(cornerRadius: 4))
Spacer()
if !hoverInfo.isEmpty {
Text(hoverInfo)
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(.white.opacity(0.6))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.black.opacity(0.3), in: RoundedRectangle(cornerRadius: 4))
}
}
.padding(8)
Spacer()
}
// Channel list (left overlay)
channelList
}
}
}
// MARK: - Coordinate conversion
/// Convert (frame, value) to screen pixels.
private func toScreen(_ fv: SIMD2<Float>, size: CGSize) -> CGPoint {
let x = CGFloat((fv.x - viewCenter.x) * viewScale.x + Float(size.width) * 0.5)
let y = CGFloat(Float(size.height) * 0.5 - (fv.y - viewCenter.y) * viewScale.y)
return CGPoint(x: x, y: y)
}
/// Convert screen pixels to (frame, value).
private func toFrameValue(_ point: CGPoint, size: CGSize) -> SIMD2<Float> {
let frame = (Float(point.x) - Float(size.width) * 0.5) / viewScale.x + viewCenter.x
let value = (Float(size.height) * 0.5 - Float(point.y)) / viewScale.y + viewCenter.y
return SIMD2(frame, value)
}
// MARK: - Grid
private func drawGrid(context: GraphicsContext, size: CGSize) {
// Compute visible range
let topLeft = toFrameValue(.zero, size: size)
let bottomRight = toFrameValue(CGPoint(x: size.width, y: size.height), size: size)
let minFrame = topLeft.x
let maxFrame = bottomRight.x
let minValue = bottomRight.y
let maxValue = topLeft.y
// Frame grid (vertical lines)
let frameStep = gridStep(range: maxFrame - minFrame, targetLines: 15)
var f = (minFrame / frameStep).rounded(.down) * frameStep
while f <= maxFrame {
let x = toScreen(SIMD2(f, 0), size: size).x
let isMajor = abs(f.truncatingRemainder(dividingBy: frameStep * 5)) < 0.01 || abs(f) < 0.01
context.stroke(
Path { p in p.move(to: .init(x: x, y: 0)); p.addLine(to: .init(x: x, y: size.height)) },
with: .color(abs(f) < 0.01 ? axisColor : (isMajor ? gridMajor : gridMinor)),
lineWidth: abs(f) < 0.01 ? 1.5 : 0.5
)
if isMajor {
context.draw(
Text(String(format: frameStep >= 1 ? "%.0f" : "%.1f", f))
.font(.system(size: 9)).foregroundStyle(textColor),
at: CGPoint(x: x + 2, y: size.height - 10),
anchor: .leading
)
}
f += frameStep
}
// Value grid (horizontal lines)
let valueStep = gridStep(range: maxValue - minValue, targetLines: 10)
var v = (minValue / valueStep).rounded(.down) * valueStep
while v <= maxValue {
let y = toScreen(SIMD2(0, v), size: size).y
let isMajor = abs(v.truncatingRemainder(dividingBy: valueStep * 5)) < 0.01 || abs(v) < 0.01
context.stroke(
Path { p in p.move(to: .init(x: 0, y: y)); p.addLine(to: .init(x: size.width, y: y)) },
with: .color(abs(v) < 0.01 ? axisColor : (isMajor ? gridMajor : gridMinor)),
lineWidth: abs(v) < 0.01 ? 1.5 : 0.5
)
if isMajor {
context.draw(
Text(String(format: valueStep >= 1 ? "%.0f" : "%.1f", v))
.font(.system(size: 9)).foregroundStyle(textColor),
at: CGPoint(x: 4, y: y - 6),
anchor: .leading
)
}
v += valueStep
}
}
private func gridStep(range: Float, targetLines: Int) -> Float {
let raw = range / Float(targetLines)
let magnitude = powf(10, floorf(log10f(abs(raw) + 0.0001)))
let normalized = raw / magnitude
let step: Float
if normalized < 1.5 { step = 1 }
else if normalized < 3.5 { step = 2 }
else if normalized < 7.5 { step = 5 }
else { step = 10 }
return step * magnitude
}
// MARK: - Playhead
private func drawPlayhead(context: GraphicsContext, size: CGSize) {
let x = toScreen(SIMD2(animSystem.currentFrame, 0), size: size).x
guard x >= 0 && x <= size.width else { return }
context.stroke(
Path { p in p.move(to: .init(x: x, y: 0)); p.addLine(to: .init(x: x, y: size.height)) },
with: .color(playheadColor),
lineWidth: 1.5
)
// Frame number at top
context.draw(
Text(String(format: "%.0f", animSystem.currentFrame))
.font(.system(size: 9, weight: .bold)).foregroundStyle(playheadColor),
at: CGPoint(x: x, y: 6)
)
}
// MARK: - F-Curve rendering
private func drawCurve(context: GraphicsContext, size: CGSize, track: AnimationTrack) {
let sorted = track.sortedKeyframes
guard sorted.count >= 2 else { return }
let color = simdToColor(track.color)
var path = Path()
// Extend before first keyframe (flat line)
let firstPt = toScreen(SIMD2(sorted[0].frame, sorted[0].value), size: size)
path.move(to: CGPoint(x: 0, y: firstPt.y))
path.addLine(to: firstPt)
// Bézier segments between keyframes
for i in 0..<(sorted.count - 1) {
let kf0 = sorted[i]
let kf1 = sorted[i + 1]
let p0 = toScreen(SIMD2(kf0.frame, kf0.value), size: size)
let cp1 = toScreen(kf0.handleRight, size: size)
let cp2 = toScreen(kf1.handleLeft, size: size)
let p3 = toScreen(SIMD2(kf1.frame, kf1.value), size: size)
switch kf0.interpolation {
case .constant:
path.addLine(to: CGPoint(x: p3.x, y: p0.y))
path.addLine(to: p3)
case .linear:
path.addLine(to: p3)
case .bezier, .bounce, .elastic, .back:
path.addCurve(to: p3, control1: cp1, control2: cp2)
}
}
// Extend after last keyframe
let lastPt = toScreen(SIMD2(sorted.last!.frame, sorted.last!.value), size: size)
path.addLine(to: CGPoint(x: size.width, y: lastPt.y))
context.stroke(path, with: .color(color), lineWidth: curveWidth)
}
// MARK: - Keyframe nodes and handles
private func drawKeyframesAndHandles(
context: GraphicsContext, size: CGSize, track: AnimationTrack
) {
let trackColor = simdToColor(track.color)
for kf in track.sortedKeyframes {
let center = toScreen(SIMD2(kf.frame, kf.value), size: size)
// Guard visibility
guard center.x > -20 && center.x < size.width + 20 else { continue }
// Draw handle lines and dots
if kf.isSelected {
let leftPt = toScreen(kf.handleLeft, size: size)
let rightPt = toScreen(kf.handleRight, size: size)
// Handle lines (dashed in Blender)
context.stroke(
Path { p in p.move(to: leftPt); p.addLine(to: center) },
with: .color(handleLineColor), lineWidth: 1
)
context.stroke(
Path { p in p.move(to: center); p.addLine(to: rightPt) },
with: .color(handleLineColor), lineWidth: 1
)
// Handle dots
drawCircle(context: context, at: leftPt, radius: handleNodeRadius,
fill: kf.isHandleLeftSelected ? .white : trackColor)
drawCircle(context: context, at: rightPt, radius: handleNodeRadius,
fill: kf.isHandleRightSelected ? .white : trackColor)
}
// Keyframe diamond
let diamond = Path { p in
p.move(to: .init(x: center.x, y: center.y - kfNodeRadius))
p.addLine(to: .init(x: center.x + kfNodeRadius, y: center.y))
p.addLine(to: .init(x: center.x, y: center.y + kfNodeRadius))
p.addLine(to: .init(x: center.x - kfNodeRadius, y: center.y))
p.closeSubpath()
}
context.fill(diamond, with: .color(kf.isSelected ? .white : trackColor))
context.stroke(diamond, with: .color(.black.opacity(0.6)), lineWidth: 1)
}
}
private func drawCircle(context: GraphicsContext, at point: CGPoint, radius: CGFloat, fill: Color) {
let rect = CGRect(x: point.x - radius, y: point.y - radius,
width: radius * 2, height: radius * 2)
context.fill(Path(ellipseIn: rect), with: .color(fill))
context.stroke(Path(ellipseIn: rect), with: .color(.black.opacity(0.5)), lineWidth: 0.5)
}
// MARK: - Channel list overlay
private var channelList: some View {
VStack(alignment: .leading, spacing: 0) {
if let action = animSystem.activeAction {
ForEach(action.tracks) { track in
let color = simdToColor(track.color)
HStack(spacing: 6) {
Circle()
.fill(color)
.frame(width: 8, height: 8)
Text(track.displayName)
.font(.system(size: 10))
.foregroundStyle(track.isVisible ? .primary : .secondary)
Spacer()
Button { track.isVisible.toggle() } label: {
Image(systemName: track.isVisible ? "eye" : "eye.slash")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.black.opacity(0.4))
}
}
Spacer()
}
.frame(width: 120)
.padding(.top, 32) // below info overlay
}
// MARK: - Hit testing & drag interaction
enum DragTarget {
case keyframe(trackID: UUID, kfID: UUID)
case handleLeft(trackID: UUID, kfID: UUID)
case handleRight(trackID: UUID, kfID: UUID)
case scrubbing
}
private func tapGesture(size: CGSize) -> some Gesture {
SpatialTapGesture()
.onEnded { value in
let point = value.location
guard let action = animSystem.activeAction else { return }
// Deselect all first
for track in action.tracks {
for kf in track.keyframes {
kf.isSelected = false
kf.isHandleLeftSelected = false
kf.isHandleRightSelected = false
}
}
// Find nearest keyframe
for track in action.tracks where track.isVisible {
for kf in track.keyframes {
let screenPos = toScreen(SIMD2(kf.frame, kf.value), size: size)
if distance(point, screenPos) < hitTestRadius {
kf.isSelected = true
hoverInfo = String(format: "F: %.0f V: %.2f", kf.frame, kf.value)
return
}
}
}
}
}
private func dragGesture(size: CGSize) -> some Gesture {
DragGesture(minimumDistance: 4)
.onChanged { value in
let point = value.location
let fv = toFrameValue(point, size: size)
guard let action = animSystem.activeAction else { return }
// Determine target on first movement
if dragTarget == nil {
// Check handles first (they're smaller, need priority)
for track in action.tracks where track.isVisible {
for kf in track.keyframes where kf.isSelected {
let leftPt = toScreen(kf.handleLeft, size: size)
if distance(point, leftPt) < hitTestRadius {
dragTarget = .handleLeft(trackID: track.id, kfID: kf.id)
kf.isHandleLeftSelected = true
return
}
let rightPt = toScreen(kf.handleRight, size: size)
if distance(point, rightPt) < hitTestRadius {
dragTarget = .handleRight(trackID: track.id, kfID: kf.id)
kf.isHandleRightSelected = true
return
}
}
}
// Check keyframes
for track in action.tracks where track.isVisible {
for kf in track.keyframes {
let screenPos = toScreen(SIMD2(kf.frame, kf.value), size: size)
if distance(point, screenPos) < hitTestRadius {
dragTarget = .keyframe(trackID: track.id, kfID: kf.id)
kf.isSelected = true
return
}
}
}
// Scrub timeline
dragTarget = .scrubbing
}
// Apply drag
switch dragTarget {
case .keyframe(let trackID, let kfID):
if let track = action.tracks.first(where: { $0.id == trackID }),
let kf = track.keyframes.first(where: { $0.id == kfID }) {
let deltaFrame = fv.x - kf.frame
let deltaValue = fv.y - kf.value
kf.frame = round(fv.x) // snap to integer frames
kf.value = fv.y
kf.handleLeft += SIMD2(deltaFrame, deltaValue)
kf.handleRight += SIMD2(deltaFrame, deltaValue)
hoverInfo = String(format: "F: %.0f V: %.2f", kf.frame, kf.value)
}
case .handleLeft(let trackID, let kfID):
if let track = action.tracks.first(where: { $0.id == trackID }),
let kf = track.keyframes.first(where: { $0.id == kfID }) {
kf.moveHandleLeft(to: fv)
hoverInfo = String(format: "Handle: (%.1f, %.2f)", fv.x, fv.y)
}
case .handleRight(let trackID, let kfID):
if let track = action.tracks.first(where: { $0.id == trackID }),
let kf = track.keyframes.first(where: { $0.id == kfID }) {
kf.moveHandleRight(to: fv)
hoverInfo = String(format: "Handle: (%.1f, %.2f)", fv.x, fv.y)
}
case .scrubbing:
animSystem.currentFrame = max(
animSystem.frameStart,
min(round(fv.x), animSystem.frameEnd)
)
case .none:
break
}
}
.onEnded { _ in
dragTarget = nil
hoverInfo = ""
// Recompute auto handles
if let action = animSystem.activeAction {
for track in action.tracks {
track.recomputeAutoHandles()
}
}
}
}
private var panGesture: some Gesture {
DragGesture(minimumDistance: 10)
.simultaneously(with: DragGesture(minimumDistance: 10))
.onChanged { value in
// Two-finger pan
guard let _ = value.second else { return }
if let first = value.first {
viewCenter.x -= Float(first.velocity.width) * 0.003 / viewScale.x * 100
viewCenter.y += Float(first.velocity.height) * 0.003 / viewScale.y * 100
}
}
}
private var zoomGesture: some Gesture {
MagnifyGesture()
.onChanged { value in
let scale = Float(value.magnification)
viewScale.x = max(0.5, min(viewScale.x * scale, 20))
viewScale.y = max(5, min(viewScale.y * scale, 500))
}
}
// MARK: - Helpers
private func distance(_ a: CGPoint, _ b: CGPoint) -> CGFloat {
sqrt(pow(a.x - b.x, 2) + pow(a.y - b.y, 2))
}
private func simdToColor(_ c: SIMD4<Float>) -> Color {
Color(red: Double(c.x), green: Double(c.y), blue: Double(c.z))
}
}

View file

@ -17,6 +17,9 @@ final class MetalContext {
// Layout state
var viewportLayout: ViewportLayout = .single
// Animation
let animationSystem = AnimationSystem()
init() {
guard let device = MTLCreateSystemDefaultDevice() else {
fatalError("Metal is not supported on this device.")

View file

@ -55,7 +55,7 @@ struct WorkspaceLayoutView: View {
// Timeline (optional bottom panel)
if workspace.showTimeline {
PanelDivider(axis: .horizontal)
TimelinePlaceholder()
AnimationPanelView(animSystem: context.animationSystem)
.frame(height: geo.size.height * workspace.timelineHeight)
}
}

View file

@ -162,6 +162,11 @@ final class WorkspaceManager {
func switchWorkspace(to type: WorkspaceType) {
withAnimation(.easeInOut(duration: 0.25)) {
activeWorkspace = type
// Animation workspace shows a larger timeline by default
if type == .animation {
showTimeline = true
timelineHeight = 0.45
}
}
}
}