前端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 处理 URL 的两个函数代码
Aug 13 Javascript
javascript 函数速查表
Feb 07 Javascript
防止xss和sql注入:JS特殊字符过滤正则
Apr 18 Javascript
JavaScript中5种调用函数的方法
Mar 12 Javascript
对Web开发中前端框架与前端类库的一些思考
Mar 27 Javascript
javascript实现页面刷新时自动清空表单并选中的方法
Jul 18 Javascript
checkbox批量选中,获取选中项的值的简单实例
Jun 28 Javascript
模板视图和AngularJS之间冲突的解决方法
Nov 22 Javascript
JavaScript在控件上添加倒计时功能的实现代码
Jul 04 Javascript
微信小程序 scroll-view 水平滚动实现过程解析
Oct 12 Javascript
小程序跳转H5页面的方法步骤
Mar 06 Javascript
js前端面试常见浏览器缓存强缓存及协商缓存实例
Jun 21 Javascript
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
自制汽车收音机天线:收听广播的技巧和方法
2021/03/02 无线电
php中JSON的使用与转换
2015/01/14 PHP
COM中获取JavaScript数组大小的代码
2009/11/22 Javascript
用JS实现一个TreeMenu效果分享
2011/08/28 Javascript
3种不同方式的焦点图轮播特效分享
2013/10/30 Javascript
javascript 数字格式化输出的实现代码
2013/12/10 Javascript
JavaScript函数参数使用带参数名的方式赋值传入的方法
2015/03/19 Javascript
js同源策略详解
2015/05/21 Javascript
JS实现带鼠标效果的头像及文章列表代码
2015/09/27 Javascript
Bootstrap框架下下拉框select搜索功能
2020/03/26 Javascript
JavaScript瀑布流布局实现代码
2017/05/06 Javascript
详解React native全局变量的使用(跨组件的通信)
2017/09/07 Javascript
微信小程序仿美团城市选择
2018/06/06 Javascript
微信小程序实现美团菜单
2018/06/06 Javascript
react-router4按需加载(踩坑填坑)
2019/01/06 Javascript
Vue切换div显示隐藏,多选,单选代码解析
2020/07/14 Javascript
通过vue刷新左侧菜单栏操作
2020/08/06 Javascript
javascript实现时间日期的格式化的方法汇总
2020/08/06 Javascript
微信小程序实现自定义底部导航
2020/11/18 Javascript
Python实现爬取逐浪小说的方法
2015/07/07 Python
关于Django外键赋值问题详解
2017/08/13 Python
查看TensorFlow checkpoint文件中的变量名和对应值方法
2018/06/14 Python
浅谈python函数调用返回两个或多个变量的方法
2019/01/23 Python
python如何获取当前文件夹下所有文件名详解
2019/01/25 Python
Python使用ctypes调用C/C++的方法
2019/01/29 Python
详解python中@的用法
2019/03/27 Python
Python中logger日志模块详解
2020/08/04 Python
CSS3过渡transition效果实例介绍
2016/05/03 HTML / CSS
Shopty西班牙:缝纫机在线销售
2018/01/26 全球购物
为有想象力的人提供的生活方式商店:Firebox
2018/06/04 全球购物
SEPHORA丝芙兰德国官方购物网站:化妆品、护肤品和香水
2020/01/21 全球购物
初中美术教学反思
2014/01/29 职场文书
就业自我评价
2014/02/04 职场文书
小学作文指导之如何写人?
2019/07/08 职场文书
Grafana可视化监控系统结合SpringBoot使用
2022/04/19 Redis
MySQL中的全表扫描和索引树扫描
2022/05/15 MySQL