用三阶贝塞尔曲线拟合圆

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了用三阶贝塞尔曲线拟合圆相关的知识,希望对你有一定的参考价值。

参考技术A

由于贝塞尔曲线控制简便且具有极强的描述能力,它常被用来生成复杂的平滑曲线;圆形是一种很常用的普通图形,在计算机图形学中也有很多画圆的算法,本文想探究一下如何用三阶贝塞尔曲线拟合圆形。
在研究这个问题时,我从Stackoverflow上搜到了(4/3)tan(π/(2n))这个公式,她的几何意义如下图所示。该公式的值表达的具体意义可以描述为:由n段三阶贝塞尔曲线拟合圆形时,曲线端点到该端点最近的控制点的最佳距离是(4/3)tan(π/(2n))。

一开始看到这个值觉得很奇怪,想知道她是如何被推导出来的,于是花了一点功夫去调查,并自己求解证明,原来是一堆中学生就会的平面几何运算题。下面给出我的求解过程。

大家把这个用三阶贝塞尔曲线拟合圆形的数值叫做魔法数,可能是因为她的不同取值会影响到拟合的圆形的效果,这个值决定了贝塞尔曲线拟合圆形的误差。我通过在上面的几何图中添加辅助线、运用平面几何性质来求解该魔法数值。

如上图命名各点,点O是圆心,P0、P3分别是圆弧(也是贝塞尔曲线)上的起点和终点,P1、P2是贝塞尔曲线的两个控制点, 点M2是线段P1P2的中点,C1、C2分别是线段P0P1和P2P3的中点,连接OM2并延长与P0P1的延长线交于点F1,过点P1作线段P0P3的垂线交于点F0。
点M1是线段C1M2和C2M2的中点的连线的中点,根据贝塞尔曲线上点的性质可知点M1是圆弧上的点,且是圆弧P0P3的中点,线段P0P3与OF1交于点M0.

一个隐含的条件(或者根据贝塞尔曲线的数学性质可证明的)是|P2P3|=|P0P1|,我们的目标就是要求出这个长度值,记为l(小写L)。
易知圆弧P0P3被射线OF1对称平分,且OF1与线段P0P3、C1C2和P1P2均垂直相交,又因为点C1、C2和M2是相关线段的中点,容易证明|M1M2| = |M0M2|/4,|P1F0| = |M0M2|,所以|M0M1| = (3/4)|M0M2| = (3/4)|P1F0|。
记|P1F0| = d,圆弧半径为r,∠P0-O-P3为θ,则∠P0-O-M0 = θ/2,|M0M1| = (3/4)d,|OM0| = |OM1| - |M0M1| = r - (3/4)d = r·cos(θ/2),整理等式为
(3/4)d = r - r·cos(θ/2) ····················· ①
因为线段P0P1与圆弧相切于点P0,OP0是圆弧的半径,容易证明∆P0-F0-P1与∆O-M0-P0相似,∠P1-P0-F0 = θ/2,则
d = l·sin(θ/2) ································ ②
由方程①②联立解得 l = (4/3)·r·(1-cos(θ/2))/sin(θ/2)
若圆弧半径为1,再由下面的三角函数二倍角公式推导
sin2α = 2·sinα·cosα
cos2α = (cosα)^2 - (sinα)^2 = 1 - 2·(sinα)^2
得到 l = (4/3)tan(θ/4) ,即是上面图中的值 (4/3)tan(π/(2n))

上面用几何运算的方式求解了魔法数,还可以直接根据贝塞尔曲线方程代入特殊点坐标计算该数值。
用三阶贝塞尔曲线拟合圆形的问题可以简化为考虑拟合1/4圆弧,如下图圆弧P0P3即是端点为P0、P3,控制点为P1、P2的贝塞尔曲线,它们的坐标分别为P0 = (0,1), P1 = (h,1), P2 = (1,h), P3 = (1,0)

我们知道三阶贝塞尔曲线的一般方程如下

把上面的点坐标分别代入曲线方程,取t=0.5计算得到点坐标

另外根据贝塞尔曲线的数学性质可知曲线方程中t=0.5时的点一定在圆弧上,根据圆形方程定义,可得到下面的等式

这样,容易解出h的值为 h=(4/3)(sqrt(2)-1) ≈ 0.552284749831

这篇博文是在前一篇《 贝塞尔曲线学习笔记 》的基础上做的一个关于贝塞尔曲线应用的深入探索,是笔者在工作之余的一点学习收获,内容比较浅陋,主要的收获在于唤起了我的学习兴趣。关于前文主要求解的魔法数值,还应该深入讨论贝塞尔曲线拟合圆形的误差, Approximate a circle with cubic Bézier curves 这篇文章中作了误差分析,并给出了一个更精确的魔法数值 0.551915024494

How to create circle with Bézier curves?
Approximate a circle with cubic Bézier curves
Drawing a circle with Bézier Curves
用三次贝塞尔曲线拟合圆弧

贝塞尔曲线的使用

什么是贝塞尔曲线

它主要用在Andorid中某些自定义VIew的时候需要绘制某些曲线。它只要有些名词介绍:

  • 数据点:通常指一条路径的起始点和终止点
  • 控制点:控制点决定可一条路径的弯曲轨迹,根据控制的点的个数,贝塞尔曲线被分为一阶贝塞尔曲线(0个控制点)、二阶贝塞尔曲线(1个控制点)、三阶贝塞尔曲线(2个控制点)

在平时开发中主要掌握二阶和三阶贝塞尔曲线

二阶贝塞尔曲线
这里写图片描述
由上图看,P0是起点,P2是终点。P1是控制点,t是一个系数,表示从0-1的变化过程,红色的线就是最终画出的曲线。其中主要是用到了quaTo()这个方法。

  • 实现代码如下
public class SecondOrderBezier extends View {

    //辅助线
    private Paint mPaintAuxiliary;
    //控制点名称
    private Paint mPaintAuxiliaryText;
    //贝塞尔曲线
    private Paint mPaintBezier;

    //控制点坐标
    private float mAuxiliaryX;
    private float mAuxiliaryY;

    //起始点坐标
    private float mStartPointX;
    private float mStartPointY;
    //终点坐标
    private float mEndPointX;
    private float mEndPiuntY;

    private Path mPath = new Path();

    public SecondOrderBezier(Context context) {
        super(context);
    }

    public SecondOrderBezier(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPaintBezier = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaintBezier.setStyle(Paint.Style.STROKE); //设置画笔为空心
        mPaintBezier.setStrokeWidth(8);

        mPaintAuxiliary = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaintAuxiliary.setStyle(Paint.Style.STROKE);
        mPaintAuxiliary.setStrokeWidth(2);

        mPaintAuxiliaryText = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaintAuxiliaryText.setStyle(Paint.Style.STROKE);
        mPaintAuxiliaryText.setTextSize(20);
    }

    public SecondOrderBezier(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);

        //起始和终点的坐标
        mStartPointX = w / 4;
        mStartPointY = h /2 - 200;

        mEndPointX = w / 4 * 3;
        mEndPiuntY = h /2 - 200;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //重置绘制路线
        mPath.reset();
        //mpath绘制的绘制起点
        mPath.moveTo(mStartPointX, mStartPointY);
        //绘制辅助控制点
       canvas.drawPoint(mAuxiliaryX, mAuxiliaryY, mPaintAuxiliary);
        canvas.drawText("控制点", mAuxiliaryX, mAuxiliaryY, mPaintAuxiliaryText);
        canvas.drawText("起始点", mStartPointX, mStartPointY, mPaintAuxiliaryText);
        canvas.drawText("终止点", mEndPointX, mEndPiuntY, mPaintAuxiliaryText);

        //辅助线
        canvas.drawLine(mStartPointX, mStartPointY, mAuxiliaryX, mAuxiliaryY, mPaintAuxiliary);
        canvas.drawLine(mEndPointX, mEndPiuntY, mAuxiliaryX, mAuxiliaryY, mPaintAuxiliary);
        //二阶贝塞尔、线,实现绘制贝塞尔平滑曲线;previousX, previousY为操作点,cX, cY为终点
        mPath.quadTo(mAuxiliaryX, mAuxiliaryY, mEndPointX, mEndPiuntY);
        canvas.drawPath(mPath, mPaintBezier);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()){
            case MotionEvent.ACTION_MOVE:
                mAuxiliaryX = event.getX();
                mAuxiliaryY = event.getY();
                //更新绘制
                invalidate();
        }
        return true;
    }
}

三阶贝塞尔曲线

三阶贝塞尔曲线主要是多了一个控制点,指定一个起点和一个终点,再指定两个控制点即可实现,主要是用到了Path对象的cubicTo()方法。

这里写图片描述

  • 实现代码如下,很多于二阶雷同:
  @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        mPath.reset();

        mPath.moveTo(mStartPointX, mStartPointY);

        // 辅助点
        canvas.drawPoint(mAuxiliaryOneX, mAuxiliaryOneY, mPaintAuxiliary);
        canvas.drawPoint(mAuxiliaryTwoX, mAuxiliaryTwoY, mPaintAuxiliary);
        canvas.drawText("控制点1", mAuxiliaryOneX, mAuxiliaryOneY, mPaintAuxiliaryText);
        canvas.drawText("控制点2", mAuxiliaryTwoX, mAuxiliaryTwoY, mPaintAuxiliaryText);
        canvas.drawText("起始点", mStartPointX, mStartPointY, mPaintAuxiliaryText);
        canvas.drawText("终止点", mEndPointX, mEndPointY, mPaintAuxiliaryText);
        mPath.cubicTo(mAuxiliaryOneX, mAuxiliaryOneY, mAuxiliaryTwoX, mAuxiliaryTwoY, mEndPointX, mEndPointY);
        // 辅助线
        canvas.drawLine(mStartPointX, mStartPointY, mAuxiliaryOneX, mAuxiliaryOneY, mPaintAuxiliary);
        canvas.drawLine(mEndPointX, mEndPointY, mAuxiliaryTwoX, mAuxiliaryTwoY, mPaintAuxiliary);
        //三阶贝塞尔曲线
        canvas.drawLine(mAuxiliaryOneX, mAuxiliaryOneY, mAuxiliaryTwoX, mAuxiliaryTwoY, mPaintAuxiliary);
        canvas.drawPath(mPath, mPaintBezier);
    }

其中有些情况下需要自己设置动画的路径效果,来实现炫酷的效果,就没有封装好的方法,需要自己使用公式:

public class BezierUtil {
    /**
     * 二阶贝塞尔曲线公式
     * B(t) = (1 - t)^2 * P0 + 2t * (1 - t) * P1 + t^2 * P2, t ∈ [0,1]
     *
     * @param t  曲线长度比例
     * @param p0 起始点
     * @param p1 控制点
     * @param p2 终止点
     * @return t对应的点
     */
    public static PointF CalculateBezierPointForQuadratic(float t, PointF p0, PointF p1, PointF p2) {
        PointF point = new PointF();
        float temp = 1 - t;
        point.x = temp * temp * p0.x + 2 * t * temp * p1.x + t * t * p2.x;
        point.y = temp * temp * p0.y + 2 * t * temp * p1.y + t * t * p2.y;
        return point;
    }

    /**
     * 三阶贝塞尔曲线公式
     * B(t) = P0 * (1-t)^3 + 3 * P1 * t * (1-t)^2 + 3 * P2 * t^2 * (1-t) + P3 * t^3, t ∈ [0,1]
     *
     * @param t  曲线长度比例
     * @param p0 起始点
     * @param p1 控制点1
     * @param p2 控制点2
     * @param p3 终止点
     * @return t对应的点
     */
    public static PointF CalculateBezierPointForCubic(float t, PointF p0, PointF p1, PointF p2, PointF p3) {
        PointF point = new PointF();
        float temp = 1 - t;
        point.x = p0.x * temp * temp * temp + 3 * p1.x * t * temp * temp + 3 * p2.x * t * t * temp + p3.x * t * t * t;
        point.y = p0.y * temp * temp * temp + 3 * p1.y * t * temp * temp + 3 * p2.y * t * t * temp + p3.y * t * t * t;
        return point;
    }
}

最后来一个示例:实现贝塞尔曲线购物车效果:
有两种方法:

  1. 二阶贝塞尔曲线公式算出路径差值:
    核心代码
 /**
     * 添加商品到购物车里
     * @param goodsImageView
     */
    private void addGoodsToCart(ImageView goodsImageView) {
        //创造出执行动画的主题goodsImg
        final ImageView goods = new ImageView(this);
        goods.setImageDrawable(goodsImageView.getDrawable());

        RelativeLayout.LayoutParams parms = new RelativeLayout.LayoutParams(100, 100);
        mRlyShoppingCartRly.addView(goods, parms);

        //得到父布局的起始点坐标
        int[] parentLocation = new int[2];
        mRlyShoppingCartRly.getLocationInWindow(parentLocation);
        //得到商品图片坐标
        int startLoc[] = new int[2];
        goodsImageView.getLocationInWindow(startLoc);
        //得到购物车图片的坐标
        int endLoc[] = new int[2];
        mIvShoppingCart.getLocationInWindow(endLoc);

        //得到开始掉落商品的起始坐标
        float startX = startLoc[0] - parentLocation[0] + goodsImageView.getWidth() / 2;
        float startY = startLoc[1] - parentLocation[1] + goodsImageView.getHeight() / 2;
        //得到商品掉落的终点坐标
        float toX = endLoc[0] - parentLocation[0] + goodsImageView.getWidth() / 5;
        float toY = endLoc[1] - parentLocation[1];

        float mContorlPointX = (startX + toX)/2;
        float mContorlPointY = startY;
        Path mPath = new Path();
        mPath.reset();

        //第一步先移动到起始位置
        mPath.moveTo(startX,startY);
        //开始绘制贝塞尔曲线
        mPath.quadTo( mContorlPointX, mContorlPointY, toX, toY);
        //贝塞尔曲线的插值器
        BezierEvaluator bezierEvaluator = new BezierEvaluator(new PointF(mContorlPointX, mContorlPointY));
        // 属性动画实现
        ValueAnimator anim = ValueAnimator.ofObject(bezierEvaluator,new PointF(startX, startY),
                new PointF(toX, toY)
        );
        anim.setDuration(600);
        anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                PointF point = (PointF) valueAnimator.getAnimatedValue();
                //根据动画每帧小点移动所在的坐标
                goods.setTranslationX((int) point.x);
                goods.setTranslationY((int) point.y);
                //重绘
                mRlyShoppingCartRly.invalidate();
            }
        });

        anim.setInterpolator(new AccelerateDecelerateInterpolator());
        anim.start();

        // 动画结束后的处理
        anim.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animator) {

            }

            @Override
            public void onAnimationEnd(Animator animator) {
                goodsCount ++;
                isShowCartGoodsCount();

                mTvShoppingCart.setText(String.valueOf(goodsCount));
                mRlyShoppingCartRly.removeView(goods);
            }

            @Override
            public void onAnimationCancel(Animator animator) {

            }

            @Override
            public void onAnimationRepeat(Animator animator) {

            }
        });

    }
  1. 用PathMeasure路径测量
    PathMeasure看似很简单,但着实很有用,有了它,再结合上 Path 、Shader、ColorMatrix 等利器,我们已经可以做出很多酷炫的效果了
    核心代码
 /**
     * 添加商品到购物车
     * @author leibing
     * @createTime 2016/09/28
     * @lastModify 2016/09/28
     * @param goodsImg 商品图标
     * @return
     */
    private void addGoodsToCart(ImageView goodsImg) {
        // 创造出执行动画的主题goodsImg(这个图片就是执行动画的图片,从开始位置出发,经过一个抛物线(贝塞尔曲线),移动到购物车里)
        final ImageView goods = new ImageView(this);
        goods.setImageDrawable(goodsImg.getDrawable());
        RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(100, 100);
        mShoppingCartRly.addView(goods, params);

        // 得到父布局的起始点坐标(用于辅助计算动画开始/结束时的点的坐标)
        int[] parentLocation = new int[2];
        mShoppingCartRly.getLocationInWindow(parentLocation);

        // 得到商品图片的坐标(用于计算动画开始的坐标)
        int startLoc[] = new int[2];
        goodsImg.getLocationInWindow(startLoc);

        // 得到购物车图片的坐标(用于计算动画结束后的坐标)
        int endLoc[] = new int[2];
        mShoppingCartIv.getLocationInWindow(endLoc);

        // 开始掉落的商品的起始点:商品起始点-父布局起始点+该商品图片的一半
        float startX = startLoc[0] - parentLocation[0] + goodsImg.getWidth() / 2;
        float startY = startLoc[1] - parentLocation[1] + goodsImg.getHeight() / 2;

        // 商品掉落后的终点坐标:购物车起始点-父布局起始点+购物车图片的1/5
        float toX = endLoc[0] - parentLocation[0] + mShoppingCartIv.getWidth() / 5;
        float toY = endLoc[1] - parentLocation[1];

        // 开始绘制贝塞尔曲线
        Path path = new Path();
        // 移动到起始点(贝塞尔曲线的起点)
        path.moveTo(startX, startY);
        // 使用二阶贝塞尔曲线:注意第一个起始坐标越大,贝塞尔曲线的横向距离就会越大,一般按照下面的式子取即可
        path.quadTo((startX + toX) / 2, startY, toX, toY);
        // mPathMeasure用来计算贝塞尔曲线的曲线长度和贝塞尔曲线中间插值的坐标,如果是true,path会形成一个闭环
        mPathMeasure = new PathMeasure(path, false);

        // 属性动画实现(从0到贝塞尔曲线的长度之间进行插值计算,获取中间过程的距离值)
        ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, mPathMeasure.getLength());
        valueAnimator.setDuration(500);

        // 匀速线性插值器
        valueAnimator.setInterpolator(new LinearInterpolator());
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                // 当插值计算进行时,获取中间的每个值,
                // 这里这个值是中间过程中的曲线长度(下面根据这个值来得出中间点的坐标值)
                float value = (Float) animation.getAnimatedValue();
                // 获取当前点坐标封装到mCurrentPosition
                // boolean getPosTan(float distance, float[] pos, float[] tan) :
                // 传入一个距离distance(0<=distance<=getLength()),然后会计算当前距离的坐标点和切线,pos会自动填充上坐标,这个方法很重要。
                // mCurrentPosition此时就是中间距离点的坐标值
                mPathMeasure.getPosTan(value, mCurrentPosition, null);
                // 移动的商品图片(动画图片)的坐标设置为该中间点的坐标
                goods.setTranslationX(mCurrentPosition[0]);
                goods.setTranslationY(mCurrentPosition[1]);
            }
        });

        // 开始执行动画
        valueAnimator.start();

        // 动画结束后的处理
        valueAnimator.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {
            }

            @Override
            public void onAnimationEnd(Animator animation) {
                // 购物车商品数量加1
                goodsCount ++;
                isShowCartGoodsCount();
                mShoppingCartCountTv.setText(String.valueOf(goodsCount));
                // 把执行动画的商品图片从父布局中移除
                mShoppingCartRly.removeView(goods);
            }

            @Override
            public void onAnimationCancel(Animator animation) {
            }

            @Override
            public void onAnimationRepeat(Animator animation) {
            }
        });
    }

源码:源码

以上是关于用三阶贝塞尔曲线拟合圆的主要内容,如果未能解决你的问题,请参考以下文章

基于三阶贝塞尔曲线的数据平滑算法

Android UI贝塞尔曲线 ② ( 二阶贝塞尔曲线公式 | 三阶贝塞尔曲线及公式 | 高阶贝塞尔曲线 )

Android UI贝塞尔曲线 ⑦ ( 使用 德卡斯特里奥算法 公式计算的 方法绘制三阶贝塞尔曲线示例 )

玩转贝塞尔曲线,教你在Unity中画Bezier贝塞尔曲线(二阶三阶),手把手教你推导公式

玩转贝塞尔曲线,教你在Unity中画Bezier贝塞尔曲线(二阶三阶),手把手教你推导公式

Android UI贝塞尔曲线 ④ ( 使用 android.graphics.Path 提供的 cubicTo 方法绘制三阶贝塞尔曲线示例 )