[Swift] Win32API Arc関数互換の描画方法


## はじめに

Twitterで困っている人がいたのでちょっと作ってみました。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">つまりこういうわけなんです。だれかおしえて！ <a href="https://twitter.com/hashtag/Swift?src=hash&amp;ref_src=twsrc%5Etfw">#Swift</a> <a href="https://twitter.com/hashtag/Win32API?src=hash&amp;ref_src=twsrc%5Etfw">#Win32API</a> <a href="https://t.co/LqEmnErtPO">pic.twitter.com/LqEmnErtPO</a></p>&mdash; 七草みけ (@nanakusamike) <a href="https://twitter.com/nanakusamike/status/1002816964639711232?ref_src=twsrc%5Etfw">2018年6月2日</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 

私もWin32のプログラムを書く身としてはこの辺の悩みはよくわかります。

## 手っ取り早くコードだけ欲しい方向け

ちょっといい加減に書いたので全パターンでうまく行くかわかりません（矩形が負になるものとか面倒なので試していません）。

```swift:
/// 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
}
```

## 開発のポイント１　真円のみの場合

CoreGraphicsの`addArc`は１つの半径と二つの角度を渡して円弧を描画をする関数です。
したがって真円に限定すれば、角度さえ求めることができれば、わりと簡単に解決します。

```swift:
// 真円にのみ対応したコード
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`関数を使用しています。

![sankaku0.gif](https://qiita-image-store.s3.amazonaws.com/0/31347/fb4fc95e-73d8-0a1c-e8dc-f68cd3980962.gif "sankaku0.gif")



上の図で言うとθは`atan2(b, a)`で求めることができます。


`atan2`は大体の処理系で標準関数として実装されています。
Appleのリファレンスはページは存在するものの残念な内容になっていますが・・・
https://developer.apple.com/documentation/coregraphics/1455332-atan2

もう一つのポイントとして、Y軸の向きが気になる方がいらっしゃるのではないでしょうか。
iOS上での座標系は数学のグラフで出てくるY軸とは向きが真逆です。

そのため、`atan2`に渡す場合もそれを考慮しないといけないのではないか...と思うかもしれませんが、そもそも最初のツイートにある通り`addArc`関数は開始角度が例えば４５度ならY=0よりも下側に出る、つまりもともとY軸が逆になっているため、逆のまま渡すと最終描画結果は期待したものになります（逆の逆で戻る）。


※この辺のY軸逆転問題はCoreGraphicsを使っていると頻出しますが、たぶんCoreGraphicsの座標系と、後から出てきたiOSの座標系が違うのにそれをラップして使えるようにした結果、いろんな所で綻びが出ているのかなと勝手に思っています。

描画テスト結果：
![Simulator Screen Shot - iPad Air 2 - 2018-06-04 at 10.50.14.png](https://qiita-image-store.s3.amazonaws.com/0/31347/1495efae-c361-68b3-fdd7-350f71742c0b.png "Simulator Screen Shot - iPad Air 2 - 2018-06-04 at 10.50.14.png")

この時使ったソース：

```swift:
// 真円にのみ対応したコード
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)
    }
}
```

## 開発のポイント２　楕円に対応させる場合

さて、Win32API側のArc関数は楕円弧にも対応しています。
しかしCoreGraphics側は`radius`しか引数が無いため、あれひょっとして楕円弧は描けないんじゃ？となりますが、なんとかなります。

`addArc`関数はよく見ると最後に`transform`という引数があります。
https://developer.apple.com/documentation/coregraphics/cgmutablepath/2427140-addarc
（まあ別にここに無くても良いんですが・・・）

transformは図形をアフィン変換するためのもので、移動・回転・拡大縮小などが行えます。
また、それらは連結（合成）することができます。

例えば`CGAffineTransform(scaleX: 1, y: 0.5)`を渡せば縦が半分に潰れた図形が描画できます。

今回は中心点のY座標を中心に縦方向の拡大縮小を行いたいので、上にずらす→スケールする→下にずらす、というアフィン変換を連結して使用します。

ここまでのコードと実行結果です。

```swift:
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)
    }
}
```

![Simulator Screen Shot - iPad Air 2 - 2018-06-04 at 11.25.18.png](https://qiita-image-store.s3.amazonaws.com/0/31347/3ea080d2-6a62-8203-9d5d-b55a3fcc261a.png "Simulator Screen Shot - iPad Air 2 - 2018-06-04 at 11.25.18.png")

外接する矩形とは一致しましたが、今度は角度がちょっとずれています。
縦に潰したのでそりゃそうです。

今度はこれを補正してやらないといけないわけですが、潰れた結果が座標（x3,y3）になれば良いわけですよね？

![aa.png](https://qiita-image-store.s3.amazonaws.com/0/31347/48589917-5d6f-9b75-7018-565bde9fad1f.png "aa.png")

そこで、transformの逆行列を作成して、(x3, y3)に適用し、その座標をもとに角度を求めてやればよさそうです。

```swift:
    let invertedTransform = transform.inverted()
    let inv3 = CGPoint(x: x3, y: y3).applying(invertedTransform)
    
    ... startAngle: atan2(inv3.y - yCenter, inv3.x - xCenter)
```

最終的にできたものが以下です。

```swift:
/// 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)
    }
}
```

実行結果：
![Simulator Screen Shot - iPad Air 2 - 2018-06-04 at 11.39.18.png](https://qiita-image-store.s3.amazonaws.com/0/31347/41631a0b-7c2c-6036-0d92-7bffddfed039.png "Simulator Screen Shot - iPad Air 2 - 2018-06-04 at 11.39.18.png")

上手くでましたね。

## 終わりに

すでに他の方法があったらごめんなさい。
バグがあったらごめんなさい（チェックはしてください）。