Stroke Joins: The Devil in the Geometry
Draw two dashed segments meeting at a 90° corner. The strokes don't connect. There's a gap.
Try to fill the gap. Now they overlap, creating a visible blob at the corner.
This is the stroke join problem.
The Problem
Skia's paint.getFillPath() handles joins automatically when stroking a continuous path. But we're stroking individual segments and compositing them together.
Two segment strokes meeting at a corner:
Segment 1 ═══════╗
║ ← Gap here
Segment 2 ║
║
The stroke edges don't meet cleanly. We need join geometry to connect them.
First Attempt: Use Skia's Joins
Maybe we could stroke each segment with Skia's built-in join handling, then composite them?
for (auto& segment : segments) {
SkPath segmentPath = createSegmentPath(segment);
SkPath strokedPath;
paint.setStrokeJoin(SkPaint::kRound_Join);
paint.getFillPath(segmentPath, &strokedPath);
canvas.drawPath(strokedPath, paint);
}
This created joins at both ends of each segment, even when one end shouldn't have a join (it's connected to another segment).
Also, for dashed strokes, the joins were appearing at dash boundaries within a segment, not just at vertices where segments meet.
Skia's join logic operates on path geometry, not on our topology model. It doesn't know which endpoints are actually connected to other segments.
Second Attempt: Detect Join Locations
We have topology. Let's use it:
for (uint32_t vertexId = 0; vertexId < vertices.size(); vertexId++) {
auto segmentsAtVertex = getSegmentsConnectingTo(vertexId);
if (segmentsAtVertex.size() == 2) {
// Exactly two segments meet - need a join
addJoin(segmentsAtVertex[0], segmentsAtVertex[1], vertexId);
}
}
Now we know where joins are needed. But what geometry should the join be?
For a miter join: calculate the intersection point of the two stroke edges. For a round join: draw an arc between the stroke edges. For a bevel join: draw a straight line connecting them.
We had to implement all three. From scratch. Because Skia's join code operates on SkPath objects we don't have.
The Solution: Port Skia's Join Math
Turns out Skia has the algorithms in SkStrokerPriv.cpp. We just needed to adapt them to work with our explicit segment topology.
Miter join (src/cf/model/CfVectorNetworkSegment.cpp:855):
void addMiterJoin(SkPathBuilder* dst, const SkPoint& joinPoint,
const SkVector& prevNormal, const SkVector& nextNormal,
SkScalar radius, SkScalar miterLimit) {
// Line intersection: where do the stroke edges meet?
SkPoint prevEdge = joinPoint + prevNormal * radius;
SkPoint nextEdge = joinPoint + nextNormal * radius;
// Solve: prevEdge + t * prevNormal = nextEdge + s * nextNormal
SkScalar denominator = prevNormal.fX * nextNormal.fY -
prevNormal.fY * nextNormal.fX;
// Calculate intersection point
SkPoint miterPoint = calculateIntersection(...);
// Check miter limit (prevents spikes at sharp angles)
if (miterLength / radius > miterLimit) {
// Fall back to bevel
addBevelJoin(...);
} else {
dst->lineTo(miterPoint);
}
}
Round join:
void addRoundJoin(SkPathBuilder* dst, const SkPoint& joinPoint,
const SkVector& prevNormal, const SkVector& nextNormal,
SkScalar radius) {
// Arc from one stroke edge to the other
SkPoint arcStart = joinPoint + prevNormal * radius;
SkPoint arcEnd = joinPoint + nextNormal * radius;
// Circular arc connecting them
dst->arcTo(arcEnd, radius, radius, 0,
SkPath::kSmall_ArcSize, SkPathDirection::kCW, arcStart);
}
Bevel join:
void addBevelJoin(SkPathBuilder* dst, const SkPoint& joinPoint,
const SkVector& prevNormal, const SkVector& nextNormal,
SkScalar radius) {
// Just connect with a straight line
SkPoint edge1 = joinPoint + prevNormal * radius;
SkPoint edge2 = joinPoint + nextNormal * radius;
dst->moveTo(edge1);
dst->lineTo(edge2);
}
The key: calculate unit normals (perpendicular vectors) for each segment's direction, scale them by stroke radius, and connect the resulting stroke edge points.
The Perpendicular Normal
For a segment direction vector (dx, dy), the perpendicular normal is (-dy, dx).
That 90° counter-clockwise rotation gives us the direction perpendicular to the stroke:
SkVector direction = endPoint - startPoint;
direction.normalize();
SkVector normal = {-direction.fY, direction.fX};
Multiply by radius to get the stroke edge offset:
SkPoint outerEdge = centerPoint + normal * radius;
SkPoint innerEdge = centerPoint - normal * radius;
The join geometry connects the outer edges (or inner edges, depending on turn direction) of adjacent segments.
Variable Width Joins
Figma supports per-segment stroke widths. Segment 1 might be 10px wide, segment 2 might be 20px wide.
Standard join formulas assume both segments have the same width. We had to extend them:
void addMiterJoinWithWidths(SkPathBuilder* dst, const SkPoint& joinPoint,
const SkVector& normal1, const SkVector& normal2,
SkScalar radius1, SkScalar radius2, ...) {
SkPoint edge1 = joinPoint + normal1 * radius1; // Different radii
SkPoint edge2 = joinPoint + normal2 * radius2;
// Calculate intersection of lines with different offsets
// ... more complex math ...
}
The miter point calculation becomes more complex when the two stroke edges aren't parallel-offset curves.
Multi-Connection Vertices
Sometimes three or more segments meet at one vertex (like the center of a star shape).
Which segments get joined to which?
We chose: find the two segments with the largest angular separation (closest to 180°) and join those. Skip joins for the other connections.
This prevents overlapping join geometry and matches Figma's rendering:
if (segmentsAtVertex.size() > 2) {
// Find the pair with largest angular separation
auto [seg1, seg2] = findFarthestPair(segmentsAtVertex);
addJoin(seg1, seg2, vertexId);
}
Results
Stroked vector networks now have clean joins at all vertices. Miter, round, and bevel all render correctly with variable-width segments.
The code is ~300 lines of join geometry (src/cf/model/CfVectorNetworkSegment.cpp:855-1050), mostly ported from Skia's SkStrokerPriv.cpp with adaptations for:
- Explicit segment topology (not continuous paths)
- Variable per-segment widths
- Multi-connection vertex handling
Skia had the algorithms. We just needed them in a form that works with explicit vertex/segment topology instead of implicit path geometry.
Copy the math, adapt the data structures.
Read next: Per-Segment Dashes: Why Global Patterns Don't Work - Figma's dash distribution algorithm and why Skia's DashPathEffect isn't enough.