Convert UIBezierPath to PKStrokePath swift
Asked Answered
V

2

5

Is there a way to convert UIBezierPath to PKStrokePath ?

for example I have these path as UIBezierPath how can I convert it to PKStrokePath to use it in PKDrawing ?

let shape = UIBezierPath()
shape.move(to: CGPoint(x: 30.2, y: 37.71))
shape.addLine(to: CGPoint(x: 15.65, y: 2.08))
shape.addLine(to: CGPoint(x: 2.08, y: 37.33))
shape.move(to: CGPoint(x: 23.46, y: 21.21))
shape.addLine(to: CGPoint(x: 8.36, y: 21.02))
Verisimilitude answered 15/8, 2021 at 8:4 Comment(0)
H
7

UIBezierPath PKStrokePath are totally diferrent objects

UIBezierPath Can have points, lines, curves and jumps to an other point, while PKStrokePath can only have points which will be connected, but they have different parameters, like size, force, etc

So to create PKStrokePath we would need to calculate points based on a UIBezierPath. Also a single UIBezierPath may produce more than one PKStrokePath, if path has move steps inside

That's a tricky path but possible

As the base I took Finding the closest point on UIBezierPath. This article talks about calculating UIBezierPath points

While author of this article is taking 100 points for each UIBezierPath curve, I took a next step and added binary search idea into the algorithm: I wanted to have points on the curve with distance not greater than stopDistance(you can play with this value)

extension UIBezierPath {
    func generatePathPoints() -> [[CGPoint]] {
        let points = cgPath.points()
        guard points.count > 0 else {
            return []
        }
        var paths = [[CGPoint]]()
        var pathPoints = [CGPoint]()
        var previousPoint: CGPoint?
        let stopDistance: CGFloat = 10
        for command in points {
            let endPoint = command.point
            defer {
                previousPoint = endPoint
            }
            guard let startPoint = previousPoint else {
                continue
            }
            let pointCalculationFunc: (CGFloat) -> CGPoint
            switch command.type {
            case .addLineToPoint:
                // Line
                pointCalculationFunc = {
                    calculateLinear(t: $0, p1: startPoint, p2: endPoint)
                }
            case .addQuadCurveToPoint:
                pointCalculationFunc = {
                    calculateQuad(t: $0, p1: startPoint, p2: command.controlPoints[0], p3: endPoint)
                }
            case .addCurveToPoint:
                pointCalculationFunc = {
                    calculateCube(t: $0, p1: startPoint, p2: command.controlPoints[0], p3: command.controlPoints[1], p4: endPoint)
                }
            case .closeSubpath:
                previousPoint = nil
                fallthrough
            case .moveToPoint:
                if !pathPoints.isEmpty {
                    paths.append(pathPoints)
                    pathPoints = []
                }
                continue
            @unknown default:
                continue
            }
            
            let initialCurvePoints = [
                CurvePoint(position: 0, cgPointGenerator: pointCalculationFunc),
                CurvePoint(position: 1, cgPointGenerator: pointCalculationFunc),
            ]
            let curvePoints = calculatePoints(
                tRange: 0...1,
                pointCalculationFunc: pointCalculationFunc,
                leftPoint: initialCurvePoints[0].cgPoint,
                stopDistance: stopDistance
            ) + initialCurvePoints
            pathPoints.append(
                contentsOf:
                    curvePoints
                    .sorted { $0.position < $1.position }
                    .map { $0.cgPoint }
            )
            previousPoint = endPoint
        }
        if !pathPoints.isEmpty {
            paths.append(pathPoints)
            pathPoints = []
        }
        return paths
    }
    
    private func calculatePoints(
        tRange: ClosedRange<CGFloat>,
        pointCalculationFunc: (CGFloat) -> CGPoint,
        leftPoint: CGPoint,
        stopDistance: CGFloat
    ) -> [CurvePoint] {
        let middlePoint = CurvePoint(position: (tRange.lowerBound + tRange.upperBound) / 2, cgPointGenerator: pointCalculationFunc)
        if hypot(leftPoint.x - middlePoint.cgPoint.x, leftPoint.y - middlePoint.cgPoint.y) < stopDistance {
            return [middlePoint]
        }
        let leftHalfPoints = calculatePoints(tRange: tRange.lowerBound...middlePoint.position, pointCalculationFunc: pointCalculationFunc, leftPoint: leftPoint, stopDistance: stopDistance)
        let rightHalfPoints = calculatePoints(tRange: middlePoint.position...tRange.upperBound, pointCalculationFunc: pointCalculationFunc, leftPoint: middlePoint.cgPoint, stopDistance: stopDistance)
        return leftHalfPoints + rightHalfPoints + [middlePoint]
    }
}

private struct CurvePoint {
    let position: CGFloat
    let cgPoint: CGPoint
    
    init(position: CGFloat, cgPointGenerator: (CGFloat) -> CGPoint) {
        self.position = position
        self.cgPoint = cgPointGenerator(position)
    }
}

struct PathCommand {
    let type: CGPathElementType
    let point: CGPoint
    let controlPoints: [CGPoint]
}

// https://mcmap.net/q/367118/-how-to-get-a-list-of-points-from-a-uibezierpath
extension CGPath {
    func points() -> [PathCommand] {
        var bezierPoints = [PathCommand]()
        forEachPoint { element in
            guard element.type != .closeSubpath else {
                return
            }
            let numberOfPoints: Int = {
                switch element.type {
                case .moveToPoint, .addLineToPoint: // contains 1 point
                    return 1
                case .addQuadCurveToPoint: // contains 2 points
                    return 2
                case .addCurveToPoint: // contains 3 points
                    return 3
                case .closeSubpath:
                    return 0
                @unknown default:
                    fatalError()
                }
            }()
            var points = [CGPoint]()
            for index in 0..<(numberOfPoints - 1) {
                let point = element.points[index]
                points.append(point)
            }
            let command = PathCommand(type: element.type, point: element.points[numberOfPoints - 1], controlPoints: points)
            bezierPoints.append(command)
        }
        return bezierPoints
    }
    
    private func forEachPoint(body: @convention(block) (CGPathElement) -> Void) {
        typealias Body = @convention(block) (CGPathElement) -> Void
        func callback(_ info: UnsafeMutableRawPointer?, _ element: UnsafePointer<CGPathElement>) {
            let body = unsafeBitCast(info, to: Body.self)
            body(element.pointee)
        }
        withoutActuallyEscaping(body) { body in
            let unsafeBody = unsafeBitCast(body, to: UnsafeMutableRawPointer.self)
            apply(info: unsafeBody, function: callback as CGPathApplierFunction)
        }
    }
}

/// Calculates a point at given t value, where t in 0.0...1.0
private func calculateLinear(t: CGFloat, p1: CGPoint, p2: CGPoint) -> CGPoint {
    let mt = 1 - t
    let x = mt*p1.x + t*p2.x
    let y = mt*p1.y + t*p2.y
    return CGPoint(x: x, y: y)
}

/// Calculates a point at given t value, where t in 0.0...1.0
private func calculateCube(t: CGFloat, p1: CGPoint, p2: CGPoint, p3: CGPoint, p4: CGPoint) -> CGPoint {
    let mt = 1 - t
    let mt2 = mt*mt
    let t2 = t*t
    
    let a = mt2*mt
    let b = mt2*t*3
    let c = mt*t2*3
    let d = t*t2
    
    let x = a*p1.x + b*p2.x + c*p3.x + d*p4.x
    let y = a*p1.y + b*p2.y + c*p3.y + d*p4.y
    return CGPoint(x: x, y: y)
}

/// Calculates a point at given t value, where t in 0.0...1.0
private func calculateQuad(t: CGFloat, p1: CGPoint, p2: CGPoint, p3: CGPoint) -> CGPoint {
    let mt = 1 - t
    let mt2 = mt*mt
    let t2 = t*t
    
    let a = mt2
    let b = mt*t*2
    let c = t2
    
    let x = a*p1.x + b*p2.x + c*p3.x
    let y = a*p1.y + b*p2.y + c*p3.y
    return CGPoint(x: x, y: y)
}

And the final step of converting points to list of PKStrokePath and a PKDrawing

let strokePaths = path.generatePathPoints().map { pathPoints in
    PKStrokePath(
        controlPoints: pathPoints.map { pathPoint in
            PKStrokePoint(
                location: pathPoint,
                timeOffset: 0,
                size: .init(width: 5, height: 5),
                opacity: 1,
                force: 1,
                azimuth: 0,
                altitude: 0
            )
        },
        creationDate: Date()
    )
    return CGPoint(x: x, y: y)
}
let drawing = PKDrawing(
    strokes: strokePaths.map { strokePath in
        PKStroke(
            ink: PKInk(.pen, color: UIColor.black),
            path: strokePath
        )
    }
)
Hosbein answered 15/8, 2021 at 12:30 Comment(1)
You solution work great with curves , thank youVerisimilitude
S
4

I would suggest capturing the array of arrays of CGPoint, e.g.

let pointArrays = [
    [CGPoint(x: 30.2, y: 37.71), CGPoint(x: 15.65, y: 2.08), CGPoint(x: 2.08, y: 37.33)], 
    [CGPoint(x: 23.46, y: 21.21), CGPoint(x: 8.36, y: 21.02)]
]

From that, you could create the appropriate UIBezierPath or PKStrokePath, respectively.

For example:

let path = UIBezierPath()
for stroke in pointArrays where pointArrays.count > 1 {
    path.move(to: stroke.first!)
    for point in stroke.dropFirst() {
        path.addLine(to: point)
    }
}

Will yield (with lineWidth of 1 and a red stroke color):

enter image description here

Whereas with PencilKit,

let ink = PKInk(.pen, color: .blue)
let strokes = pointArrays.compactMap { stroke -> PKStroke? in
    guard stroke.count > 1 else { return nil }
    let controlPoints = stroke.enumerated().map { index, point in
        PKStrokePoint(location: point, timeOffset: 0.1 * TimeInterval(index), size: CGSize(width: 3, height: 3), opacity: 2, force: 1, azimuth: 0, altitude: 0)
    }
    let path = PKStrokePath(controlPoints: controlPoints, creationDate: Date())
    return PKStroke(ink: ink, path: path)
}
let drawing = PKDrawing(strokes: strokes)

Will yield:

enter image description here

Obviously, as the two drawings illustrate, a series of points in a UIBezierPath is not the same as a series of control points in a PKStrokePath.

If you want the PKStrokePath to match, you should create separate paths for each line segment, e.g.

let ink = PKInk(.pen, color: .blue)
var strokes: [PKStroke] = []

for points in pointArrays where points.count > 1 {
    let strokePoints = points.enumerated().map { index, point in
        PKStrokePoint(location: point, timeOffset: 0.1 * TimeInterval(index), size: CGSize(width: 3, height: 3), opacity: 2, force: 1, azimuth: 0, altitude: 0)
    }

    var startStrokePoint = strokePoints.first!

    for strokePoint in strokePoints {
        let path = PKStrokePath(controlPoints: [startStrokePoint, strokePoint], creationDate: Date())
        strokes.append(PKStroke(ink: ink, path: path))
        startStrokePoint = strokePoint
    }
}
let drawing = PKDrawing(strokes: strokes)

Yielding:

enter code here

Slattery answered 15/8, 2021 at 11:57 Comment(3)
Thank you , but I notice it working perfect with straight lines but if I have curves it will show weird shapeVerisimilitude
when there is addCurve it won't work , for example let path = UIBezierPath() path.move(to: CGPoint(x: 11.458, y: 11.458)) path.addLine(to: CGPoint(x: 11.458, y: 125.816)) path.addCurve(to: CGPoint(x: 97.479, y: 60.973), controlPoint1: CGPoint(x: 11.458, y: 125.816), controlPoint2: CGPoint(x: 129.531, y: 94.336)) path.addCurve(to: CGPoint(x: 41.153999999999996, y: 67.304), controlPoint1: CGPoint(x: 65.42699999999999, y: 27.61), controlPoint2: CGPoint(x: 62.344, y: 27.677999999999997))Verisimilitude
This how the shape should looks like : a.cl.ly/kpunmeqGVerisimilitude

© 2022 - 2024 — McMap. All rights reserved.