大家好,我是前端西瓜哥。
有些图形软件,其底层是以贝塞尔样条曲线为原始图形(primitives),也就说它就只支持渲染贝塞尔曲线,数据层的其他图形(比如圆)的表达,都需要转换为贝塞尔曲线来拟合(approximation)近似表达。
所以我们需要实现一些拟合算法。
本文讲解 用三阶贝塞尔曲线拟合圆、椭圆、任意圆弧 的算法实现。
先看看怎么拟合圆形。
一条三阶贝塞尔曲线是无法表达圆的,需要几条三阶贝塞尔进行光滑相连。
通常我们选择用 4 条 1/4 圆弧进行组合。
拟合 1/4 圆弧
所以我们问题转换为,如何 用一条三阶贝塞尔曲线拟合 1/4 圆弧。
我们发现一个规律。
对于一个半径为 1 的圆,其 1/4 圆弧对应的三阶贝塞尔曲线的控制线(控制点到锚点的,下图中的线 AB)连线长度为:
(-4 + 4 * Math.sqrt(2)) / 3
计算出来的值为:
const k = 0.5522847498307936; // (-4 + 4 * Math.sqrt(2)) / 3
即,圆的半径 : 控制线长度 的比值为 0.5522847498307936。
关于这个魔法数怎么得到的,具体推导过程见后文,这里我们先记住有这么一个规律。
通过这个规律,加上控制点刚好都在锚点的垂直或水平方向上,就能很简单计算出 1/4 圆弧的三阶贝塞尔拟合。
const quarterCircleToCubic = (cx, cy, r) => {
const l = k * r;
const pathData = [];
// 起点为正右方,方向为顺时针
pathData.push(['M', cx + r, cy]);
pathData.push(['C', cx + r, cy + l, cx + l, cy + r, cx, cy + r]);
return pathData;
};
拟合圆
将这样的 4 个三阶贝塞尔曲线依次连接 ,就能得到一个圆了。
const circleToCubic = (cx, cy, r) => {
const l = k * r;
const pathData = [];
// 起点为正右方,方向为顺时针
pathData.push(['M', cx + r, cy]);
pathData.push(['C', cx + r, cy + l, cx + l, cy + r, cx, cy + r]);
pathData.push(['C', cx - l, cy + r, cx - r, cy + l, cx - r, cy]);
pathData.push(['C', cx - r, cy - l, cx - l, cy - r, cx, cy - r]);
pathData.push(['C', cx + l, cy - r, cx + r, cy - l, cx + r, cy]);
pathData.push(['Z']);
return pathData;
};
拟合椭圆
举一反三,椭圆拟合同理。
不同的地方在于 控制线的长度有两种,水平和垂直两个方向。
export const ellipseToCubic = (cx, cy, rx, ry) => {
const lx = rx * k;
const ly = ry * k;
const pathData = [];
// 起点为正右方,方向为顺时针
pathData.push(['M', cx + rx, cy]);
pathData.push(['C', cx + rx, cy + ly, cx + lx, cy + ry, cx, cy + ry]);
pathData.push(['C', cx - lx, cy + ry, cx - rx, cy + ly, cx - rx, cy]);
pathData.push(['C', cx - rx, cy - ly, cx - lx, cy - ry, cx, cy - ry]);
pathData.push(['C', cx + lx, cy - ry, cx + rx, cy - ly, cx + rx, cy]);
pathData.push(['Z']);
return pathData;
};
拟合任意圆弧
1/4 圆弧是特殊情况,下面看看如何拟合 startAngle 和 endAngle 为任意值的圆弧。
假设有一个半径为 1 的圆,我们对圆上的一段圆弧进行拟合,圆弧的一个端点 D 在 x 轴的 (1, 0) 上。
需要拟合的贝塞尔曲线是 自对称 的,所以它的两条控制线长度是相等的,将其记作 k。
圆弧扫过的角记作 a,则 A、B、C、D 的值如图。
我们有三个条件:
-
根据对称性,贝塞尔曲线 t 为 0.5 的位置应该是圆弧的中点。
L = B(0.5); -
根据三角函数,
L = (cos(a/2), sin(a/2));
化简过程省略,用到了一些三角函数的公式,总之最后化简得到一个等式。
拟合 1/4 圆弧,a 就是 90 度,带入公式,k 就得到前面说的魔法数 0.5522847498307936。
知道控制线长度 k 的之后,就能计算两个控制点的位置了,最后再做一下矩阵变换,旋转到 startAngle 的位置,然后放到为 radius 倍,最后移动到 center 位置。
import { Matrix } from 'pixi.js';
/**
* 对任意圆弧进行拟合
* 极轴坐标系规定:起点为正右方,方向为顺时针
*/
export const arcToCubic = (
cx,
cy,
r,
startAngle,
endAngle,
) => {
// 计算 k
const sweepAngle = endAngle - startAngle;
const halfSweepAngle = sweepAngle / 2;
const k =
(4 * (1 - Math.cos(halfSweepAngle))) / (3 * Math.sin(halfSweepAngle));
// 先把 startAngle 旋转对其到正右方,endAngle 也跟随旋转
const originStartAngle = startAngle;
startAngle = 0;
endAngle -= originStartAngle;
const matrix = new Matrix()
.rotate(originStartAngle)
.scale(r, r)
.translate(cx, cy);
const p1 = matrix.apply({
x: 1,
y: 0,
});
const p2 = matrix.apply({
x: 1,
y: k,
});
const p3 = matrix.apply({
x: Math.cos(sweepAngle) + k * Math.sin(sweepAngle),
y: Math.sin(sweepAngle) - k * Math.cos(sweepAngle),
});
const p4 = matrix.apply({
x: Math.cos(sweepAngle),
y: Math.sin(sweepAngle),
});
const pathData = [];
// 起点为正右方,方向为顺时针
pathData.push(['M', p1.x, p1.y]);
pathData.push(['C', p2.x, p2.y, p3.x, p3.y, p4.x, p4.y]);
return pathData;
};
算法实现用了 pixijs 的一个矩阵工具类,你可以不用,直接把矩阵运算内化到函数里。
效果:
sweepAngle 越大的,拟合效果就会越差。
太大的圆弧应该拆成多个三阶贝塞尔曲线,并控制每个曲线在 1/4 圆以内。
起始角也一起改变玩玩。
结尾
我是前端西瓜哥,关注我,学习更多解析几何知识。
后台回复 「圆弧拟合」,可获取示例 demo。
相关阅读,
贝塞尔曲线是什么?如何用 Canvas 绘制三阶贝塞尔曲线?