Skip to content

Instantly share code, notes, and snippets.

@rdev
Created May 3, 2025 23:50
Show Gist options
  • Select an option

  • Save rdev/646d2467534f9f096178ba37e2467884 to your computer and use it in GitHub Desktop.

Select an option

Save rdev/646d2467534f9f096178ba37e2467884 to your computer and use it in GitHub Desktop.
//
// CropView.swift
//
import MetalPetal
import SwiftUI
#if os(macOS)
import AppKit
#endif
// MARK: –– Aspect-ratio modes -------------------------------------------------
enum AspectMode: String, CaseIterable, Identifiable {
case free = "Unlocked"
case original = "Original"
case square = "Square"
var id: String { rawValue }
}
// MARK: –– Crop-box UI --------------------------------------------------------
/// Crop rectangle, draggable handles, rule-of-thirds grid, gestures.
private struct CropBoxOverlay: View {
@Binding var cropX: Float
@Binding var cropY: Float
@Binding var cropWidth: Float
@Binding var cropHeight: Float
@Binding var straightenAngle: Float
@Binding var aspectMode: AspectMode
// Mirrors of the parent’s transform sliders:
@Binding var scaleX: Float
@Binding var scaleY: Float
let imageSize: CGSize // px, original asset
let imageRect: CGRect // on-screen aspect-fit box
let onChange: () -> Void // ping parent to re-run Metal
// MARK: helpers -------------------------------------------------------------
private var center: CGPoint {
CGPoint(
x: imageRect.minX + imageRect.width * CGFloat(cropX + cropWidth / 2),
y: imageRect.minY + imageRect.height * CGFloat(cropY + cropHeight / 2)
)
}
private enum Corner: CaseIterable { case tl, tr, bl, br
var xSign: CGFloat { self == .tl || self == .bl ? -1 : 1 }
var ySign: CGFloat { self == .tl || self == .tr ? -1 : 1 }
}
private func cornerPos(_ c: Corner) -> CGPoint {
let x = imageRect.minX + imageRect.width * CGFloat(cropX + (c.xSign == -1 ? 0 : cropWidth))
let y = imageRect.minY + imageRect.height * CGFloat(cropY + (c.ySign == -1 ? 0 : cropHeight))
return .init(x: x, y: y)
}
// MARK: gesture-state -------------------------------------------------------
@State private var dragStart = CGPoint.zero
@State private var startCrop = CGRect.zero // % space
@State private var startCenter = CGPoint.zero
@State private var startAngle: Float = 0
@State private var shiftRatio: CGFloat? = nil
@State private var resizingFromCenter = false
@State private var driverIsHorizontal: Bool? = nil
private enum DragMode { case move, rotate }
@State private var dragMode: DragMode? = nil
// MOVE bookkeeping
@State private var currentCropDuringDrag = CGRect.zero
@State private var lastMoveLocation = CGPoint.zero
@State private var startUniformScale: Float = 1 // also used by corner drag
private let handleHitRadius: CGFloat = 14
// MARK: body ----------------------------------------------------------------
var body: some View {
ZStack {
// rule-of-thirds grid
Path { p in
let w = imageRect.width * CGFloat(cropWidth)
let h = imageRect.height * CGFloat(cropHeight)
let left = center.x - w / 2, top = center.y - h / 2
for i in 1 ... 2 {
let x = left + w * CGFloat(i) / 3
p.move(to: .init(x: x, y: top))
p.addLine(to: .init(x: x, y: top + h))
let y = top + h * CGFloat(i) / 3
p.move(to: .init(x: left, y: y))
p.addLine(to: .init(x: left + w, y: y))
}
}
.stroke(Color.white.opacity(0.4), lineWidth: 0.5)
// outline
Path { p in
let w = imageRect.width * CGFloat(cropWidth)
let h = imageRect.height * CGFloat(cropHeight)
p.addRect(.init(x: center.x - w / 2,
y: center.y - h / 2,
width: w,
height: h))
}
.stroke(.white, lineWidth: 1)
// corner handles
ForEach(Corner.allCases, id: \.self) { c in
Circle()
.fill(Color.white)
.overlay(Circle().fill(Color.blue).frame(width: 6, height: 6))
.frame(width: 11, height: 11)
.position(cornerPos(c))
.gesture(cornerDrag(c))
}
}
.contentShape(Rectangle())
.gesture(mainDrag())
.onTapGesture(count: 2) { // double-click → reset
cropX = 0; cropY = 0; cropWidth = 1; cropHeight = 1
aspectMode = .free
onChange()
}
}
// MARK: scale maths ---------------------------------------------------------
/// Exact mirror of CropView.autoFillScale; lets us simulate pending rects.
private func requiredUniformScale(for rect: CGRect) -> Float {
let w = Float(imageSize.width), h = Float(imageSize.height)
let x0 = Float(rect.minX) * w, x1 = Float(rect.maxX) * w
let y0 = Float(rect.minY) * h, y1 = Float(rect.maxY) * h
let cosT = cosf(straightenAngle), sinT = sinf(straightenAngle)
var s: Float = 1
for px in [x0, x1] {
for py in [y0, y1] {
let dx = px - w / 2, dy = py - h / 2
let ux = cosT * dx - sinT * dy
let uy = sinT * dx + cosT * dy
let needX = 2 * fabsf(ux) / (w * max(scaleX, 1e-6))
let needY = 2 * fabsf(uy) / (h * max(scaleY, 1e-6))
s = max(s, max(needX, needY))
}
}
return max(1, s)
}
// MARK: gestures ------------------------------------------------------------
// ───────────────────────────── corner drag (now clamped) ───────────────────
private func cornerDrag(_ c: Corner) -> some Gesture {
DragGesture()
.onChanged { val in
#if os(macOS)
let currentOpt = NSEvent.modifierFlags.contains(.option)
let shf = NSEvent.modifierFlags.contains(.shift)
#else
let currentOpt = false
let shf = false
#endif
// First onChanged ----------------------------------------------------
if dragStart == .zero {
dragStart = val.startLocation
startCrop = .init(x: CGFloat(cropX),
y: CGFloat(cropY),
width: CGFloat(cropWidth),
height: CGFloat(cropHeight))
startCenter = .init(x: startCrop.midX, y: startCrop.midY)
resizingFromCenter = currentOpt
driverIsHorizontal = nil
// Capture scale budget so handles can’t invoke extra zoom
startUniformScale = requiredUniformScale(for: startCrop)
}
// ⌥ toggled mid-drag → re-base --------------------------------------
if currentOpt != resizingFromCenter {
resizingFromCenter = currentOpt
dragStart = val.location
startCrop = .init(x: CGFloat(cropX),
y: CGFloat(cropY),
width: CGFloat(cropWidth),
height: CGFloat(cropHeight))
startCenter = .init(x: startCrop.midX, y: startCrop.midY)
driverIsHorizontal = nil
}
// Aspect-ratio logic -------------------------------------------------
let lockedAspect: CGFloat? = {
switch aspectMode {
case .free: return nil
case .original: return 1
case .square: return imageRect.height / imageRect.width
}
}()
var activeAspect = lockedAspect
if aspectMode == .free {
if shf {
if shiftRatio == nil { shiftRatio = startCrop.width / startCrop.height }
activeAspect = shiftRatio
} else { shiftRatio = nil }
}
// Δ in percent space
var dx = (val.location.x - dragStart.x) / imageRect.width
var dy = (val.location.y - dragStart.y) / imageRect.height
let minDim: CGFloat = 0.03
// ⌥ centre-resize ---------------------------------------------------
if currentOpt {
var newW = startCrop.width + dx * c.xSign * 2
var newH = startCrop.height + dy * c.ySign * 2
if let ar = activeAspect {
if abs(dx) > abs(dy) { newH = newW / ar } else { newW = newH * ar }
}
newW = max(minDim, min(1, newW))
newH = max(minDim, min(1, newH))
var ox = startCenter.x - newW / 2
var oy = startCenter.y - newH / 2
ox = max(0, min(1 - newW, ox))
oy = max(0, min(1 - newH, oy))
var r = CGRect(x: ox, y: oy, width: newW, height: newH)
clampRect(&r) // new magic line 🔥
cropX = Float(r.minX); cropY = Float(r.minY)
cropWidth = Float(r.width); cropHeight = Float(r.height)
onChange()
return
}
// ----- robust corner drag ------------------------------------------
func clamp(_ v: CGFloat, _ lo: CGFloat, _ hi: CGFloat) -> CGFloat {
Swift.min(hi, Swift.max(lo, v))
}
if c.xSign == -1 {
dx = clamp(dx, -startCrop.minX, startCrop.width - minDim)
} else {
dx = clamp(dx, minDim - startCrop.width, 1 - startCrop.maxX)
}
if c.ySign == -1 {
dy = clamp(dy, -startCrop.minY, startCrop.height - minDim)
} else {
dy = clamp(dy, minDim - startCrop.height, 1 - startCrop.maxY)
}
var r = startCrop
if c.xSign == -1 { r.origin.x += dx; r.size.width -= dx }
else { r.size.width += dx }
if c.ySign == -1 { r.origin.y += dy; r.size.height -= dy }
else { r.size.height += dy }
if let ar = activeAspect {
if driverIsHorizontal == nil {
driverIsHorizontal = abs(dx) > abs(dy)
}
let controlIsHorizontal = driverIsHorizontal ?? true
if controlIsHorizontal {
r.size.height = r.size.width / ar
if c.ySign == -1 { r.origin.y = startCrop.maxY - r.size.height }
} else {
r.size.width = r.size.height * ar
if c.xSign == -1 { r.origin.x = startCrop.maxX - r.size.width }
}
adjustForBounds(&r, aspect: ar, fixedCorner: c, minDim: minDim)
}
clampRect(&r) // << NEW scale-budget clamp
cropX = Float(r.minX); cropY = Float(r.minY)
cropWidth = Float(r.width); cropHeight = Float(r.height)
onChange()
}
.onEnded { _ in
dragStart = .zero
shiftRatio = nil
resizingFromCenter = false
driverIsHorizontal = nil
}
}
/// Lerp helper: t = 0 → a, t = 1 → b.
private func lerp(_ a: CGFloat, _ b: CGFloat, _ t: CGFloat) -> CGFloat {
a + (b - a) * t
}
/// Pulls `rect` back toward `startCrop` until it no longer demands extra scale.
private func clampRect(_ rect: inout CGRect) {
let allowed = startUniformScale
if requiredUniformScale(for: rect) <= allowed { return } // already safe
// Binary-search best fraction of the delta that keeps scale ≤ allowed.
var lo: CGFloat = 0, hi: CGFloat = 1, best: CGFloat = 0
for _ in 0 ..< 9 {
let mid = (lo + hi) * 0.5
let test = CGRect(
x: lerp(startCrop.minX, rect.minX, mid),
y: lerp(startCrop.minY, rect.minY, mid),
width: lerp(startCrop.width, rect.width, mid),
height: lerp(startCrop.height, rect.height, mid)
)
if requiredUniformScale(for: test) <= allowed {
best = mid; lo = mid
} else { hi = mid }
}
// Snap to best safe rect.
rect = CGRect(
x: lerp(startCrop.minX, rect.minX, best),
y: lerp(startCrop.minY, rect.minY, best),
width: lerp(startCrop.width, rect.width, best),
height: lerp(startCrop.height, rect.height, best)
)
}
/// Ensures the rect fits inside [0,1]×[0,1] while preserving aspect ratio.
private func adjustForBounds(_ r: inout CGRect,
aspect ar: CGFloat,
fixedCorner c: Corner,
minDim: CGFloat)
{
if r.minX < 0 {
let o = -r.minX
r.origin.x = 0; r.size.width -= o; r.size.width = max(minDim, r.size.width)
r.size.height = r.size.width / ar
if c.ySign == -1 { r.origin.y = r.maxY - r.size.height }
}
if r.maxX > 1 {
let o = r.maxX - 1
r.size.width -= o; r.size.width = max(minDim, r.size.width)
r.size.height = r.size.width / ar
if c.ySign == -1 { r.origin.y = r.maxY - r.size.height }
}
if r.minY < 0 {
let o = -r.minY
r.origin.y = 0; r.size.height -= o; r.size.height = max(minDim, r.size.height)
r.size.width = r.size.height * ar
if c.xSign == -1 { r.origin.x = r.maxX - r.size.width }
}
if r.maxY > 1 {
let o = r.maxY - 1
r.size.height -= o; r.size.height = max(minDim, r.size.height)
r.size.width = r.size.height * ar
if c.xSign == -1 { r.origin.x = r.maxX - r.size.width }
}
}
// ───────────────────────────── main drag (move / rotate) ───────────────────
// (unchanged – already had scale clamp)
private func mainDrag() -> some Gesture {
DragGesture()
.onChanged { val in
if dragMode == nil && isOnHandle(val.startLocation) { return }
if dragMode == nil {
dragStart = val.startLocation
startCrop = CGRect(x: CGFloat(cropX),
y: CGFloat(cropY),
width: CGFloat(cropWidth),
height: CGFloat(cropHeight))
let inside = isInside(val.startLocation, using: startCrop)
dragMode = inside ? .move : .rotate
if dragMode == .rotate {
startAngle = straightenAngle
} else {
currentCropDuringDrag = startCrop
lastMoveLocation = val.startLocation
startUniformScale = requiredUniformScale(for: startCrop)
}
}
guard let mode = dragMode else { return }
switch mode {
// MOVE – unchanged (already clamps) ----------------------------------
case .move:
var dxPct = (val.location.x - lastMoveLocation.x) / imageRect.width
var dyPct = (val.location.y - lastMoveLocation.y) / imageRect.height
var working = currentCropDuringDrag
func consume(axisIsX: Bool, delta: inout CGFloat) {
guard delta != 0 else { return }
// bounds clamp
let minB = axisIsX ? -working.minX : -working.minY
let maxB = axisIsX ? 1 - working.maxX : 1 - working.maxY
delta = min(max(delta, minB), maxB)
guard delta != 0 else { return }
// find largest safe fraction wrt uniform scale
var lo: CGFloat = 0, hi: CGFloat = 1, best: CGFloat = 0
for _ in 0 ..< 9 {
let mid = (lo + hi) * 0.5
var test = working
if axisIsX { test.origin.x += delta * mid }
else { test.origin.y += delta * mid }
if requiredUniformScale(for: test) <= startUniformScale {
best = mid; lo = mid
} else { hi = mid }
}
let used = delta * best
if axisIsX {
working.origin.x += used
lastMoveLocation.x += used * imageRect.width
} else {
working.origin.y += used
lastMoveLocation.y += used * imageRect.height
}
delta -= used // leftovers slide along edge
}
// larger component first
if abs(dxPct) > abs(dyPct) {
consume(axisIsX: true, delta: &dxPct)
consume(axisIsX: false, delta: &dyPct)
} else {
consume(axisIsX: false, delta: &dyPct)
consume(axisIsX: true, delta: &dxPct)
}
currentCropDuringDrag = working
cropX = Float(working.minX)
cropY = Float(working.minY)
onChange()
// ROTATE – unchanged -------------------------------------------------
case .rotate:
let s = center
let a0 = atan2(dragStart.y - s.y, dragStart.x - s.x)
let a1 = atan2(val.location.y - s.y, val.location.x - s.x)
straightenAngle = startAngle - Float(a1 - a0)
onChange()
}
}
.onEnded { _ in
dragStart = .zero
dragMode = nil
}
}
// MARK: hit-testing helpers -------------------------------------------------
private func isOnHandle(_ pt: CGPoint) -> Bool {
Corner.allCases.contains { hypot(cornerPos($0).x - pt.x,
cornerPos($0).y - pt.y) <= handleHitRadius }
}
private func isInside(_ pt: CGPoint, using rect: CGRect? = nil) -> Bool {
let c = rect ?? CGRect(x: CGFloat(cropX),
y: CGFloat(cropY),
width: CGFloat(cropWidth),
height: CGFloat(cropHeight))
let l = imageRect.minX + imageRect.width * c.minX
let t = imageRect.minY + imageRect.height * c.minY
let r = l + imageRect.width * c.width
let b = t + imageRect.height * c.height
return pt.x >= l && pt.x <= r && pt.y >= t && pt.y <= b
}
}
// MARK: –– Preview pane -------------------------------------------------------
private struct CropPreviewPane: View {
let displayImage: MTIImage
@Binding var cropX: Float
@Binding var cropY: Float
@Binding var cropWidth: Float
@Binding var cropHeight: Float
@Binding var straightenAngle: Float
@Binding var aspectMode: AspectMode
@Binding var scaleX: Float
@Binding var scaleY: Float
let imageSize: CGSize
let onChange: () -> Void
var body: some View {
GeometryReader { proxy in
let vSize = proxy.size
let imgSize = displayImage.size
let imgAspect = imgSize.width / imgSize.height
let vAspect = vSize.width / vSize.height
let fit: CGRect = {
if vAspect > imgAspect {
let h = vSize.height; let w = h * imgAspect
return .init(x: (vSize.width - w) / 2, y: 0, width: w, height: h)
} else {
let w = vSize.width; let h = w / imgAspect
return .init(x: 0, y: (vSize.height - h) / 2, width: w, height: h)
}
}()
let cropRect = CGRect(
x: fit.minX + fit.width * CGFloat(cropX),
y: fit.minY + fit.height * CGFloat(cropY),
width: fit.width * CGFloat(cropWidth),
height: fit.height * CGFloat(cropHeight)
)
ZStack(alignment: .topLeading) {
MetalImageViewRepresentable(image: .constant(displayImage.adjusting(exposure: -1.5)))
.frame(width: fit.width, height: fit.height)
.position(x: fit.midX, y: fit.midY)
.opacity(0.9)
MetalImageViewRepresentable(image: .constant(displayImage))
.frame(width: fit.width, height: fit.height)
.position(x: fit.midX, y: fit.midY)
.mask(Path { $0.addRect(cropRect) })
CropBoxOverlay(
cropX: $cropX, cropY: $cropY,
cropWidth: $cropWidth, cropHeight: $cropHeight,
straightenAngle: $straightenAngle,
aspectMode: $aspectMode,
scaleX: $scaleX, scaleY: $scaleY,
imageSize: imageSize,
imageRect: fit
) { onChange() }
}
}
}
}
// MARK: –– Main view ----------------------------------------------------------
struct CropView: View {
// Pipeline state
@State private var inputImage = inputImage1
@State private var transformedImage: MTIImage?
@State private var metalImage: MTIImage?
// User params
@State private var straightenAngle: Float = 0
@State private var rotationAngle: Float = 0
@State private var scaleX: Float = 1
@State private var scaleY: Float = 1
@State private var cropX: Float = 0
@State private var cropY: Float = 0
@State private var cropWidth: Float = 1
@State private var cropHeight: Float = 1
@State private var cropUnit: MTICropRegionUnit = .percentage
@State private var aspectMode: AspectMode = .free
// Filters
private let transformFilter = MTITransformFilter()
private let cropFilter = MTICropFilter()
var body: some View {
VStack {
Picker("Image", selection: $inputImage) {
Text("Image 1").tag(inputImage1); Text("Image 2").tag(inputImage2)
Text("Image 3").tag(inputImage3); Text("Image 4").tag(inputImage4)
Text("Image 5").tag(inputImage5); Text("Image 6").tag(inputImage6)
Text("Hue Wheel").tag(inputImage8)
Text("X2D 1").tag(inputImage9); Text("X2D 2").tag(inputImage10)
Text("X2D 3").tag(inputImage11)
}
.pickerStyle(.segmented)
.onChange(of: inputImage) { _ in
resetCrop(); aspectMode = .free; crop()
}
HStack {
if let img = transformedImage {
CropPreviewPane(
displayImage: img,
cropX: $cropX, cropY: $cropY,
cropWidth: $cropWidth, cropHeight: $cropHeight,
straightenAngle: $straightenAngle,
aspectMode: $aspectMode,
scaleX: $scaleX, scaleY: $scaleY,
imageSize: inputImage.size
) { crop() }
} else {
Rectangle().fill(.gray.opacity(0.3))
}
VStack(spacing: 18) {
transformControls
cropControls
aspectControls
}
.frame(minWidth: 210)
.padding(.leading, 10)
}
.padding()
}
.onAppear {
resetCrop()
crop()
}
}
// MARK: Sidebar groups ------------------------------------------------------
private var transformControls: some View {
GroupBox(label: Text("Transform").bold()) {
VStack {
Text("Straighten: \(Int(straightenAngle * 180 / .pi))°")
Slider(value: Binding(get: { straightenAngle },
set: { straightenAngle = $0; crop() }),
in: -.pi / 4 ... .pi / 4)
Text("Rotation: \(Int(rotationAngle * 180 / .pi))°")
Slider(value: Binding(get: { rotationAngle },
set: { rotationAngle = $0; crop() }),
in: -.pi ... .pi)
Text("Scale X: \(scaleX, specifier: "%.2f")")
Slider(value: Binding(get: { scaleX }, set: { scaleX = $0; crop() }),
in: 0.5 ... 2)
Text("Scale Y: \(scaleY, specifier: "%.2f")")
Slider(value: Binding(get: { scaleY }, set: { scaleY = $0; crop() }),
in: 0.5 ... 2)
}
}
}
private var cropControls: some View {
GroupBox(label: Text("Crop").bold()) {
VStack {
Picker("Unit", selection: $cropUnit) {
Text("Percentage").tag(MTICropRegionUnit.percentage)
Text("Pixels").tag(MTICropRegionUnit.pixel)
}
.pickerStyle(.segmented)
.onChange(of: cropUnit) { _ in resetCrop(); crop() }
Text("X \(cropX, specifier: "%.2f")")
Slider(value: Binding(get: { cropX },
set: { cropX = $0; crop() }),
in: 0 ... (cropUnit == .percentage ? 1 : Float(inputImage.size.width)))
Text("Y \(cropY, specifier: "%.2f")")
Slider(value: Binding(get: { cropY },
set: { cropY = $0; crop() }),
in: 0 ... (cropUnit == .percentage ? 1 : Float(inputImage.size.height)))
Text("W \(cropWidth, specifier: "%.2f")")
Slider(value: Binding(get: { cropWidth },
set: { cropWidth = $0; crop() }),
in: 0.1 ... (cropUnit == .percentage ? 1 : Float(inputImage.size.width)))
Text("H \(cropHeight, specifier: "%.2f")")
Slider(value: Binding(get: { cropHeight },
set: { cropHeight = $0; crop() }),
in: 0.1 ... (cropUnit == .percentage ? 1 : Float(inputImage.size.height)))
Button("Reset Crop") {
resetCrop(); aspectMode = .free; crop()
}
}
}
}
private var aspectControls: some View {
GroupBox(label: Text("Aspect Ratio").bold()) {
Picker("Aspect", selection: $aspectMode) {
ForEach(AspectMode.allCases) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.onChange(of: aspectMode) { _ in applyAspectMode() }
}
}
// MARK: aspect helpers ------------------------------------------------------
private func applyAspectMode() {
switch aspectMode {
case .free: break
case .original: applyOriginalCrop()
case .square: applySquareCrop()
}
crop()
}
private func applyOriginalCrop() {
let side = min(cropWidth, cropHeight)
let cx = cropX + cropWidth / 2
let cy = cropY + cropHeight / 2
var nx = cx - side / 2
var ny = cy - side / 2
nx = max(0, min(1 - side, nx))
ny = max(0, min(1 - side, ny))
cropX = nx; cropY = ny
cropWidth = side; cropHeight = side
}
private func applySquareCrop() {
let wPix = cropWidth * Float(inputImage.size.width)
let hPix = cropHeight * Float(inputImage.size.height)
let sidePix = min(wPix, hPix)
let newW = sidePix / Float(inputImage.size.width)
let newH = sidePix / Float(inputImage.size.height)
let cx = cropX + cropWidth / 2
let cy = cropY + cropHeight / 2
var nx = cx - newW / 2
var ny = cy - newH / 2
nx = max(0, min(1 - newW, nx))
ny = max(0, min(1 - newH, ny))
cropX = nx; cropY = ny
cropWidth = newW; cropHeight = newH
}
// MARK: straighten helper ---------------------------------------------------
private func autoFillScale(angle: Float) -> Float {
let w = Float(inputImage.size.width), h = Float(inputImage.size.height)
let toPxX: (Float) -> Float = { self.cropUnit == .percentage ? $0 * w : $0 }
let toPxY: (Float) -> Float = { self.cropUnit == .percentage ? $0 * h : $0 }
let x0 = toPxX(cropX), x1 = toPxX(cropX + cropWidth)
let y0 = toPxY(cropY), y1 = toPxY(cropY + cropHeight)
let cosT = cosf(angle), sinT = sinf(angle)
var s: Float = 1
for px in [x0, x1] {
for py in [y0, y1] {
let dx = px - w / 2, dy = py - h / 2
let ux = cosT * dx - sinT * dy
let uy = sinT * dx + cosT * dy
let needX = 2 * fabsf(ux) / (w * max(scaleX, 1e-6))
let needY = 2 * fabsf(uy) / (h * max(scaleY, 1e-6))
s = max(s, max(needX, needY))
}
}
return max(1, s) * 1.01
}
// MARK: pipeline ------------------------------------------------------------
private func crop() {
let totalAngle = straightenAngle + rotationAngle
let autoS = autoFillScale(angle: totalAngle)
transformFilter.inputImage = inputImage
var t = CATransform3DIdentity
t = CATransform3DRotate(t, CGFloat(totalAngle), 0, 0, 1)
t = CATransform3DScale(t, CGFloat(autoS * scaleX), CGFloat(autoS * scaleY), 1)
transformFilter.transform = t
guard let transformed = transformFilter.outputImage else { return }
transformedImage = transformed
cropFilter.inputImage = transformed
cropFilter.cropRegion = MTICropRegion(
bounds: .init(x: CGFloat(cropX),
y: CGFloat(cropY),
width: CGFloat(cropWidth),
height: CGFloat(cropHeight)),
unit: cropUnit
)
metalImage = cropFilter.outputImage
}
private func resetCrop() {
cropX = 0; cropY = 0; cropWidth = 1; cropHeight = 1
}
}
// MARK: –– Preview ------------------------------------------------------------
#Preview {
CropView()
.frame(width: 800, height: 720)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment