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


Posted in Javascript onAugust 05, 2022

前言

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

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

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

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

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

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

关于边框

边框很显然就是用一个矩形把整个物体框起来,也就是所谓的包围盒?。包围盒顾名思义就是能够把物体全部包起来的盒子,常见的有 OBB、AABB、球模型等等,按顺序分别如下图所示:

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

其中 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,
        };
    }
}

这种包围盒不仅易于理解、效率高,并且在碰撞检测中效果明显,比如一般我们判断两个物体是否发生碰撞通常都会先判断它们的包围盒是否相交,如果连包围盒都不相交,那么两个物体一定不相交,就不用再进行其他精确繁琐的计算了,是性价比很高的一种方法。事实上大部分碰撞检测算法通常也分为这两步(包围盒计算+精确计算)。

当然它的缺点也是比较明显的,假如我们有一个很斜很长的三角形,那画出来的包围盒就比较冗余,就像下图这样:

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

这时候用 OBB(Oriented Bounding Box)包围盒就会精确很多,就像下面这样:

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

它能够有效贴合物体,但是计算麻烦些,有兴趣可以自行搜索一下。然后这里再简单说一下球模型,就是用一个球将物体包围起来,那怎么计算这个球的大小呢,就是要算出球心和半径,我们可以直接将所有顶点坐标相加取平均值,当做球心,再计算出离球心最远的顶点的距离,将其当做半径即可。

显然我们采用的是 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 的影响,也就是说绘制边框的时候坐标系已经变了(可以理解成变成物体自身的坐标系),就像下面图中这样(扭个头看看就正了):

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

边框还是那个普普通通的矩形,和上图中的绿色坐标系一个方向。

关于控制点

至于另外九个控制点,写法和边框差不多,也要考虑到抵消缩放的效果,只不过需要我们多计算下每个控制点的位置(各个顶点和中点),其实也就多画 ⑨ 个矩形而已,这里以边框左上角的控制点为例子,简单看下代码:

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物体边框控制点的资料请关注三水点靠木其它相关文章!

Javascript 相关文章推荐
js常见表单应用技巧
Jan 09 Javascript
40个有创意的jQuery图片、内容滑动及弹出插件收藏集之一
Dec 31 Javascript
JavaScript var声明变量背后的原理示例解析
Oct 12 Javascript
jquery重复提交请求的原因浅析
May 23 Javascript
jquery中object对象循环遍历的方法
Dec 18 Javascript
JavaScript第一篇之实现按钮全选、功能
Aug 21 Javascript
微信小程序 实例开发总结
Apr 26 Javascript
express框架实现基于Websocket建立的简易聊天室
Aug 10 Javascript
浅析微信小程序modal弹窗关闭默认会执行cancel问题
Oct 14 Javascript
webgl实现物体描边效果的方法介绍
Nov 27 Javascript
vue中用 async/await 来处理异步操作
Jul 18 Javascript
vue-treeselect的基本用法以及解决点击无法出现拉下菜单
Apr 30 Vue.js
JS前端轻量fabric.js系列物体基类
Aug 05 #Javascript
JS前端轻量fabric.js系列之画布初始化
Aug 05 #Javascript
vue3 自定义图片放大器效果的示例代码
Jul 23 #Vue.js
JavaScript parseInt0.0000005打印5原理解析
Jul 23 #Javascript
JavaScript实现一键复制内容剪贴板
Jul 23 #Javascript
从原生JavaScript到React深入理解
Jul 23 #Javascript
JS前端监控采集用户行为的N种姿势
Jul 23 #Javascript
You might like
mysql 搜索之简单应用
2007/04/27 PHP
PHP面向对象学习笔记之二 生成对象的设计模式
2012/10/06 PHP
使用php+apc实现上传进度条且在IE7下不显示的问题解决方法
2013/04/25 PHP
php文件上传的例子及参数详解
2013/12/12 PHP
php中使用url传递数组的方法
2015/02/11 PHP
CodeIgniter使用smtp服务发送html邮件的方法
2015/06/10 PHP
php+jQuery递归调用POST循环请求示例
2016/10/14 PHP
PHP封装函数实现生成随机的字符串验证码
2017/01/24 PHP
Laravel 5.4因特殊字段太长导致migrations报错的解决
2017/10/22 PHP
PHP使用星号替代用户名手机和邮箱的实现代码
2018/02/07 PHP
php的instanceof和判断闭包Closure操作示例
2020/01/26 PHP
JS面向对象编程浅析
2011/08/28 Javascript
JavaScript中:表达式和语句的区别[译]
2012/09/17 Javascript
javascript自适应宽度的瀑布流实现思路
2013/02/20 Javascript
JS实现往下不断流动网页背景的方法
2015/02/27 Javascript
HTML+CSS+JS实现完美兼容各大浏览器的TABLE固定列
2015/04/26 Javascript
JavaScript给input的value赋值引发的关于基本类型值和引用类型值问题
2015/12/07 Javascript
在Web项目中引入Jquery插件报错的完美解决方案(图解)
2016/09/19 Javascript
基于jQuery实现滚动切换效果
2016/12/02 Javascript
微信小程序开发之录音机 音频播放 动画实例 (真机可用)
2016/12/08 Javascript
javascript 设计模式之享元模式原理与应用详解
2020/04/08 Javascript
原生JS实现音乐播放器
2021/01/26 Javascript
[01:28]2014DOTA2国际邀请赛中国区预选赛四大豪门直升机抵达会场
2014/05/24 DOTA
使用python编写监听端
2018/04/12 Python
python+opencv+caffe+摄像头做目标检测的实例代码
2018/08/03 Python
python调用Matplotlib绘制分布点图
2019/10/18 Python
python实现126邮箱发送邮件
2020/05/20 Python
python list的index()和find()的实现
2020/11/16 Python
英国标准协会商店:BSI Shop
2019/02/25 全球购物
这段代码难道不该打印出56吗
2013/02/27 面试题
xxx同志考察材料
2014/02/07 职场文书
入股协议书范本
2014/04/14 职场文书
2014年保管员工作总结
2014/11/18 职场文书
2015年勤工助学工作总结
2015/04/29 职场文书
巴黎圣母院观后感
2015/06/10 职场文书
2015年教师节新闻稿
2015/07/17 职场文书