前端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/jQuery简单实现选项卡功能
Jan 02 Javascript
js post提交调用方法
Feb 12 Javascript
把jQuery的类、插件封装成seajs的模块的方法
Mar 12 Javascript
jQuery实现首页顶部可伸缩广告特效代码
Apr 15 Javascript
javascript中indexOf技术详解
May 07 Javascript
微信小程序 详解下拉加载与上拉刷新实现方法
Jan 13 Javascript
JavaScript函数表达式详解及实例
May 05 Javascript
Vue 表单控件绑定的实现示例
Aug 11 Javascript
详解Vue 事件修饰符capture 的使用
Dec 29 Javascript
在JavaScript中实现链式调用的实现
Dec 24 Javascript
浅谈vuex为什么不建议在action中修改state
Feb 02 Javascript
javascript进阶篇深拷贝实现的四种方式
Jul 07 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
模拟flock实现文件锁定
2007/02/14 PHP
header导出Excel应用示例
2014/01/24 PHP
php实现的双色球算法示例
2017/06/20 PHP
详谈PHP中public,private,protected,abstract等关键字的用法
2017/12/31 PHP
通过javascript获取iframe里的值示例代码
2013/06/24 Javascript
文本框文本自动补全效果示例分享
2014/01/19 Javascript
jQuery中height()方法用法实例
2014/12/24 Javascript
Nodejs进阶:如何将图片转成datauri嵌入到网页中去实例
2016/11/21 NodeJs
jQuery判断邮箱格式对错实例代码讲解
2017/04/12 jQuery
ionic2屏幕适配实现适配手机、平板等设备的示例代码
2017/08/11 Javascript
jQuery 利用ztree实现树形表格的实例代码
2017/09/27 jQuery
js+canvas实现滑动拼图验证码功能
2018/03/26 Javascript
Vue框架里使用Swiper的方法示例
2018/09/20 Javascript
vue: WebStorm设置快速编译运行的方法
2018/10/18 Javascript
Three.js实现3D机房效果
2018/12/30 Javascript
Element-ui DatePicker显示周数的方法示例
2019/07/19 Javascript
js实现开关灯效果
2020/03/30 Javascript
Vue父子传递实例讲解
2020/02/14 Javascript
Node.js利用Express实现用户注册登陆功能(推荐)
2020/10/26 Javascript
Python中用于返回绝对值的abs()方法
2015/05/14 Python
Python基于whois模块简单识别网站域名及所有者的方法
2018/04/23 Python
Python实现的绘制三维双螺旋线图形功能示例
2018/06/23 Python
python实战教程之自动扫雷
2018/07/13 Python
详解django+django-celery+celery的整合实战
2019/03/19 Python
PyQt5 QTableView设置某一列不可编辑的方法
2019/06/25 Python
基于Python实现天天酷跑功能
2021/01/06 Python
CSS3绘制六边形的简单实现
2016/08/25 HTML / CSS
css3实现小箭头各种图形效果
2020/07/08 HTML / CSS
美的官方商城:Midea
2016/09/14 全球购物
财务人员个人求职信范文
2013/12/04 职场文书
美德少年事迹材料
2014/01/23 职场文书
学生会副主席竞选稿
2015/11/19 职场文书
《我是什么》教学反思
2016/02/16 职场文书
新手,如何业余时间安排好写作、提高写作能力?
2019/10/21 职场文书
实现一个简单得数据响应系统
2021/11/11 Javascript
js基于div丝滑实现贝塞尔曲线
2022/09/23 Javascript