ベジェ曲線に沿ったグラデーション
- Apple
- 開発
ベジェ曲線について調べているときに偶然見つけた記事。
グラデーションをベジェ曲線に沿って曲げながら描画するにはどうすればいいのかという話だ。図がたくさんあってとてもわかりやすい。
方法は思ったよりシンプルで、要点を抜き出すと、
- ベジェ曲線を直線の集合に分解
- それぞれの直線に沿うように長方形を並べる
- それぞれの長方形にグラデーションを描画
...というだけの話である。(上の図は前述の記事から引用)
これを Cocoa でもできないものか。いや、特に使う予定はないのだけど、楽しそうなので...!
Cocoa でやる
ところが 1 で困った。CGPath
にそういう機能がないのだ。数式から計算しないといけないのかと思いかけたが、実は意外に NSBezierPath
の機能が充実しているらしくこの場合は flattened
を取得すれば変換済みのパスが得られるらしい。汎用性を考えて CGPath
を使いたかったところだが、ただの実験なのでよしとしよう。
flattened
さえ取得してしまえば、これが含むのは直線の集合すなわち moveTo
と lineTo
の要素だけになるのでそれぞれの座標をもとに長方形をちりばめていくだけだ。
単純に点から点まで結ぶだけではカーブの外側が隙間だらけになってしまうので、各直線の中心から次の直線の中心へと結ぶ長方形も配置して無理やり埋めた。だから半透明なグラデーションだとおかしくなるけど気にしない。
参考にした記事と同じやり方ではないかもしれないけど、結果、それっぽいのができた! 使い道はないけど頭の体操としては面白かった。
以下、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
リンクも共有もお気軽に。記事を書くモチベーションの向上に役立てます。