skip to Main Content

I have array of CGPoint indicated in red. I must connect all consecutive points with CGRect as shown in the rough figure to form an array of CGRect. Consider all rect has a constant width and each point could be the mid point of the sides formed from width of the rect. How could I form this array of CGRect?

enter image description here

EDIT:

Here is what I am trying to do. As described in my previous question, I am trying to introduce an eraser function.

I have a set of straight lines (in green). I basically contain the start and end points of the line in model. When in eraser mode, user is allowed to draw freely and all draw points are connected by stroke (in purple).

enter image description here

When any green line is completely covered, the line must be identified to be erased. But, I couldn’t able to determine if the line is completed covered by the eraser’s draw points.

As in comments, I tried to follow the answer here. All I have is CGPoints and I have no CGRects. I check the intersection of both green and purple path as below and it returns false.

public override func draw(_ rect: CGRect) {
    
    let wallPath = UIBezierPath()
    wallPath.move(to: CGPoint(x: 50.0, y: 50.0))
    wallPath.addLine(to: CGPoint(x: 50.0, y: 400.0))
    wallPath.addLine(to: CGPoint(x: 300.0, y: 400.0))
    wallPath.addLine(to: CGPoint(x: 300.0, y: 50.0))
        
    let wallLayer = CAShapeLayer()
    wallLayer.path = wallPath.cgPath
    wallLayer.lineWidth = 10
    wallLayer.strokeColor = UIColor.green.cgColor
    wallLayer.fillColor = nil
    layer.addSublayer(wallLayer)
    
    let eraserPath = UIBezierPath()
    eraserPath.move(to: CGPoint(x: 40.0, y: 75.0))
    eraserPath.addLine(to: CGPoint(x: 120.0, y: 75.0))
    
    let eraserLayer = CAShapeLayer()
    eraserLayer.path = eraserPath.cgPath
    eraserLayer.lineWidth = 15
    eraserLayer.strokeColor = UIColor.purple.cgColor
    layer.addSublayer(eraserLayer)
    
    if wallPath.cgPath.intersects(eraserPath.cgPath) {
        print("Overlapping")
    } else {
        print("Not overlapping")
    }
}

enter image description here

To make it more clear about the requirement, when user draws in eraser mode, based on the draw points, I have to identify which green line falls completely in the purple stroke’s coverage and the identified green line must be taken for further processing. Multiple green lines could be selected at the same time.

2

Answers


  1. So you have two CGPaths, one for the eraser and one for a line. You stroke those paths with a certain line width, which creates two areas. You want to check if one area completely covers another.

    Let’s first not worry about "completely" – just check if the two areas intersect at any point at all. You can do this by creating new CGPaths that represent the area created when stroking with a certain line width. There is an API this – copy(strokingWithWidth:lineCap:lineJoin:miterLimit:transform:).

    Then you can just use intersect on those paths.

    Here is an idea of how that would look:

    func eraserIntersectsLine(p1: CGPoint, p2: CGPoint, lineWidth: CGFloat, eraserPath: CGPath, eraserWidth: CGFloat) -> Bool {
        let eraserArea = eraserPath.copy(
            // you should change the line cap and line join parameters to match how your eraser works
            strokingWithWidth: eraserWidth, lineCap: .butt, lineJoin: .miter, miterLimit: 10
        )
        let line = UIBezierPath()
        line.move(to: p1)
        line.addLine(to: p2)
        let lineArea = line.cgPath.copy(
            // similarly, change these parameters to match how your "pencil" tool works
            strokingWithWidth: lineWidth, lineCap: .butt, lineJoin: .miter, miterLimit: 10
        )
        return lineArea.intersects(eraserArea)
    }
    

    Determining if these areas completely intersect is not trivial at all. And I think it would be a better UX if you allow a line to be erased just by intersection, and not completely covering.

    If you really want though, here is an approximation you could do. Divide the line into small "pieces", and for each of those "piece", check if the area of that intersects with the eraser area.

    Here is an idea of how that would look:

    func eraserCompletelyCoversLine(p1: CGPoint, p2: CGPoint, lineWidth: CGFloat, eraserPath: CGPath, eraserWidth: CGFloat) -> Bool {
        let angle = atan2(p2.y - p1.y, p2.x - p1.x)
        let distance = hypot(p2.x - p1.x, p2.y - p1.y)
        // split the whole line into pieces of 0.1 (some arbitrarily chosen small length)
        for start in stride(from: 0, to: distance, by: 0.1) {
            // check if each "piece" intersects with the eraser path
            let startX = p1.x + (start * cos(angle))
            let startY = p1.y + (start * sin(angle))
            let endX = p1.x + ((start + 0.1) * cos(angle))
            let endY = p1.y + ((start + 0.1) * sin(angle))
            guard eraserIntersectsLine(
                p1: .init(x: startX, y: startY),
                p2: .init(x: endX, y: endY),
                lineWidth: lineWidth,
                eraserPath: eraserPath,
                eraserWidth: eraserWidth
            ) else { return false }
        }
        return true
    }
    

    Note that I’m calling eraserIntersectsLine every time here for convenience, which creates the same path for the eraser area every time. It should be trivial to optimise this so that it only creates one copy of the eraser area if you need to.

    For a more accurate approximation, you could consider the width of the line too. Split the line width into small pieces too.

    Login or Signup to reply.
  2. CGPath has an .lineIntersection(_:using:) method that can come in really handy here…

    Let’s start with a single line path:

    enter image description here

    We create an "eraser" path – using strokingWithWidth instead of .lineWidth so we get a "filled" outline path – and cross the path:

    enter image description here

    When we call:

    let iPth = segPth.lineIntersection(eraserPth)
    

    That returns a new path, consisting of .move(to: pt1) and .addLine(to: pt2) – if we draw that iPth in cyan we see:

    enter image description here

    We can then easily compare the resulting path to the original path and determine that the original is not completely encompassed.

    If we continue defining our eraser path:

    enter image description here

    and call segPth.lineIntersection(eraserPth) again, it will return a path of move to + line to + move to + line to:

    enter image description here

    and again, we can easily determine that it is not the same path as the original line segment.

    If we keep adding points to the eraser path until we get this:

    enter image description here

    segPth.lineIntersection(eraserPth) will now return a path:

    enter image description here

    That matches the original.

    So… a couple notes first…

    Define your "walls" path as a series of "line segments" rather than a single path:

    struct LineSegment: Equatable {
        var pt1: CGPoint = .zero
        var pt2: CGPoint = .zero
    }
    

    That makes it easy to:

    • loop through the segments
    • compare them to the returned path from .lineIntersection()
    • remove individual wall-segments
    • and… create non-contiguous walls

    Next, because points use floating-point values, and UIKit likes whole numbers, we can save ourselves some headaches by rounding everything.

    For example, if we have a line from 20, 10 to 80, 10, and we have an eraser path that encompasses the entire line path, we might get a returned path from .lineIntersection() with points like 20.00001, 10.0 to 79.99997, 10.000001.

    Here is a complete example to play with…

    The LineSegment struct:

    struct LineSegment: Equatable {
        var pt1: CGPoint = .zero
        var pt2: CGPoint = .zero
    }
    

    extension to round the x,y values of a point:

    extension CGPoint {
        var rounded: CGPoint { .init(x: self.x.rounded(), y: self.y.rounded()) }
    }
    

    This extension to return the points of a path (found here: How to get the CGPoint(s) of a CGPath):

    extension CGPath {
        
        /// this is a computed property, it will hold the points we want to extract
        var points: [CGPoint] {
            
            /// this is a local transient container where we will store our CGPoints
            var arrPoints: [CGPoint] = []
            
            // applyWithBlock lets us examine each element of the CGPath, and decide what to do
            self.applyWithBlock { element in
                
                switch element.pointee.type
                {
                case .moveToPoint, .addLineToPoint:
                    arrPoints.append(element.pointee.points.pointee)
                    
                case .addQuadCurveToPoint:
                    arrPoints.append(element.pointee.points.pointee)
                    arrPoints.append(element.pointee.points.advanced(by: 1).pointee)
                    
                case .addCurveToPoint:
                    arrPoints.append(element.pointee.points.pointee)
                    arrPoints.append(element.pointee.points.advanced(by: 1).pointee)
                    arrPoints.append(element.pointee.points.advanced(by: 2).pointee)
                    
                default:
                    break
                }
            }
            
            // We are now done collecting our CGPoints and so we can return the result
            return arrPoints
            
        }
    }
    

    A UIView subclass, with shape layers, path logic, and touch handling:

    class StrokedView: UIView {
        
        let sampleWallSegments: [LineSegment] = [
            // an "outline" box
            .init(pt1: .init(x: 40.0, y: 40.0), pt2: .init(x: 260.0, y: 40.0)),
            .init(pt1: .init(x: 260.0, y: 40.0), pt2: .init(x: 260.0, y: 120.0)),
            .init(pt1: .init(x: 260.0, y: 120.0), pt2: .init(x: 120.0, y: 120.0)),
            .init(pt1: .init(x: 120.0, y: 120.0), pt2: .init(x: 120.0, y: 80.0)),
            .init(pt1: .init(x: 120.0, y: 80.0), pt2: .init(x: 60.0, y: 80.0)),
            .init(pt1: .init(x: 60.0, y: 80.0), pt2: .init(x: 60.0, y: 120.0)),
            .init(pt1: .init(x: 60.0, y: 120.0), pt2: .init(x: 40.0, y: 120.0)),
            .init(pt1: .init(x: 40.0, y: 120.0), pt2: .init(x: 40.0, y: 40.0)),
            
            // couple criss-crossed lines
            .init(pt1: .init(x: 180.0, y: 50.0), pt2: .init(x: 220.0, y: 70.0)),
            .init(pt1: .init(x: 220.0, y: 50.0), pt2: .init(x: 180.0, y: 70.0)),
            
            // some short vertical lines
            .init(pt1: .init(x: 150.0, y: 90.0), pt2: .init(x: 150.0, y: 110.0)),
            .init(pt1: .init(x: 180.0, y: 90.0), pt2: .init(x: 180.0, y: 100.0)),
            .init(pt1: .init(x: 210.0, y: 90.0), pt2: .init(x: 210.0, y: 100.0)),
            .init(pt1: .init(x: 240.0, y: 90.0), pt2: .init(x: 240.0, y: 110.0)),
        ]
    
        // this holds the "wall" line segments
        //  will initially be set to asmpleWallSegments
        //  segments may be removed
        var wallSegments: [LineSegment] = []
    
        // holds the points as the user touches/drags
        var eraserPoints: [CGPoint] = []
        
        // will hold the indexes of the wallSegments that are completely
        //  encompassed by the eraser line
        var encompassedSegments: [Int] = []
        
        let wallLayer = CAShapeLayer()
        let eraserLayer = CAShapeLayer()
        let highlightLayer = CAShapeLayer()
    
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder aDecoder: NSCoder) {
            super.init(coder:aDecoder)
            commonInit()
        }
        
        private func commonInit() {
            
            // this layer will hold our line segments path
            wallLayer.fillColor = UIColor.clear.cgColor
            wallLayer.strokeColor = UIColor.systemGreen.cgColor
            wallLayer.lineWidth = 1.0
    
            // instead of using a path with a line-width of 10,
            //  eraser layer will be filled with no line/stroke,
            //  because we will set its .path to the "stroked" path
            eraserLayer.fillColor = UIColor.systemBlue.withAlphaComponent(0.25).cgColor
            eraserLayer.lineJoin = .round
            eraserLayer.lineCap = .round
    
            // this layer will "highlight" the fully encompassed segments
            highlightLayer.fillColor = UIColor.clear.cgColor
            highlightLayer.strokeColor = UIColor.red.withAlphaComponent(0.9).cgColor
            highlightLayer.lineWidth = 2.0
            
            [wallLayer, eraserLayer, highlightLayer].forEach { lay in
                layer.addSublayer(lay)
            }
    
            reset()
        }
        
        func reset() {
            eraserPoints = []
            wallSegments = sampleWallSegments
            setNeedsLayout()
        }
        func removeSegments() {
            encompassedSegments.reversed().forEach { i in
                wallSegments.remove(at: i)
            }
            eraserPoints = []
            setNeedsLayout()
        }
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            guard let t = touches.first else { return }
            let pt = t.location(in: self).rounded
            
            // append a new point
            eraserPoints.append(pt)
            
            setNeedsLayout()
        }
        override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
            guard let t = touches.first else { return }
            let pt = t.location(in: self).rounded
            
            // always append a new point, or
            eraserPoints.append(pt)
            
            // if we want to "rubber-band" the highlight, use this instead
            //points[points.count - 1] = pt
            
            setNeedsLayout()
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
            
            if #available(iOS 16.0, *) {
                // clear the layer paths
                [wallLayer, eraserLayer, highlightLayer].forEach { lay in
                    lay.path = nil
                }
                // clear the encompassed segments array
                encompassedSegments = []
                
                // create the "walls" path
                let wallPth = CGMutablePath()
                
                wallSegments.forEach { seg in
                    wallPth.move(to: seg.pt1)
                    wallPth.addLine(to: seg.pt2)
                }
                
                // set "walls" layer path
                wallLayer.path = wallPth
                
                // return if we have no eraser points yet
                guard eraserPoints.count > 0 else { return }
    
                // create eraser path
                let eraserPth = CGMutablePath()
                
                // create highlight path (will "highlight" the encompassed segments in red)
                let highlightPath = CGMutablePath()
    
                // add lines to the eraser path
                eraserPth.move(to: eraserPoints[0])
                if eraserPoints.count == 1 {
                    eraserPth.addLine(to: .init(x: eraserPoints[0].x + 1.0, y: eraserPoints[0].y))
                }
                for i in 1..<eraserPoints.count {
                    eraserPth.addLine(to: eraserPoints[i])
                }
                
                // get a "stroked" path from the eraser path
                let strokedPth = eraserPth.copy(strokingWithWidth: 10.0, lineCap: .round, lineJoin: .round, miterLimit: 1.0)
    
                // normalize it
                let normedPth = strokedPth.normalized()
    
                // set eraser layer path
                eraserLayer.path = normedPth
                
                // for each wall segment
                for (i, thisSeg) in wallSegments.enumerated() {
                    // create a new two-point path for the segment
                    let segPth = CGMutablePath()
                    segPth.move(to: thisSeg.pt1)
                    segPth.addLine(to: thisSeg.pt2)
                    // get the intersection with the normalized path
                    let iPth = segPth.lineIntersection(normedPth)
                    // get the points from that intersecting path
                    let iPoints = iPth.points
                    // if we have Zero or any number of points other than Two,
                    //  the segment will not be completely encompassed
                    if iPoints.count == 2 {
                        // create a LineSegment with rounded points from the intersecting path
                        let thatSeg = LineSegment(pt1: iPoints[0].rounded, pt2: iPoints[1].rounded)
                        // if that segment is equal to this segment, add a line to the hightlight path
                        //  and append the index of this segment to encompassed array (so we can remove them on demand)
                        if thatSeg == thisSeg {
                            highlightPath.move(to: thisSeg.pt1)
                            highlightPath.addLine(to: thisSeg.pt2)
                            encompassedSegments.append(i)
                        }
                    }
                    // set the highlight layer path
                    highlightLayer.path = highlightPath
                }
            }
            
        }
        
    }
    

    and a simple view controller to show it in use:

    class StrokedVC: UIViewController {
    
        let testView = StrokedView()
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
            var cfg = UIButton.Configuration.filled()
    
            cfg.title = "Remove Highlighted"
            let btnA = UIButton(configuration: cfg, primaryAction: UIAction() { _ in
                self.testView.removeSegments()
            })
            
            cfg = UIButton.Configuration.filled()
            
            cfg.title = "Reset"
            let btnB = UIButton(configuration: cfg, primaryAction: UIAction() { _ in
                self.testView.reset()
            })
    
            [btnA, btnB, testView].forEach { v in
                v.translatesAutoresizingMaskIntoConstraints = false
                view.addSubview(v)
            }
    
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
    
                btnA.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                btnA.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                btnA.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
    
                testView.topAnchor.constraint(equalTo: btnA.bottomAnchor, constant: 20.0),
                testView.widthAnchor.constraint(equalToConstant: 300.0),
                testView.heightAnchor.constraint(equalToConstant: 160.0),
                testView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
    
                btnB.topAnchor.constraint(equalTo: testView.bottomAnchor, constant: 20.0),
                btnB.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                btnB.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
                
            ])
            
            testView.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
        }
    }
    

    Here’s what it looks like when running:

    enter image description here

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search