HAKUTAI Tech Notes

IT関連、数学のことなどを主として思いつきで色々書き溜めていきます

AngularとPaper.jsでパス描画ツールを作る2

前回の記事ではパス描画ツールの基本的な処理を実装した。今回はさらに機能を拡張し、パスの交差制御と頂点マーカーのドラッグ移動を実装していく。

完成イメージ

  • パスが交差していたら警告メッセージを表示して操作を取り消す
  • 頂点をドラッグ移動して位置を更新する
  • 頂点を移動したら座標リストや面積計算の結果を更新する

実装

パスの交差を制限する

多角形の面積を計算する時、パス同士が交差してしまうことは望ましくない(交差点を基準に複数の多角形に分けてそれぞれの面積を計算した後に合算する方法もあるが面倒くさい)。なので、パスが交差する位置に点を打てないようにしてしまえばよい。 具体的には、既にキャンバスをクリックして位置が確定されているパス(以下、確定パス)と、確定パスの最先端のセグメントとマウスポインタの位置を結んだパス(以下、未確定パス)の2つのパスの交差判定を行い、両者が交差している状態では点を打てないようにする。確定パスの描画は前の記事で述べたので、ここでは未確定パスの描画について述べる。

まず、未確定パスのオブジェクトとしてunsettledPathを定義し、initialItemSetting()の中でPathオブジェクトを生成してunsettledPathに格納する。

export class PlotAreaComponent implements OnInit {
  @ViewChild('canvas', { static: true })
  canvas: ElementRef<HTMLCanvasElement>;

  // パスオブジェクト関係
  path: any;
  pathGroup: any;
  unsettledPath: any; // 追加

 // 中略 //

  // 各種フラグ
  isCross = false;

 // 中略 //

  private initialItemSetting(): void {
    this.path = new Path();
    this.unsettledPath = new Path(); // 追加
    this.pathGroup = new Group();
    this.pathGroup.addChild(this.path);
  }
}
drawUnsettledLine()

未確定パスunsettledPathを描画するためのメソッド。未確定パスは、確定パスの最先端のセグメントを始点、マウスポインタの位置を終点としたパスであり、マウスポインタがキャンバス上を移動すれば未確定パスもそれに伴って移動する。確定パスの最先端のセグメントはthis.path.lastSegment.pointで取得し、マウスカーソルの位置はいつも通りthis.currentXthis.currentYを使う。this.unsettledPath.add()で始点と終点のPointブジェクトを追加することで未確定パスを描画している。なお、マウスポインタが移動する時に移動前の未確定パスが残らないように予めthis.unsettledPath.removeSegments()で未確定パスの始点と終点のセグメントを取り除いて描画を破棄している。

  private drawUnsettledLine(): void {
    if (!this.path || !this.unsettledPath || this.polygonArea) { return; }
    this.unsettledPath.removeSegments();
    // 未確定パスの設定
    this.unsettledPath.strokeColor = 'rgb(0, 0, 0, 0.1)';
    this.unsettledPath.strokeWidth = 1;
    // 確定パスの最先端にある頂点座標を取得する
    const lastSegment = this.path.lastSegment.point;
    // 未確定パスの始点
    this.unsettledPath.add(new Point(lastSegment.x, lastSegment.y));
    // 未確定パスの終点
    this.unsettledPath.add(new Point(this.currentX, this.currentY));

    // 確定パスと未確定パスの交差を判定する
    this.checkCrossing();
  }
checkCrossing()

パスの各セグメントの座標から数学的に線分同士の交差判定を行う方法もあるが、今回はPaper.jsで用意されているgetIntersections()を用いた判定を行う。パスA.getIntersections(パスB)のようにすると、2つのパスA, Bの全ての交差をCurveLocationオブジェクトの配列として取得することができる。今回は確定パスと未確定パスの交差点の配列を取得することになる。ただし、1つ注意すべきは、確定パスと未確定パスの接続点(確定パスの最先端のセグメント)も交差点の一種と見なされるため、既に交差点の配列にはその接続点が1つ含まれている。したがって、確定パスと未確定パスが本当に交差しているかどうかは、交差点の配列の要素数が1より大きいかどうかを確認すればよい。その場合は、交差判定用フラグisCrossをtrueにする。

  private checkCrossing(): void {
    const interSection = this.path.getIntersections(this.unsettledPath);
    this.isCross = interSection.length > 1;
  }
getCurrentPosision()

drawUnsettledLine()を呼び出す処理を追加する。

  getCurrentPosision(event): void {
    const rect = event.target.getBoundingClientRect();
    this.currentX = event.clientX - rect.left;
    this.currentY = event.clientY - rect.top;
    this.drawUnsettledLine(); // 追加
  }
onClickCanvas()

isCrossがtrueの時はキャンバスをクリックしても描画できないようにしたいので、メソッドの先頭にisCrossによる条件分岐を追加する。また、交差している場合にその旨が分かるようにポップアップでメッセージを出すようにしている。

  onClickCanvas(): void {
    if (this.isCross) {
      alert('パスが交差する位置に点を打つことは出来ません。');
      // 交差フラグをクリアする
      this.isCross = false;
      return;
    }
      // 中略 //
  }
drawLine()

クリックして確定パスを描画したら元の未確定パスは消す必要があるので、this.unsettledPath.removeSegments()で未確定パスのセグメントを削除し描画をクリアする。

  private drawLine(): void {
    this.path.strokeColor = 'rgb(255, 0, 0)';
    this.path.strokeWidth = 2;
    this.path.add(new Point(this.currentX, this.currentY));
    this.unsettledPath.removeSegments(); // 追加
  }

頂点のドラッグ移動を可能にする

パスを閉じて多角形の描画が完了した後、多角形の頂点マーカーの位置をドラッグ移動によって修正できるようにする。頂点のマーカーを移動させた後は多角形の面積を再計算する。 描画された各オブジェクトそれぞれに対してマウスイベントを設定していく必要がある。描画オブジェクトに対してマウスイベントを設定する処理はコンポーネントのクラスファイルに記述していくことになる。

まずは必要な変数を定義しておく。

export class PlotAreaComponent implements OnInit {
  @ViewChild('canvas', { static: true })
  canvas: ElementRef<HTMLCanvasElement>;

    // 省略 //

  // 各種フラグ
  isCross = false;
  isMouseOnSegment = false;
  isMouseDragging = false;
  // オンマウス状態のパスの子オブジェクト
  activeSegment: any;

    // 省略 //
}

次に、多角形のパスに対して各種のマウスイベントを設定していく。Pathオブジェクトのpathを生成した後、setMouseEventToPath()メソッド(後述)を呼び出し、pathに対してマウスイベントを設定する。

  private initialItemSetting(): void {
    this.path = new Path();
    this.setMouseEventToPath(); // 追加
    this.unsettledPath = new Path();
    this.pathGroup = new Group();
    this.pathGroup.addChild(this.path);
  }

  private setMouseEventToPath(): void {
    // 内容は後述 //
  }
setMouseEventToPath()

Pathオブジェクトには各種のイベントハンドラが用意されており、イベントと処理を紐付けたい時は、

// Pathオブジェクト上をマウスポインタが移動した時に何らかの処理を実行させる例
Path.onMouseMove = () => { 処理 }

のように記述する。 今回はonMouseMoveonMouseDragonMouseUponMouseLeaveの4イベントを利用する。
onMouseMove
path上でマウスポインタが移動した時に実行する処理を定義する。 まず、マウスポインタとpathの当たり判定を行う。 paper.projecthitTest()メソッドは、第1引数の座標と描画オブジェクトとの当たり判定を行い結果(paper.HitResult型のオブジェクト)を返す。当たりがなければnullが返る。当たり判定のオプションはhitOptionsで指定し、当たり判定を有効にする部分や判定精度を設定できる。描画オブジェクトの塗り潰し部分、線分、セグメントに対して当たり判定を行いたい場合は、それぞれfillstrokesegmentをtrueにすればよい。toleranceは当たり判定の精度でデフォルトは0である。toleranceが小さすぎるとマウス操作がシビアになるし、toleranceが大きすぎると複数の要素(線分とセグメントなど)が隣接している場合にうまく当たり判定を取得できないので、状況に応じて調整するのがよい。

今回はセグメントに対する当たり判定だけあればいいので、hitOptionssegmentのみtrueにしている。マウスポインタとセグメントが当たった時はhitResult.segmentで対象のSegmentオブジェクトを取得することができるので、そのSegmentオブジェクトをactiveSegmentに格納する。また、マウスポインタがセグメント上か否かを判定するフラグisMouseOnSegmentをtrueにする。

    this.path.onMouseMove = (event) => {
      if (this.polygonArea) {
        // セグメントとストロークの当たり判定のみを有効にする
        const hitOptions = {
          fill: false,
          stroke: true,
          segments: true,
          tolerance: 1,
        };
        const hitResult = paper.project.hitTest(event.point, hitOptions);
        this.activeSegment = hitResult && hitResult.segment;
        this.isMouseOnSegment = !!this.activeSegment;
      }
    };

onMouseDrag
path上の要素がドラッグされている時に実行する処理を定義する。 activeSegmentが存在(マウスポインタがセグメント上にある)する場合のみ実行する。

まず、セグメントをドラッグしている最中か否かを判定するフラグisMouseDraggingをtrueにする。 event.point.xevent.point.yでドラッグ中のマウスポインタの座標を参照できるので、activeSegmentの座標をマウスポインタの座標で更新する。同時に、頂点マーカーもドラッグに追随させたいので、ドラッグ中のactiveSegmentに対応するマーカーの座標をマウスポインタの座標で更新する。activeSegmentindexがN番目である時、それに対応するマーカーのShapeオブジェクトはpathGroupのN+1番目の子要素になる。これは、pathGroupの先頭(index = 0)にはpathが格納されていて、2番目(index = 1)以降からマーカーのShapeオブジェクトが格納されているためである。

さらに、頂点のドラッグ移動によってパスが交差してしまうことを防ぐため、this.path.getIntersections(this.path)でパスの交点の配列を取得して交差判定を行う。前項では確定パスと未確定パスという異なる2つのパスの交差を判定していたが、このように自分自身との交差判定を行うこともできる。ただし、確定パスと未確定パスの場合は接続点が1つあるので交差点の配列の長さ > 1を交差ありの条件としていたが、今回は確定パス1本だけの交差判定なので交差点の配列の長さ > 0が交差ありの条件となるので注意。

    this.path.onMouseDrag = (event) => {
      if (this.activeSegment) {
        const index = this.activeSegment.index;
        this.isMouseDragging = true;
        // パスのセグメントの座標を更新する
        this.activeSegment.point.x = event.point.x;
        this.activeSegment.point.y = event.point.y;
        // パス頂点のマーカーの座標を更新する
        this.pathGroup.children[index + 1].position.x = event.point.x;
        this.pathGroup.children[index + 1].position.y = event.point.y;
        // パス同士の交差を判定する
        const interSection = this.path.getIntersections(this.path);
        this.isCross = interSection.length > 0;
      }
    };

onMouseUp
path上の要素のドラッグが終了した時に実行する処理を定義する。 activeSegmentが存在する場合のみ実行する。

頂点をドラッグ移動してパスが交差してしまう場合は、マウスボタンを離した時にセグメントとマーカーを移動前の位置に強制的に戻す。移動前の位置の座標はvertexListに格納されているので、activeSegmentに対応するvertexListの要素を参照して座標を更新する。パスが交差していなければ問題ないので、activeSegmentの座標でvertexListの要素の座標を更新する。 ドラッグが終了したらisMouseDraggingをfalseに戻し、多角形の面積を再計算する。

    this.path.onMouseUp = () => {
      if (this.activeSegment) {
        const index = this.activeSegment.index;
        if (this.isCross) {
          // パスのセグメントの座標をドラッグ移動前に戻す
          this.activeSegment.point.x = this.vertexList[index].x;
          this.activeSegment.point.y = this.vertexList[index].y;
          // パス頂点のマーカーの座標をドラッグ移動前に戻す
          this.pathGroup.children[index + 1].position.x = this.vertexList[index].x;
          this.pathGroup.children[index + 1].position.y = this.vertexList[index].y;
          // セグメントからカーソルが離れるのでオンマウスのフラグをクリアする
          this.isMouseOnSegment = false;
          return;
        }
        this.vertexList[index].x = this.activeSegment.point.x;
        this.vertexList[index].y = this.activeSegment.point.y;
        this.isMouseDragging  = false;
        // 面積を再計算する
        this.calculatePolygonArea();
      }
    };

onMouseLeave
pathオブジェクトからマウスポインタが離れた時に実行する処理を定義する。 activeSegmentが存在する場合のみ実行する。

pathオブジェクトからマウスポインタが離れたらactiveSegmentをクリアしてisMouseOnSegmentをfalseに戻す。しかし、これだけだとセグメントをドラッグ移動している場合も(元々のセグメントの位置からずれるので)pathオブジェクトからマウスポインタが離れたと判定されてしまうため、セグメントのドラッグ中(isMouseDraggingがtrueの時)は処理を行わないようにする必要がある。

    this.path.onMouseLeave = () => {
      if (this.activeSegment) {
        // セグメントをドラッグしている途中の場合は処理を行わない
        if (this.isMouseDragging) { return; }
        // セグメントからマウスが離れた場合はactiveItemとオンマウスのフラグをクリアする
        this.activeSegment = null;
        this.isMouseOnSegment = false;
      }
    };


以上4つの処理をPathオブジェクトのイベントハンドラにバインドしてやることで、頂点を掴んで多角形の形を自由に変える機能を実現できる。

セグメント上でマウスカーソルの表示を切り替える

頂点をドラッグ移動させる機能は一応完成したが、最後にちょっとしたマウスカーソルの切替え処理を加えておく。マウスポインタが頂点とヒットした際にマウスカーソルをpointerに切り替えてドラッグ可能な場所であることを分かりやすくする。isMouseOnSegmentフラグを利用し、[class.onSegment]="isMouseOnSegment"でマウスポインタがセグメント上にある時はcanvs要素にonSegmentクラスが付加されるようにし、onSegmentの要素に対してcssでデザイン調整を行う。

      <canvas #canvas id="canvas" #dot width="600px" height="500px" (mousemove)="getCurrentPosision($event)"
        (click)="onClickCanvas()" [class.onSegment]="isMouseOnSegment">
      </canvas>
  canvas.onSegment {
    &:hover {
      cursor: move;
    }
  }

おわりに

以上のイベントを設定することで、頂点マーカーをドラッグ移動して面積を再計算する動作が実現される。 ここまでで、多角形の描画と面積計算、頂点の移動といった一通りの機能を実装できた。 次の記事ではさらに発展させて、多角形の辺上の任意の位置に頂点を追加する機能と、頂点を削除する機能を追加してみる。


参考