torutkのブログ

ソフトウェア・エンジニアのブログ

CanvasでAffine変換で大いにはまる(数学的センスが足りなかった・・・)

JavaFXCanvasでお絵かきをして、今風にマウスのホイールで拡大縮小させ、マウスのドラッグ操作で平行移動(パン)させようとして、ずいぶんとはまってしまいました。

Java2D(Swing)でも同じような機能があるので、何を今更なところですが・・・

やろうとしたこと、ソースコードと試行錯誤の経緯は次のWikiに記載しています。
JavaFX Canvasを使ったベクター描画と拡大縮小移動操作

JavaFXCanvasで図形を描画する際は、GraphicsContextを使いますが、その基本については、櫻庭さんの連載記事で把握できました。
JavaFX 2ではじめる、GUI開発 第17回 キャンバス | 日経 xTECH(クロステック)

ただ、Affine変換をすると一気に難易度が上がります。(頭の中に2つの座標系とその間の座標変換のイメージが出来る人は簡単かもしれませんが・・・)

まず、GraphicsContext#scaleメソッドは、現在の拡大率に追加で拡大率を設定するので、現在の拡大率を上書きしたい場合には使えません。setTransformでAffineを丸ごとセットします。

GraphicsContextで描画する図形は、scaleで縮小するとどんどんかすれて最後は見えなくなってしまいます(小さくなるのとは別に、線幅がどんどん細くなっていく)。逆にscaleで拡大するとどんどん太くなっていきます。期待する線幅をscaleで割った値をGraphicsContextのsetLineWidthにセットします。

マウスのホイール操作は、CanvasのsetOnScrollメソッドで取ることができます。ホイールの回転方向はScrollEventのgetDeltaYで取った値の正負で判定します。

マウスのドラッグ操作はちょっとやっかいでした。Canvasには、ドラッグ系の操作と思われるイベントを取るメソッドがずらっとあります。

  • setOnDragDetected
  • setOnDragDone
  • setOnDragDropped
  • setOnDragEntered
  • setOnDragExited
  • setOnDragOver
  • setOnMouseDragEntered
  • setOnMouseDragExited
  • setOnMouseDragged
  • setOnMouseDragOver
  • setOnMouseDragReleased

いろいろログを入れてみて試してみましたが、Canvas内でマウスをドラッグ(ボタンを押しながらマウスを移動)させる限り、発生するイベントはOnMousePressed(最初に発生) → OnMouseDragged(移動中随時発生)→ OnMouseReleased(最後に発生)だけのようです。

これが分かった次に、OnMouseDraggedイベントでCanvasの表示を移動させるためsetTransformを再設定するのですが、これだけでは描画されず、毎回描画ロジックを実行する必要がありました。

OnMouseDraggedイベントで受け取るMouseEventに格納される座標は、ドラッグ開始位置(OnMousePressedの位置)からの相対座標での値となります。最初、ドラッグ開始位置からの相対座標値をAffineの平行移動量に設定していたため、ドラッグすればするほど激しく移動して明後日の位置を表示していました。

Java SE 8的コード

イベントハンドリングの記述がラムダ式を使ってずいぶんすっきり書けました。
canvas.setOnScroll(ev -> {
    scale.set((ev.getDeltaY() >= 0) ? scale.get() * 1.4f : scale.get() / 1.4f);
    drawCanvas();
});

こんな風に、かなりすっきりします。別に関数プログラミングやらラムダ式クロージャーとは呼べないとかなんとかは気にする必要なく書けばいいと思います。

これまでの書き方だと次のようになります。

canvas.setOnScroll(new EventHandler<ScrollEvent>() {
    
    @Override
    public void handle(ScrollEvent ev) {
        scale.set((ev.getDeltaY() >= 0) ? scale.get() * 1.4f : scale.get() / 1.4f);
        drawCanvas();
    }
});
Java標準ロギングがすっきりかけました
  logger.info(() -> String.format("OnMouseDragged, sceneXY=(%f, %f), translate=%s",
          ev.getSceneX(), ev.getSceneY(), translate));

と、ラムダ式を引数に取るようにJava標準ロギングがなりました。

これまでの書き方では

  logger.log(Level.INFO, "OnMouseDragged, sceneXY=({0}, {1}), translate={2}",
          new Object[] {ev.getSceneX(), ev.getSceneY(), translate});

となります。これだけだとあまり長さに違いが見えないですが、前の書き方では、infoメソッドには書式化がなかったのでlogメソッドと第1引数にログレベルを指定する必要があったこと、可変長引数をサポートさいていないのでObject配列を生成して引数を格納する必要があったこと、ログのレベルが有効かどうかによらず、引数とする値のメソッド呼び出し式(この例ではev.getSceneX()とev.getSceneY()が必ず実行されていたことなどがあります。

ラムダ式を指定する場合、ログのレベルが有効なときのみ書式化文字列の引数のメソッド呼び出し式が実行されるので、ログ無効時のオーバーヘッドが少なくなります。