// 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)) } }