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:
- Skia: Caps are decorations added to the geometry
- Figma: Caps are the endpoints themselves
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):
- Gets the segment direction at the endpoint
- Computes the perpendicular normal
- 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:
- Generate left and right offset curves for the path
- Connect them at the start and end with manually-drawn cap geometry
- Handle joins at vertices where multiple segments meet
About 200 lines of cap geometry code (src/cf/model/CfVectorNetworkSegment.cpp:1084-1200), including support for:
- Round caps (semicircular arcs)
- Square caps (rectangles at endpoints)
- Butt caps (flat, no extension)
- Custom caps (arrows, triangles, diamonds)
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.