Four Steps to Round Corners: Topology Transformation

You have a sharp corner. Apply "corner radius: 20px".

The corner doesn't round. It disappears.

Not a rendering bug. A topology bug. Corner radius isn't about drawing—it's about changing the graph structure itself.

The Problem

Vector networks store geometry as vertices and segments:

Vertex 0: (0, 0)
Vertex 1: (100, 0)
Vertex 2: (100, 100)

Segment A: 0→1 (horizontal)
Segment B: 1→2 (vertical)

Sharp 90° corner at vertex 1.

Apply corner radius. What happens?

The corner should become a smooth arc connecting segments A and B. But where do the arc points go? You can't just draw a curve—you need to add vertices and segments to represent it.

First Attempt: Direct Arc Insertion

Try inserting an arc segment at the corner:

void addCornerRadius(uint32_t vertexId, float radius) {
  // Find segments meeting at this vertex
  Segment& seg1 = findSegment1(vertexId);
  Segment& seg2 = findSegment2(vertexId);

  // Create arc connecting them
  uint32_t arcId = addArcSegment(seg1.toVertex, seg2.fromVertex, radius);
}

Run it. The corner vertex still exists. Segments A and B still end at the sharp corner, then an arc segment also starts there.

Result: sharp corner + arc segment stacked on top = visual mess.

The arc needs cut points—the segments have to be shortened so the arc connects them smoothly.

Second Attempt: Shorten Segments Manually

Calculate where to cut the segments:

void addCornerRadius(uint32_t vertexId, float radius) {
  // Calculate cut distance based on angle
  float angle = calculateAngle(seg1, seg2, vertexId);
  float cutDist = radius / tan(angle / 2);

  // Move segment endpoints
  seg1.toVertex = createVertexAt(seg1, cutDist);
  seg2.fromVertex = createVertexAt(seg2, cutDist);

  // Add arc between new endpoints
  addArcSegment(seg1.toVertex, seg2.fromVertex, radius);
}

This creates the cut points. But those cut point locations aren't vertices yet. We reference vertex IDs that don't exist.

Crash.

You need to actually create the vertices in the vertex array before you can reference them in segments.

This is topology transformation: you're modifying the graph structure, not just drawing.

The Solution: Four-Pass Algorithm

Process corners in stages, maintaining valid topology throughout:

Pass 1: Detect Corners

Identify which vertices should have corner radius applied:

for (uint32_t vertexId = 0; vertexId < vertices.size(); vertexId++) {
  if (vertexInfo[vertexId].cornerRadius > 0) {
    // Find two segments meeting at this vertex
    auto [seg1, seg2] = findAdjacentSegments(vertexId);

    float angle = calculateAngle(seg1, seg2, vertexId);

    // Skip near-straight or near-180° corners
    if (angle < 0.01 || (π - angle) < 0.01) continue;

    cornerInfo.isValid[vertexId] = true;
  }
}

Pass 2: Calculate Cut Points and Create Virtual Vertices

For each valid corner, calculate where segments should be shortened and create vertices:

for (uint32_t vertexId : validCorners) {
  float radius = vertexInfo[vertexId].cornerRadius;
  float angle = cornerInfo.angle[vertexId];

  // Distance from corner to cut point
  float cutDistance = radius / tan(angle / 2);

  // Create two virtual vertices (cut points on each segment)
  uint32_t cutVertex1 = createVirtualVertex(seg1, cutDistance);
  uint32_t cutVertex2 = createVirtualVertex(seg2, cutDistance);

  cornerInfo.cutVertex1[vertexId] = cutVertex1;
  cornerInfo.cutVertex2[vertexId] = cutVertex2;
}

These "virtual vertices" are real entries in the vertex array—the term just means they're algorithmically generated, not part of the original input data.

Pass 3: Shorten Segments

Now that cut point vertices exist, update segment endpoints:

for (uint32_t vertexId : validCorners) {
  auto& seg1 = segments[cornerInfo.segment1[vertexId]];
  auto& seg2 = segments[cornerInfo.segment2[vertexId]];

  // Change endpoints to cut point vertices
  if (seg1.toVertex == vertexId) {
    seg1.toVertex = cornerInfo.cutVertex1[vertexId];
  } else {
    seg1.fromVertex = cornerInfo.cutVertex1[vertexId];
  }

  if (seg2.toVertex == vertexId) {
    seg2.toVertex = cornerInfo.cutVertex2[vertexId];
  } else {
    seg2.fromVertex = cornerInfo.cutVertex2[vertexId];
  }
}

Segments now end at the cut points instead of the original corner.

Pass 4: Insert Bezier Arcs

Connect the cut points with a smooth curve using the bezier constant:

for (uint32_t vertexId : validCorners) {
  SkPoint cutPos1 = vertices[cornerInfo.cutVertex1[vertexId]];
  SkPoint cutPos2 = vertices[cornerInfo.cutVertex2[vertexId]];
  SkPoint cornerPos = vertices[vertexId];

  // Vector from cut point toward corner
  SkVector toCorner1 = cornerPos - cutPos1;
  SkVector toCorner2 = cornerPos - cutPos2;

  // Magic constant for circular arc approximation
  constexpr float k = 0.5522847290039062f;

  // Bezier tangents
  SkVector tangent1 = toCorner1 * k;
  SkVector tangent2 = toCorner2 * k;

  // Create arc segment connecting cut points
  uint32_t arcSegId = addSegment(
    cornerInfo.cutVertex1[vertexId],
    cornerInfo.cutVertex2[vertexId]
  );

  setSegmentTangents(arcSegId, tangent1, tangent2);
}

The constant 0.5522847290039062 comes from the formula for approximating a circular arc with a cubic bezier: R × (4 × (√2 - 1)) / 3.

For a 90° arc, this produces a curve that visually matches a perfect circle to within 0.02% error.

The Bezier Circle Constant

Why that specific number?

A circular arc can be approximated by a cubic bezier curve where the control points are offset from the endpoints by a specific fraction of the radius.

For a quarter circle (90°):

The optimal value of k that minimizes error:

k = 4 × (√2 - 1) / 3 ≈ 0.5522847290039062

This isn't arbitrary—it's derived from the geometry of circular arcs and bezier curves. The formula balances curvature error across the entire arc length.

Results

Corner radius implementation now:

For a rectangle with 4 rounded corners:

The algorithm handles:

For non-90° corners, the constant k is applied to the vector toward the corner, which automatically adapts to the angle. It's less accurate for very sharp (< 60°) or very wide (> 120°) angles, but produces good results for typical corner cases.

The four-pass structure ensures topology stays valid: vertices exist before segments reference them, segments are updated before arcs connect them.

Corner radius isn't rendering. It's graph surgery.


Read next: Figma's Blend Modes: Not Quite Photoshop - Manual shader implementation for divergent blend formulas.