Blogopolisから学ぶ計算幾何

第5回平面走査法による線分の交差検出(後編)

はじめに

前々回前回と、平面走査法による線分交差検出について説明してきました。今回はその仕上げとして、いよいよ平面走査法のアルゴリズムの実装に挑戦します。

前回の復習

まず、前回導入した、平面走査法に必要な3つのデータ構造を復習しておきましょう。

  • イベントは、線分の始点と終点、そして交点に対応するオブジェクトです。前回は、イベントを表現するEventクラスを作成しました。
  • イベントキューは、イベントを要素とするコレクションです。キューの要素は、イベント点のy座標が小さい順に、常にソートされています。前回、イベントキューには、java.util.TreeSetクラスを利用することを説明しました。
  • ステータスは、現在の走査線と交わる線分を要素とするコレクションです。ステータスの要素は、走査線と線分の交点のx座標が小さい順に、常にソートされています。ステータスにもTreeSetクラスを利用します。

この3つを使って交差検出のアルゴリズムを組み立てることが、今回のテーマになります。

イベントの処理

イベントキューには、未処理のイベント点が上から順番に格納されています。走査線を次のイベント点の位置まで動かす操作は、イベントキューから先頭の(最小のy座標を持つ)イベントを取り出す作業に対応します。

さて、こうしてイベントを取り出した後は、そのイベントに関してどんな処理を実行すれば良いのでしょうか。イベントの種類ごとに見ていきましょう。

始点イベントの処理

始点イベントをきっかけとして、その始点を持つ線分(Sstartとします)は、走査線と交わり始めます。したがって、Sstartをステータスに挿入する必要があります。

さらにこのとき、Sstartと以下の線分の交差をチェックします。

  • ステータス中でSstartの直前(左隣)に位置する線分(Sl
  • ステータス中でSstartの直後(右隣)に位置する線分(Sr

もし交差する場合には、その交点イベントを新しく作成し、イベントキューに登録します。

図1は始点イベントの例です。この図の場合、SstartとSrの交差が検出されます(赤い交点⁠⁠。

図1 始点イベント
図1 始点イベント

交点イベントの処理

交点イベントには、交差する2つの線分が関わります。この2線分のうち、交点通過前の走査線上で左にあった線分をS'l右にあった線分をS'rとします。

交点を境にして、S'lとS'rの走査線上の位置関係は逆転し、S'lが右に、S'rが左に来るようになります。したがって、ステータス上でのS'lとS'rの位置を入れ替える必要があります。

このとき、ステータスの線分の隣接関係が変化した部分に関して、以下の交差をチェックします。

  • 元のステータス中でS'lの直前(左隣)に位置した線分とS'r
  • 元のステータス中でS'rの直後(右隣)に位置した線分とS'l

それぞれ、交差する場合には、始点イベントでの交点検出時と同様に、交点イベントを作成してイベントキューに登録します図2⁠。

図2 交点イベント
図2 交点イベント

終点イベントの処理

走査線が終点イベントに着くと、その終点を持つ線分(Sendとします)は、今後走査線と交わらなくなります。したがって、Sendをスタータスから削除する必要があります。

ここで、以下の線分を考えます。

  • ステータス中でSendの直前(左隣)に位置する線分(S''l
  • ステータス中でSendの直後(右隣)に位置する線分(S''r

S''lとS''rはこれまで、ステータス上で離れた位置にありました。しかし、Sendを削除したことによって、今後は隣接するようになります。したがって、このS''lとS''rの交差を新たにチェックし、交差する場合には交点イベントを作成・登録します図3⁠。

図3 終点イベント
図3 終点イベント

以上をまとめると、表1のようになります。

表1 イベントの処理
種類ステータスの変更交差チェック対象
始点線分を挿入挿入した線分とその左隣/右隣
交点2線分の位置を交換新しく左に来る線分とその左隣/新しく右に来る線分とその右隣
終点線分を削除削除する線分の左隣と右隣

平面走査法の実装

それでは、ここまで解説してきたアルゴリズムをコーディングしてみましょう。IntersectionDetectorインターフェースを実装するPlaneSweepIntersectionDetectorクラスを、以下のように作成します。

リスト1 PlaneSweepIntersectionDetectorクラス(Java)
public class PlaneSweepIntersectionDetector implements IntersectionDetector {
    public Collection<Intersection> execute(List<LineSegment> segments) {
        // イベントキューを作成
        TreeSet<Event> eventQueue = new TreeSet<Event>();

        for (LineSegment s : segments) {
            // 線分の端点のうち上にある方を始点、下にある方を終点としてイベントを登録
            // 線分が水平な場合は左の端点を始点とする
            if (s.y1 < s.y2 || (s.y1 == s.y2 && s.x1 < s.x2)) {
                eventQueue.add(new Event(Event.Type.SEGMENT_START, s.x1, s.y1, s, null));
                eventQueue.add(new Event(Event.Type.SEGMENT_END, s.x2, s.y2, s, null));
            } else {
                eventQueue.add(new Event(Event.Type.SEGMENT_START, s.x2, s.y2, s, null));
                eventQueue.add(new Event(Event.Type.SEGMENT_END, s.x1, s.y1, s, null));
            }
        }

        SweepLineBasedComparator sweepComparator = new SweepLineBasedComparator();
        // ステータスを作成。要素の順序関係はsweepComparatorにしたがう
        TreeSet<LineSegment> status = new TreeSet<LineSegment>(sweepComparator);

        // 今回の実装では同一の交点が複数回検出される可能性があるため、HashSetを使って重複を防ぐ
        Collection<Intersection> result = new HashSet<Intersection>();

        Event event;
        // キューから先頭のイベントを取り出す
        while ((event = eventQueue.pollFirst()) != null) {
            double sweepY = event.y;
            switch (event.type) {
            case SEGMENT_START: // 始点イベントの場合
                sweepComparator.setY(sweepY); // 走査線を更新

                LineSegment newSegment = event.segment1;
                status.add(newSegment); // ステータスに線分を追加

                LineSegment left = status.lower(newSegment);
                LineSegment right = status.higher(newSegment);
                // 左隣の線分との交差を調べる
                checkIntersection(left, newSegment, sweepY, eventQueue);
                // 右隣の線分との交差を調べる
                checkIntersection(newSegment, right, sweepY, eventQueue);
                break;
            case INTERSECTION: // 交点イベントの場合
                left = event.segment1;
                right = event.segment2;
                // 交点を戻り値に追加
                result.add(new Intersection(left, right));

                LineSegment moreLeft = status.lower(left);
                LineSegment moreRight = status.higher(right);

                // ステータス中のleftとrightの位置を交換するため、いったん削除する
                status.remove(left);
                status.remove(right);
                sweepComparator.setY(sweepY); // 走査線を更新
                // 計算誤差により、走査線の更新後も順序が交換されない場合は
                // 走査線を少し下げて順序が確実に変わるようにする
                if (sweepComparator.compare(left, right) < 0) {
                    sweepComparator.setY(sweepY + 0.001);
                }
                // 更新後の走査線を基準としてleftとrightを再追加(位置が交換される)
                status.add(left);
                status.add(right);

                // right(位置交換によって新しく左側に来た線分)と、そのさらに左隣の線分の交差を調べる
                checkIntersection(moreLeft, right, sweepY, eventQueue);
                // left(位置交換によって新しく右側に来た線分)と、そのさらに右隣の線分の交差を調べる
                checkIntersection(left, moreRight, sweepY, eventQueue);
                break;
            case SEGMENT_END: // 終点イベントの場合
                LineSegment endSegment = event.segment1;
                left = status.lower(endSegment);
                right = status.higher(endSegment);

                // 線分の削除によって新しく隣り合う2線分の交差を調べる
                checkIntersection(left, right, sweepY, eventQueue);
                status.remove(endSegment); // ステータスから線分を削除

                sweepComparator.setY(sweepY); // 走査線を更新
                break;
            }
        }

        return result;
    }

    // 線分leftとrightが走査線の下で交差するかどうか調べ、交差する場合は交点イベントを登録する
    private void checkIntersection(LineSegment left, LineSegment right,
            double sweepY, TreeSet<Event> eventQueue) {
        // 2線分のうち少なくとも一方が存在しない場合、何もしない
        if (left == null || right == null) {
            return;
        }
        Point2D p = left.getIntersectionPoint(right);
        // 交点が走査線よりも下に存在するときのみ、キューに交点イベントを登録
        if (p != null && p.getY() >= sweepY) {
            eventQueue.add(new Event(Event.Type.INTERSECTION, p.getX(), p
                    .getY(), left, right));
        }
    }
}

プログラムではまず、与えられたすべての線分について始点イベントと終点イベントを作成し、イベントキューに登録しています。このとき、線分の2端点のうち、上にある方を始点、下にある方を終点としています。こうしてイベントキューを初期化したら、後はイベントを先頭から1つずつ取り出し、表1にしたがって処理を行っていきます。イベントが全部消化され、イベントキューが空になったとき、すべての交差が戻り値として得られていることになります。

ただし、今回の実装ではプログラムを簡潔にするため、3つ以上の線分が1点で交わるなどの例外的な状況は考慮していません。

平面走査法の計算量

平面走査法による線分交差検出の計算量は、入力となる線分の数をn、交点の数をkとすると

図4
図4

で表されます。交点数に依存した計算量となるため、全ての線分が互いに交わっているような、極端に「こんがらがった」配置に対しては、かえって遅くなってしまいます。しかし、交点検出が必要になる実際の状況では、せいぜい線分数に比例する程度の交点しか存在しないことが一般的なので、前々回で実装した総当たり法よりも計算量を大きく削減できることになります。

デモプログラム

今回解説したアルゴリズムをグラフィカルに動作確認できるよう、Swingアプリケーションのデモプログラムを用意しました。Java Web Start形式になっており、 こちらをクリックして直接起動できます。ソースコードは紙面には掲載しませんので、アーカイブをダウンロードして確認してください。

デモプログラムを起動すると、キャンバスにいくつかの線分が表示されます。⁠移動」⁠追加」⁠削除」のツールボタンを選択し、キャンバス上でマウスクリックやドラッグをすることで、線分を自由に追加したり、動かしたりすることができます。⁠次のイベント」ボタンをクリックすると、走査線が次のイベント点まで移動し、イベント点に関係する線分や、このとき検出される交点が強調表示されます。線分の配置をいろいろと変更し、処理の流れを追ってみてください。

まとめと次回予告

今回は、平面走査法による線分交差検出の完全な実装を行いました。走査線を少しずつ動かしていくという平面走査法の発想は、交差の検出に限らず、平面幾何のさまざまな問題に広く適用できますので、ぜひ覚えておいてください。

今回作成したプログラムのソースコードがダウンロードできます。

次回からは、線分を連結して作られる凸状の多角形について考えていきます。お楽しみに!

おすすめ記事

記事・ニュース一覧