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.
311 lines
12 KiB
Swift
311 lines
12 KiB
Swift
// 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))
|
|
}
|
|
}
|