前端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 相关文章推荐
Javascript常用运算符(Operators)-javascript基础教程
Dec 14 Javascript
基于jquery的获取mouse坐标插件的实现代码
Apr 01 Javascript
js日期、星座的级联显示代码
Jan 23 Javascript
js设置function参数默认值(适合没有传参情况)
Feb 24 Javascript
js记录点击某个按钮的次数-刷新次数为初始状态的实例
Feb 15 Javascript
JavaScript正则表达式校验与递归函数实际应用实例解析
Aug 04 Javascript
jquery 给动态生成的标签绑定事件的几种方法总结
Feb 24 jQuery
vue多级复杂列表展开/折叠及全选/分组全选实现
Nov 05 Javascript
js中对象和面向对象与Json介绍
Jan 21 Javascript
解决Can't find variable: SockJS vue项目的问题
Sep 22 Javascript
微信小程序实现天气预报功能(附源码)
Dec 10 Javascript
Vue看了就会的8个小技巧
Jan 21 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
PHP JSON出错:Cannot use object of type stdClass as array解决方法
2014/08/16 PHP
如何让CI框架支持service层
2014/10/29 PHP
javascript动画效果类封装代码
2007/08/28 Javascript
jQuery EasyUI API 中文文档 - Draggable 可拖拽
2011/09/29 Javascript
JS this作用域以及GET传输值过长的问题解决方法
2013/08/06 Javascript
jquery遍历数组与筛选数组的方法
2013/11/05 Javascript
jQuery中Dom的基本操作小结
2014/01/23 Javascript
Node调试工具JSHint的安装及配置教程
2014/05/27 Javascript
JS判断移动端访问设备并加载对应CSS样式
2014/06/13 Javascript
Javascript中的数据类型之旅
2015/10/18 Javascript
javascript从作用域链谈闭包
2020/07/29 Javascript
js无提示关闭浏览器窗口的两种方法分析
2016/11/06 Javascript
jQuery EasyUi 验证功能实例解析
2017/01/06 Javascript
AngularJS2 与 D3.js集成实现自定义可视化的方法
2017/12/01 Javascript
详解ES6中的三种异步解决方案
2018/06/28 Javascript
vue2 中二级路由高亮问题及配置方法
2019/06/10 Javascript
JS事件流与事件处理程序实例分析
2019/08/16 Javascript
Python实现的维尼吉亚密码算法示例
2018/04/12 Python
可能是最全面的 Python 字符串拼接总结【收藏】
2018/07/09 Python
Python实现的插入排序,冒泡排序,快速排序,选择排序算法示例
2019/05/04 Python
python的turtle库使用详解
2019/05/10 Python
Python Django框架url反向解析实现动态生成对应的url链接示例
2019/10/18 Python
Django中提示消息messages的设置方式
2019/11/15 Python
Python批量将图片灰度化的实现代码
2020/04/11 Python
python输入一个水仙花数(三位数) 输出百位十位个位实例
2020/05/03 Python
scrapy在python爬虫中搭建出错的解决方法
2020/11/22 Python
python3爬虫中多线程进行解锁操作实例
2020/11/25 Python
在线课程:Skillshare
2019/04/02 全球购物
AJAX都有哪些有点和缺点
2012/11/03 面试题
项目合作计划书
2014/01/09 职场文书
初一生物教学反思
2014/01/18 职场文书
2014年党风建设工作总结
2014/11/19 职场文书
铁人观后感
2015/06/16 职场文书
关于Vue Router的10条高级技巧总结
2021/05/06 Vue.js
【DOTA2】半决赛强强对话~ PSG LGD vs EHOME - DPC 2022 CN REGIONAL FINALS WINTER
2022/04/02 DOTA
Python线程池与GIL全局锁实现抽奖小案例
2022/04/13 Python