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:

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.