前端canvas中物體邊框和控制點的實現示例

前言

在上一章中我們已經搞定瞭下層畫佈,也就是能夠對物體進行繪制瞭,現在就可以開始搞搞上層交互瞭。

不過在和畫佈產生交互之前,我們還要做一件事情,就是讓物體支持邊框和控制點的繪制,亦即物體被選中時的狀態,就像下面這樣:

這樣一來如果要對物體進行一些操作,那就變成瞭對上圖中的紅色和藍色邊框進行一些操作,而邊框一定是矩形的

(很少有其他形狀的,反正我是沒咋見過😂),即便物體不是四四方方的,可以類比一些低代碼和可視化平臺的操作(調試頁面也是)。所以選中態是產生交互的前提,這個章節要搞定的就是邊框和控制點的繪制。

關於邊框

邊框很顯然就是用一個矩形把整個物體框起來,也就是所謂的包圍盒🥳。包圍盒顧名思義就是能夠把物體全部包起來的盒子,常見的有 OBB、AABB、球模型等等,按順序分別如下圖所示:

其中 AABB 最為簡單,應用也最為廣泛,它的全稱是 Axis-aligned bounding box,也就是邊平行於坐標軸的包圍盒,理解和計算起來都非常容易,就是取物體所有頂點(也可叫做離散點)坐標的最大最小值,就像下面這樣:

class Utils {
    // 一個物體通常是一堆點的集合
    static makeBoundingBoxFromPoints(points: Point[]) {
        const xPoints = points.map(point => point.x);
        const yPoints = points.map(point => point.y);
        const minX = Util.min(xPoints);
        const maxX = Util.max(xPoints);
        const minY = Util.min(yPoints);
        const maxY = Util.max(yPoints);
        const width = Math.abs(maxX - minX);
        const height = Math.abs(maxY - minY);
        return {
            left: minX,
            top: minY,
            width: width,
            height: height,
        };
    }
}

這種包圍盒不僅易於理解、效率高,並且在碰撞檢測中效果明顯,比如一般我們判斷兩個物體是否發生碰撞通常都會先判斷它們的包圍盒是否相交,如果連包圍盒都不相交,那麼兩個物體一定不相交,就不用再進行其他精確繁瑣的計算瞭,是性價比很高的一種方法。事實上大部分碰撞檢測算法通常也分為這兩步(包圍盒計算+精確計算)。

當然它的缺點也是比較明顯的,假如我們有一個很斜很長的三角形,那畫出來的包圍盒就比較冗餘,就像下圖這樣:

這時候用 OBB(Oriented Bounding Box)包圍盒就會精確很多,就像下面這樣:

它能夠有效貼合物體,但是計算麻煩些,有興趣可以自行搜索一下。然後這裡再簡單說一下球模型,就是用一個球將物體包圍起來,那怎麼計算這個球的大小呢,就是要算出球心和半徑,我們可以直接將所有頂點坐標相加取平均值,當做球心,再計算出離球心最遠的頂點的距離,將其當做半徑即可。

顯然我們采用的是 AABB 包圍盒。又因為包圍盒是每個物體所共有的,所以它會被加在 FabricObject 物體基類裡,並且應該是在繪制物體之後才繪制,因為相對來說它的層級較高,當然在 canvas 中沒有層級的概念,它就是一幅畫,隻是後面繪制的會覆蓋之前繪制的,簡單看下代碼👇🏻:

class FabricObject {
    render() {
        ...
        // 坐標系變換
        this.transform(ctx);
        // 繪制物體
        this._render(ctx);
        // 如果是選中態
        if (this.active) {
            // 繪制物體邊框
            this.drawBorders(ctx);
            // 繪制物體四周的控制點,共⑨個
            this.drawControls(ctx);
        }
        ...
    }
}

那具體怎麼繪制邊框呢?這個比較簡單,剛才也說瞭,它就是個普通矩形,所以矩形怎麼畫它就怎麼畫。

但要註意什麼呢,因為我們是在 transform 之後進行操作的,所以要考慮到 transform 的影響,主要是 scale。

比如我們放大瞭兩倍之後,如果不對邊框進行處理,那畫出來的邊框線寬也會變成兩倍大,邊框寬度就會隨著 scale 的改變而改變,這顯然不是我們期望的結果,所以就需要把 scale 給縮回去,以保持邊框寬度始終一樣😬。

而相反的,邊框的寬高大小和物體本身一樣會受到 scale 的影響,當我們把 scale 縮回去之後,繪制出來的邊框寬高大小應該像這樣取值 this.width * this.scaleX 才能得到實際的大小,註意這裡並沒有改變物體自身寬高,隻是取值的時候需要簡單處理下。這裡簡單貼下代碼👇🏻:

class FabricObject {
    /** 繪制激活物體邊框 */
    drawBorders(ctx: CanvasRenderingContext2D): FabricObject {
        let padding = this.padding, // 邊框和物體的內間距,也是個配置項,和 css 中的 padding 一個意思
            padding2 = padding * 2,
            strokeWidth = 1; // 邊框寬度始終是 1,不受縮放的影響,當然可以做成配置項
        ctx.save();
        ctx.globalAlpha = this.isMoving ? 0.5 : 1; // 物體變換的時候使其透明度減半,提升用戶體驗
        ctx.strokeStyle = this.borderColor;
        ctx.lineWidth = strokeWidth;
        /** 畫邊框的時候需要把 transform 變換中的 scale 效果抵消,這樣才能畫出原始大小的線條 */
        ctx.scale(1 / this.scaleX, 1 / this.scaleY);
        let w = this.getWidth(),
            h = this.getHeight();
        // 這裡直接用原生的 api strokeRect 畫邊框即可,當然要考慮到邊寬和內間距的影響
        // 就是畫一個規規矩矩的矩形
        ctx.strokeRect(
            (-(w / 2) - padding - strokeWidth / 2),
            (-(h / 2) - padding - strokeWidth / 2),
            (w + padding2 + strokeWidth),
            (h + padding2 + strokeWidth)
        );
        // 除瞭畫邊框,還要畫旋轉控制點和邊框相連接的那條線
        if (this.hasRotatingPoint && this.hasControls) {
            let rotateHeight = (-h - strokeWidth - padding * 2) / 2;
            ctx.beginPath();
            ctx.moveTo(0, rotateHeight);
            ctx.lineTo(0, rotateHeight - this.rotatingPointOffset); // rotatingPointOffset 是旋轉控制點到邊框的距離
            ctx.closePath();
            ctx.stroke();
        }
        ctx.restore();
        return this;
    }
    /** 獲取當前大小,包含縮放效果 */
    getWidth(): number {
        return this.width * this.scaleX;
    }
    /** 獲取當前大小,包含縮放效果 */
    getHeight(): number {
        return this.height * this.scaleY;
    }
}

有同學可能會覺得如果物體產生瞭旋轉,也還是直接畫一個規規矩矩的矩形麼,不用稍微旋轉下矩形?其實不用的,正如前面所說,我們的邊框是在 transform 之後繪制的,所以已經考慮瞭 transform 的影響,也就是說繪制邊框的時候坐標系已經變瞭(可以理解成變成物體自身的坐標系),就像下面圖中這樣(扭個頭看看就正瞭):

邊框還是那個普普通通的矩形,和上圖中的綠色坐標系一個方向。

關於控制點

至於另外九個控制點,寫法和邊框差不多,也要考慮到抵消縮放的效果,隻不過需要我們多計算下每個控制點的位置(各個頂點和中點),其實也就多畫 ⑨ 個矩形而已,這裡以邊框左上角的控制點為例子,簡單看下代碼:

class FabricObject {
    /** 繪制控制點 */
    drawControls(ctx: CanvasRenderingContext2D): FabricObject {
        if (!this.hasControls) return;
        // 因為畫佈已經經過變換,所以大部分數值需要除以 scale 來抵消變換
        // 而上面那種畫邊框的操作則是把坐標系縮放回去,寫法不同,效果是一樣的
        let size = this.cornerSize,
            size2 = size / 2,
            strokeWidth2 = this.strokeWidth / 2,
            // top 和 left 值為物體左上角的點
            left = -(this.width / 2),
            top = -(this.height / 2),
            _left,
            _top,
            sizeX = size / this.scaleX,
            sizeY = size / this.scaleY,
            paddingX = this.padding / this.scaleX,
            paddingY = this.padding / this.scaleY,
            scaleOffsetY = size2 / this.scaleY,
            scaleOffsetX = size2 / this.scaleX,
            scaleOffsetSizeX = (size2 - size) / this.scaleX,
            scaleOffsetSizeY = (size2 - size) / this.scaleY,
            height = this.height,
            width = this.width,
        ctx.save();
        ctx.lineWidth = this.borderWidth / Math.max(this.scaleX, this.scaleY);
        ctx.globalAlpha = this.isMoving ? 0.5 : 1;
        ctx.strokeStyle = ctx.fillStyle = this.cornerColor;
        // top-left 左上角的控制點,也要考慮到線寬和 padding 的影響
        _left = left - scaleOffsetX - strokeWidth2 - paddingX;
        _top = top - scaleOffsetY - strokeWidth2 - paddingY;
        ctx.clearRect(_left, _top, sizeX, sizeY);
        ctx.fillRect(_left, _top, sizeX, sizeY);
        // 其他八個點...
        ctx.restore();
        return this;
    }
}

這裡強調下上面代碼中的一個點:就是我們的邊框(線寬)和控制點(大小和線寬)不應該隨物體縮放的改變而改變(另外兩個變換並不會改變物體大小,所以沒關系),但是我們繪制的時候已經是在 transform 之後瞭,要想抵消變換有兩種方法✌:

  • 調用 ctx.scale(1 / scaleX, 1 / scaleY) 把坐標系縮放回去,接下來正常繪制
  • 繪制的時候把線寬、大小的值除以 scale 來抵消變換

上面的邊框是包圍盒的一個簡單體現,後面講到 Group 類的時候還會重復一下這個包圍盒的概念。現在我們已經可以愉快的繪制物體的選中態啦!下一章節就可以開始真正的交互瞭,也就是 hover 和點選事件,算是這個系列的難點之一瞭,所以…敬請期待吧😁。

本章小結

這個章節我們主要介紹瞭物體邊框和控制點的繪制,其中最重要的一點是:它們本質都是矩形,並且是在 transform 變換之後繪制的,所以要考慮到 transform 的影響,以保持邊框寬度和控制點大小不會隨之改變。然後這裡是簡版 fabric.js 的代碼鏈接,有興趣的可以看看。

實現一個輕量 fabric.js 系列三(物體基類)🏖

實現一個輕量 fabric.js 系列二(畫佈初始化)🏖

實現一個輕量 fabric.js 系列一(摸透 canvas)💥

更多關於前端canvas物體邊框控制點的資料請關註WalkonNet其它相關文章!

推薦閱讀: