diff --git a/Sources/Animation/AnimationData.swift b/Sources/Animation/AnimationData.swift new file mode 100644 index 0000000..e6b32ae --- /dev/null +++ b/Sources/Animation/AnimationData.swift @@ -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 + + /// Right handle (outgoing tangent), in absolute coordinates + var handleRight: SIMD2 + + /// 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(frame - 5, value) + self.handleRight = SIMD2(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(frame - dt, value - slope * dt) + handleRight = SIMD2(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(frame - dt, value - slope * dt) + handleRight = SIMD2(frame + dt, value) + } else if let next { + let slope = (next.value - value) / (next.frame - frame) + handleLeft = SIMD2(frame - dt, value) + handleRight = SIMD2(frame + dt, value + slope * dt) + } else { + handleLeft = SIMD2(frame - dt, value) + handleRight = SIMD2(frame + dt, value) + } + } + + /// Move handle maintaining aligned constraint if needed. + func moveHandleLeft(to pos: SIMD2) { + handleLeft = pos + if handleType == .aligned { + let delta = SIMD2(frame, value) - pos + handleRight = SIMD2(frame, value) + delta + } + } + + func moveHandleRight(to pos: SIMD2) { + handleRight = pos + if handleType == .aligned { + let delta = SIMD2(frame, value) - pos + handleLeft = SIMD2(frame, value) + delta + } + } +} + +extension Comparable { + func clamped(to range: ClosedRange) -> 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 { + 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(kf0.frame, kf0.value) + let p1 = kf0.handleRight + let p2 = kf1.handleLeft + let p3 = SIMD2(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.. 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(loc[0], loc[1], loc[2]) + } + if let rot = values["rotation_euler"] { + obj.transform.rotation = SIMD3(rot[0], rot[1], rot[2]) + } + if let scl = values["scale"] { + obj.transform.scale = SIMD3(scl[0], scl[1], scl[2]) + } + } +} diff --git a/Sources/Animation/AnimationPanelView.swift b/Sources/Animation/AnimationPanelView.swift new file mode 100644 index 0000000..3b8dd0c --- /dev/null +++ b/Sources/Animation/AnimationPanelView.swift @@ -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) + } + } +} diff --git a/Sources/Animation/AnimationPlayer.swift b/Sources/Animation/AnimationPlayer.swift new file mode 100644 index 0000000..564feee --- /dev/null +++ b/Sources/Animation/AnimationPlayer.swift @@ -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() + } +} diff --git a/Sources/Animation/DopeSheetView.swift b/Sources/Animation/DopeSheetView.swift new file mode 100644 index 0000000..459d9e6 --- /dev/null +++ b/Sources/Animation/DopeSheetView.swift @@ -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) -> Color { + Color(red: Double(c.x), green: Double(c.y), blue: Double(c.z)) + } +} diff --git a/Sources/Animation/GraphEditorView.swift b/Sources/Animation/GraphEditorView.swift new file mode 100644 index 0000000..67b3570 --- /dev/null +++ b/Sources/Animation/GraphEditorView.swift @@ -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 = SIMD2(50, 0) // center in (frame, value) + @State private var viewScale: SIMD2 = 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, 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 { + 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) -> Color { + Color(red: Double(c.x), green: Double(c.y), blue: Double(c.z)) + } +} diff --git a/Sources/Scene/MetalContext.swift b/Sources/Scene/MetalContext.swift index 67e21eb..955ad7d 100644 --- a/Sources/Scene/MetalContext.swift +++ b/Sources/Scene/MetalContext.swift @@ -17,6 +17,9 @@ final class MetalContext { // Layout state var viewportLayout: ViewportLayout = .single + // Animation + let animationSystem = AnimationSystem() + init() { guard let device = MTLCreateSystemDefaultDevice() else { fatalError("Metal is not supported on this device.") diff --git a/Sources/Workspace/WorkspaceLayoutView.swift b/Sources/Workspace/WorkspaceLayoutView.swift index 9d42801..a30a007 100644 --- a/Sources/Workspace/WorkspaceLayoutView.swift +++ b/Sources/Workspace/WorkspaceLayoutView.swift @@ -55,7 +55,7 @@ struct WorkspaceLayoutView: View { // ── Timeline (optional bottom panel) ── if workspace.showTimeline { PanelDivider(axis: .horizontal) - TimelinePlaceholder() + AnimationPanelView(animSystem: context.animationSystem) .frame(height: geo.size.height * workspace.timelineHeight) } } diff --git a/Sources/Workspace/WorkspaceManager.swift b/Sources/Workspace/WorkspaceManager.swift index 062ab44..57be1cf 100644 --- a/Sources/Workspace/WorkspaceManager.swift +++ b/Sources/Workspace/WorkspaceManager.swift @@ -162,6 +162,11 @@ final class WorkspaceManager { func switchWorkspace(to type: WorkspaceType) { withAnimation(.easeInOut(duration: 0.25)) { activeWorkspace = type + // Animation workspace shows a larger timeline by default + if type == .animation { + showTimeline = true + timelineHeight = 0.45 + } } } }