Sticky-Out Caps: When Round Isn't Round

Draw a 100-pixel line with round caps in Skia. Measure it. 120 pixels.

Draw the same line in Figma. 100 pixels.

Same stroke width. Same round caps. Different results.

The Problem

Skia's round caps extend beyond the path endpoints. For a stroke width of 10px, each round cap adds a 5px-radius semicircle past the endpoint—10px total extra length.

Figma's round caps stay within the path endpoints. A 100px line is exactly 100px, caps included.

This isn't a bug in either system. It's a philosophical difference about what a "cap" means:

First Attempt: Offset the Path

Maybe we could shorten the path by the cap radius to compensate?

SkPath shortenedPath = path;
// Trim both ends by stroke width / 2
shortenedPath.trim(capRadius, pathLength - capRadius);

paint.setStrokeCap(SkPaint::kRound_Cap);
canvas.drawPath(shortenedPath, paint);

This worked for straight lines between two points.

Then we tried it on a complex path with multiple segments. The caps at segment junctions (where two segments meet) also got trimmed, creating gaps at the joins.

Also: Skia's Path::trim() doesn't exist. We'd have to implement it ourselves using SkPathMeasure, extracting portions of the path, reconnecting them. Messy for paths with bezier curves.

Second Attempt: Custom Stroke Caps

What if we drew the caps manually, aligned exactly to the endpoints?

void addRoundCapAtPoint(SkPoint point, SkVector direction, float radius) {
  SkVector perpendicular = {-direction.fY, direction.fX};
  perpendicular.normalize();
  perpendicular.scale(radius);

  // Semicircle at endpoint, not beyond it
  SkPoint arcStart = point + perpendicular;
  SkPoint arcEnd = point - perpendicular;

  path.moveTo(arcStart);
  // Arc control points for 180° semicircle...
  path.arcTo(arcEnd, ...);
}

Better. But now we had a different problem: which segments get caps?

In a closed loop (rectangle, triangle), no segments should get caps—they're all joined to other segments. Only open endpoints get caps.

In a vector network where 3 segments meet at one vertex, none of those segments get a cap at that vertex. But if a segment's endpoint doesn't connect to anything, it needs a cap.

We needed topology information. Which segments connect at each vertex?

The Solution: Endpoint Detection + Manual Caps

Using the vector network's connectivity tracking:

// For each segment being stroked:
const auto& fromVertex = vn.fVertices[segment.fromVertex];
const auto& toVertex = vn.fVertices[segment.toVertex];

// Check if this endpoint connects to other segments
bool fromIsEndpoint = (fromVertex.segmentCount == 1);
bool toIsEndpoint = (toVertex.segmentCount == 1);

if (fromIsEndpoint) {
  addManualCapAtSegmentEnd(vn, dst, segmentPath,
                          segment.fromVertex, width, true);
}
if (toIsEndpoint) {
  addManualCapAtSegmentEnd(vn, dst, segmentPath,
                          segment.toVertex, width, false);
}

The addManualCapAtSegmentEnd function (src/cf/model/CfVectorNetworkSegment.cpp:1084):

  1. Gets the segment direction at the endpoint
  2. Computes the perpendicular normal
  3. Draws the cap geometry starting at the endpoint, not beyond it

For a round cap:

SkVector perpendicular = {-direction.fY, direction.fX};
perpendicular.normalize();
perpendicular.scale(radius);

SkPoint capStart = endPoint + perpendicular;
SkPoint capEnd = endPoint - perpendicular;

// Semicircle from one side to the other
dst->arcTo(capEnd, radius, radius, 0, SkPath::kSmall_ArcSize,
           SkPathDirection::kCW, capStart);

Understanding the Perpendicular Vector

If you have a line pointing in direction (dx, dy), how do you get a perpendicular direction (90° rotated)?

The trick: swap x and y, negate one of them: (-dy, dx)

Why does this work? Two vectors are perpendicular if their dot product is zero:

direction · perpendicular = dx*(-dy) + dy*dx
                         = -dx*dy + dy*dx
                         = 0  ✓

Example with a horizontal line:

direction = (1, 0)       // pointing right
perpendicular = (0, 1)   // pointing up

Example with a diagonal line:

direction = (1, 1)       // pointing northeast (45°)
perpendicular = (-1, 1)  // pointing northwest (135°)

This rotation is counterclockwise (by 90°). If you want clockwise, use (dy, -dx) instead.

Once we have the perpendicular direction, we scale it to the stroke radius and offset the endpoint by that amount on both sides. This gives us the two points where the semicircular cap should start and end.

The arc starts at the endpoint (±perpendicular offset), not radius past it.

The Difference

Skia's approach:

Endpoint ──────●======) Cap extends this far
       endpoint^      ^cap radius beyond

Figma's approach:

     ╭─────────●
     │    Cap starts at endpoint
     ╰─────────●

Both are circular arcs. The difference is where they're positioned relative to the path endpoint.

Why This Matters

For UI design, visual length matters more than geometric path length.

If you create a 100px horizontal divider line in Figma, you expect it to visually span 100 pixels. Not 110 pixels because the caps add 5px on each end.

The "caps are decorative" model makes sense for stroking arbitrary paths. The "caps are endpoints" model makes sense for precise layout.

Figma chose the latter. So we had to rebuild the stroke system to match.

The Cost

This means we can't use paint.getFillPath(path, &strokedPath). That function applies Skia's standard stroking, which includes the extending caps.

Instead, every segment gets manual stroke generation:

About 200 lines of cap geometry code (src/cf/model/CfVectorNetworkSegment.cpp:1084-1200), including support for:

All positioned at the endpoint, not beyond it.

Results

A 100px line is now exactly 100px, regardless of cap style.

Figma designs rendered in the exported renderer match the source pixel-for-pixel. No unexpected length changes.

The trade-off: we wrote stroke code Skia already has. But Skia's stroke code makes different assumptions about where caps should go.

Sometimes you can't configure your way out of a design mismatch. You reimplement.


Read next: Making Corners Round: The Four-Point Circle - Corner radius with bezier tangents and the R×(4×(√2-1))/3 formula.