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

(可以在我的编辑器中体验,钢笔工具下按住 Alt 键)
思路
-
先找到 path 上离光标点最近的点,计算出在第几段上,以及对应的 t 值; -
基于上述参数在对应曲线应用 De Casteljau 算法,将 path 上的一条贝塞尔曲线拆分成两个贝塞尔曲线。表现为删除一段曲线,然后在这个位置加上两端曲线。
点到 path 的最近点
path 是连续多条三阶贝塞尔连接后的多段线(可能会闭合)。
求 path 的最近点,就是遍历求这些三阶贝塞尔曲线到光标点的最近点,取出这些最近点距离最小的。
关于三阶贝塞尔曲线最近点的算法,这个可以看我之前写的文章:《贝塞尔曲线:求点到贝塞尔曲线的投影》。
代码大致为:
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;}
贝塞尔曲线拆分
贝塞尔曲线拆分算法具体看我的另一篇文章:《如何将一条贝塞尔曲线拆分为两条贝塞尔曲线?》。
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),拟合得到一个曲线,就是我们想要的新曲线。
https://www.figma.com/blog/delete-and-heal-for-vector-networks/

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

Figma 后面应该换了个更合适的算法。新算法应该新增了 handle 方向的限制,所以误差较低。
算法
下面不是完整算法,你可以理解为伪代码,但给出一些关键的部分。
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 对曲线做拆分处理。
移除并连接锚点,核心点在于求出插值点,对这些点进行拟合,得到一个相对更正确合理的新曲线,但要修正方向偏差的问题。
我是前端西瓜哥,关注我,学习更多图形编辑器知识。
相关阅读,
夜雨聆风