Stuart Kent
(Android Developer)

Really Rounded Polygons

Nick Butcher recently demonstrated a Path-and-CornerPathEffect-based method for drawing regular polygons with rounded corners. My library PolygonDrawingUtil solves the same problem but produces noticeably different results:

This inspired me to dig into how CornerPathEffect is implemented and compare it to the internals of PolygonDrawingUtil. Read on for commentary, code, and conclusions.

The Base Case

The light gray triangle of radius 500px in the animation above is the shape we will be rounding throughout this post. Here it is drawn a little darker for clarity:

All subsequent discussion applies to any n-sided regular polygon.

PolygonDrawingUtil

PolygonDrawingUtil dynamically generates and draws rounded polygon Paths based on user-specified attributes:

These Paths use exactly two components: straight lines for sides, and circular arcs for corners.

Below are some examples created using this method. The full circles used to create the rounded corner arcs are shown in light gray for illustration:

This approach creates corners with exact and uniform radii. If the corner radius becomes too large relative to the polygon radius, the drawn shape gracefully degrades to a circle:

The code that constructs these Paths is shown below. I’m not going to dissect it line by line, but the essential steps are:

  1. Calculate the length of arc to use for each corner;
  2. Calculate where each arc should be centered to ensure a smooth join with the sides;
  3. Draw each arc in turn, using a neat feature of Path.arcTo to automatically insert the sides:

    If the start of the path is different from the path’s current last point, then an automatic lineTo() is added to connect the current contour to the start of the arc.

private void constructRoundedPolygonPath(
    int   sides,
    float centerX,
    float centerY,
    float polyRadius,
    float cornerRadius) {

  float arcSweep = 180.0 / sides;
  double arcCenterRadius = polyRadius - cornerRadius / sin(radians(90 - arcSweep));

  for (int nCorner = 0; nCorner < sides; nCorner++) {
    double cornerAngle = 360.0 * nCorner / sides;
    float arcCenterX = (float) (centerX + arcCenterRadius * cos(radians(cornerAngle)));
    float arcCenterY = (float) (centerY + arcCenterRadius * sin(radians(cornerAngle)));

    arcBounds.set(
        arcCenterX - cornerRadius,
        arcCenterY - cornerRadius,
        arcCenterX + cornerRadius,
        arcCenterY + cornerRadius);

    backingPath.arcTo(
        arcBounds,
        (float) (cornerAngle - 0.5 * arcSweep),
        arcSweep);
  }

  backingPath.close();
}

CornerPathEffect

PathEffects are used to modify how an existing Path is drawn. Applied via Paint.setPathEffect, they grant Paints some “artistic license”. For example, CornerPathEffect allows the Paint to draw rounded corners in place of any sharp corners. Users can specify a corner “radius” that influences the amount of rounding applied. Note that the Path itself is never altered by the application of a PathEffect.

CornerPathEffect draws rounded polygon Paths using two components: straight lines for sides, and quadratic Bézier curves for corners.

Below are some examples created using this method:

This approach creates corners with nonuniform radii. Note too that the actual corner radius does not seem to match the specified corner radius if we interpret it as a value in pixels (this discrepancy is also illustrated by the earlier animation which shows that PolygonDrawingUtil corner roundness and CornerPathEffect corner roundness differ for the same input radius.)

If the corner radius becomes too large relative to the polygon radius, the drawn shape settles in this form:

As far as I can tell, there’s no easy way to predict what this degenerate shape will look like ahead of time.

The relevant native code from SkCornerPathEffect.cpp is below1. For each line in the original path, the essential steps are:

  1. Calculate the control and end point locations for the corner curve;
  2. Draw a quadratic Bézier curve using these points;
  3. If necessary (i.e. the corner radius is small compared to the polygon radius), draw the remainder of the original straight side.
bool SkCornerPathEffect::filterPath(SkPath* dst, const SkPath& src, SkStrokeRec*, const SkRect*) const {
  SkPath::Iter    iter(src, false);
  SkPath::Verb    verb;
  SkPoint         pts[4];
  SkVector        step;

  for (;;) {
    switch (verb = iter.next(pts, false)) {
      case SkPath::kLine_Verb: {
        bool drawSegment = ComputeStep(pts[0], pts[1], fRadius, &step);
                
        dst->quadTo(pts[0].fX, pts[0].fY, pts[0].fX + step.fX, pts[0].fY + step.fY);

        if (drawSegment) {
          dst->lineTo(pts[1].fX - step.fX, pts[1].fY - step.fY);
        }

        break;
      }

      // Other verb cases omitted.
    }
  }

  // Other iteration implementation omitted.
}

static bool ComputeStep(const SkPoint& a, const SkPoint& b, SkScalar radius, SkPoint* step) {
  SkScalar dist = SkPoint::Distance(a, b);

  *step = b - a;

  if (dist <= radius * 2) {
    *step *= SK_ScalarHalf;
    return false;
  } else {
    *step *= radius / dist;
    return true;
  }
}

Usage

The implementation differences highlighted above are interesting but perhaps still a little abstract. Let’s get real. Which rounding method should you use?

My recommendation would be to consider PolygonDrawingUtil if:

On the other hand, consider CornerPathEffect if:

Either way, I hope you learned a little about Paths, PathEffects, and geometry during this exploration :)

  1. More useful native code if you want to go deeper: SkPath.h, SkPath.cpp, SkPathEffect.h, SkPathEffect.cpp

  2. Trivia: PolygonDrawingUtil was originally inspired by games based on hexagonal grids, which is why it’s polygon-specific and allows such precise corner control.