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

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

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

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

方法需要接收的参数为:

  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 绘制三阶贝塞尔曲线?

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

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

相关推荐
代码搬运媛7 小时前
Jest 测试框架详解与实现指南
前端
吃好睡好便好7 小时前
在Matlab中绘制横直方图
开发语言·学习·算法·matlab
counterxing7 小时前
我把 Codex 里的 Skills 做成了一个 MCP,还支持分享
前端·agent·ai编程
仰泳之鹅8 小时前
【C语言】自定义数据类型2——联合体与枚举
c语言·开发语言·算法
wangqiaowq8 小时前
windows下nginx的安装
linux·服务器·前端
之歆8 小时前
DAY_12JavaScript DOM 完全指南(二):实战与性能篇
开发语言·前端·javascript·ecmascript
发现一只大呆瓜8 小时前
Vite凭什么这么快?3分钟带你彻底搞懂 Vite 热更新的幕后黑手
前端·面试·vite
Maimai108088 小时前
React如何用 @microsoft/fetch-event-source 落地 SSE:比原生 EventSource 更灵活的实时推送方案
前端·javascript·react.js·microsoft·前端框架·reactjs·webassembly
x_yeyue10 小时前
三角形数
笔记·算法·数论·组合数学
kyriewen10 小时前
产品经理把PRD写成“天书”,我用AI半小时重写了一遍,他当场愣住
前端·ai编程·cursor