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:
parent
07d217a3d8
commit
f947abd77b
8 changed files with 1598 additions and 1 deletions
422
Sources/Animation/AnimationData.swift
Normal file
422
Sources/Animation/AnimationData.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
254
Sources/Animation/AnimationPanelView.swift
Normal file
254
Sources/Animation/AnimationPanelView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
119
Sources/Animation/AnimationPlayer.swift
Normal file
119
Sources/Animation/AnimationPlayer.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
311
Sources/Animation/DopeSheetView.swift
Normal file
311
Sources/Animation/DopeSheetView.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
483
Sources/Animation/GraphEditorView.swift
Normal file
483
Sources/Animation/GraphEditorView.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,9 @@ final class MetalContext {
|
||||||
// Layout state
|
// Layout state
|
||||||
var viewportLayout: ViewportLayout = .single
|
var viewportLayout: ViewportLayout = .single
|
||||||
|
|
||||||
|
// Animation
|
||||||
|
let animationSystem = AnimationSystem()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
guard let device = MTLCreateSystemDefaultDevice() else {
|
guard let device = MTLCreateSystemDefaultDevice() else {
|
||||||
fatalError("Metal is not supported on this device.")
|
fatalError("Metal is not supported on this device.")
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ struct WorkspaceLayoutView: View {
|
||||||
// ── Timeline (optional bottom panel) ──
|
// ── Timeline (optional bottom panel) ──
|
||||||
if workspace.showTimeline {
|
if workspace.showTimeline {
|
||||||
PanelDivider(axis: .horizontal)
|
PanelDivider(axis: .horizontal)
|
||||||
TimelinePlaceholder()
|
AnimationPanelView(animSystem: context.animationSystem)
|
||||||
.frame(height: geo.size.height * workspace.timelineHeight)
|
.frame(height: geo.size.height * workspace.timelineHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,11 @@ final class WorkspaceManager {
|
||||||
func switchWorkspace(to type: WorkspaceType) {
|
func switchWorkspace(to type: WorkspaceType) {
|
||||||
withAnimation(.easeInOut(duration: 0.25)) {
|
withAnimation(.easeInOut(duration: 0.25)) {
|
||||||
activeWorkspace = type
|
activeWorkspace = type
|
||||||
|
// Animation workspace shows a larger timeline by default
|
||||||
|
if type == .animation {
|
||||||
|
showTimeline = true
|
||||||
|
timelineHeight = 0.45
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue