はじめに
Twitterで困っている人がいたのでちょっと作ってみました。
つまりこういうわけなんです。だれかおしえて! #Swift #Win32API pic.twitter.com/LqEmnErtPO
— 七草みけ (@nanakusamike) 2018年6月2日
私もWin32のプログラムを書く身としてはこの辺の悩みはよくわかります。
手っ取り早くコードだけ欲しい方向け
ちょっといい加減に書いたので全パターンでうまく行くかわかりません(矩形が負になるものとか面倒なので試していません)。
/// Win32API Arc API互換っぽい関数 by Takabo Soft
func arcWin32(_ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat, _ x3: CGFloat, _ y3: CGFloat, _ x4: CGFloat, _ y4: CGFloat) -> CGPath {
let path = CGMutablePath()
let width = x2 - x1
let height = y2 - y1
let xCenter = (x1 + x2) / 2
let yCenter = (y1 + y2) / 2
let center = CGPoint(x: xCenter, y: yCenter)
let radius = max(width, height) / 2
let yScale = height / width
let transform = CGAffineTransform(translationX: 0, y: -yCenter).concatenating(CGAffineTransform(scaleX: 1, y: yScale)).concatenating(CGAffineTransform(translationX: 0, y: +yCenter))
let invertedTransform = transform.inverted()
let inv3 = CGPoint(x: x3, y: y3).applying(invertedTransform)
let inv4 = CGPoint(x: x4, y: y4).applying(invertedTransform)
path.addArc(center: center, radius: radius, startAngle: atan2(inv3.y - yCenter, inv3.x - xCenter), endAngle: atan2(inv4.y - yCenter, inv4.x - xCenter), clockwise: true, transform: transform)
return path
}
開発のポイント1 真円のみの場合
CoreGraphicsのaddArc
は1つの半径と二つの角度を渡して円弧を描画をする関数です。
したがって真円に限定すれば、角度さえ求めることができれば、わりと簡単に解決します。
// 真円にのみ対応したコード
func arcWin32(_ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat, _ x3: CGFloat, _ y3: CGFloat, _ x4: CGFloat, _ y4: CGFloat) -> CGPath {
let path = CGMutablePath()
let xCenter = (x1 + x2) / 2
let yCenter = (y1 + y2) / 2
let center = CGPoint(x: xCenter, y: yCenter)
let radius = (x2 - x1) / 2
path.addArc(center: center, radius: radius, startAngle: atan2(y3 - yCenter, x3 - xCenter), endAngle: atan2(y4 - yCenter, x4 - xCenter), clockwise: true)
return path
}
中心点や半径は特に説明の必要は無いと思います。
問題はstartAngle
とendAngle
に渡している値です。
ここでは直角三角形で言うところの「高さ」と「底辺」の長さを渡すと「成す角度(ラジアン単位)」を返してくれる便利なatan2
関数を使用しています。
上の図で言うとθはatan2(b, a)
で求めることができます。
atan2
は大体の処理系で標準関数として実装されています。
Appleのリファレンスはページは存在するものの残念な内容になっていますが・・・
https://developer.apple.com/documentation/coregraphics/1455332-atan2
もう一つのポイントとして、Y軸の向きが気になる方がいらっしゃるのではないでしょうか。
iOS上での座標系は数学のグラフで出てくるY軸とは向きが真逆です。
そのため、atan2
に渡す場合もそれを考慮しないといけないのではないか...と思うかもしれませんが、そもそも最初のツイートにある通りaddArc
関数は開始角度が例えば45度ならY=0よりも下側に出る、つまりもともとY軸が逆になっているため、逆のまま渡すと最終描画結果は期待したものになります(逆の逆で戻る)。
※この辺のY軸逆転問題はCoreGraphicsを使っていると頻出しますが、たぶんCoreGraphicsの座標系と、後から出てきたiOSの座標系が違うのにそれをラップして使えるようにした結果、いろんな所で綻びが出ているのかなと勝手に思っています。
この時使ったソース:
// 真円にのみ対応したコード
func arcWin32(_ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat, _ x3: CGFloat, _ y3: CGFloat, _ x4: CGFloat, _ y4: CGFloat) -> CGPath {
let path = CGMutablePath()
let xCenter = (x1 + x2) / 2
let yCenter = (y1 + y2) / 2
let center = CGPoint(x: xCenter, y: yCenter)
let radius = (x2 - x1) / 2
path.addArc(center: center, radius: radius, startAngle: atan2(y3 - yCenter, x3 - xCenter), endAngle: atan2(y4 - yCenter, x4 - xCenter), clockwise: true)
return path
}
/// テスト用に枠や点をつけたもの
func arcWin32Test(_ context: CGContext, _ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat, _ x3: CGFloat, _ y3: CGFloat, _ x4: CGFloat, _ y4: CGFloat) {
let xCenter = (x1 + x2) / 2
let yCenter = (y1 + y2) / 2
UIColor.blue.setStroke()
context.stroke(CGRect(x: x1, y: y1, width: x2 - x1, height: y2 - y1))
UIColor.green.setStroke()
for (x, y) in [(x3, y3), (x4, y4)] {
context.move(to: CGPoint(x: xCenter, y: yCenter))
context.addLine(to: CGPoint(x: x, y: y))
context.strokePath()
}
UIColor.red.setFill()
for (x, y) in [(x3, y3), (x4, y4), (xCenter, yCenter)] {
context.fillEllipse(in: CGRect(x: x - 2, y: y - 2, width: 4, height: 4))
}
UIColor.black.setStroke()
context.addPath(arcWin32(x1, y1, x2, y2, x3, y3, x4, y4))
context.strokePath()
}
class MyView: UIView {
override func draw(_ rect: CGRect) {
guard let c = UIGraphicsGetCurrentContext() else { return }
arcWin32Test(c, 30, 30, 30 + 100, 30 + 100, 110, 30, 150, 150)
}
}
class ViewController: UIViewController {
override func loadView() {
super.loadView()
view.backgroundColor = .black
let v = MyView(frame: CGRect(x: 20, y: 20, width: 200, height: 200))
v.backgroundColor = .white
view.addSubview(v)
}
}
開発のポイント2 楕円に対応させる場合
さて、Win32API側のArc関数は楕円弧にも対応しています。
しかしCoreGraphics側はradius
しか引数が無いため、あれひょっとして楕円弧は描けないんじゃ?となりますが、なんとかなります。
addArc
関数はよく見ると最後にtransform
という引数があります。
https://developer.apple.com/documentation/coregraphics/cgmutablepath/2427140-addarc
(まあ別にここに無くても良いんですが・・・)
transformは図形をアフィン変換するためのもので、移動・回転・拡大縮小などが行えます。
また、それらは連結(合成)することができます。
例えばCGAffineTransform(scaleX: 1, y: 0.5)
を渡せば縦が半分に潰れた図形が描画できます。
今回は中心点のY座標を中心に縦方向の拡大縮小を行いたいので、上にずらす→スケールする→下にずらす、というアフィン変換を連結して使用します。
ここまでのコードと実行結果です。
func arcWin32(_ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat, _ x3: CGFloat, _ y3: CGFloat, _ x4: CGFloat, _ y4: CGFloat) -> CGPath {
let path = CGMutablePath()
let width = x2 - x1
let height = y2 - y1
let xCenter = (x1 + x2) / 2
let yCenter = (y1 + y2) / 2
let center = CGPoint(x: xCenter, y: yCenter)
let radius = max(width, height) / 2
let yScale = height / width
let transform = CGAffineTransform(translationX: 0, y: -yCenter).concatenating(CGAffineTransform(scaleX: 1, y: yScale)).concatenating(CGAffineTransform(translationX: 0, y: +yCenter))
path.addArc(center: center, radius: radius, startAngle: atan2(y3 - yCenter, x3 - xCenter), endAngle: atan2(y4 - yCenter, x4 - xCenter), clockwise: true, transform: transform)
return path
}
(中略)
class MyView: UIView {
override func draw(_ rect: CGRect) {
guard let c = UIGraphicsGetCurrentContext() else { return }
arcWin32Test(c, 30, 30, 30 + 100, 30 + 50, 110, 30, 150, 150)
}
}
外接する矩形とは一致しましたが、今度は角度がちょっとずれています。
縦に潰したのでそりゃそうです。
今度はこれを補正してやらないといけないわけですが、潰れた結果が座標(x3,y3)になれば良いわけですよね?
そこで、transformの逆行列を作成して、(x3, y3)に適用し、その座標をもとに角度を求めてやればよさそうです。
let invertedTransform = transform.inverted()
let inv3 = CGPoint(x: x3, y: y3).applying(invertedTransform)
... startAngle: atan2(inv3.y - yCenter, inv3.x - xCenter)
最終的にできたものが以下です。
/// Win32API Arc API互換っぽい関数 by Takabo Soft
func arcWin32(_ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat, _ x3: CGFloat, _ y3: CGFloat, _ x4: CGFloat, _ y4: CGFloat) -> CGPath {
let path = CGMutablePath()
let width = x2 - x1
let height = y2 - y1
let xCenter = (x1 + x2) / 2
let yCenter = (y1 + y2) / 2
let center = CGPoint(x: xCenter, y: yCenter)
let radius = max(width, height) / 2
let yScale = height / width
let transform = CGAffineTransform(translationX: 0, y: -yCenter).concatenating(CGAffineTransform(scaleX: 1, y: yScale)).concatenating(CGAffineTransform(translationX: 0, y: +yCenter))
let invertedTransform = transform.inverted()
let inv3 = CGPoint(x: x3, y: y3).applying(invertedTransform)
let inv4 = CGPoint(x: x4, y: y4).applying(invertedTransform)
path.addArc(center: center, radius: radius, startAngle: atan2(inv3.y - yCenter, inv3.x - xCenter), endAngle: atan2(inv4.y - yCenter, inv4.x - xCenter), clockwise: true, transform: transform)
return path
}
/// テスト用に枠や点をつけたもの
func arcWin32Test(_ context: CGContext, _ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat, _ x3: CGFloat, _ y3: CGFloat, _ x4: CGFloat, _ y4: CGFloat) {
let xCenter = (x1 + x2) / 2
let yCenter = (y1 + y2) / 2
UIColor.blue.setStroke()
context.stroke(CGRect(x: x1, y: y1, width: x2 - x1, height: y2 - y1))
UIColor.green.setStroke()
for (x, y) in [(x3, y3), (x4, y4)] {
context.move(to: CGPoint(x: xCenter, y: yCenter))
context.addLine(to: CGPoint(x: x, y: y))
context.strokePath()
}
UIColor.red.setFill()
for (x, y) in [(x3, y3), (x4, y4), (xCenter, yCenter)] {
context.fillEllipse(in: CGRect(x: x - 2, y: y - 2, width: 4, height: 4))
}
UIColor.black.setStroke()
context.addPath(arcWin32(x1, y1, x2, y2, x3, y3, x4, y4))
context.strokePath()
}
class MyView: UIView {
override func draw(_ rect: CGRect) {
guard let c = UIGraphicsGetCurrentContext() else { return }
arcWin32Test(c, 30, 30, 30 + 100, 30 + 50, 110, 30, 150, 150)
}
}
class ViewController: UIViewController {
override func loadView() {
super.loadView()
view.backgroundColor = .black
let v = MyView(frame: CGRect(x: 20, y: 20, width: 200, height: 200))
v.backgroundColor = .white
view.addSubview(v)
}
}
上手くでましたね。
終わりに
すでに他の方法があったらごめんなさい。
バグがあったらごめんなさい(チェックはしてください)。