Blender4iOS/Sources/Animation/GraphEditorView.swift

484 lines
20 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
// 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))
}
}