Search code examples
cocoamathgeometryuibezierpathbezier

Which are the control points to create (approximate) a circle using 8 cubic bezier curves


The premises

I have a circle that animates into a shape made out of 8 bezier curves. For the transition to be smooth, I need the circle to also be made out of 8 cubic bezier curves. Here's what I have so far:

The code

- (UIBezierPath*)pathBubbleLeft {
    UIBezierPath *path = [UIBezierPath new];
    [path moveToPoint:p(sqlx, sqlMidy)];

    CGFloat r = sqlW/2;
    CGFloat sin45 = 0.7071 * r;
    CGFloat cos45 = 0.7071 * r;

    [path addRelativeCurveToPoint:point(sqlMidx - cos45, sqlMidy - sin45) control1:vector(0, 0.4) control2:vector(0.5, 0.8)];
    [path addRelativeCurveToPoint:point(sqlMidx, sqly) control1:vector(0.2, 0.5) control2:vector(0.4, 1)];

    [path addRelativeCurveToPoint:point(sqlMidx + cos45, sqlMidy - sin45) control1:vector(0.6, 0) control2:vector(0.8, 0.5)];
    [path addRelativeCurveToPoint:point(sqlMaxx, sqlMidy) control1:vector(0.5, 0.2) control2:vector(1, 0.5)];

    [path addRelativeCurveToPoint:point(sqlMidx + cos45, sqlMidy + sin45) control1:vector(0, 0.4) control2:vector(0.5, 0.8)];
    [path addRelativeCurveToPoint:point(sqlMidx, sqlMaxy) control1:vector(0.2, 0.5) control2:vector(0.4, 1)];

    [path addRelativeCurveToPoint:point(sqlMidx - cos45, sqlMidy + sin45) control1:vector(0.6, 0) control2:vector(0.8, 0.5)];
    [path addRelativeCurveToPoint:point(sqlx, sqlMidy) control1:vector(0.5, 0.2) control2:vector(1, 0.5)];

    return path;
}

the path starts from left and goes clockwise (from pi to pi/2, 0, 3pi/4, pi)

point and vector are shorts for CGPointMake and CGVectorMake

'sql' in sqlx, sqly, sqlMidx, sqlMidY, sqlMaxx & sqlMaxy stands for 'squareLeft', the bounding rect of the circle. These are all CGFloats.

addRelativeCurveToPoint is used to define the control points relatively to the start/end points. (0,0) is start, (1,1) is end. Easier to read.

- (void)addRelativeCurveToPoint:(CGPoint)endPoint control1:(CGVector)controlPoint1 control2:(CGVector)controlPoint2 {
    CGPoint start = self.currentPoint;
    CGPoint end = endPoint;
    CGFloat x1 = start.x + controlPoint1.dx*(end.x - start.x);
    CGFloat x2 = start.x + controlPoint2.dx*(end.x - start.x);
    CGFloat y1 = start.y + controlPoint1.dy*(end.y - start.y);
    CGFloat y2 = start.y + controlPoint2.dy*(end.y - start.y);
    [self addCurveToPoint:endPoint controlPoint1:CGPointMake(x1, y1) controlPoint2:CGPointMake(x2, y2)];
}

The results so far: static

The red circle is a bit wavy. That's what I'd like to fix.

Below, the left circle uses the above code, the right circle is made out of 4 curves with 2 zero length insertions at the top and 2 at the bottom ([path addLineToPoint:path.currentPoint];).

enter image description here

The transition is ok from the left one to mid-peanut but weird from mid to the right


Solution

  • Using four segments, a circle approximation using cubic Bezier curves cannot get rounder than what it is with the 0.55228[...] value that @fang gave you in a comment: it is simply the mathematically only value at which a cubic Bezier curve best approximates a circle. In infinite precision representation, it's actually the value that you get from:

         4        angle       4                     sqrt(2) - 1
    k =  - * tan(-------)  =  - * tan(pi/8)  =  4 * -----------
         3          2         3                          3
    

    and is 0.5522847498307933984022516322796[...]. This gets you the best possible approximation with 4 segments, so if you need to use 8 segments, we need a different value, which means we need to use the derivation that gives us k for an angle of pi/2 (a quarter circle), and see what it gives us for pi/4 (an eighth circle). So: we plug the angle pi/4 into the functions outlined in this Primer on Bezier Curve's section on approximating circles with cubic curves, and we get:

    start = {
      x: 1,
      y: 0
    }
    
    c1 = {
      x: 1,
      y: 4/3 * tan(pi/16)
    }
    
    c2 = {
      x: cos(pi/4) + 4/3 * tan(pi/16) * sin(pi/4)
      y: sin(pi/4) - 4/3 * tan(pi/16) * cos(pi/4)
    }
    
    e = {
      x: cos(pi/4)
      y: sin(pi/4)
    }
    

    which gives us these (entirely useful) approximated coordinates:

    s  = (1,           0)
    c1 = (1,           0.265216...)
    c2 = (0.894643..., 0.51957...)
    e  = (0.7071...,   0.7071...)
    

    That'll be segment 1, and then the rest of the segments are simply derived through symmetry, with segment 2 being:

    s  = (0.7071...,   0.7071...)
    c1 = (0.51957...,  0.894643...)
    c2 = (0.265216..., 1)
    e  = (0,           1)
    

    A demonstration of these coordinates used overlayed a quarter circle is here: http://jsbin.com/ridedahixu/edit?html,output

    And the rest are the obvious symmetries in the (+,-), (-,+), and (-,-) quadrants.

    These are the best possible approximations, so: if bezierPathWithOvalInRect(...) does something else, it's being less correct than values we worked out decades ago =)