Implement GPU-accelerated inpainting using Metal compute shaders: - Add Shaders.metal with dilateMask, gaussianBlur, diffuseInpaint, edgeAwareBlend kernels - Add PatchMatchInpainter class for exemplar-based inpainting - Update InpaintEngine to use Metal with Accelerate fallback - Add BrushCanvasView for manual brush-based mask painting - Add LineBrushView for wire removal line drawing - Update CanvasView to integrate brush canvas overlay Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
283 lines
9.0 KiB
Swift
283 lines
9.0 KiB
Swift
//
|
|
// CanvasView.swift
|
|
// CheapRetouch
|
|
//
|
|
// Main editing canvas with pinch-to-zoom, pan, mask overlay, and before/after comparison.
|
|
//
|
|
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
struct CanvasView: View {
|
|
@Bindable var viewModel: EditorViewModel
|
|
|
|
@State private var scale: CGFloat = 1.0
|
|
@State private var lastScale: CGFloat = 1.0
|
|
@State private var offset: CGSize = .zero
|
|
@State private var lastOffset: CGSize = .zero
|
|
@State private var isShowingOriginal = false
|
|
@State private var viewSize: CGSize = .zero
|
|
|
|
private let minScale: CGFloat = 1.0
|
|
private let maxScale: CGFloat = 10.0
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
ZStack {
|
|
Color.black
|
|
|
|
if let cgImage = isShowingOriginal ? viewModel.originalImage : viewModel.displayImage {
|
|
// Main image
|
|
Image(decorative: cgImage, scale: 1.0)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.scaleEffect(scale)
|
|
.offset(offset)
|
|
|
|
// Mask overlay
|
|
if let mask = viewModel.maskPreview {
|
|
Image(decorative: mask, scale: 1.0)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.scaleEffect(scale)
|
|
.offset(offset)
|
|
.blendMode(.multiply)
|
|
.colorMultiply(.red.opacity(0.5))
|
|
}
|
|
|
|
// Brush canvas overlay
|
|
if viewModel.selectedTool == .brush && !viewModel.showingMaskConfirmation {
|
|
BrushCanvasView(
|
|
viewModel: viewModel,
|
|
imageSize: viewModel.imageSize,
|
|
displayedImageFrame: displayedImageFrame(in: geometry.size)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
.gesture(tapGesture(in: geometry))
|
|
.gesture(magnificationGesture(in: geometry))
|
|
.simultaneousGesture(dragGesture(in: geometry))
|
|
.simultaneousGesture(longPressGesture)
|
|
.onTapGesture(count: 2) {
|
|
doubleTapZoom()
|
|
}
|
|
.onAppear {
|
|
viewSize = geometry.size
|
|
}
|
|
.onChange(of: geometry.size) { _, newSize in
|
|
viewSize = newSize
|
|
}
|
|
}
|
|
.clipped()
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
private func displayedImageFrame(in viewSize: CGSize) -> CGRect {
|
|
let imageSize = viewModel.imageSize
|
|
guard imageSize.width > 0, imageSize.height > 0 else {
|
|
return .zero
|
|
}
|
|
|
|
let imageAspect = imageSize.width / imageSize.height
|
|
let viewAspect = viewSize.width / viewSize.height
|
|
|
|
let displayedSize: CGSize
|
|
if imageAspect > viewAspect {
|
|
displayedSize = CGSize(
|
|
width: viewSize.width,
|
|
height: viewSize.width / imageAspect
|
|
)
|
|
} else {
|
|
displayedSize = CGSize(
|
|
width: viewSize.height * imageAspect,
|
|
height: viewSize.height
|
|
)
|
|
}
|
|
|
|
let scaledSize = CGSize(
|
|
width: displayedSize.width * scale,
|
|
height: displayedSize.height * scale
|
|
)
|
|
|
|
let origin = CGPoint(
|
|
x: (viewSize.width - scaledSize.width) / 2 + offset.width,
|
|
y: (viewSize.height - scaledSize.height) / 2 + offset.height
|
|
)
|
|
|
|
return CGRect(origin: origin, size: scaledSize)
|
|
}
|
|
|
|
// MARK: - Gestures
|
|
|
|
private func tapGesture(in geometry: GeometryProxy) -> some Gesture {
|
|
SpatialTapGesture()
|
|
.onEnded { value in
|
|
guard !viewModel.isProcessing,
|
|
!viewModel.showingMaskConfirmation,
|
|
viewModel.selectedTool != .brush else { return }
|
|
|
|
let imagePoint = convertViewPointToImagePoint(value.location, in: geometry.size)
|
|
Task {
|
|
await viewModel.handleTap(at: imagePoint)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func magnificationGesture(in geometry: GeometryProxy) -> some Gesture {
|
|
MagnificationGesture()
|
|
.onChanged { value in
|
|
let newScale = lastScale * value
|
|
scale = min(max(newScale, minScale), maxScale)
|
|
}
|
|
.onEnded { _ in
|
|
lastScale = scale
|
|
withAnimation(.spring(duration: 0.3)) {
|
|
clampOffset(in: geometry.size)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func dragGesture(in geometry: GeometryProxy) -> some Gesture {
|
|
DragGesture()
|
|
.onChanged { value in
|
|
if scale > 1.0 {
|
|
offset = CGSize(
|
|
width: lastOffset.width + value.translation.width,
|
|
height: lastOffset.height + value.translation.height
|
|
)
|
|
}
|
|
}
|
|
.onEnded { _ in
|
|
lastOffset = offset
|
|
withAnimation(.spring(duration: 0.3)) {
|
|
clampOffset(in: geometry.size)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var longPressGesture: some Gesture {
|
|
LongPressGesture(minimumDuration: 0.3)
|
|
.onEnded { _ in
|
|
// Toggle to show original briefly
|
|
isShowingOriginal = true
|
|
Task {
|
|
try? await Task.sleep(for: .seconds(0.5))
|
|
isShowingOriginal = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func doubleTapZoom() {
|
|
withAnimation(.spring(duration: 0.3)) {
|
|
if scale > 1.0 {
|
|
scale = 1.0
|
|
offset = .zero
|
|
lastScale = 1.0
|
|
lastOffset = .zero
|
|
} else {
|
|
scale = 2.5
|
|
lastScale = 2.5
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Coordinate Conversion
|
|
|
|
private func convertViewPointToImagePoint(_ viewPoint: CGPoint, in viewSize: CGSize) -> CGPoint {
|
|
let imageSize = viewModel.imageSize
|
|
guard imageSize.width > 0, imageSize.height > 0 else { return viewPoint }
|
|
|
|
// Calculate the displayed image frame
|
|
let imageAspect = imageSize.width / imageSize.height
|
|
let viewAspect = viewSize.width / viewSize.height
|
|
|
|
let displayedSize: CGSize
|
|
if imageAspect > viewAspect {
|
|
displayedSize = CGSize(
|
|
width: viewSize.width,
|
|
height: viewSize.width / imageAspect
|
|
)
|
|
} else {
|
|
displayedSize = CGSize(
|
|
width: viewSize.height * imageAspect,
|
|
height: viewSize.height
|
|
)
|
|
}
|
|
|
|
// Account for centering
|
|
let displayedOrigin = CGPoint(
|
|
x: (viewSize.width - displayedSize.width) / 2,
|
|
y: (viewSize.height - displayedSize.height) / 2
|
|
)
|
|
|
|
// Account for scale and offset
|
|
let scaledSize = CGSize(
|
|
width: displayedSize.width * scale,
|
|
height: displayedSize.height * scale
|
|
)
|
|
|
|
let scaledOrigin = CGPoint(
|
|
x: (viewSize.width - scaledSize.width) / 2 + offset.width,
|
|
y: (viewSize.height - scaledSize.height) / 2 + offset.height
|
|
)
|
|
|
|
// Convert view point to image coordinates
|
|
let relativeX = (viewPoint.x - scaledOrigin.x) / scaledSize.width
|
|
let relativeY = (viewPoint.y - scaledOrigin.y) / scaledSize.height
|
|
|
|
return CGPoint(
|
|
x: relativeX * imageSize.width,
|
|
y: relativeY * imageSize.height
|
|
)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func clampOffset(in size: CGSize) {
|
|
guard scale > 1.0 else {
|
|
offset = .zero
|
|
lastOffset = .zero
|
|
return
|
|
}
|
|
|
|
let imageSize = viewModel.imageSize
|
|
guard imageSize.width > 0, imageSize.height > 0 else { return }
|
|
|
|
let imageAspect = imageSize.width / imageSize.height
|
|
let viewAspect = size.width / size.height
|
|
|
|
let imageDisplaySize: CGSize
|
|
if imageAspect > viewAspect {
|
|
imageDisplaySize = CGSize(
|
|
width: size.width,
|
|
height: size.width / imageAspect
|
|
)
|
|
} else {
|
|
imageDisplaySize = CGSize(
|
|
width: size.height * imageAspect,
|
|
height: size.height
|
|
)
|
|
}
|
|
|
|
let scaledSize = CGSize(
|
|
width: imageDisplaySize.width * scale,
|
|
height: imageDisplaySize.height * scale
|
|
)
|
|
|
|
let maxOffsetX = max(0, (scaledSize.width - size.width) / 2)
|
|
let maxOffsetY = max(0, (scaledSize.height - size.height) / 2)
|
|
|
|
offset.width = min(max(offset.width, -maxOffsetX), maxOffsetX)
|
|
offset.height = min(max(offset.height, -maxOffsetY), maxOffsetY)
|
|
lastOffset = offset
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
let viewModel = EditorViewModel()
|
|
return CanvasView(viewModel: viewModel)
|
|
}
|