視覺化學習:利用向量計算點到線段的距離並展示

beckyye發表於2023-11-21

本文可配合本人錄製的影片一起食用。

引言

最近我在學視覺化的東西,藉此來鞏固一下學習的內容,向量運算是計算機圖形學的基礎,這個例子就是向量的一種應用,是利用向量來計算點到線段的距離,這個例子中視覺化的展示採用Canvas2D來實現。

說起向量,當時一看到這個詞,我是一種很模糊的記憶;這些是中學學的東西,感覺好像都還給老師了。然後又說起了向量的乘法,當看到點積、叉積這兩個詞,我才猛然想起點乘和叉乘;但整體上還是模模糊糊的,不太記得兩者具體的定義了;就找資料快速過了一遍吧。

因為本文中不涉及向量的基礎知識;如果有跟我一樣遺忘的小夥伴,可以找點影片回憶一下,或者是找點資料看下。

題面

首先本次的例子中要獲取兩個值,一個是點到線段的距離,另一個是點到線段所在直線的距離。

假設存在一個線段AB,以及一個點C;則他們之前的位置可能有三種情況:

  • 點C線上段AB左側

    left
  • 點C線上段AB的上方或下方

    top
  • 點C線上段AB的右側

    right

在第一種和第三種情況下,點C到線段AB的距離為點C到點A或點B的距離,即向量AC或向量BC的長度。

在第二種情況下,點C到線段AB和到線段AB所在直線的距離是一樣的,這個時候,我們就可以利用向量的乘法來解決這個距離的計算。

這個例子給的思路是利用向量的乘法,因為向量叉乘的幾何意義就是平行四邊形的面積,已知底邊長度,也就是線段AB的長度,然後就可以得出點C到直線的距離;但因為要在頁面上展示出來,所以我們需要求得點D的座標。

d

思路

一開始我想的有點複雜,想要去求AB所在直線的函式方程,從而計算出點C是在直線的上方還是下方,雖然向量的叉乘我記得不太多了,但我依舊還記得,如果向量AB旋轉到向量CD為順時針,則向量AB叉乘向量CD的值就為正,如果是逆時針,就為負。

接著再利用叉乘和點乘,去計算點D的x座標和y座標;這其實有點把事情搞複雜了,另外還需要去特殊處理CD和X軸平行以及Y軸平行的特殊情況。

然後我看了別人的提示才反應過來,我們只要充分地利用向量的乘法就可以了,而不需要去求什麼直線的函式方程,當然這也就不用考慮什麼特殊情況。

d-2

由上圖可知AD是AC在AB上的投影,然後我們知道投影可以透過點乘來求得,要求兩個向量的點乘,有兩種計算方式,一種是透過座標來計算,另一種是透過向量的模和夾角來計算;分別對應以下兩個公式:

  • AC · AB = AC.x * AB.x + AC.y * AB.y
  • AC · AB = |AC| * |AB| * cosθ

因為已知點A、點B和點C的座標,所以我們可以利用以上兩個公式計算點D的座標。

具體實現

現在我們就來透過Canvas來實現以上效果。

HTML

首先我們在HTML中先放一個Canvas標籤。

<canvas width="512" height="512"></canvas>

CSS

然後寫一點簡單的CSS樣式。

canvas {
  margin: 0;
  width: 512px;
  height: 512px;
  border: 1px solid #eee;
}

JavaScript

最後我們來編寫最重要的JavaScript程式碼。

這裡預先定義了一個Vector2D的類用於表示二維向量。

/*
* 定義二維向量
* */
export default class Vector2D extends Array {
    constructor(x = 1, y = 0) {
        super(x, y);
    }
    get x() {
        return this[0];
    }
    set x(value) {
        this[0] = value;
    }
    get y() {
        return this[1];
    }
    set y(value) {
        this[1] = value;
    }
    // 獲取向量的長度
    get len() {
        // x、y的平方和的平方根
        return Math.hypot(this.x, this.y);
    }
    // 獲取向量與X軸的夾角
    get dir() {
        // 向量與X軸的夾角
        return Math.atan2(this.y, this.x);
    }
    // 複製向量
    copy() {
        return new Vector2D(this.x, this.y);
    }
    // 向量的加法
    add(v) {
        this.x += v.x;
        this.y += v.y;
        return this;
    }
    // 向量旋轉
    rotate(rad) {
        const c = Math.cos(rad),
            s = Math.sin(rad);
        const [x, y] = this;

        this.x = x * c - y * s;
        this.y = x * s + y * c;

        return this;
    }
    scale(length) {
        this.x *= length;
        this.y *= length;

        return this;
    }
    // 向量的點乘
    dot(v) {
        return this.x * v.x + this.y * v.y;
    }
    // 向量的叉乘
    cross(v) {
        return this.x * v.y - v.x * this.y;
    }
    reverse() {
        return this.copy().scale(-1);
    }
    // 向量的減法
    minus(v) {
        return this.copy().add(v.reverse());
    }
    // 向量歸一化
    normalize() {
        return this.copy().scale(1 / this.len);
    }
}

x和y分別是向量的座標,len獲取的是向量的長度、利用了Math物件上的方法,dot和cross方法分別對應的就是向量的點乘和叉乘。

接著就來編寫功能程式碼。

  • 首先是獲取canvas2d的上下文,並完成座標的轉換

    let canvas = document.querySelector('canvas'),
       ctx = canvas.getContext('2d');
    
    ctx.translate(canvas.width / 2, canvas.height / 2);
    ctx.scale(1, -1);
    

    因為畫布原始的座標系是以左上角為原點,X軸向左,Y軸向下,這不符合我們在數學中常用的配置。

    這裡我們先透過translate方法把座標挪到畫布中心,再透過scale方法將座標系繞X軸翻轉;透過這樣的轉換,就可以按照我們在數學中常見的座標系來操作了。

  • 然後我們來初始化三個點,也就是之前說的點A、點B和點C。

    座標可以隨便寫,只要範圍在-256到256之間就可以。

    我這裡就簡單定義三個在X軸上的點,並維護在一個Map中,方便後續在canvas上顯示三個點的標識;後面會加一個事件監聽來更新點C的座標。

    let map = new Map();
    let v0 = new Vector2D(0, 0),
        v1 = new Vector2D(100, 0),
        v2 = new Vector2D(-100, 0);
    map.set('C', v0);
    map.set('A', v1);
    map.set('B', v2);
    
  • 然後就可以開始繪製

    這裡我們定義一個draw函式,然後呼叫它。

    draw();
    
    function draw() {}
    
    • 首先,為了看上去更清晰,我們可以把座標系繪製出來。

      因為接下去繪製的直線比較多,這裡我簡單封裝一個繪製直線的方法。

      function drawLine(start, end, color) {
        ctx.beginPath();
        ctx.save();
        ctx.lineWidth = '4px';
        ctx.strokeStyle = color;
        ctx.moveTo(...start);
        ctx.lineTo(...end);
        ctx.stroke();
        ctx.restore();
        ctx.closePath();
      }
      

      然後我們來繪製座標系。

      drawAxis();
      
      function drawAxis() {
        drawLine([-canvas.width / 2, 0], [canvas.width / 2, 0], "#333");
        drawLine([0, canvas.height / 2], [0, -canvas.height / 2], "#333");
      }
      
    • 接著我們把點繪製到畫布上

      for(const p of map) {
        drawPoint(p[1], p[0]);
      }
      
      function drawPoint(v, name, color='#333') {
        ctx.beginPath();
        ctx.save();
        ctx.fillStyle = color;
        ctx.arc(v.x, v.y, 2, 0, Math.PI * 2);
        ctx.scale(1, -1);
        ctx.fillText(`${name}`, v.x, 16 - v.y);
        ctx.restore();
        ctx.fill();
      }
      

      這裡我們想把點的標識透過fillText也繪製到畫布上,但由於之前座標被繞X軸翻轉過一次,所以直接繪製表示會導致文字是倒過來的,所以我們這裡臨時把座標系翻轉回來,完成文字繪製後,再透過restore恢復回去。

    • 現在我們把線段AB也繪製出來

      drawBaseline();
      
      function drawBaseline() {
        drawLine(map.get('A'), map.get('B'), "blue");
      }
      
    • 最後就是最關鍵的一步,把點C到線段AB和直線的距離求出來並展示在canvas畫布上

      d為點C到線段AB的距離,dLine為點C到直線的距離;

      result儲存的是AC和AB的點乘結果;crossProduct儲存的是AC和AB的叉乘結果。

      根據叉乘結果,我們就可以計算出dLine的值,也就是點C到直線的距離。

      drawLines();
      
      function drawLines() {
        let AC = map.get('C').minus(map.get('A'));
        let AB = map.get('B').minus(map.get('A'));
        let BC = map.get('C').minus(map.get('B'));
        let result = AC.dot(AB);
        let d, dLine; // distance
      
        let crossProduct = AC.cross(AB);
        dLine = Math.abs(crossProduct) / AB.len;
        let pd = getD();
        map.set('D', pd);
        if (result < 0) {
          // 角CAB為鈍角
          drawLine(map.get('A'), map.get('C'), 'red');
          drawLine(map.get('C'), pd, 'green');
          d = AC.len;
        } else if (result > Math.pow(AB.len, 2)) {
          // 角CBA為鈍角
          drawLine(map.get('B'), map.get('C'), 'red');
          drawLine(map.get('C'), pd, 'green');
          d = BC.len;
        } else {
          d = dLine;
          drawLine(map.get('C'), pd, 'red');
        }
      
        let text = `點C到線段AB的距離:${Math.floor(d)}, 點C到AB所在直線的距離為${Math.floor(dLine)}`;
        drawText(text);
      }
      
      function getD() {
        let AC = map.get('C').minus(map.get('A'));
        let AB = map.get('B').minus(map.get('A'));
        let A = map.get('A'); // 即:向量OA
        // 已知:AD為AC在AB上的投影
        // AD = (AB / |AB|) * (AC·AB / |AB|)
        //    = AB * (AC·AB / |AB|²) 
        // D.x - A.x = AD.x, D.y - A.y = AD.y
        let AD = AB.scale(AC.dot(AB) / AB.len**2);
        let D = new Vector2D(
          AD.x + A.x,
          AD.y + A.y
        );
        return D;
      }
      

      然後我們來計算點D的座標:

      已知:AD是AC在AB上的投影。

      所以AD可以表示為這樣:(AB / |AB|) * (AC·AB / |AB|)

      向量AB除以AB的模即代表和向量AB同一方向夾角的單位向量,單位向量可以簡單理解為長度為1的向量;

      AC和AB的點積除以AB的模結果等於AC的模乘以兩個向量夾角的餘弦值

      所以這兩個值相乘,就等於是向量AD。

      透過調整上面的公式,我們可以得到AD = AB * (AC·AB / |AB|²) ,因為A、B、C的座標都已知,也就可以得到向量AD的座標。

      然後我們又知道向量AD的座標可以直接透過向量的減法得到,也就是:

      • AD.x = D.x - A.x
      • AD.y = D.y - A.y

      所以我們就可以得到點D的座標,即(AD.x + A.x, AD.y + A.y)

      接著我們根據AC和AB的點乘結果result,來繪製相應的直線。

      • 當result為負數時,說明AC和AB夾角的餘弦值大於90度

        即∠CAB為鈍角,說明點C到線段AB的距離就是點C到點A的距離。

      • 而當result大於AC長度的平方,也就是AC的模乘以餘弦值大於AB的模,也就是說,AC在向量AB上的投影大於AB的長度

        那麼此時∠CBA是鈍角,點C到線段AB的距離就是點C到點B的距離。

      • 當result為0時,說明兩個向量互相垂直

        此時,點C線上段AB的上方或下方,點C到線段AB的距離就是點C到直線的距離。也就是我們前面求到的dLine的值。

      最後我們將結果透過fillText方法繪製到螢幕上。

      function drawText(distance) {
        ctx.beginPath();
        ctx.save();
        ctx.font = "16px serif";
        ctx.scale(1, -1);
        ctx.fillText(`${distance}`, -250, 240);
        ctx.restore();
      }
      
    • 最後我們加一個滑鼠移動事件,動態地更新點C的座標,以及點C到線段AB和直線的距離。

      initEvents();
      
      function initEvents() {
        canvas.addEventListener('mousemove', e => {
          const rect = canvas.getBoundingClientRect();
          ctx.clearRect(-canvas.width / 2, -canvas.height / 2, canvas.width, canvas.height);
          let x = e.pageX - rect.left - canvas.width / 2;
          let y = -(e.pageY - rect.top - canvas.height / 2);
          v0 = new Vector2D(x, y);
          map.set('C', v0);
          draw();
       });
      }
      

    好啦,到這裡為止一個簡單的距離展示就完成了;我們可以透過移動滑鼠來檢視最後的效果。

相關文章