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

948 阅读6分钟

效果预览

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

认识贝塞尔曲线

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

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

三次贝塞尔曲线

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

曲线的参数形式为:

600px-Bezier_curve.svg.png转存失败,建议直接上传图片文件

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

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

使用贝塞尔曲线拟合圆

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

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

/**
	* 从最后一个点开始添加三次贝塞尔曲线,接近控制点 (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 圆
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)

    }

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


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 即是拟合圆弧最佳魔法数值。

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

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)表示曲线上的每一点。

根据贝塞尔曲线的对称性,可以知道 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

参考文档

zh.wikipedia.org/wiki/貝茲曲線

How to create circle with Bézier curves?

spencermortensen.com/articles/be…

递归的方式推动出贝塞尔曲线的计算公式

绘图工具

示例代码链接