反転変換(円に関する反転)について(3)

f:id:shspage:20170419192135p:plain

上の図は、真ん中の「1」を囲んでいる四角形のような形を、 四つの辺(円弧)に関してそれぞれ反転して「2」の位置に移し、 さらにそれを「3」・「4」の位置に反転して移し、… という手順で描かれています。

f:id:shspage:20170419192247p:plain

このような図を描くために必要な決まりごとは、 これらの辺に使われている円弧を、 外側の円周と垂直に交わる円の一部とすることです。 *1

双曲幾何学のモデルの1つであるポアンカレ円板では このような円弧が「直線」という扱いになります。(以下、双曲直線という。)

ネットを hyperbolic tessellation, hyperbolic tiling などで画像検索すると似たような感じの図がいろいろ出てきます。

円板の中に表されている双曲平面とは何か?ということから 調べていくと気が遠くなってくるのですが、

とりあえず上のような図を描くために必要なのは、 この双曲直線、要するに円弧を作図する方法です。

双曲直線を作図する

f:id:shspage:20170419192256p:plain

円の内部の2点を通る双曲直線は、片方の点を円に関して反転し、 これらの3点を通る円を作図することで描けます。

f:id:shspage:20170419192301p:plain

また、2点を通る双曲直線に関して別の1点を反転させる場合は、 まず2点を通る双曲直線を取得し、それに関して別の1点を反転変換するという 処理になります。

Illustrator用のサンプルスクリプトを Gist に置きました。

Illustrator:ポアンカレ円板上での反転関連 · GitHub

内部処理に関しては以下の過去記事で書いています。
反転 →「 反転変換(円に関する反転)について(1) - s.h's page
2点をつなぐ円弧 →「 2点をつなぐ弧を描く - s.h's page

最初の図も、このスクリプトを使って点を1つずつ反転しては 円弧でつないで、という地道な作業によって作ったのですよ。 *2

もっと複雑なのを描こうと思ったら スクリプトで全部やらせる方向になるでしょう。

参考文献

上の双曲直線の作図方法は「作図で身につく双曲幾何学」(阿原一志 著、共立出版 2016) という本に掲載されていたものです。
ネットで双曲幾何学について調べていていまいち要領を得なかったのですが、 この本では作図から入るので感覚的にわかりやすかったです。
(※ 基本を抑える感じの内容なので、冒頭の図のようなものの作図は扱われていません。)

作図で身につく双曲幾何学: GeoGebraで見る非ユークリッドな世界 | 阿原 一志 |本 | 通販 | Amazon

*1:最初の点の配置をどうするかということもあるのですが、 これについては私もまだ曖昧な部分があるので、別の機会にします。

*2:線の部分は右の1/4作ってから回転して複製。数字はサンプルにあるパスごと反転するやつを使用。

2点をつなぐ弧を描く

図形を描く系のコードでやっている計算を図解するシリーズ。

Illustratorスクリプトで円を描くのは次のように簡単です。

// 引数は、(上端、左端、幅、高さ)
activeDocument.activeLayer.pathItems.ellipse(0, 10, 100, 100);

ところが円弧を描こうとするとですね、これが面倒なんですよ。

スクリプトの全体は Gist に置きました。

Illustrator : 2点をつなぐ弧を描く · GitHub

以下でそれぞれの工程について説明します。

主な部分は次のような感じです。

f:id:shspage:20170415195650p:plain

// 点.  x, y : float
var Point = function(x, y){
    this.x = x;
    this.y = y;
}
// ------------------------
// o を中心として p1 から反時計回りに p2 へ円弧を描く
// p1, p2, o : Point
function drawArc(p1, p2, o){
    // p1, p2が同じ位置の場合は何もしない
    if(p1.equals(p2)) return;

    // 1. 弧の中心角を求める
    var v1 = p1.sub(o).normalize();
    var v2 = p2.sub(o).normalize();

    var t = Math.acos(v1.dot(v2));
    if(v1.cross(v2) < 0) t = WPI - t;

    // 2. 始点・終点のハンドルの位置を求める
    var t1 = p1.getAngle(o);
    var t2 = p2.getAngle(o);

    var radius = dist(p1, o);
    var han = radius * getHandleLengthBase(t);  // ハンドル長の決定

    // ハンドルの位置
    var h1 = new Point().byAngle(t1 + HPI).mul(han).add(p1);
    var h2 = new Point().byAngle(t2 - HPI).mul(han).add(p2);

    var p = createAPath().pathPoints;

    // 3. 中間点のアンカーの位置を決める
    if(t > Math.PI){
        t /= 3;
        setPathPoint(p.add(), p1, h1, p1);
        setPathPoint(p.add(), p1.rot(t, o),
                     h1.rot(t, o),
                     h2.rot(-t * 2, o));
        setPathPoint(p.add(), p2.rot(-t, o),
                     h1.rot(t * 2, o),
                     h2.rot(-t, o));
        setPathPoint(p.add(), p2, p2, h2);
        
    } else if(t > HPI){
        t /= 2;
        setPathPoint(p.add(), p1, h1, p1);
        setPathPoint(p.add(), p1.rot(t, o),
                     h1.rot(t, o),
                     h2.rot(-t, o));
        setPathPoint(p.add(), p2, p2, h2);
        
    } else {
        setPathPoint(p.add(), p1, h1, p1);
        setPathPoint(p.add(), p2, p2, h2);
    }
}

1. 弧の中心角を求める

内積

弧の中心角を求めるために、内積を使っています。

平面上に2つのベクトル  a ,  b があるとき、内積は次のように計算されます。

a.x * b.x + a.y * b.y

    // Point のメソッド
    dot : function(p){  // 内積. p : Point
        return this.x * p.x + this.y * p.y;
    },

f:id:shspage:20170415143314p:plain

 a,  b のなす角を  \theta とすると、内積(  a \cdot b )と角度には次の関係があります。

 \displaystyle
\cos \theta = \frac{a \cdot b}{\|a\| \|b\|}

(  \|a\| は ベクトル a の長さ(ノルム)を表します。)

 a,  b が単位ベクトルの場合、分母は 1 になるので無視できます。

冒頭のスクリプトではベクトルを正規化(normalize)により単位ベクトルにしているので、  \theta は Math.acos(a.dot(b)) のように求められます。

ただしこの角度は 180度より小さいほうの角度です。

描きたい円弧の中心角が大きい側の場合は修正する必要があります。

f:id:shspage:20170415140533p:plain

外積

角度の修正のために、中心から p1 に視線を向けたとき、p2 は左右どちらにあるかを判定します。

f:id:shspage:20170415141316p:plain

これには外積を使うことができます。

平面上に2つのベクトル  a ,  b があるとき、外積は次のように計算されます。

a.x * b.y - a.y * b.x

    // Point のメソッド
    cross : function(p){  // 外積. p : Point
        return this.x * p.y - this.y * p.x;
    },

 a,  b のなす角を  \theta とすると、外積(  a \times b )と角度には次の関係があります。

 \displaystyle
\sin \theta = \frac{a \times b}{\|a\| \|b\|}

sin の値ですので、 \theta が180~360度の場合はマイナスになります。

外積がマイナスなら、内積から求めた角度  \theta は時計回り方向の角度ということなので、 360度から  \theta を引いて、反時計周り方向の角度に直します。

2. 始点・終点のハンドルの位置を求める

ハンドル長の決定

Illustrator ではベジエ曲線を使うので、曲線を描くときはハンドルの位置を決めないといけません。

一般的に(Illustratorの楕円ツールで描く円くらいの精度で)、半径 1 の円弧を描く場合、 中心角  \theta に対するハンドルの長さは次の式で求められます。 *1

 \displaystyle
\frac{4}{3}(\tan \frac{\theta}{4} )

実際に使うハンドルの長さはこれに半径を掛けた値です。

楕円ツールで円を描いた場合、アンカー間の弧の中心角は90度です。

今回の場合、描きたい弧の中心角が90度を超える場合は、 弧の中間にもアンカーを置いてあげないと円弧らしくなりません。

そこで、中心角が90度以下の場合はアンカー2つ、180度以下の場合は3つ、 それより大きい場合は4つで円弧を描くようにしました。
このためアンカー間の弧の中心角は、それぞれ上で求めた中心角の 1/1, ½, 1/3 になり、 これによってハンドルの長さも違ってきます。
以下はこの部分の処理です。

// 弧の中心角に対して半径が1の場合のハンドル長さを返す
// t : float, radian
function getHandleLengthBase(t){
    if(t > Math.PI){  // 4点で描画
        t /= 3;
    } else if(t > HPI){  // 3点で描画
        t /= 2;
    } // else : 2点で描画
    return 4 * Math.tan(t / 4) / 3;
}

ハンドルの位置を決定

ハンドルの長さが決まったので座標を決めます。

ベジエ曲線の性質として、ハンドルはアンカー位置での接線になります。 また、円の接線は、接点と中心を結んだ線と直角に交わります。

f:id:shspage:20170415135523p:plain

これに基づき、 始点・終点のハンドルの位置は、中心 o から始点・終点を向いたベクトルを それぞれ90度回転させた方向に向けて決めます。

冒頭のサンプルスクリプトでは以下の部分でこれを行っています。

    var t1 = p1.getAngle(o);
    var t2 = p2.getAngle(o);

    var radius = dist(p1, o);
    var han = radius * getHandleLengthBase(t);  // ハンドル長の決定

    // ハンドルの位置
    var h1 = new Point().byAngle(t1 + HPI).mul(han).add(p1);
    var h2 = new Point().byAngle(t2 - HPI).mul(han).add(p2);

3. 中間点のアンカーの位置を決める

円弧の中間に作る点についても、アンカーとハンドルの位置を計算する必要があります。

これらは始点・終点のアンカーとハンドルを、上で求めた角度で回転させれば求められます。

f:id:shspage:20170415135534p:plain

原点を中心に、点 (x, y) を反時計回りに角度  \theta だけ回転させた座標  (x', y') は次のようになります。

 \displaystyle
x' = x \cos \theta - y \sin \theta \\
y' = x \sin \theta + y \cos \theta

サンプルスクリプトでは以下のように実装しています。

    // Point のメソッド
    // o を中心に反時計回りに回転する
    rot : function(t, o){  // t : float (radian), o : Point
        var c = Math.cos(t);
        var s = Math.sin(t);
        var x = this.x - o.x;
        var y = this.y - o.y;
        return new Point(x * c - y * s,
                         x * s + y * c).add(o);
    },

以上です

やろうとしていることに対して非常に面倒な手順ですが、 ベジエ曲線の数式を使わずにできるという意味では、 意外とシンプルな処理とも言えるのではないでしょうか。

*1:Wikipedia英語版 Bézier curve - Wikipedia - #Properties

反転変換(円に関する反転)について(2)

前回(1) の続きです。

反転変換した円を Illustrator で描く

反転変換は円を円に変換しますが、変換後の円をIllustratorでどう描けばよいでしょう。

前回の図で見たように、元の円の中心は写った先の円の中央には来ません。
円周上の点にアンカーポイントを置いていくのも非効率的です。

しかし基準円の中心から見て、元の円の最も近い点・遠い点は、 それぞれ変換後の円の最も遠い点・近い点になることは言えます。

そこで、この2点の中点を中心として、2点を通る円を描けばよいことになります。

f:id:shspage:20170411195629p:plain

Illustratorスクリプトの関数として書くと以下のようになります。

// 半径 r, 中心 o の円に関する 円 circle の反転を返す。
// o: Point
// r2: float 半径の2乗
// circle: Circle
// return: Circle
function inverseCircle(o, r2, circle){
    var d = dist(circle.center, o);
    
    var v = d == 0
      ? new Point(circle.radius, 0)
        : circle.center.sub(o).mul(circle.radius / d);  // ※

    var p1 = circle.center.sub(v);
    var p2 = circle.center.add(v); 

    var i1 = inverse(o, r2, p1);
    var i2 = inverse(o, r2, p2);

    var c = new Circle(getMidPoint(i1, i2),
                       dist(i1, i2) / 2);
    return c;
}
// ----------------------------------------------
Point.prototype = {
    add : function(p){  // p : Point
        return new Point(this.x + p.x, this.y + p.y);
    },
    sub : function(p){  // p : Point
        return new Point(this.x - p.x, this.y - p.y);
    },
    mul : function(m){  // m : float
        return new Point(this.x * m, this.y * m);
    }
}
// ------------------------
// 円
// o : Point, 中心
// r : float, 半径
function Circle(o, r){
    this.center = o;
    this.radius = r;
}
// ------------------------
// Point p1, p2 の間の距離を返す
function dist(p1, p2) {
    return Math.sqrt(dist2(p1, p2));
}
// ------------------------
// Point p1, p2 の中点を返す
function getMidPoint(p1, p2){
    return new Point((p1.x + p2.x) / 2, (p1.y + p2.y) / 2);
}

inverse, dist2, Point(コンストラクタ) については前回載せたものになるので省略しています。

inverseCircle の
circle.center.sub(o).mul(circle.radius / d); の部分についてですが、

f:id:shspage:20170411195655p:plain

まず元の円の中心の座標値から基準円の中心の座標値を引くと、 基準円の中心を原点とした場合の、元の円の中心へのベクトルが得られます。

それを中心同士の距離 d で割ると単位ベクトル *1 になります。

ここではその単位ベクトルに元の円の半径を掛けたベクトルを作っています。

これを元の円の中心から引いたものを、基準円の中心に最も近い点(p1)、 足したものを最も遠い点(p2)としています。

反転変換しても接点は保たれる。

次のネタに行く前に、反転変換の性質をもう一つ挙げておきます。

接している円が共有している接点は1つの点です。 反転の仕組みから考えて、 1つの点を反転すると2つに分かれるというのはあり得ません。

なので接している2円を反転した場合、接している2円になります。

2円に交点があるなら、交点についても同じことが言えます。

これを踏まえて、

2円に接し1点を通る円を描く

これは前に書いた「 3つの円の隙間に円を描く 」で保留した説明にもなるのですが、単に面白い形を描く以外にも反転が役立つ例です。

下の図の点 P を通り2円に接する円を描きたいというときに、反転変換が利用できます。

f:id:shspage:20170411195734p:plain

Illustratorスクリプトは、長くなったのでGistに置きました。

Illustrator : 1点を通り2円に接する円を描く · GitHub

手順としては、まず点 P を中心とした適当な円を基準円として、2円を反転変換します。 そのうえで、変換後の2円に共通接線を引きます。 *2

f:id:shspage:20170411195751p:plain

前回書いたように、この直線は無限遠を通る円と考えられます。
そしてこの「円」は2円に接していますので、 反転すると元の2円に接する円になるはずです。

また、無限遠を反転変換すると基準円の中心になるのでした。

つまり、共通接線の接点を反転すると、
基準円の中心点 P と合わせて求める円の円周上の3点が決まります。

あとは3点を結ぶ三角形の外接円を描く要領で円を描くことができます。

f:id:shspage:20170411200139p:plain

考えてみると反転によって無限遠をはるばる点 P の位置に持ってきてるわけで、 無限遠が目の前に!おおっ!って感じです。

……ところで2円の共通接線は上の図に書いたものの他にも最大3本あります。 上の考え方からすると、これらも反転すると点Pを通り2円に接する円になるはずです。 この場合はいずれかまたは両方の円が描いた円の内側に接するような形になります。

円の内側に接するケースも考えると、共通接線が引けない場合でも 円が描けることがあります。 このためサンプルスクリプトは本当はもっと場合分けする必要があります。

詳しくは「 アポロニウスの問題 - Wikipedia 」を調べてみてください。

つづく

↓つづき

反転変換(円に関する反転)について(3) - s.h's page

*1:長さが1のベクトル

*2:スクリプトでの共通接線の書き方は以前の記事「2円をつなぐ線を描く」参照