iosswiftcgrectcgpoint

iOS - Get CGRect around CGPoints


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.


Solution

  • 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:

    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