贝塞尔曲线算法:求贝塞尔曲线和直线的交点

最近都在玩黑神话,太好玩了,都没空写文章了。

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

今天我们来实现求三阶贝塞尔曲线和直线交点的方法。

方法需要接收的参数为:

  1. 三阶贝塞尔曲线的 4 个点;

  2. 直线的 2 个点。

返回结果为交点集合,和它们对应的 t。

下面我们来看看算法实现。

实现思路

我们有三阶贝塞尔曲线参数方程:

P0 到 P3 为贝塞尔连续的 4 个点。

直线方程也有两点式方程(不是参数方程):

对齐到 x 轴

对着这两个方程瞪了半天,感觉它俩八字不合,合并不到一起去。

但我们有一个非常巧妙的办法,就是让它们都做移动和旋转操作,让直线对齐到 x 轴上,并让直线起点和原点重合。

变换后,虽然贝塞尔曲线和直线的点都改变了,但它们的交点对应的 t 还是没有变。

此时,直线变成了一条特殊的直线:y = 0

于是我们的问题其实变成了:对于对齐后的贝塞尔曲线,y 为 0 时,对应的 t 值是是多少

也就是说,我们要求下面这个方程的实数根:

化成标准的一元三次方程是这样子的:

求出这个方程的 t 后,我们过滤掉不在 0 和 1 范围的值,然后用再把 t 带入原来的贝塞尔曲线上,就能求出交点了。

对齐逻辑的代码实现:

ts 复制代码
const getBezierAndLineIntersection = (
  bezier: Point[],
  line: Point[],
) => {
  // 1. bezier 和 line 一起旋转对齐 x 轴
  const angle = -Math.atan2(line[1].y - line[0].y, line[1].x - line[0].x);
  // 移动,然后旋转
  const matrix = new Matrix().translate(-line[0].x, -line[0].y).rotate(angle);

  const alignedBezier = bezier.map((pt) => matrix.apply(pt));

  // ...
}

这里用了个矩阵库,主要是为了提高代码可读性,矩阵运算在图形编辑器中是非常常见的。

如果你不打算用矩阵库,可以换成下面这样:

ts 复制代码
const alignedBezier = bezier.map((pt) => {
  return {
    x:
      (pt.x - line[0].x) * Math.cos(angle) -
      (pt.y - line[0].y) * Math.sin(angle),
    y:
      (pt.x - line[0].x) * Math.sin(angle) +
      (pt.y - line[0].y) * Math.cos(angle),
  };
});

求三次方程实数根

接下来的难题是,如何求一个三次方程的所有实数根

三次方程求根也有公式,就是比较复杂,核心用到 卡尔达诺公式(Cardano's method)

其过程涉及到变量替换(Change of variables)、丢掉二次项(Depressed cubic)、二次方程求根公式、分类讨论、复数运算、棣莫弗公式(De Moivre's formula)、三角函数等一系列操作。

因为不是本文的重点,具体推导过程也非常复杂繁琐,这里就不展开叙述了。但这里有一篇推导过程的文章,感兴趣的读者可以读一读。

www.trans4mind.com/personal\\\...

这里我直接基于这篇文章末尾的结论,实现对应代码。

三次方程的求根方法:

ts 复制代码
/** 求一元三次方程的根 */
const roots3 = (w: number, a: number, b: number, c: number) => {
  if (w !== 0) {
    // 三次方程
    // https://www.trans4mind.com/personal_development/mathematics/polynomials/cubicAlgebra.htm
    // 转成 x^3 + a * x^2 + b * x + c 的格式(三次项系数变成 1)
    a /= w;
    b /= w;
    c /= w;

    // 使用 "Cardano formula" 求根,转成没有二次项的形式(Depressed Cubic)
    // t ^ 3 + p * t + q = 0
    // 令 x = t - a / 3,p 和 q 会得到如下值
    const p = (3 * b - a * a) / 3;
    const q = (2 * a * a * a - 9 * a * b + 27 * c) / 27;

    // 判别式 delta
    const delta = (q * q) / 4 + (p * p * p) / 27;
    // 根有三个。
    if (delta === 0) {
      // 根都是实数根,且两个根相等,共两个不同的实数根
      const t = cubicRoot(-q / 2);
      const x1 = 2 * t - a / 3;
      const x2 = t - a / 3;
      return [x1, x2];
    }
    if (delta > 0) {
      // 一个实数根,两个复数根(复数根我们不需要,直接丢掉)
      const halfQ = q / 2;
      const sqrtDelta = Math.sqrt(delta);
      return [
        cubicRoot(-halfQ + sqrtDelta) - cubicRoot(halfQ + sqrtDelta) - a / 3,
      ];
    }
    // 三个不同实根
    const r = Math.sqrt(Math.pow(-p / 3, 3));
    // De Moivre's formula(棣莫弗公式)
    const cosVal = Math.max(Math.min(-q / (2 * r), 1), -1); // 处理误差超出 cos 值区间的情况
    const angle = Math.acos(cosVal);
    const x1 = 2 * cubicRoot(r) * Math.cos(angle / 3) - a / 3;
    const x2 = 2 * cubicRoot(r) * Math.cos((angle + Math.PI * 2) / 3) - a / 3;
    const x3 = 2 * cubicRoot(r) * Math.cos((angle + Math.PI * 4) / 3) - a / 3;
    return [x1, x2, x3];
  } else {
    // 退化为二次方程
    return roots2(a, b, c);
  }
};

三次项系数为 0 的话,会退化为二次方程,所以还要实现个求二次方程的方法,这个比较简单,直接套高中学过的求根公式。

JavaScript 内置的 Math.pow() 方法可以做指数幂运算。但如果是负数,该方法会返回 NaN,即使是开立方。所以要特殊处理下,对于负数要先转成正数计算完再把符号放回去。

负数是不能开平方的,因为两数相乘一定是非负数(不引入复数的情况);但负数可以开立方,三个负数相乘还是负数。

ts 复制代码
/** 开立方 */
const cubicRoot = (num: number) => {
  return num > 0 ? Math.pow(num, 1 / 3) : -Math.pow(-num, 1 / 3);
};

/** 求一元二次方程的根 */
const roots2 = (a: number, b: number, c: number) => {
  if (a !== 0) {
    const delta = b * b - 4 * a * c;
    if (delta < 0) {
      // 无实数根
      return [];
    }
    const denominator = a * 2;
    if (delta > 0) {
      // 两个实数根
      const deltaSqrt = Math.sqrt(delta);
      return [(-b + deltaSqrt) / denominator, (-b - deltaSqrt) / denominator];
    }
    // 一个实数根
    return [-b / denominator];
  }
  if (b !== 0) {
    // 一次方程
    return [-c / b];
  }
  return [];
};

回到我们的算法主逻辑中,我们将三次方程的系数传入 roots3 方法,得到 t 数组。

然后过滤掉不在 0 到 1 的 t,并计算出 t 在原贝塞尔曲线上对应的点。

这些点也需要在线段范围内,所以我们再过滤掉不在线段包围盒的点。

ts 复制代码
const [y0, y1, y2, y3] = alignedBezier.map((pt) => pt.y);

// 2. 求对齐后的贝塞尔曲线和直线 y=0 的交点
// 其实就是找贝塞尔曲线上,y 为 0 的点
const a = -y0 + 3 * y1 - 3 * y2 + y3;
const b = 3 * y0 - 6 * y1 + 3 * y2;
const c = -3 * y0 + 3 * y1;
const d = y0;

// 求三次方程的实数根
const tArr = roots3(a, b, c, d);

const lineBbox = getPointsBbox(line);

return tArr
  .filter((t) => t >= 0 && t <= 1)
  .map((t) => {
    // 计算 t 对应的坐标
    return {
      t,
      point: getBezier3Point(bezier, t),
    };
  })
  .filter((item) => {
    // 点也需要在线段内(需要判断点是否在线段包围盒内)
    return isPointInBox(lineBbox, item.point);
  });

完整代码

贴一下完整代码,有点长。

ts 复制代码
interface Point {
  x: number;
  y: number;
}

interface Box {
  minX: number;
  minY: number;
  maxX: number;
  maxY: number;
}

/** 求三阶贝塞尔曲线和直线的交点 */
const getBezierAndLineIntersection = (
  bezier: Point[],
  line: Point[],
) => {
  // 1. bezier 和 line 一起旋转对齐 x 轴
  const angle = -Math.atan2(line[1].y - line[0].y, line[1].x - line[0].x);

  // const matrix = new Matrix().translate(-line[0].x, -line[0].y).rotate(angle);
  // const alignedBezier = bezier.map((pt) => matrix.apply(pt));
  const alignedBezier = bezier.map((pt) => {
    return {
      x:
        (pt.x - line[0].x) * Math.cos(angle) -
        (pt.y - line[0].y) * Math.sin(angle),
      y:
        (pt.x - line[0].x) * Math.sin(angle) +
        (pt.y - line[0].y) * Math.cos(angle),
    };
  });

  const [y0, y1, y2, y3] = alignedBezier.map((pt) => pt.y);

  // 2. 求对齐后的贝塞尔曲线和直线 y=0 的交点
  // 其实就是找贝塞尔曲线上,y 为 0 的点
  const a = -y0 + 3 * y1 - 3 * y2 + y3;
  const b = 3 * y0 - 6 * y1 + 3 * y2;
  const c = -3 * y0 + 3 * y1;
  const d = y0;

  const tArr = roots3(a, b, c, d);
  const lineBbox = getPointsBbox(line);

  return tArr
    .filter((t) => t >= 0 && t <= 1)
    .map((t) => {
      // 计算 t 对应的坐标
      return {
        t,
        point: getBezier3Point(bezier, t),
      };
    })
    .filter((item) => {
      // 点也需要在线段内(需要判断点是否在线段包围盒内)
      return isPointInBox(lineBbox, item.point);
    });
};

/** 求一元三次方程的根 */
const roots3 = (w: number, a: number, b: number, c: number) => {
  if (w !== 0) {
    // 三次方程
    // https://www.trans4mind.com/personal_development/mathematics/polynomials/cubicAlgebra.htm
    // 转成 x^3 + a * x^2 + b * x + c 的格式(三次项系数变成 1)
    a /= w;
    b /= w;
    c /= w;

    // 使用 "Cardano formula" 求根,转成没有二次项的形式(Depressed Cubic)
    // t ^ 3 + p * t + q = 0
    // 令 x = t - a / 3
    const p = (3 * b - a * a) / 3;
    const q = (2 * a * a * a - 9 * a * b + 27 * c) / 27;

    // 判别式 delta
    const delta = (q * q) / 4 + (p * p * p) / 27;
    // 根有三个。
    if (delta === 0) {
      // 根都是实数根,且两个根相等,共两个不同的实数根
      const t = cubicRoot(-q / 2);
      const x1 = 2 * t - a / 3;
      const x2 = t - a / 3;
      return [x1, x2];
    }
    if (delta > 0) {
      // 一个实数根,两个复数根(复数根我们不需要,直接丢掉)
      const halfQ = q / 2;
      const sqrtDelta = Math.sqrt(delta);
      return [
        cubicRoot(-halfQ + sqrtDelta) - cubicRoot(halfQ + sqrtDelta) - a / 3,
      ];
    }
    // 三个不同实根
    const r = Math.sqrt(Math.pow(-p / 3, 3));
    // De Moivre's formula(棣莫弗公式)
    const cosVal = Math.max(Math.min(-q / (2 * r), 1), -1); // 处理误差超出 cos 值区间的情况
    const angle = Math.acos(cosVal);
    const x1 = 2 * cubicRoot(r) * Math.cos(angle / 3) - a / 3;
    const x2 = 2 * cubicRoot(r) * Math.cos((angle + Math.PI * 2) / 3) - a / 3;
    const x3 = 2 * cubicRoot(r) * Math.cos((angle + Math.PI * 4) / 3) - a / 3;
    return [x1, x2, x3];
  } else {
    // 退化为二次方程
    return roots2(a, b, c);
  }
};

const cubicRoot = (num: number) => {
  // num 如果是负数,Math.pow 就会返回 NaN,即使是开立方。
  // 所以要特殊处理下,先转成正数计算完再把符号加上
  return num > 0 ? Math.pow(num, 1 / 3) : -Math.pow(-num, 1 / 3);
};

/** 求一元二次方程的根 */
const roots2 = (a: number, b: number, c: number) => {
  if (a !== 0) {
    const delta = b * b - 4 * a * c;
    if (delta < 0) {
      // 无实数根
      return [];
    }
    const denominator = a * 2;
    if (delta > 0) {
      // 两个实数根
      const deltaSqrt = Math.sqrt(delta);
      return [(-b + deltaSqrt) / denominator, (-b - deltaSqrt) / denominator];
    }
    // 一个实数根
    return [-b / denominator];
  }
  if (b !== 0) {
    // 一次方程
    return [-c / b];
  }
  return [];
};

const getBezier3Point = (pts: Point[], t: number) => {
  const [p1, cp1, cp2, p2] = pts;

  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 getPointsBbox = (points: Point[]) => {
  let minX = Infinity;
  let minY = Infinity;
  let maxX = -Infinity;
  let maxY = -Infinity;

  for (const pt of points) {
    minX = Math.min(minX, pt.x);
    minY = Math.min(minY, pt.y);
    maxX = Math.max(maxX, pt.x);
    maxY = Math.max(maxY, pt.y);
  }

  return {
    minX,
    minY,
    maxX,
    maxY,
  };
};

const isPointInBox = (box: Box, point: Point, tol = 0) => {
  return (
    point.x >= box.minX - tol &&
    point.y >= box.minY - tol &&
    point.x <= box.maxX + tol &&
    point.y <= box.maxY + tol
  );
};

使用

js 复制代码
const bezierPts = [
  {
    x: 124,
    y: 219,
  },
  {
    x: 269,
    y: 63,
  },
  {
    x: 157,
    y: 480,
  },
  {
    x: 379,
    y: 275,
  },
];

const line = [
  {
    x: 80,
    y: 159,
  },
  {
    x: 381,
    y: 344,
  },
];

const intersectionPts = getBezierAndLineIntersection(bezierPts, line);

intersectionPts 的值为:

json 复制代码
[
  {
    "t": 0.9186091674208959,
    "point": {
      "x": 331.11277204253514,
      "y": 313.33841471052824
    }
  },
  {
    "t": 0.052826835956346574,
    "point": {
      "x": 144.91519022950996,
      "y": 198.89804050650946
    }
  },
  {
    "t": 0.4358385846022045,
    "point": {
      "x": 216.06306327798808,
      "y": 242.62679968912894
    }
  }
]

对应图像:

结尾

三阶贝塞尔和直线交点,对应求三次方程的根,虽然比较繁琐,但可以通过数学方式解决。

更低阶的二阶贝塞尔也同理,是求二次方程,求根公式更简单。

但三阶往上就比较复杂了。四次方程还有公式解,虽然更复杂了。

但到了更高的 n 次方程,就没办法通过数学的方式求了,通常我们会使用牛顿法求大量的点做判断,逼近正解,但最后在实际生产中,考虑到计算耗时问题,一般会取一个非常接近、精度合适的近似解。

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


相关阅读,

贝塞尔曲线算法:求贝塞尔曲线的包围盒

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

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

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

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

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

相关推荐
羊小猪~~39 分钟前
前端入门一之DOM、获取元素、DOM核心、事件高级、操作元素、事件基础、节点操作
前端·javascript·css·vscode·html·浏览器·edge浏览器
T0uken1 小时前
【前端】Svelte:生命周期函数
前端
陈随易1 小时前
wangEditor,从开源、停更到重生
前端·后端·程序员
getaxiosluo1 小时前
vue3使用element-plus,树组件el-tree增加引导线
前端·javascript·vue.js·elementui·css3·element-plus
闻缺陷则喜何志丹1 小时前
【C++ 滑动窗口】2134. 最少交换次数来组合所有的 1 II
c++·算法·leetcode·力扣·交换·组合·最少
gsgbgxp1 小时前
C++类中的const成员变量和const成员函数
开发语言·c++·算法
YUJIANYUE1 小时前
6KBhtm+js实现提交名单随机抽取功能适用活动或课堂随机点名
前端·javascript·css
丶Darling.2 小时前
Day41 | 动态规划 :完全背包应用 完全平方数&&单词拆分(类比爬楼梯)
算法·动态规划·dp·lambda·记忆化搜索·回溯·c++\
寅时码2 小时前
【奇淫技巧】让你的路由跳转拥有TS类型提示,告别人工记路由path
前端·javascript·react.js
可缺不可滥2 小时前
前端 性能优化 (图片与样式篇)
前端·性能优化