前端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 常用函数
Dec 30 Javascript
Extjs学习笔记之六 面版
Jan 08 Javascript
jquery必须知道的一些常用特效方法及使用示例(整理)
Jun 24 Javascript
js实现鼠标移到链接文字弹出一个提示层的方法
May 11 Javascript
JS表单验证的代码(常用)
Apr 08 Javascript
jQuery实现带遮罩层效果的blockUI弹出层示例【附demo源码下载】
Sep 14 Javascript
easyui-combobox 实现简单的自动补全功能示例
Nov 08 Javascript
jQuery Validate表单验证插件的基本使用方法及功能拓展
Jan 04 Javascript
jQuery插件FusionCharts实现的2D柱状图效果示例【附demo源码下载】
Mar 06 Javascript
JS桶排序的简单理解与实现方法示例
Nov 25 Javascript
Vue过渡效果之CSS过渡详解(结合transition,animation,animate.css)
Feb 05 Javascript
深入理解javascript中的this
Feb 08 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
Search Engine Friendly的URL设计
2006/10/09 PHP
做个自己站内搜索引擎
2006/10/09 PHP
php发送html格式文本邮件的方法
2015/06/10 PHP
PHP性能优化大全(php.ini)
2016/05/20 PHP
PHP中str_split()函数的用法讲解
2019/04/11 PHP
JS中eval函数的使用示例
2013/07/21 Javascript
javascript中SetInterval与setTimeout的定时器用法
2015/08/24 Javascript
JavaScript性能优化之小知识总结
2015/11/20 Javascript
详解JavaScript的Date对象(制作简易钟表)
2020/04/07 Javascript
jquery实现右侧栏菜单选择操作
2016/03/04 Javascript
bootstrap3 兼容IE8浏览器!
2016/05/02 Javascript
js删除局部变量的实现方法
2016/06/25 Javascript
js文件中直接alert()中文出来的是乱码的解决方法
2016/11/01 Javascript
security.js实现的RSA加密功能示例
2018/06/06 Javascript
详解vue项目中实现图片裁剪功能
2019/06/07 Javascript
Vue-Cli 3.0 中配置高德地图的两种方式
2019/06/19 Javascript
JavaScript canvas实现雪花随机动态飘落
2020/02/08 Javascript
python使用新浪微博api上传图片到微博示例
2014/01/10 Python
python解析中国天气网的天气数据
2014/03/21 Python
python实现探测socket和web服务示例
2014/03/28 Python
Python写的Discuz7.2版faq.php注入漏洞工具
2014/08/06 Python
使用Python的Twisted框架实现一个简单的服务器
2015/04/16 Python
解决django-xadmin列表页filter关联对象搜索问题
2019/11/15 Python
Python Sphinx使用实例及问题解决
2020/01/17 Python
python语言中有算法吗
2020/06/16 Python
网页布局中CSS样式无效的十个重要原因详解
2017/08/10 HTML / CSS
孕妇装中的著名品牌:Isabella Oliver(伊莎贝拉·奥利弗)
2016/10/31 全球购物
美国网上鞋子零售商:Dr. Scholl’s Shoes
2017/11/17 全球购物
澳大利亚手表品牌:Time IV Change
2018/10/06 全球购物
美国婴儿用品及配件购买网站:Munchkin
2019/04/03 全球购物
最新大学生创业计划书写作攻略
2014/04/02 职场文书
《山谷中的谜底》教学反思
2014/04/26 职场文书
三八红旗手事迹材料
2014/12/26 职场文书
李强为自己工作观后感
2015/06/11 职场文书
九年级化学教学反思
2016/02/22 职场文书
python机器学习创建基于规则聊天机器人过程示例详解
2021/11/02 Python