前端canvas中物体边框和控制点的实现示例

发布时间:2022-8-03 09:34

在之前我们已经搞定了下层画布,也就是能够对物体进行绘制了,现在就可以开始搞搞上层交互了。

不过在和画布产生交互之前,我们还要做一件事情,就是让物体支持边框和控制点的绘制,亦即物体被选中时的状态,就像下面这样:

这样一来如果要对物体进行一些操作,那就变成了对上图中的红色和蓝色边框进行一些操作,而边框一定是矩形的

(很少有其他形状的,反正我是没咋见过),即便物体不是四四方方的,可以类比一些低代码和可视化平台的操作(调试页面也是)。所以选中态是产生交互的前提,这个章节要搞定的就是边框和控制点的绘制。

关于边框

边框很显然就是用一个矩形把整个物体框起来,也就是所谓的包围盒。包围盒顾名思义就是能够把物体全部包起来的盒子,常见的有 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)

Vue3学习笔记之依赖注入Provide/Inject 网站建设

Vue3学习笔记之依赖注入Provide/Inject

Provide / Inject 通常,当我们需要从父组件向子组件传递数据时,我们使用 props。想象一下这样的结构:有一些深度嵌套的组件,而深层的子组件只需要父组件的部分内容。在这种情况下,如果...
Vue3全局实例上挂载属性方法案例讲解 网站建设

Vue3全局实例上挂载属性方法案例讲解

在大多数开发需求中,我们有时需要将某个数据,或者某个函数方法,挂载到,全局实例身上,以便于,在项目全局的任何位置都能够调用其方法,或读取其数据。 在Vue2 中,我们是在 main.js 中 直...