贝塞尔曲线:求点到贝塞尔曲线的投影

334 阅读7分钟

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

今天我们来学习点到贝塞尔曲线的投影

点到贝塞尔曲线投影(project),也有别的类似的名称:

  1. 点到贝塞尔曲线的最近点(closestPoint 或 nearestPoint);

  2. 点到贝塞尔曲线的距离(distance),这个距离是需要先求出最近点位置,然后才能计算出来的。

这些基本都是用的一个算法。通常算法库会把投影点、距离、还有 t 打包进一个对象返回。

图片

一些用途

  1. 判断点到贝塞尔曲线的距离是否小于某个阈值,对曲线做高精度的图形拾取;

  2. 找到光标到贝塞尔的最近点,将一条贝塞尔曲线打断为两条贝塞尔曲线。

图片

思路

直接能想到的暴力思路是,把贝塞尔上的所有点都计算起来,一个个计算到目标点的距离,取其中距离最小的点。

当然我们是不可能拿到所有点,一条线上有无数个点,我们不可能真的取无数个点出来。

所以我们应该选取合适的间距取出一些点。

几个好呢?999999?

这个也太多了,算力有限,我们可以选择一个较小的间隔,大概排查一下,确定最近点应该再哪个区域

然后对这个区域做更精细针对性排查,然后再确定最近点所在的子区域,重复前面的操作,越来越逼近正确位置。

这样就能有效减少无用功。

完整思路为:

  1. 记 t1 为 0,t2 为 1。从 t1 到 t2,不断步进特定长度 step(假设为 0.01,将遍历 101 次),计算步进过程中 t 对应的所有点;

  2. 遍历这些点,找出最近点;

  3. 取最近点的上一个点和下一个点对应的 t,设置为 t1、t2;

  4. 重复步骤 1 和 2,但步进值 step 要比上一次的步进值更小(比如原来的十分之一,即 0.001,将遍历 21 次),重复步骤 1 和 步骤 2 操作;

  5. 当 step 小于某个值时结束,我们求出了最近点。

如果你希望得到更准确的值,我们可以提高精度,用更小的精度继续递归算下去。当然,精度的提高对应的代价是计算量的增加。

图片

这种思路属于 牛顿迭代法,求的是 近似解

看起来有点像二分查找,但可惜的是,t 到 t 对应点和目标的距离这二者之间不满足单调性,所以我们还是要切得很细,一个个判断,运算量还是很大的。

完整算法实现

下面是点投影到三阶贝塞尔曲线的算法实现。

如果要扩展到其他 N 阶贝塞尔曲线,替换掉 getBezier3Point 方法然后调整传入的点的数量即可。

/** 点到三阶贝塞尔曲线上的投影 */
const calcBezier3Project = (
  p1: Point,
  cp1: Point,
  cp2: Point,
  p2: Point,
  targetPt: Point,
  lookupTable: { t: number; pt: Point }[] = [],
) => {
  const count = 100;

  if (lookupTable.length === 0) {
    // 第一步是一定要执行的,缓存起来。
    // 因为 t += step 这种写法会不断累加浮点数误差,稍微换成 i / count 的写法
    for (let i = 0; i <= count; i++) {
      const t = i / count;
      const pt = getBezier3Point(p1, cp1, cp2, p2, t);
      lookupTable[i] = { t, pt };
    }
  }

  let minDist = Number.MAX_SAFE_INTEGER;
  let minIndex = -1;

  for (let i = 0; i < lookupTable.length; i++) {
    const item = lookupTable[i];
    const dist = distance(targetPt, item.pt); // 可优化,考虑到单调性,可以不开平方
    if (dist < minDist) {
      minDist = dist;
      minIndex = i;
      // 找到 0 距离点,提前结束
      if (dist === 0) {
        break;
      }
    }
  }

  if (minDist === 0) {
    const projectPt = getBezier3Point(
      p1,
      cp1,
      cp2,
      p2,
      lookupTable[minIndex].t,
    );
    return {
      point: projectPt,
      t: lookupTable[minIndex].t,
      distdistance(targetPt, projectPt),
    };
  }

  let minT = lookupTable[minIndex].t;

  const t1 = minIndex > 0 ? lookupTable[minIndex - 1].t : minT;
  const t2 =
    minIndex < lookupTable.length - 1 ? lookupTable[minIndex + 1].t : minT;

  let step = 0.001// 原来的 1/10
  for (let t = t1; t <= t2; t += step) {
    const pt = getBezier3Point(p1, cp1, cp2, p2, t);
    const dist = distance(targetPt, pt);
    if (dist < minDist) {
      minDist = dist;
      minT = t;
      // 找到 0 距离点,提前结束
      if (dist === 0) {
        break;
      }
    }
  }

  if (minT < 0) {
    minT = 0;
  }
  if (minT > 1) {
    minT = 1;
  }
  const projectPt = getBezier3Point(p1, cp1, cp2, p2, minT);
  return {
    point: projectPt,
    t: minT,
    distdistance(targetPt, projectPt),
  };
};


/** 计算三阶贝塞尔曲线 t 对应的点(套用参数方程公式) */
const getBezier3Point = (
  p1: Point,
  cp1: Point,
  cp2: Point,
  p2: Point,
  t: number,
) => {
  const t2 = t * t;
  const ct = 1 - t;
  const ct2 = ct * ct;
  const a = ct2 * ct;
  const b = 3 * t * ct2;
  const c = 3 * t2 * ct;
  const d = t2 * t;

  return {
    x: a * p1.x + b * cp1.x + c * cp2.x + d * p2.x,
    y: a * p1.y + b * cp1.y + c * cp2.y + d * p2.y,
  };
};

/** 两点的距离 */
const distance = (p1: Point, p2: Point) => {
  const dx = p2.x - p1.x;
  const dy = p2.y - p1.y;
  return Math.sqrt(dx * dx + dy * dy);
};

这里我们缓存了 t 为 0、0.01、...  0.99、 1 和对应点的查找表(LookUp Table)。

如果你的贝塞尔曲线一直不变,变得只是目标点,比如实现图形拾取,缓存第一轮每次都会执行的遍历结果是很要必要的。

后面我们又基于 0.001 步长再遍历了一轮就结束了,此时看起来效果已经足够,所以就没有进行下一轮的遍历了。

如果觉得还不够准确,可以用更小的步长再继续遍历几轮。

该遍历几轮这个通常是动态的。比如画布放得越放大,精度就要求越高。或者贝塞尔曲线越长,相比短的贝塞尔曲线点分布会越稀疏,需要加多几轮来减少误差,或减小 step 值。

结尾

我是前端西瓜哥,关注我,学习更多平面几何知识。


相关阅读,

贝塞尔曲线算法:求 t 在三阶贝塞尔曲线上的点、切向量、法向量

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

平面几何算法:求点到直线和圆的最近点

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

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