图形编辑器开发:参考线吸附功能,让图形自动对齐

最近我给图形编辑器增加了参照线吸附功能,讲讲我的实现思路。

我正在开发的图形设计工具:

https://github.com/F-star/suika

线上体验:

https://blog.fstars.wang/app/suika/

效果是被移动的图形会参考周围图形,自动与它们进行吸附对齐。

不得不说,很酷炫。

感觉这个图形编辑器突然变得灵动起来,有了灵魂一般。
在这里插入图片描述

为什么需要参照线吸附功能?

这里的参照线,指的是在移动目标图形时,当靠近其他图形的包围盒的延长线(看不见)时,会(1)绘制出最近的延长线和延长线上的点,(2)并将目标图形吸附上去,轻松实现(3)对齐的效果。

在这里插入图片描述

可以看到,通过参照线,我们很容易就能实现各种对齐,比如两图形的底边和定边对齐、右下角和左上角对齐。

这在 以对齐为基本要素 的视觉设计中,是非常好用的功能。

在这里插入图片描述

整体思路

整体思路为:

  1. 记录参照线;
  2. 找出目标图形最靠近的水平参照线和垂直参照线;
  3. 计算出偏移值 offsetX、offsetY;
  4. 标记要绘制的所有参照线段(不是两端无限延长的);
  5. 修正图形的 x、y;
  6. 绘制参照线和点。

记录参照线

首先是确定能够作为 “参照” 的参照图形。

通常来说,参照图形为视口内的图形,并排除掉被移动的目标图形。视口外的图形通常都不在设计师的关注区域内。

确认好参照图形后,计算出它们的包围盒(bbox)。

这次的包围盒有点特殊,要多给一个中点坐标,因为中线也要作为参照线。

接口签名为:

export interface IBoxWithMid {minX: number;minY: number;midX: number;midY: number;maxX: number;maxY: number;
}

它们组成了参照图形的 8 个点,沿着这些点绘制竖线和横线,就是被移动的目标图形对应要吸附的参照线。

被移动的图形也要计算包围盒,并得到 5 个点。基于这些点的产生的水平线和垂直线,在靠近参照线时会吸附到最近的参照线上,分为水平移动和垂直移动两个维度。

编辑器上的效果:

我们首先要把所有的参照线记录下来,在图形准备移动(mousedown)的时候。大致有以下这几个操作:

  1. 遍历参照图形(在视口内,且不为被移动目标图形);
  2. 计算出它们的包围盒,得到 8 个点,3 条垂直线和 3 条水平线。在一条垂直线上的多个点,其 x 值是相同的,y 不同,我们 x 作为 key,y 的数组为 value,保存到 hLineMap 映射对象中。每一项代表一条垂直线;
  3. 水平线同理,保存在 vLineMap 中。
  4. 然后对这两个 map 的 key 保存到 sortedXs 或 sortedYs 数组中,并排序,方便之后二分查找提高查找效率。

抽象一个 RefLine(参照线)类。

interface IVerticalLine { // 有多个端点的垂直线x: number;ys: number[];
}interface IHorizontalLine { // 有多个端点的水平线y: number;xs: number[];
}class RefLine {// 参照图形产生的垂直参照线,y 相同(作为 key),x 值不同(作为 value)private hLineMap = new Map<number, number[]>();// 参照图形产生的水平照线,x 相同(作为 key),y 值不同(作为 value)private vLineMap = new Map<number, number[]>(); // 对 hLineMap 的 key 排序,方便高效二分查找,找到最近的线private sortedXs: number[] = []; // 对 vLineMap 的 key 排序private sortedYs: number[] = []; private toDrawVLines: IVerticalLine[] = []; // 等待绘制的垂直参照线private toDrawHLines: IHorizontalLine[] = []; // 等待绘制的水平参照线constructor(private editor: Editor) {}cacheXYToBbox() {this.clear();const hLineMap = this.hLineMap;const vLineMap = this.vLineMap;const selectIdSet = this.editor.selectedElements.getIdSet();const viewportBbox = this.editor.viewportManager.getBbox2();for (const graph of this.editor.sceneGraph.children) {// 排除掉被移动的图形if (selectIdSet.has(graph.id)) {continue;}const bbox = bboxToBboxWithMid(graph.getBBox2());// 排除在视口外的图形if (!isRectIntersect2(viewportBbox, bbox)) {continue;}// 将参照图形记录下来// 这里是水平线,特点是 x 相同。this.addBboxToMap(hLineMap, bbox.minX, [bbox.minY, bbox.maxY]);this.addBboxToMap(hLineMap, bbox.midX, [bbox.minY, bbox.maxY]);this.addBboxToMap(hLineMap, bbox.maxX, [bbox.minY, bbox.maxY]);this.addBboxToMap(vLineMap, bbox.minY, [bbox.minX, bbox.maxX]);this.addBboxToMap(vLineMap, bbox.midY, [bbox.minX, bbox.maxX]);this.addBboxToMap(vLineMap, bbox.maxY, [bbox.minX, bbox.maxX]);}this.sortedXs = Array.from(hLineMap.keys()).sort((a, b) => a - b);this.sortedYs = Array.from(vLineMap.keys()).sort((a, b) => a - b);}private addBboxToMap(m: Map<number, number[]>,xOrY: number,xsOrYs: number[],) {const line = m.get(xOrY);if (line) {line.push(...xsOrYs);} else {m.set(xOrY, [...xsOrYs]);}}// ...
}

找出最近参照线

然后是找出目标图形最靠近的水平参照线和垂直参照线。

这一步是在图形移动(mousemove)时做的,是动态变化的。

首先我们分别找到目标图形的 minX、midX、maxX 的最近垂直参照线。

然后计算出它们各自的绝对距离。

最后找出这里面最小的一个。

class RefLinet {updateRefLine(_targetBbox: IBox2): {offsetX: number;offsetY: number;} {// 重置this.toDrawVLines = [];this.toDrawHLines = [];// 目标对象的包围盒,这里补上 midX,midYconst targetBbox = bboxToBboxWithMid(_targetBbox);const hLineMap = this.hLineMap;const vLineMap = this.vLineMap;const sortedXs = this.sortedXs;const sortedYs = this.sortedYs;// 一个参照图形都没有,结束if (sortedXs.length === 0 && sortedYs.length === 0) {return { offsetX: 0, offsetY: 0 };}// 如果 offsetX 到最后还是 undefined,说明没有找到最靠近的垂直参照线let offsetX: number | undefined = undefined;let offsetY: number | undefined = undefined;// 分别找到目标图形的 minX、midX、maxX 的最近垂直参照线const closestMinX = getClosestValInSortedArr(sortedXs, targetBbox.minX);const closestMidX = getClosestValInSortedArr(sortedXs, targetBbox.midX);const closestMaxX = getClosestValInSortedArr(sortedXs, targetBbox.maxX);// 分别计算出距离const distMinX = Math.abs(closestMinX - targetBbox.minX);const distMidX = Math.abs(closestMidX - targetBbox.midX);const distMaxX = Math.abs(closestMaxX - targetBbox.maxX);// 找到最近距离const closestXDist = Math.min(distMinX, distMidX, distMaxX);// y 同理}
}

这里有一个比较重要的算法,就是找出排序数组中,离目标值最近的数组元素。

该算法二分查找的变体,虽然原理不复杂,但一次能写对,很难。这里我是找 gpt 帮我写的,非常完美。

实现如下:

const getClosestValInSortedArr = (sortedArr: number[],target: number,
) => {if (sortedArr.length === 0) {throw new Error('sortedArr can not be empty');}if (sortedArr.length === 1) {return sortedArr[0];}let left = 0;let right = sortedArr.length - 1;while (left <= right) {const mid = Math.floor((left + right) / 2);if (sortedArr[mid] === target) {return sortedArr[mid];} else if (sortedArr[mid] < target) {left = mid + 1;} else {right = mid - 1;}}// check if left or right is out of boundif (left >= sortedArr.length) {return sortedArr[right];}if (right < 0) {return sortedArr[left];}// check which one is closerreturn Math.abs(sortedArr[right] - target) <=Math.abs(sortedArr[left] - target)? sortedArr[right]: sortedArr[left];
};

计算偏移值

前面我们得到了最小距离 closestXDist。

接着我们要判断其是否小于一个特定的临界值 tol。不可能你离着十米开外,移动一下就千里迢迢吸附过来了吧。

如果满足,我们继续。

offsetX 就差一步了,我们需要确定正负,因为 closestXDist 是一个绝对值。

那我们就拿这个最小距离和之前计算出的三个距离 distMinX、distMidX、distMaxX对比,找到相等的,就能计算出 offsetX 了。

const isEqualNum = (a: number, b: number) => Math.abs(a - b) < 0.00001;const tol = 5 / zoom; // 最小距离不能超过这个// 确认偏移值 offsetX
if (closestXDist <= tol) {// 这里考虑了一下浮点数误差if (isEqualNum(closestXDist, distMinX)) {offsetX = closestMinX - targetBbox.minX;} else if (isEqualNum(closestXDist, distMidX)) {offsetX = closestMidX - targetBbox.midX;} else if (isEqualNum(closestXDist, distMaxX)) {offsetX = closestMaxX - targetBbox.maxX;} else {throw new Error('it should not reach here, please put a issue to us');}
}

offsetY 同理,不赘述。

标记需绘制参照线段

计算出了 offsetX 和 offsetY。

接下来要修正一下我们的 targetBbox。

const correctedTargetBbox = { ...targetBbox };
if (offsetX !== undefined) {correctedTargetBbox.minX += offsetX;correctedTargetBbox.midX += offsetX;correctedTargetBbox.maxX += offsetX;
}
if (offsetY !== undefined) {correctedTargetBbox.minY += offsetY;correctedTargetBbox.midY += offsetY;correctedTargetBbox.maxY += offsetY;
}

修正后的目标图形,它的边就和一些参照线发生了对齐。

对齐的参照线,可能一条没有,可能只有一条,也可能有最多的 6 条

基于新的目标图形,我们来找它落在的参照线有哪些。

// offsetX 不为 undefined,说明落在了临界值内
if (offsetX !== undefined) {/*************** 左垂直的参考线 ************/// 对比 “offset” 和 “离 minX 最近的垂直线到 minX 的距离(不是绝对值)”if (isEqualNum(offsetX, closestMinX - targetBbox.minX)) {// 创建一个垂直线对象(特点是这些点的 x 相同)const vLine: IVerticalLine = {x: closestMinX,ys: [],};// 修正后的目标图形的对应点。vLine.ys.push(correctedTargetBbox.minY);vLine.ys.push(correctedTargetBbox.maxY);// 参照图形上的点vLine.ys.push(...hLineMap.get(closestMinX)!);// 添加到 “待绘制垂线集合”this.toDrawVLines.push(vLine);}/*************** 中间垂直的参考线 ************/if (isEqualNum(offsetX, closestMidX - targetBbox.midX)) {const vLine: IVerticalLine = {x: closestMidX,ys: [],};vLine.ys.push(correctedTargetBbox.midY);vLine.ys.push(...hLineMap.get(closestMidX)!);this.toDrawVLines.push(vLine);}/*************** 右垂直的参考线 ************/// ...
}// 水平线同理
if (offsetY !== undefined) {/*************** 上水平的参考线 ************//*************** 中间水平的参考线 ************//*************** 下水平的参考线 ************/
}

修正图形的 x、y

计算出的 offsetX 和 offsetY,记得拿去修正被移动目标图形的 x 和 y。

const onMousemove = (e) => {// ...const { offsetX, offsetY } = this.editor.refLine.updateRefLine(bboxToBbox2(this.editor.selectedElements.getBBox()!),);// 修正for (let i = 0, len = selectedElements.length; i < len; i++) {selectedElements[i].x = startPoints[i].x + dx + offsetX;selectedElements[i].y = startPoints[i].y + dy + offsetY;}
}

绘制参照线和点

最后是绘制参照线,以绘制垂直线为例。

for (const vLine of this.toDrawVLines) {let minY = Infinity;let maxY = -Infinity;// 这个是世界坐标系转视口坐标系const { x } = this.editor.sceneCoordsToViewport(vLine.x, 0);// 遍历绘制点for (const y_ of vLine.ys) {// TODO: optimizeconst { y } = this.editor.sceneCoordsToViewport(0, y_);minY = Math.min(minY, y);maxY = Math.max(maxY, y);// 可能有重复的点,用备忘录排除掉const key = `${x},${y}`;if (pointsSet.has(key)) {continue;}pointsSet.add(key);// 绘制点drawXShape(ctx, x, y, pointSize);}// 所有点中的 minY 和 maxY,绘制线段drawLine(ctx, x, minY, x, maxY);
}

水平线同理。

优化点

  1. 这里的实现,在图形有旋转角度的时候,参照线会过多显得冗余,可以精简一些,减少要对比的参照线;
  2. 对齐到像素网格的时候,包围盒的值要取整;
  3. 考虑和按住 Shift 固定 x 或 y 平移的情况。

最后

总结一下,参考线吸附的实现,就是找出最近的垂直线和水平线,计算出 offsetX 和 offsetY,修正被移动图形的 x 和 y,并记录并绘制出最终重合的参考线。

我是前端西瓜哥,欢迎关注我,学习更多图形编辑器知识。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/587.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

MySQl数据库第八课-------SQL命令查询-------主要命脉

作者前言 欢迎小可爱们前来借鉴我的gtiee秦老大大 (qin-laoda) - Gitee.com —————————————————————————————— 目录 查询数据 条件 逻辑运算符 模糊查询 范围查询 in 判断空 UNION 排序 聚合 分组&#xff1a;group by —————————…

c++计算贝塞尔曲线(折线平滑为曲线)坐标方法

效果可查看上一篇博文&#xff1a;js手动画平滑曲线&#xff0c;贝塞尔曲线拟合【代码】js手动画平滑曲线&#xff0c;贝塞尔曲线拟合。https://blog.csdn.net/qiufeng_xinqing/article/details/131711963?spm1001.2014.3001.5502 代码如下&#xff1a; #include <cmath&…

FPGA实验三:状态机的设计

目录 一、实验目的 二、实验要求 三、实验代码 1.design source文件部分代码 2.测试文件代码 四、实验结果及分析 1、引脚锁定 2、仿真波形及分析 &#xff08;1&#xff09;设计好序列检测器 &#xff08;2&#xff09;仿真波形&#xff08;检测11010&#xff09; 3…

【网络系统集成】路由器实验

1.实验名称:路由器RIP协议配置 2.实验目的 在PacketTracer中进行模拟实验,配置RIP协议,验证RIP协议更新时间及路由状态变化,加深对路由器RIP协议相关知识的理解与掌握。 3.实验内容 (1)拓扑结构图 (2)ip地址分配与端口分配

linux - bc 命令安装

一.引言 迁移新机器后发现没有 bc 命令&#xff0c;之前 shell 脚本的一些计算逻辑会出错&#xff0c;下面快速安装一下。 二.安装 bc 请确保在 root 权限下执行该命令&#xff1a; sudo yum install bc 出现下述界面即安装成功&#xff1a; 三.测试 bc 测试下脚本里 % 的…

快速小巧的粘贴应用程序Hasty Paste

什么是 Hasty Paste? Hasty Paste 是一个快速粘贴文本并共享的地方&#xff0c;主要用于共享调试日志等&#xff0c;以帮助开发人员提供技术支持。该项目的目标是既快又小。 命令行安装 在群晖上以 Docker 方式安装。 官方的镜像没有发布在 docker hub&#xff0c;而是在 gh…

尚医通04:Axios Node Npm bable webpack+前端工程改造

目录 本日学习 内容介绍 Axios Node NPM包管理器 Babel 模块化 Webpack 搭建前端工程 前端框架介绍 前端开发过程介绍 登录改造成本地接口 本日学习 1. 了解Axios :他是异步请求用的&#xff0c;前后端。 用于在浏览器和 Node.js 中发送 HTTP 请求。它支持从服务器…

[桌面运维] 显示器 色准,色域,色深,分辨率,带宽,刷新率的基本概念,图像呈现的基本原理

⬜⬜⬜ &#x1f430;&#x1f7e7;&#x1f7e8;&#x1f7e9;&#x1f7e6;&#x1f7ea;(*^▽^*)欢迎光临 &#x1f7e7;&#x1f7e8;&#x1f7e9;&#x1f7e6;&#x1f7ea;&#x1f430;⬜⬜⬜ ✏️write in front✏️ &#x1f4dd;个人主页&#xff1a;陈丹宇jmu &am…

华为数通智选交换机S5735S-L24T4S-QA2无法SSH远程访问

以前都是按照华为S5700交换机开启SSH远程访问方法配置不同网段通过静态路由实现互通,华为S5700交换机开启ssh远程登陆,现在新买的华为数通智选交换机S5735S-L24T4S-QA2,也是按照这步骤配置,令人不解的是,竟然无法ssh访问,仔细看了配置也没有发现问题,在华为eNSP模拟器上验…

初识mysql数据库之复合查询

目录 一、多表查询的概念 二、笛卡尔积 1. 笛卡尔积的概念 2. 笛卡尔积使用案例 2.1 显示雇员名、雇员工资以及所在部门的名字 2.2 显示部门号为10的部门名&#xff0c;员工名和工资 2.3 显示所有员工的姓名、工资和工资级别 3. 自连接 3.1 自连接的概念 3.2 自连接案…

微信小程序中的条件渲染和列表渲染,wx:if ,wx:elif,wx:else,wx:for,wx:key的使用,以及block标记和hidden属性的说明

微信小程序中的条件渲染和列表渲染 1. 条件渲染1.1. 语法格式 (wx:if, wx:elif ,wx:else)1.2. block标记1.3. hidden属性1.4. wx:if 与 hidden 的对比 2. 列表渲染2.1. wx:for 语法格式 及 wx:key的使用 1. 条件渲染 1.1. 语法格式 (wx:if, wx:elif ,wx:else) <view wx:if…

MachineLearningWu_13_AGI

AGI的全称是artificial general intelligence&#xff0c;通用人工智能&#xff0c;而我们现在做的关于医学影像的分析&#xff0c;可以说完全是ANI。 而我们使用MLP对于大脑中神经网络的模拟更是完全不同于人类大脑的行为。

flutter开发实战-Running Gradle task ‘assembleDebug‘ 的解决方法

flutter开发实战-Running Gradle task ‘assembleDebug‘ 的解决方法 使用Android studio经常出现Running Gradle task ‘assembleDebug‘问题&#xff0c;记录一下解决方法。 一、在Android目录下更改build.gradle 将repositories中的google(), mavenCentral() repositori…

IIS 部署的应用禁用HTTP TRACE / TRACK方法【原理扫描】

远程Web服务器支持TRACE和/或TRACK方法。 TRACE和TRACK是用于调试Web服务器连接的HTTP方法。 直接在网站Web.config文件中进行如下操作&#xff1a;在Web.config中的<system.webServer>节点内添加以下配置即可&#xff1a; <security> <requestFiltering> &…

数据结构day2(2023.7.15)

一、Xmind整理&#xff1a; 二、课上练习&#xff1a; 练习1&#xff1a;定义车的信息&#xff1a;品牌&#xff0c;单价&#xff0c;颜色&#xff0c;车牌号 struct Car{char name[20]; //品牌int price; //单价char color[10] //颜色char id[20] //车牌…

MySQL基础

MySQL基础 数据库基础&#xff08;重点&#xff09;一.登陆选项的认识二.什么是数据库三.见一见数据库四.主流数据库五.服务器、数据库、表之间的关系六.MySQL架构七.SQL分类八.存储引擎 数据库基础&#xff08;重点&#xff09; 一.登陆选项的认识 mysql -h 127.0.0.1 -P 33…

计算机网络 day5 子网划分 - IP包 - arp协议

目录 子网划分 为什么需要子网划分&#xff1f; 我们为什么不直接使用一个A类的IP地址给一家2000人的公司使用呢&#xff1f; 子网划分本质 子网划分的步骤&#xff1a; 实验&#xff1a;将192.168.1.0/24 划分为4个小网段 --》192.168.1.0/26 减少的IP地址去哪里了&…

【C++】位图和布隆过滤器

文章目录 位图概念难点代码 布隆过滤器概念插入查找删除优缺点代码 位图 概念 所谓位图&#xff0c;就是用每一个比特位位来存放某种状态&#xff0c;适用于海量数据&#xff0c;数据无重复的场景。通常是用来判断某个数据存不存在的。 给40亿个不重复的无符号整数&#xff…

RestFul风格讲解

以前是localhost:8080/user?methodadd&uid1; RestFul风格是以/接上的 localhost:8080/user/马云/6562 package com.qf.controller; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annot…

【华为机试】HJ17 坐标移动详解+完整源代码示例

忙碌了一周&#xff0c;一直没时间更新&#xff0c;趁着周末来更新第二个题目。 题目 题目解析 这个题目相比于上一个题目来说&#xff0c;会简单一些&#xff0c;不涉及到那些复杂的算法&#xff0c;就是对于字符串的处理。 算法步骤 输入一个字符串根据分号&#xff0c;将…