最近我给图形编辑器增加了参照线吸附功能,自动讲讲我的对齐实现思路。
我正在开发的图形设计工具:
https://github.com/F-star/suika
线上体验:
https://blog.fstars.wang/app/suika/
效果是被移动的图形会参考周围图形,自动与它们进行吸附对齐。编辑
不得不说,器开很酷炫。发参
感觉这个图形编辑器突然变得灵动起来,考线有了灵魂一般。吸附效功
这里的自动参照线,指的是在移动目标图形时,当靠近其他图形的包围盒的延长线(看不见)时,会(1)绘制出最近的延长线和延长线上的点,(2)并将目标图形吸附上去,轻松实现(3)对齐的效果。
可以看到,通过参照线,我们很容易就能实现各种对齐,比如两图形的底边和定边对齐、右下角和左上角对齐。
这在 以对齐为基本要素 的视觉设计中,是非常好用的功能。
整体思路为:
首先是确定能够作为 “参照” 的参照图形。
通常来说,参照图形为视口内的图形,并排除掉被移动的目标图形。视口外的图形通常都不在设计师的关注区域内。
确认好参照图形后,计算出它们的包围盒(bbox)。
这次的包围盒有点特殊,要多给一个中点坐标,因为中线也要作为参照线。
接口签名为:
export interface IBoxWithMid { minX: number; minY: number; midX: number; midY: number; maxX: number; maxY: number;}
它们组成了参照图形的 8 个点,沿着这些点绘制竖线和横线,就是被移动的目标图形对应要吸附的参照线。
被移动的图形也要计算包围盒,并得到 5 个点。
基于这些点的产生的水平线和垂直线,在靠近参照线时会吸附到最近的参照线上,分为水平移动和垂直移动两个维度。
编辑器上的效果:
我们首先要把所有的参照线记录下来,在图形准备移动(mousedown)的时候。大致有以下这几个操作:
抽象一个 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; // 最小距离不能超过这个// 确认偏移值 offsetXif (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) { /(责任编辑:探索)
证券类私募瞄准创业板改革红利 以价值为导向的市场风格没有发生改变
中证金力挺民企债券融资专项计划 完善民营企业债券融资支持机制