Search code examples
iosswiftobjective-cuibezierpath

Get subpath from two intersecting Bezier paths


I have two intersecting bezier paths of type UIBezierPath like the following picture. How can I get the subpath depicted by red dashed line in Swift?

enter image description here


Solution

  • Here's some sample code using ClippingBezier that may get you on your way...

    It looks like this when run:

    enter image description here enter image description here

    enter image description here enter image description here

    So, we define two sample shapes:

    class SamplePaths: NSObject {
    
        // each set of 6 values defines:
        //  curve To point
        //  control point 1
        //  control point 2
        
        let start1: CGPoint = CGPoint(x: 29, y: 134)
        let vals1: [CGFloat] = [
            15, 34, 5, 94, -6, 57,
            274, 69, 68, -22, 148, 57,
            626, 7, 420, 82, 590, 22,
            871, 102, 662, -7, 845, 33,
            789, 286, 896, 172, 877, 188,
            700, 490, 702, 383, 719, 393,
            569, 605, 682, 587, 626, 638,
            330, 503, 433, 525, 375, 594,
            180, 227, 283, 282, 295, 271,
            29, 134, 65, 182, 53, 174,
        ]
    
        let start2: CGPoint = CGPoint(x: 240, y: 452)
        let vals2: [CGFloat] = [
            421.0, 369.0, 289.0, 452.0, 337.0, 369.0,
            676.0, 468.0, 506.0, 369.0, 581.0, 462.0,
            925.0, 369.0, 771.0, 474.0, 875.0, 385.0,
            1120.0, 397.0, 976.0, 354.0, 1090.0, 334.0,
            1086.0, 581.0, 1150.0, 460.0, 1119.0, 519.0,
            1127.0, 770.0, 1053.0, 643.0, 1173.0, 669.0,
            997.0, 845.0, 1081.0, 871.0, 1093.0, 857.0,
            786.0, 790.0, 902.0, 833.0, 910.0, 790.0,
            536.0, 854.0, 663.0, 790.0, 710.0, 860.0,
            290.0, 731.0, 362.0, 848.0, 405.0, 742.0,
            92.0, 770.0, 174.0, 721.0, 181.0, 794.0,
            3.0, 652.0, 3.0, 746.0, 3.0, 705.0,
            92.0, 519.0, 3.0, 587.0, 13.0, 550.0,
            171.0, 452.0, 172.0, 489.0, 131.0, 464.0,
            240.0, 452.0, 211.0, 439.0, 191.0, 452.0
        ]
    
        func samplePath(_ n: Int) -> UIBezierPath {
            var pt: CGPoint = .zero
            var c1: CGPoint = .zero
            var c2: CGPoint = .zero
            
            var start: CGPoint = .zero
            var vals: [CGFloat] = []
            
            if n == 1 {
                start = start1
                vals = vals1
            } else {
                start = start2
                vals = vals2
            }
            
            let bez = UIBezierPath()
            
            pt = start
            bez.move(to: pt)
            
            for i in stride(from: 0, to: vals.count, by: 6) {
                pt = CGPoint(x: vals[i + 0], y: vals[i + 1])
                c1 = CGPoint(x: vals[i + 2], y: vals[i + 3])
                c2 = CGPoint(x: vals[i + 4], y: vals[i + 5])
                bez.addCurve(to: pt, controlPoint1: c1, controlPoint2: c2)
            }
            
            bez.close()
            
            return bez
        }
        
    }
    

    Then we create a UIView subclass that strokes either First, Second, or Both paths, or only the Clipped path:

    class TestView: UIView {
        
        enum Show {
            case both
            case first
            case second
            case clippedPath
        }
        
        public var show: Show = .both {
            didSet {
                setNeedsDisplay()
            }
        }
        
        public var path1: UIBezierPath!
        public var path2: UIBezierPath!
        
        override func draw(_ rect: CGRect) {
    
            // don't do anything if we don't have valid paths
            guard let path1 = self.path1,
                  let path2 = self.path2
            else { return }
            
            // this fits the paths into self.bounds
            let margin: CGFloat = 8
            let fittingBounds = self.bounds.insetBy(dx: margin, dy: margin)
            let entireBounds = path1.bounds.union(path2.bounds)
            let scale = min(fittingBounds.size.width / entireBounds.size.width, fittingBounds.size.height / entireBounds.size.height)
            var transform: CGAffineTransform = .identity
            transform = transform.translatedBy(x: margin, y: margin)
            transform = transform.scaledBy(x: scale, y: scale)
            
            let ctx = UIGraphicsGetCurrentContext()
            ctx?.saveGState()
            ctx?.concatenate(transform)
    
            path1.lineWidth = 4
            path2.lineWidth = 4
    
            switch show {
            case .first:
                UIColor.systemGreen.setStroke()
                path1.stroke()
                
            case .second:
                UIColor.blue.setStroke()
                path2.stroke()
                
            case .clippedPath:
                // get the unique shapes from slicing path2 with path1
                guard let shapes: [DKUIBezierPathShape] = path2.uniqueShapesCreatedFromSlicing(withUnclosedPath: path1) else { return }
                // get the first shape
                guard let shape: DKUIBezierPathShape = shapes.first else { return }
                // get the first segment
                guard let seg = shape.segments.firstObject as? DKUIBezierPathClippedSegment else { return }
                // get the path from that segment
                let pth: UIBezierPath = seg.pathSegment
                // stroke that segment's path
                UIColor.red.setStroke()
                pth.stroke()
                
                // print the segment's path to the debug console
                print(pth)
                
            default:
                UIColor.systemGreen.setStroke()
                path1.stroke()
                UIColor.blue.setStroke()
                path2.stroke()
                
            }
            
            ctx?.restoreGState()
        }
    
    }
    

    and a sample controller to produce the above images:

    // import the ClippingBezier library
    import ClippingBezier
    
    class ViewController: UIViewController {
    
        let testView: TestView = {
            let v = TestView()
            v.backgroundColor = .white
            return v
        }()
        
        let infoLabel: UILabel = {
            let v = UILabel()
            v.textAlignment = .center
            return v
        }()
    
        // index to step through examples
        var idx: Int = 0
    
        override func viewDidLoad() {
            super.viewDidLoad()
        
            view.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
    
            // create a stack view
            let stack: UIStackView = {
                let v = UIStackView()
                v.axis = .vertical
                v.spacing = 8
                v.translatesAutoresizingMaskIntoConstraints = false
                return v
            }()
            
            // create a button
            let btn: UIButton = {
                let v = UIButton()
                v.setTitle("Next Step", for: [])
                v.setTitleColor(.white, for: .normal)
                v.setTitleColor(.lightGray, for: .highlighted)
                v.backgroundColor = .systemBlue
                v.layer.cornerRadius = 8
                v.addTarget(self, action: #selector(nextStep), for: .touchUpInside)
                return v
            }()
            
            // add elements to stack view
            [infoLabel, testView, btn].forEach { v in
                stack.addArrangedSubview(v)
            }
            
            stack.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(stack)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                stack.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                stack.centerYAnchor.constraint(equalTo: g.centerYAnchor),
                
                // let's use 300 x 300 for our test view
                testView.widthAnchor.constraint(equalToConstant: 300.0),
                testView.heightAnchor.constraint(equalTo: testView.widthAnchor),
            ])
            
            // set the bezier path shapes
            testView.path1 = SamplePaths().samplePath(1)
            testView.path2 = SamplePaths().samplePath(2)
            
            // get started
            nextStep()
    
        }
    
        
        @objc func nextStep() {
            
            switch idx % 4 {
            case 1:
                infoLabel.text = "Stroke Second Path"
                testView.show = .second
            case 2:
                infoLabel.text = "Stroke Both Paths"
                testView.show = .both
            case 3:
                infoLabel.text = "Stroke only the Clipped Path"
                testView.show = .clippedPath
            default:
                infoLabel.text = "Stroke First Path"
                testView.show = .first
            }
    
            idx += 1
            
        }
    
    }
    

    Notes:

    This is Sample Code Only!!! It is meant to be a starting point.

    You will need (probably a lot of) additional logic. For example, if your paths look like this:

    enter image description here enter image description here

    you will have multiple clipped segments.