Created
May 3, 2025 23:50
-
-
Save rdev/646d2467534f9f096178ba37e2467884 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // 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