Cursor blinking

使用四段三次 Bézier 曲线拟合圆

Android 基础|Bezier|字数 1,100|阅读时长≈ 3 分钟

效果预览

fig.1 demo 示例
fig.1 demo 示例

使用四段三次贝塞尔曲线拟合圆

认识贝塞尔曲线

数学数值分析领域中,贝塞尔曲线(英语:Bézier curve)是计算机图形学中相当重要的参数曲线(贝塞尔曲线通常用于生成平滑曲线,因为它们的计算成本较低并且可以产生高质量的结果),为计算机矢量图形学奠定了基础。

贝塞尔曲线于 1962 年,由法国工程师皮埃尔·贝兹(Pierre Bézier)所广泛发表,他运用贝塞尔曲线来为汽车的主体进行设计。贝塞尔曲线最初由保尔·德·卡斯特里奥于 1959 年运用德卡斯特里奥算法开发,以稳定数值的方法求出贝塞尔曲线。

三次贝塞尔曲线

P0、P1、P2、P3四个点在平面或在三维空间中定义了三次贝塞尔曲线。曲线起始于P0 走向 P1,并从 P2 的方向来到 P3。一般不会经过 P1 或 P2;这两个点只是在那里提供方向信息。P0 和 P1之间的间距,决定了曲线在转而趋进 P2 之前,走向 P1 方向的“长度有多长”。 曲线的参数形式为:

fig.2 三次贝塞尔曲线
fig.2 三次贝塞尔曲线
附件未导入:671f651757c2e7efb48da5801e835d3cafe4eeba

对于三次曲线,可由线性贝塞尔曲线描述的中介点 Q0、Q1、Q2,和由二次曲线描述的点 R0、R1 所建构:

三次贝塞尔曲线的结构三次贝塞尔曲线演示动画,t 在 [0,1] 区间

附件未导入:fig.3 三次贝塞尔曲线计算 附件未导入:fig.4 三次贝塞尔曲线演示

MatheMatica 演示三次贝塞尔曲线的计算

Code
p0 = {1, 4};p1 = {3, 5};p2 = {4, 3};p3 = {6, 4}; PA1[t_] := p0 + (p1 - p0)*t;PA2[t_] := p1 + (p2 - p1)*t;PA3[t_] := p2 + (p3 - p2)*t; PB1[t_] := (1 - t)*PA1[t] + t*PA2[t];PB2[t_] := (1 - t)*PA2[t] + t*PA3[t]; PC1[t_] := (1 - t)*PB1[t] + t*PB2[t]; Animate[Show[  ParametricPlot[PA1[t*a], {t, 0, 1}, PlotStyle -> {Thick, Blue},    PlotRange -> {{-1, 8}, {-1, 8}}, GridLines -> Automatic,    GridLinesStyle -> LightGray, AxesLabel -> {"X", "Y"}],   ParametricPlot[PA2[t*a], {t, 0, 1}, PlotStyle -> {Thick, Blue}],   ParametricPlot[PA3[t*a], {t, 0, 1}, PlotStyle -> {Thick, Blue}],   Graphics[{Brown, Thick, Line[{PA1[a], PA2[a]}] (*直接使用 a 作为参数*)}],   Graphics[{Brown, Thick, Line[{PA2[a], PA3[a]}] (*直接使用 a 作为参数*)}],   Graphics[{Green, Thick, Line[{PB1[a], PB2[a]}] (*直接使用 a 作为参数*)}],   ParametricPlot[PC1[t*a], {t, 0, 1}, PlotStyle -> {Thick, Red}],   Graphics[{PointSize[0.01],(*点的大小*)Black,(*点颜色*)    Point[{p0, p1, p2, p3}],(*绘制所有点*)Black,     Text[Style["p0", FontSize -> 12], p0, {0, 1.5}],(*{0,    1.5} 表示标签在点正上方*)Text[Style["p1", FontSize -> 12], p1, {0, 1.5}],     Text[Style["p2", FontSize -> 12], p2, {0, 1.5}],     Text[Style["p3", FontSize -> 12], p3, {0, 1.5}],     GrayLevel[0.5],(*直线颜色*)Line[{p0, p1}],(*连接 p0 和 p1*)    Line[{p1, p2}], Line[{p2, p3}],}], ImageSize -> Large], {a, 0, 1,   0.01}, AnimationRunning -> False, RefreshRate -> 60]
fig.5 演示三次贝塞尔曲线的计算
fig.5 演示三次贝塞尔曲线的计算

使用贝塞尔曲线拟合圆

思路:近似圆的标准方法是将其分成四个相等的部分,并用三次贝塞尔曲线替换每个直角圆弧。

在 Android 中,可以使用绘图库 Canvas 来绘制贝塞尔曲线,Canvas 提供 cubicTo 函数来构建一段三次 Bezier 曲线。

Code
/**	* 从最后一个点开始添加三次贝塞尔曲线,接近控制点 (x1,y1) 和 (x2,y2),并在 (x3,y3) 处结束。如果没有对此轮廓进行 moveTo() 调用,则第一个点将自动设置为 (0,0)	*/public void cubicTo(float x1, float y1, float x2, float y2,float x3, float y3) {		nCubicTo(mNativePath, x1, y1, x2, y2, x3, y3);}
  1. 通过一段三次贝塞尔曲线,拟合 1/4 圆
Code
private val C = 0.551915024494f // 常量,绘制圆形贝塞尔曲线控制点的位置,拟合圆的最佳系数	 /**     * 计算锚点和控制点位置     */    private fun computePoints() {        anchorPoint1.apply {            x = radius            y = 0f        }         anchorPoint2.apply {            x = viewWidth            y = radius        }         contPoint1.apply {            x = radius + radius * C            y = 0f        }         contPoint2.apply {            x = viewWidth            y = radius - radius * C        }    }     @SuppressLint("DrawAllocation")    override fun onDraw(canvas: Canvas) {        super.onDraw(canvas)        val path = Path().apply {            moveTo(anchorPoint1.x, anchorPoint1.y)            cubicTo(contPoint1.x, contPoint1.y, contPoint2.x, contPoint2.y, anchorPoint2.x, anchorPoint2.y)        }        canvas.drawPath(path, paint)     }
  1. 使用四段三次贝塞尔曲线,拟合圆
Code
 class CircleBezierView @JvmOverloads constructor(context: Context?, attrs: AttributeSet? = null) : View(context, attrs) {    private val C = 0.551915024494f     private val paint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)    private var centerX = 0    private var centerY = 0    private val radius = 200f // 圆的半径    private val fittingValue = radius * C // 控制点距离锚点的距离     private val anchors = FloatArray(8) // 顺时针记录绘制圆形的四个数据点    private val conts = FloatArray(16) // 顺时针记录绘制圆形的八个控制点        // 模拟从圆形变成心形    private var isAnimActivate = false    private val duration = 1000f // 变化总时长    private var current = 0f // 当前已进行时长    private val count = 100f // 将时长总共划分多少份    private val piece = duration / count // 每一份的时长     init {        paint.color = Color.BLACK        paint.strokeWidth = 8f        paint.style = Paint.Style.STROKE        paint.textSize = 60f          // 初始化数据点        anchors[0] = 0f        anchors[1] = radius        anchors[2] = radius        anchors[3] = 0f        anchors[4] = 0f        anchors[5] = -radius        anchors[6] = -radius        anchors[7] = 0f         // 初始化控制点        conts[0] = anchors[0] + fittingValue        conts[1] = anchors[1]        conts[2] = anchors[2]        conts[3] = anchors[3] + fittingValue        conts[4] = anchors[2]        conts[5] = anchors[3] - fittingValue        conts[6] = anchors[4] + fittingValue        conts[7] = anchors[5]        conts[8] = anchors[4] - fittingValue        conts[9] = anchors[5]        conts[10] = anchors[6]        conts[11] = anchors[7] - fittingValue        conts[12] = anchors[6]        conts[13] = anchors[7] + fittingValue        conts[14] = anchors[0] - fittingValue        conts[15] = anchors[1]    }     override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {        super.onSizeChanged(w, h, oldw, oldh)        centerX = w / 2        centerY = h / 2    }     override fun onDraw(canvas: Canvas) {        super.onDraw(canvas)        drawCoordinateSystem(canvas) // 绘制坐标系        canvas.translate(centerX.toFloat(), centerY.toFloat()) // 将坐标系移动到画布中央        canvas.scale(1f, -1f) // 翻转Y轴        drawAuxiliaryLine(canvas)         // 绘制贝塞尔曲线        paint.color = Color.RED        paint.style = Paint.Style.STROKE        paint.strokeWidth = 8f        val path = Path().apply {            moveTo(anchors[0], anchors[1])            cubicTo(conts[0], conts[1], conts[2], conts[3], anchors[2], anchors[3])            cubicTo(conts[4], conts[5], conts[6], conts[7], anchors[4], anchors[5])            cubicTo(conts[8], conts[9], conts[10], conts[11], anchors[6], anchors[7])            cubicTo(conts[12], conts[13], conts[14], conts[15], anchors[0], anchors[1])        }        canvas.drawPath(path, paint)        toHeartAnim()    }     fun startToHeartAnim() {        isAnimActivate = true        postInvalidate()    }     private fun toHeartAnim() {        if (!isAnimActivate) {            return        }        current += piece        if (current < duration) {            anchors[1] -= 120 / count            conts[7] += 80 / count            conts[9] += 80 / count            conts[4] -= 20 / count            conts[10] += 20 / count            postInvalidateDelayed(piece.toLong())        }    }}

魔法数值 C

对三次贝塞尔曲线拟合圆弧,一个关键的因素,魔数数值 C 的计算,开始之前,我们先观察不同 C 对应的三次贝塞尔曲线与圆弧的比较,如下图所示,根据贝塞尔曲线的对称性,图中蓝色斜线上的点对应三次贝塞尔曲线参数方程中的 t=0.5。当蓝色斜线、圆弧、贝塞尔曲线上的点三者重叠时的 C 即是拟合圆弧最佳魔法数值。

fig.6 魔法数值
fig.6 魔法数值

针对三次贝塞尔曲线拟合圆弧进行通用公式的求解,如下图所示

fig.7 魔法数值求解
fig.7 魔法数值求解

P1,P4 为锚点,P2,P3 为控制点。通过圆心 O 作一段圆弧 P1P4,P1P2,P4P3 为切线并且 P1P2=P4P3=C,E 点为圆弧 P1P4 的中点,这样,以 P1、P2、P3 和 P4 作为三次贝塞尔曲线的控制点,求得使曲线的中点经过 E 时,对应的 C 值。

根据贝塞尔曲线的知识,我们知道三次贝塞尔曲线的参数方程如下,其中P1、P2、P3、P4为四个控制点坐标,B(t)表示曲线上的每一点。

附件未导入:671f651757c2e7efb48da5801e835d3cafe4eeba

根据贝塞尔曲线的对称性,可以知道 E 点位于 B(0.5)处,代入公式可以得到:

B(0.5)=(1/8)·P1+(3/8)·P2+(3/8)·P3+(1/8)·P4

根据三角函数的性质,带入P1、P2、P3、P4 可以求得:

C=(4/3)·(1−𝑐𝑜𝑠(𝜃/2))/𝑠𝑖𝑛(𝜃/2)

这样就求出了使用三次贝塞尔曲线拟合圆弧的一般性公式。

我们要拟合 1/4 圆弧,也就是说在 θ=π/2 时,可以计算出该 C 值为 0.551915024494

参考文档