Files
CheapRetouch/CheapRetouch/Features/Editor/CanvasView.swift
jared 48ee7ecd7c Add Metal-based inpainting and brush selection tools
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>
2026-01-23 23:41:43 -05:00

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