Swift で計算して Bezier 曲線を描く

  • 25
    いいね
  • 0
    コメント

今回は、UIBezierPath をコードで計算して表示する方法を紹介したいと思います。現実的には iOS や macOS であれば、UIBezierPath なり NSBezierPath を生成して、stroke() するだけでいいのですが、どうやってその座標を計算しているか気になる型は参考にしてください。

Bezier Path は以下のような ベジエ曲線または直線を組み合わせたものです。下図の P0から P3の間に、コントロールポイントと呼ばれる P1、P2 を設け滑らかに曲線を描きます。

300px-Bezier_curve.svg.png

計算のイメージは言葉で説明するより図になっている方がわかりやすいと思うので、GIF アニメーションを引用します。


https://upload.wikimedia.org/wikipedia/commons/d/db/Bézier_3_big.gif

これを swift で計算してみる事にしましょう。まず自分の計算と実際の bezier path とを比較するために、Core Graphics の CGPath を使う事にします。Core Graphics の機能に CGPath の要素を調べる機能があるので、これを使いますが、C のコールバックなので、Swift の extension を書いて要素を簡単に取得できるようにします。

CGPath+Z.swift
import Foundation
import CoreGraphics

//
//  PathElement
//

public enum PathElement {
    case moveToPoint(CGPoint)
    case addLineToPoint(CGPoint)
    case addQuadCurveToPoint(CGPoint, CGPoint)
    case addCurveToPoint(CGPoint, CGPoint, CGPoint)
    case closeSubpath
}

internal class Info {
    var pathElements = [PathElement]()
}


//
//  CGPathRef
//

public extension CGPath {

    var pathElements: [PathElement] {
        var info = Info()


        self.apply(info: &info) { (info, element) -> Void in

            if let infoPointer = UnsafeMutablePointer<Info>(OpaquePointer(info)) {
                switch element.pointee.type {
                case .moveToPoint:
                    let pt = element.pointee.points[0]
                    infoPointer.pointee.pathElements.append(PathElement.moveToPoint(pt))
                case .addLineToPoint:
                    let pt = element.pointee.points[0]
                    infoPointer.pointee.pathElements.append(PathElement.addLineToPoint(pt))
                case .addQuadCurveToPoint:
                    let pt1 = element.pointee.points[0]
                    let pt2 = element.pointee.points[1]
                    infoPointer.pointee.pathElements.append(PathElement.addQuadCurveToPoint(pt1, pt2))
                case .addCurveToPoint:
                    let pt1 = element.pointee.points[0]
                    let pt2 = element.pointee.points[1]
                    let pt3 = element.pointee.points[2]
                    infoPointer.pointee.pathElements.append(PathElement.addCurveToPoint(pt1, pt2, pt3))
                case .closeSubpath:
                    infoPointer.pointee.pathElements.append(PathElement.closeSubpath)
                }
            }
        }

        return info.pathElements
    }

}

これで以下のように、CGPath を細かくパスの要素に分割できます。

let bezier: UIBezierPath = ....
let pathElements: [PathElement] = bezier.cgPath.pathElements

もう一つ、CGPoint でベクターの演算がしやすいように、CGPoint の extension を用意します。

CGPoint+Z.swift
import Foundation
import CoreGraphics

infix operator 
infix operator ×


public extension CGPoint {

    static func - (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
        return CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y)
    }

    static func + (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
        return CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
    }

    static func * (lhs: CGPoint, rhs: CGFloat) -> CGPoint {
        return CGPoint(x: lhs.x * rhs, y: lhs.y * rhs)
    }

    static func / (lhs: CGPoint, rhs: CGFloat) -> CGPoint {
        return CGPoint(x: lhs.x / rhs, y: lhs.y / rhs)
    }

    static func  (lhs: CGPoint, rhs: CGPoint) -> CGFloat { // dot product
        return lhs.x * rhs.x + lhs.y * rhs.y
    }

    static func × (lhs: CGPoint, rhs: CGPoint) -> CGFloat { // cross product
        return lhs.x * rhs.y - lhs.y * rhs.x
    }

    var length²: CGFloat {
        return (x * x) + (y * y)
    }

    var length: CGFloat {
        return sqrt(self.length²)
    }

    var normalized: CGPoint {
        let length = self.length
        return CGPoint(x: x/length, y: y/length)
    }

}

Playground でも確認しやすように UIView のサブクラスに CGPath を表示してみましょう。CGPath の要素は以下の通りです。

PathElement Description
moveToPoint The path element that starts a new subpath. The element holds a single point for the destination. See the function moveTo().
addLineToPoint The path element that adds a line from the current point to a new point. The element holds a single point for the destination. See the function addLineTo().
addQuadCurveToPoint The path element that adds a quadratic curve from the current point to the specified point. The element holds a control point and a destination point. See the function addQuadCurve()
addCurveToPoint The path element that adds a cubic curve from the current point to the specified point. The element holds two control points and a destination point. See the function addCurve()
closeSubpath The path element that closes and completes a subpath. The element does not contain any points. See the function closeSubpath()

CGPath を要素に応じて処理します。

for element in bezier.cgPath.pathElements {
    switch element {
    case .moveToPoint(let point1):
        // ...
    case .addLineToPoint(let point1):
        // ...
    case .addQuadCurveToPoint(let point1, let point2):
        // ...
    case .addCurveToPoint(let point1, let point2, let point3):
        // ...
    case .closeSubpath:
        // ...
    }
}

そして、それぞれの要素に応じて、以下のメソッドを呼ぶ事とします。実際の座標の計算はこの中で行う事とします。

関数 説明
plot(p) p に点を描く
plot(p0, p1) p0 〜 p1 へ連続した点で描く
plot(p0, p1, p2 p0 〜 p2 へ quadratic curve を連続した点で描く
plot(p0, p1, p2, p3) P0 〜 p3 へ cubic curve を連続した点で描く

例えば cubic curve の p0, p1, p2, p3 から描く曲線は以下のようになります。CGPoint が + や * 演算子を extension で実装しているので、座標の計算の部分は見やすいかと思います。課題は p0 〜 p3 の間にいくつ点を打つか n の計算でしょうか?ここでは p0-p1, p1-p2, p2-p3 の距離の和にしていますが、別にいい方法があるかと思います。

plot
func plot(_ p0: CGPoint, _ p1: CGPoint, _ p2: CGPoint, _ p3: CGPoint) {

    let n = Int((p1 - p0).length + (p2 - p1).length + (p3 - p2).length)
    for i in 0 ..< n {
        let t = CGFloat(i) / CGFloat(n)

        let q1 = p0 + (p1 - p0) * t
        let q2 = p1 + (p2 - p1) * t
        let q3 = p2 + (p3 - p2) * t

        let r1 = q1 + (q2 - q1) * t
        let r2 = q2 + (q3 - q2) * t

        let s = r1 + (r2 - r1) * t
        plot(s)

    }

}

さて、コードの全体像は以下の通りです。色々な形の bezier path で試したい場合は path プロパティの戻す値を色々試して見てください。

わかりやすいように、UIBezierPath が描く曲線を黄色に、コードで計算した曲線は赤色で表示しています。当方の確認した範囲では、正しく計算できていると考えています。

Screen Shot 2017-01-06 at 0.52.50.png

コードの全文は以下の通りです。また記事の末尾に GitHub の URL も記載する事とします。

MyView.swift
class MyView: UIView {

    override func layoutSubviews() {
        super.layoutSubviews()
        self.backgroundColor = UIColor.white
    }

    lazy var path: UIBezierPath = {
        let bezier = UIBezierPath()
        bezier.move(to: CGPoint(x: 50.0, y: 25.0))
        bezier.addCurve(to: CGPoint(x: 300.0, y: 300.0),
                controlPoint1: CGPoint(x: 150.0, y: 25.0),
                controlPoint2: CGPoint(x: 300-25, y: 300-150))
        bezier.addQuadCurve(to: CGPoint(x: 50, y: 500),
                controlPoint: CGPoint(x: 400, y: 400))
        bezier.addLine(to: CGPoint(x: 120, y: 600))
        bezier.close()
        return bezier
    }()

    override func draw(_ rect: CGRect) {
        super.draw(rect)

        UIColor.yellow.set()
        let bezier = self.path
        bezier.lineWidth = 5.0
        bezier.stroke()


        var startPoint: CGPoint?
        var lastPoint: CGPoint?
        for element in bezier.cgPath.pathElements {
            switch element {
            case .moveToPoint(let point1):
                self.plot(point1)
                startPoint = point1
                lastPoint = point1
            case .addLineToPoint(let point1):
                if let point0 = lastPoint {
                    self.plot(point0, point1)
                }
                lastPoint = point1
            case .addQuadCurveToPoint(let point1, let point2):
                if let point0 = lastPoint {
                    self.plot(point0, point1, point2)
                }
                lastPoint = point2
            case .addCurveToPoint(let point1, let point2, let point3):
                if let point0 = lastPoint {
                    plot(point0, point1, point2, point3)
                }
                self.plot(point3)
                lastPoint = point3
            case .closeSubpath:
                if let startPoint = startPoint, let lastPoint = lastPoint {
                    plot(lastPoint, startPoint)
                }

            }
        }
    }

    func plot(_ point: CGPoint) {
        let sqrt2 = sqrt(CGFloat(2.0))
        UIColor.red.set()
        UIBezierPath(ovalIn: CGRect(x: point.x-1, y: point.y-1, width: sqrt2, height: sqrt2)).fill()
    }

    func plot(_ p0: CGPoint, _ p1: CGPoint) {
        let v = p1 - p0
        let n = Int(v.length)
        for i in 0 ..< n {
            let t = CGFloat(i) / CGFloat(n)
            let q = p0 + v * t
            plot(q)
        }
    }

    func plot(_ p0: CGPoint, _ p1: CGPoint, _ p2: CGPoint) {
        let n = Int((p1 - p0).length + (p2 - p1).length)
        for i in 0 ..< n {
            let t = CGFloat(i) / CGFloat(n)

            let q1 = p0 + (p1 - p0) * t
            let q2 = p1 + (p2 - p1) * t

            let r = q1 + (q2 - q1) * t
            plot(r)
        }
    }

    func plot(_ p0: CGPoint, _ p1: CGPoint, _ p2: CGPoint, _ p3: CGPoint) {

        let n = Int((p1 - p0).length + (p2 - p1).length + (p3 - p2).length)
        for i in 0 ..< n {
            let t = CGFloat(i) / CGFloat(n)

            let q1 = p0 + (p1 - p0) * t
            let q2 = p1 + (p2 - p1) * t
            let q3 = p2 + (p3 - p2) * t

            let r1 = q1 + (q2 - q1) * t
            let r2 = q2 + (q3 - q2) * t

            let s = r1 + (r2 - r1) * t
            plot(s)

        }

    }

}

Playground のコードは GitHub より入所可能です。描画する点の個数が多いため、表示には30秒またはそれ以上かかる場合がありますので、ご容赦くださいませ。

https://github.com/codelynx/ComputeBezierPath

[環境に関する表記]

Versions
Xcode Version 8.2.1 (8C1002)
Apple Swift version 3.0.2 (swiftlang-800.0.63 clang-800.0.42.1)