togo

= ひとりごと to go

zumuya の人による机の上系情報サイト

ベジェ曲線に沿ったグラデーション

  • Apple
  • 開発

ベジェ曲線について調べているときに偶然見つけた記事。

グラデーションをベジェ曲線に沿って曲げながら描画するにはどうすればいいのかという話だ。図がたくさんあってとてもわかりやすい。

Before
After

方法は思ったよりシンプルで、要点を抜き出すと、

  1. ベジェ曲線を直線の集合に分解
  1. それぞれの直線に沿うように長方形を並べる
  2. それぞれの長方形にグラデーションを描画

...というだけの話である。(上の図は前述の記事から引用)

これを Cocoa でもできないものか。いや、特に使う予定はないのだけど、楽しそうなので...!

Cocoa でやる

ところが 1 で困った。CGPath にそういう機能がないのだ。数式から計算しないといけないのかと思いかけたが、実は意外に NSBezierPath の機能が充実しているらしくこの場合は flattened を取得すれば変換済みのパスが得られるらしい。汎用性を考えて CGPath を使いたかったところだが、ただの実験なのでよしとしよう。

flattened さえ取得してしまえば、これが含むのは直線の集合すなわち moveTolineTo の要素だけになるのでそれぞれの座標をもとに長方形をちりばめていくだけだ。

単純に点から点まで結ぶだけではカーブの外側が隙間だらけになってしまうので、各直線の中心から次の直線の中心へと結ぶ長方形も配置して無理やり埋めた。だから半透明なグラデーションだとおかしくなるけど気にしない。

参考にした記事と同じやり方ではないかもしれないけど、結果、それっぽいのができた! 使い道はないけど頭の体操としては面白かった。

Result

以下、Playground(macOS)で動く雑なコード。

import Cocoa

extension Array where Element == NSPoint
{
    func average() -> NSPoint?
    {
        if (count > 0) {
            var point = NSPoint.zero
            forEach {
                point.x += $0.x
                point.y += $0.y
            }
            point.x /= CGFloat(count)
            point.y /= CGFloat(count)
            return point
        } else {
            return nil
        }
    }
}
func drawPoint(at point: NSPoint, color: NSColor = .red)
{
    color.setFill()
    NSColor.white.setStroke()
    let ovalShadow = NSShadow(); do {
        ovalShadow.shadowOffset = NSSize(width: 0, height: -1)
        ovalShadow.shadowBlurRadius = 2
        ovalShadow.shadowColor = NSColor(calibratedWhite: 0.0, alpha: 0.4)
    }
    var ovalRect = CGRect(origin: point, size: NSMakeSize(8, 8)); do {
        ovalRect = ovalRect.offsetBy(dx: -(ovalRect.width * 0.5), dy: -(ovalRect.height * 0.5))
    }
    let oval = NSBezierPath(ovalIn: ovalRect); do {
        oval.lineWidth = 1
    }
    NSGraphicsContext.saveGraphicsState(); do {
        ovalShadow.set()
        oval.fill()
    }; NSGraphicsContext.restoreGraphicsState()
    oval.stroke()
}
func strokeGradientLine(from point0: NSPoint, to point1: NSPoint, gradient: NSGradient, thickness: CGFloat)
{
    let delta = NSSize(width: (point1.x - point0.x), height: (point1.y - point0.y))
    let distance = sqrt(pow(delta.width, 2.0) + pow(delta.height, 2.0))
    let unrotatedRect = NSMakeRect(0, 0, distance, thickness)

    let radian = atan2(delta.width, delta.height)
    NSGraphicsContext.saveGraphicsState(); do {
        let transform = NSAffineTransform(); do {
            transform.translateX(by: point0.x, yBy: point0.y)
            transform.rotate(byRadians: CGFloat.pi * 0.5 - radian)
            transform.translateX(by: 0.0, yBy: -(thickness * 0.5))
        }
        transform.set()
        gradient.draw(in: unrotatedRect, angle: 90)
    }; NSGraphicsContext.restoreGraphicsState()
}

let drawingSize = NSSize(width: 300, height: 150)
let padding = NSEdgeInsets(top: 10, left: 20, bottom: 30, right: 20)

let bounds = NSMakeRect(padding.left, padding.bottom, drawingSize.width, drawingSize.height)
let imageSize = NSSize(width: (drawingSize.width + padding.left + padding.right), height: (drawingSize.height + padding.top + padding.bottom))
let image = NSImage(size: imageSize, flipped: false, drawingHandler: { dirtyRect -> Bool in

    //Define Bezier Path:
    let points = [
        NSPoint(x: bounds.minX, y: bounds.minY),
        NSPoint(x: bounds.midX, y: bounds.maxY),
        NSPoint(x: bounds.maxX, y: bounds.minY)
    ]
    var bezierPath = NSBezierPath(); do {
        bezierPath.move(to: points[0])
        bezierPath.appendArc(from: points[1], to: points[2], radius: (bounds.width * 0.4))
        bezierPath.line(to: points[2])
        bezierPath.flatness = 5 //Ignored?
    }
    bezierPath = bezierPath.flattened

    //Gradient Stroke:
    let lineGradient = NSGradient(colors: [.orange, .green])!
    for interpolationType in 0..<2 {
        var lastElementPoints: [NSPoint] = []
        for elementIndex in 0..<bezierPath.elementCount {
            var elementPoints = [NSPoint](repeatElement(NSPoint.zero, count: 5))
            let element = bezierPath.element(at: elementIndex, associatedPoints: &elementPoints)

            switch element {
            case .moveToBezierPathElement:
                print("move to \(elementPoints[0])")
            case .lineToBezierPathElement:
                print("line to \(elementPoints[0])")
                if (interpolationType == 0) {
                    if let previousPoint = lastElementPoints.last {
                        strokeGradientLine(from: previousPoint, to: elementPoints[0], gradient: lineGradient, thickness: 40)
                    }
                } else { //center to center
                    if (lastElementPoints.count == 2), let previousCenter = lastElementPoints.average() {
                        let newCenter = [lastElementPoints.last!, elementPoints[0]].average()!
                        strokeGradientLine(from: previousCenter, to: newCenter, gradient: lineGradient, thickness: 40)
                    }
                }
            case .closePathBezierPathElement:
                print("close")
            default:
                print("curve should not appear in flattened path!")
            }
            lastElementPoints.append(elementPoints[0])
            if (lastElementPoints.count > 2) {
                lastElementPoints.removeFirst()
            }
        }
    }

    //Dashed Line:
    let dash = NSBezierPath(); do {
        var points = points
        dash.appendPoints(&points, count: points.count)
        var dashLengths: [CGFloat] = [3, 3]
        dash.setLineDash(&dashLengths, count: dashLengths.count, phase: 0.0)
    }
    NSColor.brown.set()
    dash.lineWidth = 1
    dash.stroke()

    //Original Line:
    NSColor.black.set()
    bezierPath.lineWidth = 1
    bezierPath.stroke()

    //Dots:
    points.forEach { drawPoint(at: $0) }

    return true
})
image //Result

Share

リンクも共有もお気軽に。記事を書くモチベーションの向上に役立てます。