最近我给图形编辑器增加了参照线吸附功能,讲讲我的实现思路。
我正在开发的图形设计工具:
https://github.com/F-star/suika
线上体验:
https://blog.fstars.wang/app/suika/
效果是被移动的图形会参考周围图形,自动与它们进行吸附对齐。
不得不说,很酷炫。
感觉这个图形编辑器突然变得灵动起来,有了灵魂一般。
为什么需要参照线吸附功能?
这里的参照线,指的是在移动目标图形时,当靠近其他图形的包围盒的延长线(看不见)时,会(1)绘制出最近的延长线和延长线上的点,(2)并将目标图形吸附上去,轻松实现(3)对齐的效果。
可以看到,通过参照线,我们很容易就能实现各种对齐,比如两图形的底边和定边对齐、右下角和左上角对齐。
这在 以对齐为基本要素 的视觉设计中,是非常好用的功能。
整体思路
整体思路为:
- 记录参照线。
- 找出目标图形最靠近的水平参照线和垂直参照线。
- 计算出偏移值 offsetX、offsetY。
- 标记要绘制的所有参照线段(不是两端无限延长的)。
- 修正图形的 x、y。
- 绘制参照线和点。
记录参照线
首先是确定能够作为 “参照” 的参照图形。
通常来说,参照图形为视口内的图形,并排除掉被移动的目标图形。视口外的图形通常都不在设计师的关注区域内。
确认好参照图形后,计算出它们的包围盒(bbox)。
这次的包围盒有点特殊,要多给一个中点坐标,因为中线也要作为参照线。
接口签名为:
export interface IBoxWithMid {
minX: number;
minY: number;
midX: number;
midY: number;
maxX: number;
maxY: number;
}
它们组成了参照图形的 8 个点,沿着这些点绘制竖线和横线,就是被移动的目标图形对应要吸附的参照线。
被移动的图形也要计算包围盒,并得到 5 个点。
基于这些点的产生的水平线和垂直线,在靠近参照线时会吸附到最近的参照线上,分为水平移动和垂直移动两个维度。
编辑器上的效果:
我们首先要把所有的参照线记录下来,在图形准备移动(mousedown)的时候。大致有以下这几个操作:
- 遍历参照图形(在视口内,且不为被移动目标图形);
- 计算出它们的包围盒,得到 8 个点,3 条垂直线和 3 条水平线。在一条垂直线上的多个点,其 x 值是相同的,y 不同,我们 x 作为 key,y 的数组为 value,保存到 hLineMap 映射对象中。每一项代表一条垂直线;
- 水平线同理,保存在 vLineMap 中。
- 然后对这两个 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,midY
const 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 bound
if (left >= sortedArr.length) {
return sortedArr[right];
}
if (right < 0) {
return sortedArr[left];
}
// check which one is closer
return 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: optimize
const { 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);
}
水平线同理。
优化点
- 这里的实现,在图形有旋转角度的时候,参照线会过多显得冗余,可以精简一些,减少要对比的参照线。
- 对齐到像素网格的时候,包围盒的值要取整。
- 考虑和按住 Shift 固定 x 或 y 平移的情况,此时有一个 offset 不能去进行校正。
最后
总结一下,参考线吸附的实现,就是找出最近的垂直线和水平线,计算出 offsetX 和 offsetY,修正被移动图形的 x 和 y,并记录并绘制出最终重合的参考线。
另外很感谢 Github Copilot,帮我写了很多模板代码。如果让我自己复制然后改改的话,很容易写错。