大家好,我是前端西瓜哥。
今天讲解钢笔工具的新增锚点,和删除并连接锚点的实现。
新增锚点(insert),指的是在 path 的一条三阶贝塞尔曲线上,基于某个位置使曲线一分为二,新增锚点后设计师可进行更细节的曲线调整。
删除并连接锚点(delete and heal),指的是将 path 上将两条连续三阶贝塞尔合并为一条贝塞尔曲线,作用是移除掉多余的点,绘制出更简洁的 path。
suika 图形编辑器 github 地址:
线上体验:
新增锚点
新增锚点会将 path 上的一条曲线变成两条曲线。
效果

(可以在我的编辑器中体验,钢笔工具下按住 Alt 键)
思路
-
先找到 path 上离光标点最近的点,计算出在第几段上,以及对应的 t 值;
-
基于上述参数在对应曲线应用 De Casteljau 算法,将 path 上的一条贝塞尔曲线拆分成两个贝塞尔曲线。表现为删除一段曲线,然后在这个位置加上两端曲线。
点到 path 的最近点
path 是连续多条三阶贝塞尔连接后的多段线(可能会闭合)。
求 path 的最近点,就是遍历求这些三阶贝塞尔曲线到光标点的最近点,取出这些最近点距离最小的。
关于三阶贝塞尔曲线最近点的算法,这个可以看我之前写的文章:《贝塞尔曲线:求点到贝塞尔曲线的投影》。
代码大致为:
ini
project(point: IPoint, tol = Infinity) {
const result = {
dist: tol,
point: { x: 0, y: 0 },
index: [-1, -1],
t: -1,
};
for (let i = 0; i < this.bezierLists.length; i++) {
const { curves } = this.bezierLists[i];
for (let j = 0; j < curves.length; j++) {
const bezier = curves[j];
// 求出每段贝塞尔的最近点
const projectInfo = bezier.project(point);
// 如果比之前的还要小,写入到返回值
if (projectInfo.dist < result.dist) {
result.dist = projectInfo.dist;
result.point = projectInfo.point;
result.index = [i, j];
result.t = projectInfo.t;
}
if (projectInfo.dist === 0) {
break;
}
}
}
if (result.index[0] === -1) {
returnnull;
}
return result;
}
贝塞尔曲线拆分
贝塞尔曲线拆分算法具体看我的另一篇文章:《如何将一条贝塞尔曲线拆分为两条贝塞尔曲线?》。
ini
const splitCubicBezier = (p1, p2, p3, p4, t) => {
// 第一次线性插值
const a = lerp(p1, p2, t);
const b = lerp(p2, p3, t);
const c = lerp(p3, p4, t);
// 第二次线性插值
const d = lerp(a, b, t);
const e = lerp(b, c, t);
// 第三次线性插值
const f = lerp(d, e, t);
return [
[p1, a, d, f],
[f, e, c, p4],
];
};
// 线性插值
const lerp = (p1, p2, t): Point => {
return {
x: p1.x + (p2.x - p1.x) * t,
y: p1.y + (p2.y - p1.y) * t,
};
};
删除并连接锚点
这里的删除并连接,它的效果是在 path 中将一个锚点移除,然后前后两个锚点再连接起来。
还有另一种删除的操作:删除这个锚点,然后会将一段闭合 path 变成不闭合路径,或是将一段不闭合 path 变成两段 path。
二者是不一样的,我们这里讨论的是前者。
效果

(可以在我的编辑器中体验,钢笔工具下按住 Alt 键)
思路
首先是找最近的锚点,这个就比较简单了,一一对比每个锚点到光标的位置,取其中最近的,这个就不展开说了。
然后是将这个锚点丢掉,表现上是将这个锚点两边的两条曲线 A 和 B 合并为新的曲线 C。

具体有:
-
C 的 p1 为 A 的 p1;
-
C 的 p2 为 B 的 p2;
-
C 的 handle1 为 A 的 handle1;
-
C 的 handle2 为 B 的handle2;
虽然确实删掉了一个锚点,但有个问题,就是 生成的新曲线会和原来的曲线可能有较大的差异,虽然有些场景差异大是合理的,即两条曲线趋势过于不一致,导致无法用一条曲线就足够表达。
但一些场景其实是可以用一条曲线表达两条曲线的,比如先新增锚点,然后再移除锚点的场景,这种场景我们希望差异能够小一些。

可以看出,新曲线的 handle1 和 handle2 的长度应该再增加一些。
Figma 的文章(2016年)给出了一个方案,就是分别给原来的曲线 A 和 B 找出一些 中间插值点 ,加上原来的 3 个锚点,对这些点进行 曲线拟合(Curve Fitting),拟合得到一个曲线,就是我们想要的新曲线。

我用了一个库,它是基于 "Algorithm for Automatically Fitting Digitized Curves" 的一个拟合算法。
使用了这个库后,虽然得到了拟合的曲线了,但还是有些小的误差,不过相比直接删掉效果好了不少。
但此外 还有一个问题,就是 handle 的方向发生了改变,导致 path 的趋势不够连贯,所以这里要做一个额外处理,将新的 handle 保持长度不变,修正回原来 handle 的方向。
下面是优化后的效果,效果明显比直接移除锚点的要好很多,虽然还是会有一点差异。

Figma 后面应该换了个更合适的算法。新算法应该新增了 handle 方向的限制,所以误差较低。
算法
下面不是完整算法,你可以理解为伪代码,但给出一些关键的部分。
ini
const deletePathSegAndHeal = (
pathItem,
targetIndex,
) => {
// ...
// 得到 leftBezier, rightBezier
// 求左曲线插值点
const leftPoints = [
getBezierPoint(leftBezier, 0.3),
getBezierPoint(leftBezier, 0.6),
];
// 求右曲线插值点
const rightPoints = [
getBezierPoint(rightBezier, 0.3),
getBezierPoint(rightBezier, 0.6),
];
// 基于这些点拟合为一条曲线,使用了 fit-curve 库
const curve = fitCurve(
[
leftBezier.p1,
...leftPoints,
leftBezier.p2,
...rightPoints,
rightBezier.p2,
].map(({ x, y }) => [x, y]),
9999, // 值越大,曲线越少,给个非常大的值,就会生成一段曲线
)[0];
const handle1 = {
x: curve[1][0] - leftBezier.point.x,
y: curve[1][1] - leftBezier.point.y,
};
const handle2 = {
x: curve[2][0] - rightBezier.point.x,
y: curve[2][1] - rightBezier.point.y,
};
// 求左曲线的 handle1 的单位方向向量(如果没有,即为零向量,就用新曲线的 handle1 的)
let leftOutDir = normalizeVec(leftBezier.handle1);
if (Number.isNaN(leftOutDir.x) || Number.isNaN(leftOutDir.y)) {
leftOutDir = normalizeVec(handle1);
}
// 右曲线 handle2 方向向量
let rightInDir = normalizeVec(rightSeg.in);
if (Number.isNaN(rightInDir.x) || Number.isNaN(rightInDir.y)) {
rightInDir = normalizeVec(handle2);
}
const newLeftOutLen = distance({ x: 0, y: 0 }, handle1);
const newRightInLen = distance({ x: 0, y: 0 }, handle2);
// 原曲线的 handle 的单位方向向量,乘以新曲线的 handle 的长度,
// 作为最终的 handle
leftBezier.handle1 = {
x: leftOutDir.x * newLeftOutLen,
y: leftOutDir.y * newLeftOutLen,
};
rightBezier.handle2 = {
x: rightInDir.x * newRightInLen,
y: rightInDir.y * newRightInLen,
};
// ...
return pathItem;
};
结尾
总结一下,
新增锚点,核心点在于使用 De Casteljau 对曲线做拆分处理。
移除并连接锚点,核心点在于求出插值点,对这些点进行拟合,得到一个相对更正确合理的新曲线,但要修正方向偏差的问题。
我是前端西瓜哥,关注我,学习更多图形编辑器知识。
相关阅读,