diff --git a/CheapRetouch.xcodeproj/project.pbxproj b/CheapRetouch.xcodeproj/project.pbxproj index 37bdfa6..18bc9f4 100644 --- a/CheapRetouch.xcodeproj/project.pbxproj +++ b/CheapRetouch.xcodeproj/project.pbxproj @@ -252,7 +252,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 7X85543FQQ; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -292,7 +292,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 7X85543FQQ; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/CheapRetouch/Features/Editor/BrushCanvasView.swift b/CheapRetouch/Features/Editor/BrushCanvasView.swift index 70171ef..718128e 100644 --- a/CheapRetouch/Features/Editor/BrushCanvasView.swift +++ b/CheapRetouch/Features/Editor/BrushCanvasView.swift @@ -2,7 +2,8 @@ // BrushCanvasView.swift // CheapRetouch // -// Canvas overlay for brush-based manual selection. +// Canvas overlay for brush-based manual selection with support for +// single-finger drawing and two-finger pan/zoom. // import SwiftUI @@ -12,40 +13,74 @@ struct BrushCanvasView: View { @Bindable var viewModel: EditorViewModel let imageSize: CGSize let displayedImageFrame: CGRect + @Binding var scale: CGFloat + @Binding var lastScale: CGFloat + @Binding var offset: CGSize + @Binding var lastOffset: CGSize + let minScale: CGFloat + let maxScale: CGFloat + let clampOffset: () -> Void - @State private var currentStroke: [CGPoint] = [] @State private var allStrokes: [[CGPoint]] = [] + @State private var currentStroke: [CGPoint] = [] @State private var currentTouchLocation: CGPoint? @State private var gradientImage: EdgeRefinement.GradientImage? + /// The effective brush size on screen, scaled by zoom level + private var scaledBrushSize: CGFloat { + viewModel.brushSize * scale + } + var body: some View { - Canvas { context, size in - // Draw all completed strokes - for stroke in allStrokes { - drawStroke(stroke, in: &context, color: .white) - } - - // Draw current stroke - if !currentStroke.isEmpty { - drawStroke(currentStroke, in: &context, color: .white) - } - - // Draw brush preview circle at current touch location - if let location = currentTouchLocation { - let previewRect = CGRect( - x: location.x - viewModel.brushSize / 2, - y: location.y - viewModel.brushSize / 2, - width: viewModel.brushSize, - height: viewModel.brushSize - ) - context.stroke( - Path(ellipseIn: previewRect), - with: .color(.white.opacity(0.8)), - lineWidth: 2 + GeometryReader { geometry in + ZStack { + // UIKit gesture handler for proper single/two-finger differentiation + // This is at the bottom of the ZStack but receives all touches + BrushGestureView( + displayedImageFrame: displayedImageFrame, + scale: $scale, + lastScale: $lastScale, + offset: $offset, + lastOffset: $lastOffset, + minScale: minScale, + maxScale: maxScale, + currentStroke: $currentStroke, + allStrokes: $allStrokes, + currentTouchLocation: $currentTouchLocation, + clampOffset: clampOffset ) + .frame(width: geometry.size.width, height: geometry.size.height) + + // SwiftUI Canvas for rendering strokes (on top for visibility) + Canvas { context, size in + // Draw all completed strokes + for stroke in allStrokes { + drawStroke(stroke, in: &context, color: .white) + } + + // Draw current stroke + if !currentStroke.isEmpty { + drawStroke(currentStroke, in: &context, color: .white) + } + + // Draw brush preview circle at current touch location + if let location = currentTouchLocation { + let previewRect = CGRect( + x: location.x - scaledBrushSize / 2, + y: location.y - scaledBrushSize / 2, + width: scaledBrushSize, + height: scaledBrushSize + ) + context.stroke( + Path(ellipseIn: previewRect), + with: .color(.white.opacity(0.8)), + lineWidth: 2 + ) + } + } + .allowsHitTesting(false) // Let the gesture view handle all touches } } - .gesture(drawingGesture) .onAppear { // Precompute gradient for edge refinement if enabled if viewModel.useEdgeRefinement, let image = viewModel.displayImage { @@ -59,11 +94,9 @@ struct BrushCanvasView: View { } .onChange(of: viewModel.editedImage) { _, _ in // Recompute gradient when image changes (e.g., after undo/redo) - // to ensure edge refinement works correctly with the current image if viewModel.useEdgeRefinement, let image = viewModel.displayImage { computeGradientAsync(from: image) } else { - // Invalidate cached gradient so it will be recomputed when edge refinement is enabled gradientImage = nil } } @@ -119,47 +152,24 @@ struct BrushCanvasView: View { path, with: .color(color.opacity(0.7)), style: StrokeStyle( - lineWidth: viewModel.brushSize, + lineWidth: scaledBrushSize, lineCap: .round, lineJoin: .round ) ) } - private var drawingGesture: some Gesture { - DragGesture(minimumDistance: 0) - .onChanged { value in - let point = value.location - currentTouchLocation = point - - // Only add points within the image bounds - if displayedImageFrame.contains(point) { - currentStroke.append(point) - } - } - .onEnded { _ in - currentTouchLocation = nil - if !currentStroke.isEmpty { - allStrokes.append(currentStroke) - currentStroke = [] - } - } - } - private func applyBrushMask() async { DebugLogger.action("BrushCanvasView.applyBrushMask called") - - // Check if we have strokes to apply or a pending mask to refine + let hasStrokes = !allStrokes.isEmpty let hasPendingMask = viewModel.pendingRefineMask != nil - + guard hasStrokes || hasPendingMask else { DebugLogger.log("No strokes or pending mask to apply") return } - - // Get the actual display image dimensions - this may differ from originalImage - // after undo/redo due to preview scaling in renderPreview + guard let displayImage = viewModel.displayImage else { DebugLogger.error("No display image available") return @@ -172,12 +182,10 @@ struct BrushCanvasView: View { DebugLogger.log("Displayed frame: \(displayedImageFrame)") DebugLogger.log("Has pending mask: \(hasPendingMask)") - // Use actual display image size for scaling, not the original image size let scaleX = actualImageSize.width / displayedImageFrame.width let scaleY = actualImageSize.height / displayedImageFrame.height DebugLogger.log("Scale factors: X=\(scaleX), Y=\(scaleY)") - // Convert all strokes to image coordinates (using actual display image dimensions) var imageCoordStrokes: [[CGPoint]] = [] for stroke in allStrokes { let imageStroke = stroke.map { point in @@ -189,7 +197,6 @@ struct BrushCanvasView: View { imageCoordStrokes.append(imageStroke) } - // Apply edge refinement if enabled and gradient is available if viewModel.useEdgeRefinement, let gradient = gradientImage { imageCoordStrokes = imageCoordStrokes.map { stroke in EdgeRefinement.refineSelectionToEdges( @@ -200,24 +207,17 @@ struct BrushCanvasView: View { } } - // Create mask image from strokes at the ACTUAL display image size - // (not the original image size, which may differ after undo/redo) let format = UIGraphicsImageRendererFormat() - format.scale = 1.0 // Don't use screen scale, use actual pixel size + format.scale = 1.0 let renderer = UIGraphicsImageRenderer(size: actualImageSize, format: format) let maskImage = renderer.image { ctx in - // Fill with black (not masked) UIColor.black.setFill() ctx.fill(CGRect(origin: .zero, size: actualImageSize)) - - // If we have a pending mask from Person/Object detection, draw it first - // Note: pendingMask might be at original size, so we draw it scaled + if let pendingMask = viewModel.pendingRefineMask { - // Draw the pending mask - white areas become part of the combined mask ctx.cgContext.draw(pendingMask, in: CGRect(origin: .zero, size: actualImageSize)) } - // Draw brush strokes in white (masked areas) on top UIColor.white.setStroke() for stroke in imageCoordStrokes { @@ -239,7 +239,6 @@ struct BrushCanvasView: View { if let cgImage = maskImage.cgImage { DebugLogger.imageInfo("Created brush mask", image: cgImage) - // Clear the pending mask since we've incorporated it viewModel.pendingRefineMask = nil viewModel.maskPreview = nil await viewModel.applyBrushMask(cgImage) @@ -247,17 +246,281 @@ struct BrushCanvasView: View { DebugLogger.error("Failed to create CGImage from brush mask") } - // Clear strokes after applying allStrokes.removeAll() DebugLogger.log("Strokes cleared") } } +// MARK: - UIKit Gesture Handler + +/// A UIViewRepresentable that handles touch gestures with proper single vs two-finger differentiation. +/// - Single finger: Drawing brush strokes +/// - Two fingers: Pan (when zoomed) and pinch to zoom +struct BrushGestureView: UIViewRepresentable { + let displayedImageFrame: CGRect + @Binding var scale: CGFloat + @Binding var lastScale: CGFloat + @Binding var offset: CGSize + @Binding var lastOffset: CGSize + let minScale: CGFloat + let maxScale: CGFloat + @Binding var currentStroke: [CGPoint] + @Binding var allStrokes: [[CGPoint]] + @Binding var currentTouchLocation: CGPoint? + let clampOffset: () -> Void + + func makeUIView(context: Context) -> BrushGestureUIView { + let view = BrushGestureUIView() + view.backgroundColor = .clear + view.isMultipleTouchEnabled = true + view.coordinator = context.coordinator + return view + } + + func updateUIView(_ uiView: BrushGestureUIView, context: Context) { + context.coordinator.displayedImageFrame = displayedImageFrame + context.coordinator.scale = $scale + context.coordinator.lastScale = $lastScale + context.coordinator.offset = $offset + context.coordinator.lastOffset = $lastOffset + context.coordinator.minScale = minScale + context.coordinator.maxScale = maxScale + context.coordinator.currentStroke = $currentStroke + context.coordinator.allStrokes = $allStrokes + context.coordinator.currentTouchLocation = $currentTouchLocation + context.coordinator.clampOffset = clampOffset + } + + func makeCoordinator() -> Coordinator { + Coordinator( + displayedImageFrame: displayedImageFrame, + scale: $scale, + lastScale: $lastScale, + offset: $offset, + lastOffset: $lastOffset, + minScale: minScale, + maxScale: maxScale, + currentStroke: $currentStroke, + allStrokes: $allStrokes, + currentTouchLocation: $currentTouchLocation, + clampOffset: clampOffset + ) + } + + class Coordinator: NSObject { + var displayedImageFrame: CGRect + var scale: Binding + var lastScale: Binding + var offset: Binding + var lastOffset: Binding + var minScale: CGFloat + var maxScale: CGFloat + var currentStroke: Binding<[CGPoint]> + var allStrokes: Binding<[[CGPoint]]> + var currentTouchLocation: Binding + var clampOffset: () -> Void + + // Track if we're in drawing mode (single finger) or navigation mode (two fingers) + var isDrawing = false + var initialPinchScale: CGFloat = 1.0 + + init( + displayedImageFrame: CGRect, + scale: Binding, + lastScale: Binding, + offset: Binding, + lastOffset: Binding, + minScale: CGFloat, + maxScale: CGFloat, + currentStroke: Binding<[CGPoint]>, + allStrokes: Binding<[[CGPoint]]>, + currentTouchLocation: Binding, + clampOffset: @escaping () -> Void + ) { + self.displayedImageFrame = displayedImageFrame + self.scale = scale + self.lastScale = lastScale + self.offset = offset + self.lastOffset = lastOffset + self.minScale = minScale + self.maxScale = maxScale + self.currentStroke = currentStroke + self.allStrokes = allStrokes + self.currentTouchLocation = currentTouchLocation + self.clampOffset = clampOffset + } + } +} + +/// Custom UIView that handles touch events directly for precise control over single vs multi-touch. +class BrushGestureUIView: UIView { + var coordinator: BrushGestureView.Coordinator? + + // Track active touches ourselves for reliability + private var activeTouches: [UITouch] = [] + private var isDrawing = false + private var isNavigating = false + private var initialPinchDistance: CGFloat = 0 + private var initialPinchScale: CGFloat = 1.0 + private var lastPanLocation: CGPoint = .zero + + private func activeTouchCount() -> Int { + return activeTouches.count + } + + private func getActiveTouchLocations() -> [CGPoint] { + return activeTouches.map { $0.location(in: self) } + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let coordinator = coordinator else { return } + + // Add new touches to our tracking + for touch in touches { + if !activeTouches.contains(touch) { + activeTouches.append(touch) + } + } + + let touchCount = activeTouchCount() + + if touchCount == 1 { + // Single finger - start drawing + let point = activeTouches[0].location(in: self) + isDrawing = true + isNavigating = false + + coordinator.currentTouchLocation.wrappedValue = point + if coordinator.displayedImageFrame.contains(point) { + coordinator.currentStroke.wrappedValue.append(point) + } + } else if touchCount >= 2 { + // Two or more fingers - switch to navigation mode + if isDrawing { + // Was drawing, cancel current stroke + coordinator.currentStroke.wrappedValue.removeAll() + coordinator.currentTouchLocation.wrappedValue = nil + isDrawing = false + } + + // Start navigation + let locations = getActiveTouchLocations() + let p1 = locations[0] + let p2 = locations[1] + initialPinchDistance = hypot(p2.x - p1.x, p2.y - p1.y) + initialPinchScale = coordinator.scale.wrappedValue + lastPanLocation = CGPoint(x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2) + isNavigating = true + } + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard let coordinator = coordinator else { return } + + let touchCount = activeTouchCount() + + if touchCount == 1 && isDrawing { + // Single finger - continue drawing + let point = activeTouches[0].location(in: self) + coordinator.currentTouchLocation.wrappedValue = point + if coordinator.displayedImageFrame.contains(point) { + coordinator.currentStroke.wrappedValue.append(point) + } + } else if touchCount >= 2 && isNavigating { + // Two fingers - pan and zoom + let locations = getActiveTouchLocations() + let p1 = locations[0] + let p2 = locations[1] + + // Handle pinch zoom + if initialPinchDistance > 0 { + let currentDistance = hypot(p2.x - p1.x, p2.y - p1.y) + let scaleFactor = currentDistance / initialPinchDistance + let newScale = initialPinchScale * scaleFactor + coordinator.scale.wrappedValue = min(max(newScale, coordinator.minScale), coordinator.maxScale) + } + + // Handle pan + let currentCenter = CGPoint(x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2) + let deltaX = currentCenter.x - lastPanLocation.x + let deltaY = currentCenter.y - lastPanLocation.y + + coordinator.offset.wrappedValue = CGSize( + width: coordinator.offset.wrappedValue.width + deltaX, + height: coordinator.offset.wrappedValue.height + deltaY + ) + lastPanLocation = currentCenter + } + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + guard let coordinator = coordinator else { return } + + // Remove ended touches from tracking + activeTouches.removeAll { touches.contains($0) } + + let remainingCount = activeTouchCount() + + if isDrawing && remainingCount == 0 { + // Finish drawing stroke + coordinator.currentTouchLocation.wrappedValue = nil + if !coordinator.currentStroke.wrappedValue.isEmpty { + coordinator.allStrokes.wrappedValue.append(coordinator.currentStroke.wrappedValue) + coordinator.currentStroke.wrappedValue = [] + } + isDrawing = false + } + + if isNavigating && remainingCount < 2 { + // End navigation + coordinator.lastScale.wrappedValue = coordinator.scale.wrappedValue + coordinator.lastOffset.wrappedValue = coordinator.offset.wrappedValue + withAnimation(.spring(duration: 0.3)) { + coordinator.clampOffset() + } + isNavigating = false + // Don't start drawing with remaining finger - wait for fresh touch + } + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + guard let coordinator = coordinator else { return } + + // Remove cancelled touches + activeTouches.removeAll { touches.contains($0) } + + // Cancel any ongoing gesture + coordinator.currentTouchLocation.wrappedValue = nil + coordinator.currentStroke.wrappedValue.removeAll() + + if isNavigating { + coordinator.lastScale.wrappedValue = coordinator.scale.wrappedValue + coordinator.lastOffset.wrappedValue = coordinator.offset.wrappedValue + coordinator.clampOffset() + } + + isDrawing = false + isNavigating = false + } +} + #Preview { + @Previewable @State var scale: CGFloat = 1.0 + @Previewable @State var lastScale: CGFloat = 1.0 + @Previewable @State var offset: CGSize = .zero + @Previewable @State var lastOffset: CGSize = .zero + let viewModel = EditorViewModel() return BrushCanvasView( viewModel: viewModel, imageSize: CGSize(width: 1000, height: 1000), - displayedImageFrame: CGRect(x: 0, y: 0, width: 300, height: 300) + displayedImageFrame: CGRect(x: 0, y: 0, width: 300, height: 300), + scale: $scale, + lastScale: $lastScale, + offset: $offset, + lastOffset: $lastOffset, + minScale: 1.0, + maxScale: 10.0, + clampOffset: {} ) } diff --git a/CheapRetouch/Features/Editor/CanvasView.swift b/CheapRetouch/Features/Editor/CanvasView.swift index 6e9dde9..2781096 100644 --- a/CheapRetouch/Features/Editor/CanvasView.swift +++ b/CheapRetouch/Features/Editor/CanvasView.swift @@ -51,20 +51,28 @@ struct CanvasView: View { BrushCanvasView( viewModel: viewModel, imageSize: viewModel.imageSize, - displayedImageFrame: displayedImageFrame(in: geometry.size) + displayedImageFrame: displayedImageFrame(in: geometry.size), + scale: $scale, + lastScale: $lastScale, + offset: $offset, + lastOffset: $lastOffset, + minScale: minScale, + maxScale: maxScale, + clampOffset: { clampOffset(in: geometry.size) } ) } } } .contentShape(Rectangle()) - .gesture(tapGesture(in: geometry)) - .gesture(magnificationGesture(in: geometry)) - .simultaneousGesture(combinedDragGesture(in: geometry)) - .simultaneousGesture(longPressGesture) - .onTapGesture(count: 2) { - doubleTapZoom() - } + .applyGestures( + isBrushMode: viewModel.selectedTool == .brush, + tapGesture: tapGesture(in: geometry), + magnificationGesture: magnificationGesture(in: geometry), + dragGesture: combinedDragGesture(in: geometry), + longPressGesture: longPressGesture, + doubleTapAction: doubleTapZoom + ) .onAppear { viewSize = geometry.size } @@ -155,8 +163,8 @@ struct CanvasView: View { private func combinedDragGesture(in geometry: GeometryProxy) -> some Gesture { DragGesture(minimumDistance: 1) .onChanged { value in - // Only allow panning when move tool is selected - guard viewModel.selectedTool == .move else { return } + // Allow panning for person, object, and move tools (not brush - uses two-finger pan) + guard viewModel.selectedTool == .person || viewModel.selectedTool == .object || viewModel.selectedTool == .move else { return } // Pan mode: only when zoomed in if scale > 1.0 { @@ -167,8 +175,8 @@ struct CanvasView: View { } } .onEnded { _ in - // Only allow panning when move tool is selected - guard viewModel.selectedTool == .move else { return } + // Allow panning for person, object, and move tools (not brush - uses two-finger pan) + guard viewModel.selectedTool == .person || viewModel.selectedTool == .object || viewModel.selectedTool == .move else { return } lastOffset = offset withAnimation(.spring(duration: 0.3)) { @@ -180,15 +188,14 @@ struct CanvasView: View { private func magnificationGesture(in geometry: GeometryProxy) -> some Gesture { MagnificationGesture() .onChanged { value in - // Only allow zooming when move tool is selected - guard viewModel.selectedTool == .move else { return } + // Allow zooming for person, object, and move tools (brush uses UIKit pinch gesture) + guard viewModel.selectedTool != .brush else { return } let newScale = lastScale * value scale = min(max(newScale, minScale), maxScale) } .onEnded { _ in - // Only allow zooming when move tool is selected - guard viewModel.selectedTool == .move else { return } + guard viewModel.selectedTool != .brush else { return } lastScale = scale withAnimation(.spring(duration: 0.3)) { @@ -210,9 +217,7 @@ struct CanvasView: View { } private func doubleTapZoom() { - // Only allow double-tap zoom when move tool is selected - guard viewModel.selectedTool == .move else { return } - + // Allow double-tap zoom for all tools withAnimation(.spring(duration: 0.3)) { if scale > 1.0 { scale = 1.0 @@ -312,6 +317,37 @@ struct CanvasView: View { } } +// MARK: - Conditional Gesture Application + +extension View { + /// Applies gestures conditionally based on whether brush mode is active. + /// In brush mode, all SwiftUI gestures are disabled to allow UIKit touch handling in BrushCanvasView. + @ViewBuilder + func applyGestures( + isBrushMode: Bool, + tapGesture: T, + magnificationGesture: M, + dragGesture: D, + longPressGesture: L, + doubleTapAction: @escaping () -> Void + ) -> some View { + if isBrushMode { + // In brush mode, don't attach any gestures - let BrushCanvasView handle everything + self + } else { + // In other modes, attach all gestures + self + .gesture(tapGesture) + .gesture(magnificationGesture) + .simultaneousGesture(dragGesture) + .simultaneousGesture(longPressGesture) + .onTapGesture(count: 2) { + doubleTapAction() + } + } + } +} + #Preview { let viewModel = EditorViewModel() return CanvasView(viewModel: viewModel) diff --git a/screenshots/iPad/1.PNG b/screenshots/iPad/1.PNG new file mode 100644 index 0000000..6a6a348 Binary files /dev/null and b/screenshots/iPad/1.PNG differ diff --git a/screenshots/iPad/2.png b/screenshots/iPad/2.png new file mode 100644 index 0000000..7dc95a7 Binary files /dev/null and b/screenshots/iPad/2.png differ diff --git a/screenshots/iPad/3.png b/screenshots/iPad/3.png new file mode 100644 index 0000000..e77c6d3 Binary files /dev/null and b/screenshots/iPad/3.png differ diff --git a/screenshots/iPhone/1.png b/screenshots/iPhone/1.png new file mode 100644 index 0000000..7a130ab Binary files /dev/null and b/screenshots/iPhone/1.png differ diff --git a/screenshots/iPhone/2.png b/screenshots/iPhone/2.png new file mode 100644 index 0000000..fec2e34 Binary files /dev/null and b/screenshots/iPhone/2.png differ diff --git a/screenshots/iPhone/3.png b/screenshots/iPhone/3.png new file mode 100644 index 0000000..f9fc7cc Binary files /dev/null and b/screenshots/iPhone/3.png differ diff --git a/screenshots/iPhone/4.png b/screenshots/iPhone/4.png new file mode 100644 index 0000000..563120f Binary files /dev/null and b/screenshots/iPhone/4.png differ diff --git a/screenshots/iPhone/5.png b/screenshots/iPhone/5.png new file mode 100644 index 0000000..a765187 Binary files /dev/null and b/screenshots/iPhone/5.png differ diff --git a/screenshots/iPhone/6.png b/screenshots/iPhone/6.png new file mode 100644 index 0000000..00ccfb2 Binary files /dev/null and b/screenshots/iPhone/6.png differ