前端canvas中物體邊框和控制點(diǎn)的實(shí)現(xiàn)示例
前言
在上一章中我們已經(jīng)搞定了下層畫(huà)布,也就是能夠?qū)ξ矬w進(jìn)行繪制了,現(xiàn)在就可以開(kāi)始搞搞上層交互了。
不過(guò)在和畫(huà)布產(chǎn)生交互之前,我們還要做一件事情,就是讓物體支持邊框和控制點(diǎn)的繪制,亦即物體被選中時(shí)的狀態(tài),就像下面這樣:


這樣一來(lái)如果要對(duì)物體進(jìn)行一些操作,那就變成了對(duì)上圖中的紅色和藍(lán)色邊框進(jìn)行一些操作,而邊框一定是矩形的
(很少有其他形狀的,反正我是沒(méi)咋見(jiàn)過(guò)??),即便物體不是四四方方的,可以類(lèi)比一些低代碼和可視化平臺(tái)的操作(調(diào)試頁(yè)面也是)。所以選中態(tài)是產(chǎn)生交互的前提,這個(gè)章節(jié)要搞定的就是邊框和控制點(diǎn)的繪制。
關(guān)于邊框
邊框很顯然就是用一個(gè)矩形把整個(gè)物體框起來(lái),也就是所謂的包圍盒??。包圍盒顧名思義就是能夠把物體全部包起來(lái)的盒子,常見(jiàn)的有 OBB、AABB、球模型等等,按順序分別如下圖所示:

其中 AABB 最為簡(jiǎn)單,應(yīng)用也最為廣泛,它的全稱(chēng)是 Axis-aligned bounding box,也就是邊平行于坐標(biāo)軸的包圍盒,理解和計(jì)算起來(lái)都非常容易,就是取物體所有頂點(diǎn)(也可叫做離散點(diǎn))坐標(biāo)的最大最小值,就像下面這樣:
class Utils {
// 一個(gè)物體通常是一堆點(diǎn)的集合
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,
};
}
}
這種包圍盒不僅易于理解、效率高,并且在碰撞檢測(cè)中效果明顯,比如一般我們判斷兩個(gè)物體是否發(fā)生碰撞通常都會(huì)先判斷它們的包圍盒是否相交,如果連包圍盒都不相交,那么兩個(gè)物體一定不相交,就不用再進(jìn)行其他精確繁瑣的計(jì)算了,是性?xún)r(jià)比很高的一種方法。事實(shí)上大部分碰撞檢測(cè)算法通常也分為這兩步(包圍盒計(jì)算+精確計(jì)算)。
當(dāng)然它的缺點(diǎn)也是比較明顯的,假如我們有一個(gè)很斜很長(zhǎng)的三角形,那畫(huà)出來(lái)的包圍盒就比較冗余,就像下圖這樣:

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

它能夠有效貼合物體,但是計(jì)算麻煩些,有興趣可以自行搜索一下。然后這里再簡(jiǎn)單說(shuō)一下球模型,就是用一個(gè)球?qū)⑽矬w包圍起來(lái),那怎么計(jì)算這個(gè)球的大小呢,就是要算出球心和半徑,我們可以直接將所有頂點(diǎn)坐標(biāo)相加取平均值,當(dāng)做球心,再計(jì)算出離球心最遠(yuǎn)的頂點(diǎn)的距離,將其當(dāng)做半徑即可。
顯然我們采用的是 AABB 包圍盒。又因?yàn)榘鼑惺敲總€(gè)物體所共有的,所以它會(huì)被加在 FabricObject 物體基類(lèi)里,并且應(yīng)該是在繪制物體之后才繪制,因?yàn)橄鄬?duì)來(lái)說(shuō)它的層級(jí)較高,當(dāng)然在 canvas 中沒(méi)有層級(jí)的概念,它就是一幅畫(huà),只是后面繪制的會(huì)覆蓋之前繪制的,簡(jiǎn)單看下代碼????:
class FabricObject {
render() {
...
// 坐標(biāo)系變換
this.transform(ctx);
// 繪制物體
this._render(ctx);
// 如果是選中態(tài)
if (this.active) {
// 繪制物體邊框
this.drawBorders(ctx);
// 繪制物體四周的控制點(diǎn),共⑨個(gè)
this.drawControls(ctx);
}
...
}
}
那具體怎么繪制邊框呢?這個(gè)比較簡(jiǎn)單,剛才也說(shuō)了,它就是個(gè)普通矩形,所以矩形怎么畫(huà)它就怎么畫(huà)。
但要注意什么呢,因?yàn)槲覀兪窃?transform 之后進(jìn)行操作的,所以要考慮到 transform 的影響,主要是 scale。
比如我們放大了兩倍之后,如果不對(duì)邊框進(jìn)行處理,那畫(huà)出來(lái)的邊框線(xiàn)寬也會(huì)變成兩倍大,邊框?qū)挾染蜁?huì)隨著 scale 的改變而改變,這顯然不是我們期望的結(jié)果,所以就需要把 scale 給縮回去,以保持邊框?qū)挾仁冀K一樣??。
而相反的,邊框的寬高大小和物體本身一樣會(huì)受到 scale 的影響,當(dāng)我們把 scale 縮回去之后,繪制出來(lái)的邊框?qū)捀叽笮?yīng)該像這樣取值 this.width * this.scaleX 才能得到實(shí)際的大小,注意這里并沒(méi)有改變物體自身寬高,只是取值的時(shí)候需要簡(jiǎn)單處理下。這里簡(jiǎn)單貼下代碼????:
class FabricObject {
/** 繪制激活物體邊框 */
drawBorders(ctx: CanvasRenderingContext2D): FabricObject {
let padding = this.padding, // 邊框和物體的內(nèi)間距,也是個(gè)配置項(xiàng),和 css 中的 padding 一個(gè)意思
padding2 = padding * 2,
strokeWidth = 1; // 邊框?qū)挾仁冀K是 1,不受縮放的影響,當(dāng)然可以做成配置項(xiàng)
ctx.save();
ctx.globalAlpha = this.isMoving ? 0.5 : 1; // 物體變換的時(shí)候使其透明度減半,提升用戶(hù)體驗(yàn)
ctx.strokeStyle = this.borderColor;
ctx.lineWidth = strokeWidth;
/** 畫(huà)邊框的時(shí)候需要把 transform 變換中的 scale 效果抵消,這樣才能畫(huà)出原始大小的線(xiàn)條 */
ctx.scale(1 / this.scaleX, 1 / this.scaleY);
let w = this.getWidth(),
h = this.getHeight();
// 這里直接用原生的 api strokeRect 畫(huà)邊框即可,當(dāng)然要考慮到邊寬和內(nèi)間距的影響
// 就是畫(huà)一個(gè)規(guī)規(guī)矩矩的矩形
ctx.strokeRect(
(-(w / 2) - padding - strokeWidth / 2),
(-(h / 2) - padding - strokeWidth / 2),
(w + padding2 + strokeWidth),
(h + padding2 + strokeWidth)
);
// 除了畫(huà)邊框,還要畫(huà)旋轉(zhuǎn)控制點(diǎn)和邊框相連接的那條線(xiàn)
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 是旋轉(zhuǎn)控制點(diǎn)到邊框的距離
ctx.closePath();
ctx.stroke();
}
ctx.restore();
return this;
}
/** 獲取當(dāng)前大小,包含縮放效果 */
getWidth(): number {
return this.width * this.scaleX;
}
/** 獲取當(dāng)前大小,包含縮放效果 */
getHeight(): number {
return this.height * this.scaleY;
}
}
有同學(xué)可能會(huì)覺(jué)得如果物體產(chǎn)生了旋轉(zhuǎn),也還是直接畫(huà)一個(gè)規(guī)規(guī)矩矩的矩形么,不用稍微旋轉(zhuǎn)下矩形?其實(shí)不用的,正如前面所說(shuō),我們的邊框是在 transform 之后繪制的,所以已經(jīng)考慮了 transform 的影響,也就是說(shuō)繪制邊框的時(shí)候坐標(biāo)系已經(jīng)變了(可以理解成變成物體自身的坐標(biāo)系),就像下面圖中這樣(扭個(gè)頭看看就正了):

邊框還是那個(gè)普普通通的矩形,和上圖中的綠色坐標(biāo)系一個(gè)方向。
關(guān)于控制點(diǎn)
至于另外九個(gè)控制點(diǎn),寫(xiě)法和邊框差不多,也要考慮到抵消縮放的效果,只不過(guò)需要我們多計(jì)算下每個(gè)控制點(diǎn)的位置(各個(gè)頂點(diǎn)和中點(diǎn)),其實(shí)也就多畫(huà) ⑨ 個(gè)矩形而已,這里以邊框左上角的控制點(diǎn)為例子,簡(jiǎn)單看下代碼:
class FabricObject {
/** 繪制控制點(diǎn) */
drawControls(ctx: CanvasRenderingContext2D): FabricObject {
if (!this.hasControls) return;
// 因?yàn)楫?huà)布已經(jīng)經(jīng)過(guò)變換,所以大部分?jǐn)?shù)值需要除以 scale 來(lái)抵消變換
// 而上面那種畫(huà)邊框的操作則是把坐標(biāo)系縮放回去,寫(xiě)法不同,效果是一樣的
let size = this.cornerSize,
size2 = size / 2,
strokeWidth2 = this.strokeWidth / 2,
// top 和 left 值為物體左上角的點(diǎn)
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 左上角的控制點(diǎn),也要考慮到線(xiàn)寬和 padding 的影響
_left = left - scaleOffsetX - strokeWidth2 - paddingX;
_top = top - scaleOffsetY - strokeWidth2 - paddingY;
ctx.clearRect(_left, _top, sizeX, sizeY);
ctx.fillRect(_left, _top, sizeX, sizeY);
// 其他八個(gè)點(diǎn)...
ctx.restore();
return this;
}
}
這里強(qiáng)調(diào)下上面代碼中的一個(gè)點(diǎn):就是我們的邊框(線(xiàn)寬)和控制點(diǎn)(大小和線(xiàn)寬)不應(yīng)該隨物體縮放的改變而改變(另外兩個(gè)變換并不會(huì)改變物體大小,所以沒(méi)關(guān)系),但是我們繪制的時(shí)候已經(jīng)是在 transform 之后了,要想抵消變換有兩種方法?:
- 調(diào)用 ctx.scale(1 / scaleX, 1 / scaleY) 把坐標(biāo)系縮放回去,接下來(lái)正常繪制
- 繪制的時(shí)候把線(xiàn)寬、大小的值除以 scale 來(lái)抵消變換
上面的邊框是包圍盒的一個(gè)簡(jiǎn)單體現(xiàn),后面講到 Group 類(lèi)的時(shí)候還會(huì)重復(fù)一下這個(gè)包圍盒的概念?,F(xiàn)在我們已經(jīng)可以愉快的繪制物體的選中態(tài)啦!下一章節(jié)就可以開(kāi)始真正的交互了,也就是 hover 和點(diǎn)選事件,算是這個(gè)系列的難點(diǎn)之一了,所以...敬請(qǐng)期待吧??。
本章小結(jié)
這個(gè)章節(jié)我們主要介紹了物體邊框和控制點(diǎn)的繪制,其中最重要的一點(diǎn)是:它們本質(zhì)都是矩形,并且是在 transform 變換之后繪制的,所以要考慮到 transform 的影響,以保持邊框?qū)挾群涂刂泣c(diǎn)大小不會(huì)隨之改變。然后這里是簡(jiǎn)版 fabric.js 的代碼鏈接,有興趣的可以看看。
實(shí)現(xiàn)一個(gè)輕量 fabric.js 系列三(物體基類(lèi))??
實(shí)現(xiàn)一個(gè)輕量 fabric.js 系列二(畫(huà)布初始化)??
實(shí)現(xiàn)一個(gè)輕量 fabric.js 系列一(摸透 canvas)??
更多關(guān)于前端canvas物體邊框控制點(diǎn)的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
JavaScript原型鏈中函數(shù)和對(duì)象的理解
這篇文章主要為大家介紹了JavaScript原型鏈中函數(shù)和對(duì)象的理解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-06-06
JavaScript高級(jí)程序設(shè)計(jì)之基本引用類(lèi)型
這篇文章主要介紹了JavaScript高級(jí)程序設(shè)計(jì)之基本引用類(lèi)型,引用值(對(duì)象)都是某個(gè)特定的引用類(lèi)型的實(shí)例對(duì)象,例如 Date類(lèi)型、Function類(lèi)型,RegExp 正則表達(dá)式類(lèi)型等等。JavaScript內(nèi)置了一些常用的引用類(lèi)型給開(kāi)發(fā)者使用。下面來(lái)看看文章詳細(xì)內(nèi)容2021-11-11
JavaScript中的設(shè)計(jì)模式 單例模式
這篇文章主要給大家介紹的是JavaScript中的單例模式,設(shè)計(jì)模式代表了最佳的實(shí)踐,通常被有經(jīng)驗(yàn)的面向?qū)ο蟮能浖_(kāi)發(fā)人員所采用。設(shè)計(jì)模式是軟件開(kāi)發(fā)人員在軟件開(kāi)發(fā)過(guò)程中面臨的一般問(wèn)題的解決方案,需要的朋友可以參考一下2021-09-09
TypeScript新語(yǔ)法之infer?extends示例詳解
這篇文章主要為大家介紹了TypeScript新語(yǔ)法之infer?extends示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-08-08

