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