最近都在玩黑神话,太好玩了,都没空写文章了。
大家好,我是前端西瓜哥。
今天我们来实现求三阶贝塞尔曲线和直线交点的方法。
方法需要接收的参数为:
-
三阶贝塞尔曲线的 4 个点;
-
直线的 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 在三阶贝塞尔曲线上的点、切向量、法向量