如何用三阶贝塞尔曲线拟合圆形、椭圆、任意圆弧?

1,090 阅读5分钟

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

有些图形软件,其底层是以贝塞尔样条曲线为原始图形(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 的值如图。

图片

我们有三个条件:

  1. 根据对称性,贝塞尔曲线 t 为 0.5 的位置应该是圆弧的中点。L = B(0.5)

  2. 根据三角函数,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({
    x1,
    y0,
  });
  const p2 = matrix.apply({
    x1,
    y: k,
  });

  const p3 = matrix.apply({
    xMath.cos(sweepAngle) + k * Math.sin(sweepAngle),
    yMath.sin(sweepAngle) - k * Math.cos(sweepAngle),
  });

  const p4 = matrix.apply({
    xMath.cos(sweepAngle),
    yMath.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 绘制三阶贝塞尔曲线?

简简单单实现画笔工具,轻松绘制丝滑曲线

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