ActionScript 3.0で始めるオブジェクト指向スクリプティング

第53回座標にもっとも近い線分上の点を内積で求める

今回は、ベクトルの「内積」をお題にして新たなムービーをつくる。ステージに階段状の折れ線を描き、インスタンスをマウスでその線に沿ってのみドラッグできるようにしよう図1⁠。マウス座標をどのようにして折れ線上の点に投影して、インスタンスの動きを制限するかが課題だ。

図1 インスタンスをドラッグすると折れ線に沿って動く
図1 インスタンスをドラッグすると折れ線に沿って動く

内積から射影を取出す

まず直線の場合なら、ある座標からもっとも近い直線上の点は、その座標から直線に下ろした垂線との交点になる。しかし、折れ線は長さの決まった線分をつなぎ合わせてできる。そのため、座標の位置を場合分けしなければならない。線分をabとしたとき、点aとbにそれぞれ垂線を引くと、3つの領域に分かれる図2⁠。

図2 線分の両端に引いた垂線で座標を3つの領域に分ける
図2 線分の両端に引いた垂線で座標を3つの領域に分ける

ふたつの垂線の内側(点c)については、直線について述べたとおり、座標から線分に垂線を下ろした交点になる。座標が垂線の外側のとき(点c'またはc")は、線分の端のaもしくはbがもっとも近い点だ。それでは、これらの場合分けや長さの計算に、ベクトルの内積がどう使えるのか。

ここで、第51回「ベクトルの内積で面の向きを調べる」ベクトルの内積を数学的に理解するで、ふたつのベクトルAとBの内積を説明した図がヒントになる第51回図6再掲⁠⁠。ベクトルAとBのなす角をθとすると、内積はふたつのベクトルそれぞれの長さ(|A|と|B|で表す)とcosθの積|A||B|cosθで定められる。

第51回 図6 内積はベクトルAのBへの射影と|B|との積(再掲)
第51回 図6 内積はベクトルAのBへの射影と|B|との積(再掲)

第51回図6では、|A|cosθを青い線で示した。すると、青い線の長さは、ベクトルBに垂直な光を当てたとき、BのうえにかかるベクトルAの影に等しい(この長さをAの「射影」という⁠⁠。そして、ベクトルAの先端(終点)とその影の先端を結ぶ線は、ベクトルBと直交する。したがって、この交点がベクトルAの終点からもっとも近いベクトルB上の点となる。

つまり、ベクトルBの始点からベクトルAの射影である|A|cosθの距離の点が、ベクトルAの終点座標からもっとも近いベクトルB上の点である。ただし、射影はベクトルB上に収まらなければならない。Aの射影がベクトルBの外に出るのは、射影がBの長さ|B|より大きいか、ふたつのベクトルのなす角θが鈍角の(90度より大きい)ときだ。

内積による場合分けと線分との距離

内積を使って線分に対する3つの領域を分け、線分にもっとも近い点を計算しよう。線分をabとし、3つの領域の中の任意の座標c(またはc'、もしくはc")を考える。そのために、点aを始点としたふたつのベクトルを定めて内積を求める。ひとつは線分abのベクトルPで、もうひとつは任意の点c(またはc'、もしくはc")と結んだベクトルQ(またはQ'、もしくはQ")図3⁠。

図3 点cが点aより外にあるかどうかはベクトルPとQの内積でわかる
図3 点cが点aより外にあるかどうかはベクトルPとQの内積でわかる

まず、点aより外にある任意の点c'は、ベクトルPとQ'の内積からすぐにわかる。ふたつのベクトルのなす角θが鈍角のとき、内積は負数になるからだ(第51回「ベクトルの内積で面の向きを調べる」ベクトルの内積から面の向きがわかる参照⁠⁠。

ベクトルPとQの内積が正数なら、つぎにベクトルQの射影|Q|cosθを求める。前述のとおりベクトルPとQの内積は|P||Q|cosθなのだから、射影はこの内積をベクトルPの長さ|P|で割ればよい。射影|Q|cosθがベクトルPの長さ|P|よりも小さければ、点aから射影の距離の線分上の点dが点cにもっとも近い。

そして、射影|Q|cosθがベクトルPの長さ|P|よりも大きかったら、点cにもっとも近い線分上の点はbだ。下図4のベクトルQ"と点c"が、その場合を表す。

図4 射影が線分よりも長い点c"にもっとも近いのは点b
図4 射影が線分よりも長い点c

任意の1点と線分の両端の座標から線分上のもっとも近い点を返す関数の定義

計算の仕方はわかったので、スクリプトを書こう。まず、任意の座標から線分上のもっとも近い点を返す関数の定義だ。引数には、Pointオブジェクトで座標を3つ渡す。第1引数が任意の座標で、第2と第3引数は線分の両端の座標とする。関数名はxGetClosestPoint()とした。
  • xGetClosestPoint(任意の点, 線分の始点, 線分の終点)

以下のスクリプト1はフレームアクションに関数xGetClosestPoint()を定めた。計算の手順は前項に述べたとおりである。

スクリプト1 任意の1点と線分の両端の座標から線分上のもっとも近い点を返す関数
// フレームアクション
function xGetClosestPoint(myPoint:Point, begin:Point, end:Point):Point {
  var myVector3D:Vector3D = new Vector3D(myPoint.x - begin.x, myPoint.y - begin.y);
  var baseVector3D:Vector3D = new Vector3D(end.x - begin.x, end.y - begin.y);
  var nDotProduct:Number = myVector3D.dotProduct(baseVector3D);
  if (nDotProduct > 0) {
    var nBaseLength:Number = baseVector3D.length;
    var nProjection:Number = nDotProduct / nBaseLength;
    if (nProjection < nBaseLength) {
      baseVector3D.scaleBy(nProjection / nBaseLength);
      return new Point(begin.x + baseVector3D.x, begin.y + baseVector3D.y);
    } else {
      return end;
    }
  } else {
    return begin;
  }
}

まず、関数の第1引数とした任意の点と第2引数の線分の始点を結んで、Vector3Dインスタンス(myVector3D)をつくる。Vector3Dクラスのコンストラクタメソッドにz座標値を渡さなければ、デフォルト値の0が設定される。つぎに、第2および第3引数の線分の両端を結び、線分のVector3Dオブジェクト(baseVector3D)が生成される。そして、ふたつのベクトルからVector3D.dotProduct()メソッドで内積(nDotProduct)を求めた。

あとは、3つの領域に場合分けして、それぞれのもっとも近い点をPointオブジェクトで返す。if条件は、ふたつのベクトルの内積が正かどうかである。負の場合elseステートメント)は、第2引数で受取った線分の始点(begin)をそのまま返す。正の場合には、内積を線分の長さVector3D.lengthプロパティ)で割れば射影(nProjection)が得られる。

入れ子のif条件は、射影が線分の長さより小さいことを確かめる。その場合、線分のベクトルと方向が同じで射影の長さのベクトルを、Vector3D.scaleBy()メソッドで求めた。このメソッドは、参照するVector3Dオブジェクトを、引数に渡した比率が乗じられた長さに変える。

  • Vector3Dオブジェクト.scaleBy(乗数)

そのVector3Dオブジェクトのxy座標に線分の始点の座標を加えれば、戻り値のPointインスタンスがつくれる。射影が線分よりも長かったとき(入れ子のelseステートメント)は、第3引数で受取った線分の終点のPointオブジェクト(end)を返す。

インスタンスを折れ線に沿ってドラッグする

いよいよ仕上げだ。お題に述べたとおり、ステージに階段状の折れ線を描き、インスタンスをマウスでその線に沿ってのみドラッグできるようにする。まず、フレームアクションにつぎのスクリプトを加えると、折れ線が描かれる図5⁠。

// フレームアクションに追加
var vertices:Vector.<Point> = new Vector.<Point>();
vertices.push(new Point(40, 40));
vertices.push(new Point(80, 60));
vertices.push(new Point(100, 90));
vertices.push(new Point(160, 110));
vertices.push(new Point(200, 140));
var mySprite:Sprite = new Sprite();
var myGraphics:Graphics = mySprite.graphics;
addChildAt(mySprite, 0);
myGraphics.lineStyle(1, 0);
myGraphics.moveTo(vertices[0].x, vertices[0].y);
for (var i:uint = 1; i < vertices.length; i++) {
  myGraphics.lineTo(vertices[i].x, vertices[i].y);
}
図5 階段状に折れ線を描く
図5 階段状に折れ線を描く

折れ線の座標はPointオブジェクトにして、Vectorインスタンス(Pointベース型)に加えた。これらのPointオブジェクトは後で、マウス座標からもっとも近い線分上の点を求めるときにも使う。描画用のSpriteインスタンス(myGraphics)をつくり、Graphicsクラスの描画メソッドGraphics.moveTo()Graphics.lineTo()など)で折れ線を描いた。

仕上げは、インスタンスを折れ線に沿ってドラッグするスクリプトだ。ドラッグするMovieClipインスタンスmy_mcは予めタイムラインに置いておこう。前項で定めた、任意の座標から線分上のもっとも近い点を返す関数(xGetClosestPoint())が使われる。つぎのスクリプトをフレームアクションに加えればよい。

// フレームアクションに追加
var nStart:Number = new Point(stage.stageWidth, stage.stageHeight).length;
var my_mc:MovieClip;
my_mc.x = vertices[0].x;
my_mc.y = vertices[0].y;
my_mc.addEventListener(MouseEvent.MOUSE_DOWN, xStartMove);
function xStartMove(eventObject:MouseEvent):void {
  my_mc.addEventListener(Event.ENTER_FRAME, xMove);
  stage.addEventListener(MouseEvent.MOUSE_UP, xStopMove);
}
function xStopMove(eventObject:MouseEvent):void {
  my_mc.removeEventListener(Event.ENTER_FRAME, xMove);
  stage.removeEventListener(MouseEvent.MOUSE_UP, xStopMove);
}
function xMove(eventObject:Event):void {
  var nClosest:Number = nStart;
  var mousePoint:Point = new Point(mouseX, mouseY);
  var nLength:uint = vertices.length - 1;
  for (var i:uint = 0; i < nLength; i++) {
    var currentPoint:Point = 
      xGetClosestPoint(mousePoint, vertices[i], vertices[i + 1]);
    var nDistance:Number = mousePoint.subtract(currentPoint).length;
    if (nDistance <= nClosest) {
      var closestPoint:Point = currentPoint;
      nClosest = nDistance;
    }
  }
  my_mc.x = closestPoint.x;
  my_mc.y = closestPoint.y;
}

イベントInteractiveObject.mouseDown(定数MouseEvent.MOUSE_DOWNでドラッグを始め(リスナー関数xStartMove()⁠⁠、InteractiveObject.mouseUp(定数MouseEvent.MOUSE_UPにより終える(リスナー関数xStopMove()⁠⁠。ドラッグのアニメーションは、DisplayObject.enterFrame(定数Event.ENTER_FRAMEに加えられたリスナー関数(xMove())が行う。

関数xMove()は、ドラッグのためのマウスポインタ座標をPointインスタンス(mousePoint)に入れる。そして、forループで線分の両端座標を順に取出し、マウス座標にもっとも近い線分上の座標(currentPoint)を調べていく。その座標とマウス座標と結ぶ2次元ベクトルをPoint.subtract()メソッドでつくれば、Point.lengthプロパティの値が2点間の距離(nDistance)になる。メソッドPoint.subtract()は、2次元ベクトルの引き算で、ふたつのベクトルの終点を結ぶ新たなPointオブジェクトを返す[1]⁠。

  • Pointオブジェクト.subtract(差引くPointオブジェクト)

調べた距離がこれまでの線分より短ければ、そのPointインスタンス(closestPoint)と距離(nClosest)をともに変数に保持する。forループですべての線分を調べ終えたとき保持されているPointオブジェクトが、もっとも近い線分上の点になるので、その位置にインスタンスの座標を設定する。

なお、初めて距離を調べたときには以前の値がないので、予め変数(nStart)に初期値としてステージの対角線の長さを設定した。また、インスタンスは初めに折れ線の始まりの点に置いた。フレームアクション全体をまとめたのが、つぎのスクリプト2だ。

スクリプト2 インスタンスを折れ線に沿ってドラッグする
// フレームアクション: メインタイムライン
function xGetClosestPoint(myPoint:Point, begin:Point, end:Point):Point {
  var myVector3D:Vector3D = new Vector3D(myPoint.x - begin.x, myPoint.y - begin.y);
  var baseVector3D:Vector3D = new Vector3D(end.x - begin.x, end.y - begin.y);
  var nDotProduct:Number = myVector3D.dotProduct(baseVector3D);
  if (nDotProduct > 0) {
    var nBaseLength:Number = baseVector3D.length;
    var nProjection:Number = nDotProduct / nBaseLength;
    if (nProjection < nBaseLength) {
      baseVector3D.scaleBy(nProjection / nBaseLength);
      return new Point(begin.x + baseVector3D.x, begin.y + baseVector3D.y);
    } else {
      return end;
    }
  } else {
    return begin;
  }
}
var vertices:Vector.<Point> = new Vector.<Point>();
vertices.push(new Point(40, 40));
vertices.push(new Point(80, 60));
vertices.push(new Point(100, 90));
vertices.push(new Point(160, 110));
vertices.push(new Point(200, 140));
var mySprite:Sprite = new Sprite();
var myGraphics:Graphics = mySprite.graphics;
addChildAt(mySprite, 0);
myGraphics.lineStyle(1, 0);
myGraphics.moveTo(vertices[0].x, vertices[0].y);
for (var i:uint = 1; i < vertices.length; i++) {
  myGraphics.lineTo(vertices[i].x, vertices[i].y);
}
var nStart:Number = new Point(stage.stageWidth, stage.stageHeight).length;
var my_mc:MovieClip;
my_mc.x = vertices[0].x;
my_mc.y = vertices[0].y;
my_mc.addEventListener(MouseEvent.MOUSE_DOWN, xStartMove);
function xStartMove(eventObject:MouseEvent):void {
  my_mc.addEventListener(Event.ENTER_FRAME, xMove);
  stage.addEventListener(MouseEvent.MOUSE_UP, xStopMove);
}
function xStopMove(eventObject:MouseEvent):void {
  my_mc.removeEventListener(Event.ENTER_FRAME, xMove);
  stage.removeEventListener(MouseEvent.MOUSE_UP, xStopMove);
}
function xMove(eventObject:Event):void {
  var nClosest:Number = nStart;
  var mousePoint:Point = new Point(mouseX, mouseY);
  var nLength:uint = vertices.length - 1;
  for (var i:uint = 0; i < nLength; i++) {
    var currentPoint:Point = 
      xGetClosestPoint(mousePoint, vertices[i], vertices[i + 1]);
    var nDistance:Number = mousePoint.subtract(currentPoint).length;
    if (nDistance <= nClosest) {
      var closestPoint:Point = currentPoint;
      nClosest = nDistance;
    }
  }
  my_mc.x = closestPoint.x;
  my_mc.y = closestPoint.y;
}

これで、インスタンスをドラッグすると、折れ線に沿って移動する図6⁠。さて、次回は外積をお題としたサンプルのムービーをつくろう。

図6 折れ線に沿ってインスタンスがドラッグできる
図6 折れ線に沿ってインスタンスがドラッグできる

今回解説した次のサンプルファイルがダウンロードできます。

おすすめ記事

記事・ニュース一覧