Search code examples
iosswiftuibezierpathcgcontext

iOS - Detecting path is below another path


Here is my requirement. Basically it is an eraser concept. I have polygon with n sides. (x,y) of all lines are stored in a model. To erase any part in the polygon, user can draw on the polygon over the lines and if the line comes completely under the coverage of the free hand drawing, the particular lines must be highlighted and later it could be erased.

Context drawing is used to draw both polygon and eraser. Based on the line data in model, the polygon is drawn. When user draws in eraser mode, it is stroked with larger line width in light blue colour. When the drawing has a complete coverage over any line in polygon, the line could be highlighted in dark blue indicating selection.

enter image description here

Problem is, I couldn't able to determine if the line has complete coverage over the eraser. Since I have only start and end point of a line, it gets difficult to determine it. What could be reliable way to identify the lines as user draws in eraser mode?

EDIT Trying to provide answer for the questions in comment enter image description here

For the first question, the bottom straight line has to be identified and deleted since it is covered completely. To answer second situation, none of the red lines will be selected/identified. To answer third, all lines that comes completely under the coverage of blue stroke must be selected - highlighted in green.


Solution

  • So, I guess you want something like this:

    A shape made of straight lines. As I drag my finger over it, I draw 44-point wide stroke. Where the stroke overlaps the straight lines, the lines turn green. When a straight line is completely green, it becomes a dashed line.

    I'm turning the completely “erased” lines dashed instead of removing them entirely.

    If your deployment target is at least iOS 16, then you can use the lineSubtracting method of CGPath to do the “heavy lifting”.

    Apple still hasn't provided real documentation of this method on the web site, but the header file describes it as follows:

    Returns a new path with a line from this path that does not overlap the filled region of the given path.

    • Parameters:
      • other: The path to subtract.
      • rule: The rule for determining which areas to treat as the interior of other. Defaults to the CGPathFillRule.winding rule if not specified.
    • Returns: A new path.

    The line of the resulting path is the line of this path that does not overlap the filled region of other.

    Intersected subpaths that are clipped create open subpaths. Closed subpaths that do not intersect other remain closed.

    So, here's the strategy:

    • Create a CGPath for one of your straight line segments.
    • Create a CGPath of the user's erase gesture.
    • Stroke the erase path.
    • Use lineSubtracting on the straight line segment path, passing the stroked erase path, to get a path containing just the part of the straight line segment that is not covered by the eraser.
    • If the path returned by lineSubtracting is empty, the straight line has been completely erased.
    • Repeat for each straight line segment.

    Let's try it out. First, I'll write a model type to store both an original path and the part of that path that remains after erasing:

    struct ErasablePath {
        var original: Path
        var remaining: Path
    }
    

    Let's add a couple of extra initializers, and a method that updates the remaining path by subtracting a (stroked) eraser path:

    extension ErasablePath {
        init(_ path: Path) {
            self.init(original: path, remaining: path)
        }
        
        init(start: CGPoint, end: CGPoint) {
            self.init(Path {
                $0.move(to: start)
                $0.addLine(to: end)
            })
        }
        
        func erasing(_ eraserPath: CGPath) -> Self {
            return Self(
                original: original,
                remaining: Path(remaining.cgPath.lineSubtracting(eraserPath))
            )
        }
    }
    

    I'll use the following function to turn an array of points into an array of ErasablePaths:

    func makeErasableLines(points: [CGPoint]) -> [ErasablePath] {
        guard let first = points.first, let last = points.dropFirst().last else {
            return []
        }
        return zip(points, points.dropFirst()).map {
            ErasablePath(start: $0, end: $1)
        } + [ErasablePath(start: last, end: first)]
    }
    

    Here is the complete data model for the toy app:

    struct Model {
        var erasables: [ErasablePath] = makeErasableLines(points: [
            CGPoint(x: 50, y: 100),
            CGPoint(x: 300, y: 100),
            CGPoint(x: 300, y: 400),
            CGPoint(x: 175, y: 400),
            CGPoint(x: 175, y: 250),
            CGPoint(x: 50, y: 250),
        ])
    
        var eraserPath: Path = Path()
    
        var strokedEraserPath: Path = Path()
    
        var isErasing: Bool = false
    
        let lineWidth: CGFloat = 44
    }
    

    To update the model as the user interacts with the app, I'll need methods to respond to the user starting, moving, and ending a touch, and a way to reset the data model:

    extension Model {
        mutating func startErasing(at point: CGPoint) {
            eraserPath.move(to: point)
            isErasing = true
        }
        
        mutating func continueErasing(to point: CGPoint) {
            eraserPath.addLine(to: point)
            strokedEraserPath = eraserPath.strokedPath(.init(
                lineWidth: 44,
                lineCap: .round,
                lineJoin: .round
            ))
            let cgEraserPath = strokedEraserPath.cgPath
            erasables = erasables
                .map { $0.erasing(cgEraserPath) }
        }
        
        mutating func endErasing() {
            isErasing = false
        }
        
        mutating func reset() {
            self = .init()
        }
    }
    

    We need a view that draws the erasable paths and the eraser path. I'll draw each original erasable path in green, and draw it dashed if it's been fully erased. I'll draw the remaining (unerased) part of each erasable path in red. And I'll draw the stroked eraser path in semitransparent purple.

    struct DrawingView: View {
        @Binding var model: Model
    
        var body: some View {
            Canvas { gc, size in
                for erasable in model.erasables {
                    gc.stroke(
                        erasable.original,
                        with: .color(.green),
                        style: .init(
                            lineWidth: 2,
                            lineCap: .round,
                            lineJoin: .round,
                            miterLimit: 1,
                            dash: erasable.remaining.isEmpty ? [8, 8] : [],
                            dashPhase: 4
                        )
                    )
                }
    
                for erasable in model.erasables {
                    gc.stroke(
                        erasable.remaining,
                        with: .color(.red),
                        lineWidth: 2
                    )
                }
    
                gc.fill(
                    model.strokedEraserPath,
                    with: .color(.purple.opacity(0.5))
                )
            }
        }
    }
    

    In my ContentView, I'll add a DragGesture on the drawing view, and also show a reset button:

    struct ContentView: View {
        @Binding var model: Model
        
        var body: some View {
            VStack {
                DrawingView(model: $model)
                    .gesture(eraseGesture)
    
                Button("Reset") { model.reset() }
                    .padding()
            }
        }
        
        var eraseGesture: some Gesture {
            DragGesture(minimumDistance: 0, coordinateSpace: .local)
                .onChanged { drag in
                    if model.isErasing {
                        model.continueErasing(to: drag.location)
                    } else {
                        model.startErasing(at: drag.location)
                    }
                }
                .onEnded { drag in
                    model.endErasing()
                }
        }
    }
    

    That's the code I used to generate the animation at the top of the answer. But I confess that it was a rigged demo. The lineSubtracting method is a little buggy and I was careful to avoid triggering the bug. Here's the bug:

    Bug demo. See text for a description of the bug.

    If an ErasablePath is a horizontal line segment, and the eraser path starts below that segment, then lineSubtracting removes the entire erasable path, even if the eraser path and the line segment have no overlap!

    To work around the bug, I insert the following init method into Model:

    struct Model {
        ... existing code ...
    
        init() {
            // lineSubtracting has a bug (still present in iOS 17.0 beta 1):
            // If the receiver is a horizontal line, and the argument (this eraserPath) starts below that line, the entire receiver is removed, even if the argument doesn't intersect the receiver at all.
            // To work around the bug, I put a zero-length segment at the beginning of eraserPath way above the actual touchable area.
            startErasing(at: .init(x: -1000, y: -1000))
            continueErasing(to: .init(x: -1000, y: -1000))
            endErasing()
        }
    }
    

    The eraser path always starts above the erasable paths, so it no longer triggers the bug:

    demo showing the bug not triggering