图形编辑器开发:钢笔工具新增和删除并连接锚点

大家好,我是前端西瓜哥。

今天讲解钢笔工具的新增锚点,和删除并连接锚点的实现。

新增锚点(insert),指的是在 path 的一条三阶贝塞尔曲线上,基于某个位置使曲线一分为二,新增锚点后设计师可进行更细节的曲线调整。

删除并连接锚点(delete and heal),指的是将 path 上将两条连续三阶贝塞尔合并为一条贝塞尔曲线,作用是移除掉多余的点,绘制出更简洁的 path。

suika 图形编辑器 github 地址:

github.com/F-star/suik...

线上体验:

blog.fstars.wang/app/suika/

新增锚点

新增锚点会将 path 上的一条曲线变成两条曲线。

效果

(可以在我的编辑器中体验,钢笔工具下按住 Alt 键)

思路

  1. 先找到 path 上离光标点最近的点,计算出在第几段上,以及对应的 t 值;

  2. 基于上述参数在对应曲线应用 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。

具体有:

  1. C 的 p1 为 A 的 p1;

  2. C 的 p2 为 B 的 p2;

  3. C 的 handle1 为 A 的 handle1;

  4. C 的 handle2 为 B 的handle2;

虽然确实删掉了一个锚点,但有个问题,就是 生成的新曲线会和原来的曲线可能有较大的差异,虽然有些场景差异大是合理的,即两条曲线趋势过于不一致,导致无法用一条曲线就足够表达。

但一些场景其实是可以用一条曲线表达两条曲线的,比如先新增锚点,然后再移除锚点的场景,这种场景我们希望差异能够小一些。

可以看出,新曲线的 handle1 和 handle2 的长度应该再增加一些。

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

www.figma.com/blog/delete...

我用了一个库,它是基于 "Algorithm for Automatically Fitting Digitized Curves" 的一个拟合算法。

github.com/soswow/fit-...

使用了这个库后,虽然得到了拟合的曲线了,但还是有些小的误差,不过相比直接删掉效果好了不少。

但此外 还有一个问题,就是 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 对曲线做拆分处理。

移除并连接锚点,核心点在于求出插值点,对这些点进行拟合,得到一个相对更正确合理的新曲线,但要修正方向偏差的问题。

我是前端西瓜哥,关注我,学习更多图形编辑器知识。


相关阅读,

简简单单实现画笔工具,轻松绘制丝滑曲线

贝塞尔曲线是什么?如何用 Canvas 绘制三阶贝塞尔曲线?

图形编辑器开发:钢笔工具的实现

图形编辑器开发:钢笔工具功能说明书

相关推荐
OpenTiny社区5 分钟前
HDC2025即将拉开序幕!OpenTiny重新定义前端智能化解决方案~
前端·vue.js·github
每天都想着怎么摸鱼的前端菜鸟11 分钟前
【uniapp】uniapp开发安卓应用接入谷歌登录获取idtoken
前端·google
anyup14 分钟前
震惊了!中石化将开源组件二次封装申请专利,这波操作你怎么看?
前端·程序员
Oriel15 分钟前
Strapi对接OSS:私有链接导致富文本图片过期问题的解决方案
前端
noodb软件工作室24 分钟前
支持中文搜索的markdown轻量级笔记flatnotes来了
前端·后端
Catfood_Eason43 分钟前
HTML5 盒子模型
前端·html
小李小李不讲道理1 小时前
「Ant Design 组件库探索」二:Tag组件
前端·react.js·ant design
1024小神1 小时前
在rust中执行命令行输出中文乱码解决办法
前端·javascript
wordbaby1 小时前
React Router v7 中的 `Layout` 组件工作原理
前端·react.js