大家好,我是前端西瓜哥。
本文将介绍图形编辑器中吸附系统中,各种吸附类型的吸附逻辑和算法实现,让大家对吸附有一个概念。
吸附类型常见的有这么几种:
下面我们来具体看看吧。
首先是网格吸附。
所谓网格,指的是在图形所在的场景世界上,以原点出发按照特定的 x 和 y 间隔绘制出一条条直线,所构成的网格。我们把两条直线的交点叫做网格点。
网格吸附就是 让目标点吸附到最近的网格点上。
特殊的,如果 x 和 y 间隔为 1,那就变成了像素网格吸附了。
吸附算法很简单,找到距离目标点的 x 最近的两个网格点的 x 值:space * n
和 space * (n+1)
,取其中最近的。y 同理。
// 计算网格吸附点
const getGridSnapPt(point: IPoint, snapSpacing: IPoint) {
return {
x: getClosestNum(point.x, snapSpacing.x),
y: getClosestNum(point.y, snapSpacing.y),
};
}
// 找出离 value 最近的 space 的倍数值
const getClosestNum = (value: number, space: number) => {
const n = Math.floor(value / space);
const left = space * n;
const right = space * (n + 1);
return value - left <= right - value ? left : right;
};
更详细的说明,可以看我的这篇文章:
极轴追踪,就是以某个参照点为极坐标原点构造极坐标系,并指定特定的增量角度,绘制多条直线,然后找到目标点到其中距离最近的直线,对其作投影作为吸附点。
吸附实现需要用到 点到直线的投影(最近点) 算法。我们先计算目标点投影到所有直线的位置,然后计算目标点到投影点的距离,取其中最近的直线的投影点作为吸附点。
// -- 极轴追踪 --
// 求目标点 p,以 center 为极坐标原点,增量角为 180 / count 构造的直线最近的投影点
// count 的 4 代表角度:0, 45, 90, 135, 150...
const getPolarTrackSnapPt = (center: IPoint, p: IPoint, count = 4) => {
let closestPt: IPoint = { x: 0, y: 0 };
let closestDist = Infinity;
for (let i = 1; i <= count; i++) {
const rad = (Math.PI / count) * i;
const pt = {
x: center.x + Math.cos(rad),
y: center.y + Math.sin(rad),
};
// 基于增量角,得到直线 [center, pt], 然后 p 到直线的投影点
const { point } = closestPtOnLine(center, pt, p);
const dist = distance(point, p);
if (dist === 0) {
return point;
}
if (dist < closestDist) {
closestDist = dist;
closestPt = point;
}
}
return closestPt;
};
求点到直线投影的算法实现如下。
// 求点 p 到直线 p1-p2 的投影点(最近点)
const closestPtOnLine = (
p1: IPoint,
p2: IPoint,
p: IPoint,
) => {
if (p1.x === p2.x && p1.y === p2.y) {
return {
t: 0,
point: { x: p1.x, y: p1.y },
};
}
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
let t = ((p.x - p1.x) * dx + (p.y - p1.y) * dy) / (dx * dx + dy * dy);
const closestPt = {
x: p1.x + t * dx,
y: p1.y + t * dy,
};
return {
t,
point: closestPt,
};
};
算法的详解可以看我的这篇文章:
和网格吸附不同,极轴追踪下,可以强制吸附,也可以不强制吸附。
如果不要求强制吸附,通常我们会规定一个阈值(比如 4px)。当目标点距离吸附点小于这个值,才应用吸附,使用吸附点;否则不做吸附。
需要注意,阈值指的是在视口坐标系下的距离,计算要考虑视口的 zoom。
const viewportPolarTrackTol = 4;
const snapPt = getPolarTrackSnapPt(lastPt, mousePt);
const resPt =
distance(mousePt, snapPt) < viewportPolarTrackTol / zoom ? snapPt : mousePt;
AutoCAD 中开启极轴追踪,不要求强制吸附。
Figma 用钢笔工具绘制时,按住 Shift 会 强制做极轴追踪吸附。
参考线指的是一些水平或垂直线。然后我们要让目标点和其中最近的水平线和垂直线贴合。
通常我们可以通过标尺可以拖出来这种参考线,比如 Figma 是这样的。
参考线有是可见的,也有不可见的,比如我们可以将视口范围内图形的 AABB 包围盒的 4 条边以及经过包围盒中心的垂直水平两条线,延申为 6 条参考线,以实现灵活地对齐功能。
Figma 中点吸附到图形参照线的效果:
参考线通常有多条,图形很多的情况下,上百条也是有可能的,所以可以在合适的时机(比如移动图形前)做一下缓存。
以 x 值吸附为例,对所有垂直线(垂直线表达为 x = b)的 x 值去重然后排序,然后缓存下来。接着通过二分查找找到里最近值,这个值就是吸附后的 x 值。y 同理,不赘述。
// 求参考吸附点
const getRefLineSnapPt = (point: IPoint, sortedXs: number[], sortedYs: number[]) {
return {
x: getClosestValInSortedArr(sortedXs, point.x),
y: getClosestValInSortedArr(sortedYs, point.y),
};
}
找到排序数组中最近值的算法实现。
// 找到排序数组中的最近值
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;
}
}
// 靠边的情况
if (left >= sortedArr.length) {
return sortedArr[right];
}
if (right < 0) {
return sortedArr[left];
}
// 找到左右两边的值后,再取其中较近的
return Math.abs(sortedArr[right] - target) <=
Math.abs(sortedArr[left] - target)
? sortedArr[right]
: sortedArr[left];
};
同样,参考线吸附也有一个最短距离阈值,小于该阈值才做吸附。
如果是对被移动的图形要做参考线吸附,又会麻烦一点。
我们会取被移动图形的 4 个顶点和中心点都作为目标点,先找到它们各自距离最近的参考线吸附点,再取这些其中 x 值最小的,计算出相对水平位移 dx,应用到图形上。y 方向同理。
正交是线性代数的概念:若内积空间中两向量的内积为0,则称它们是正交的。简单理解就是这两向量是垂直关系。
在图形编辑器,正交锁定指的就是强制目标点只能在参照点的水平或垂直方向上。
效果等价 增量角为 90 且要求强制吸附的极轴追踪。
所以正交锁定的吸附算法实现,可以直接套用极轴追踪吸附算法。
const getOrthSnapPt = (center: IPoint, p: IPoint) => {
// 2 对应增量角为 90,对应角度依次为:0, 90, 180, ...
return getPolarTrackSnapPt(center, p, 2);
};
对象吸附,指的是吸附到图形的一些设定好的吸附点上,吸附不是强制的。
我们根据需要配置图形的可吸附点,比如图形的端点、中点、最近点(线上的某一点)。
吸附算法为:先判断目标点是否在图形的包围盒内,然后再计算目标点到所有吸附点的距离,取其中距离最短的,然后和上面的极轴吸附一样,看距离是否小于某个阈值。
如果是,使用吸附点;如果不是,还使用原来的点。
不同的吸附类型如果做叠加,在某些场景下可能会发生冲突,需要选择合适的策略去处理的。
我们来看看几个场景。
1、像素网格吸附和参考线吸附同时开启
像素网格吸附(间隔为 1 的网格吸附)要求点强制吸附在像素网格上,即 x 和 y 的值是整数。
但是参考线可能是小数,如果吸附到参考线上,就对不上像素网格点了。
Figma 的做法是,像素网格吸附优先,参考线做让步。
具体做法是 调整参照图形的 bbox,让它所有点位置都修正为整数。(被移动图形的点也会修正为整数)
2、正交和极轴追踪同时开启。
前面说了正交是特殊的极轴追踪,增量角为 90 度,且强制吸附。极轴追踪增量角的设置可不一定是 90,而且可能也不强制吸附。
二者完全冲突无法同时存在,解决方式是同时只能启用其中一个。
3、网格吸附和正交同时开启
如果我在一个非网格点绘制了第一个点(参照点),然后开启网格吸附和正交,绘制第二个点(目标点)。
如果应用正交,因为要求目标点垂直或垂直于参照点,这样会导致点无法落在网格点上。二者无法同时满足。
最后方案是,先计算网格吸附后,然后对这个网格吸附点再做正交吸附。
4、网格吸附和对象吸附同时开启
同上,先求网格吸附点,然后再对这个网格吸附点做对象吸附。
今天就简单介绍介绍吸附系统,主要围绕吸附是什么,和一些算法实现,希望对你有所帮助。