Per-Segment Dashes: Why Global Patterns Don't Work

Apply a [20, 10] dash pattern (20px dash, 10px gap) to a path in Skia. The pattern flows continuously across all segments. Dash starts mid-way through segment 2, ends mid-way through segment 5.

Apply the same pattern in Figma. Each segment gets its own distribution of the pattern. Every segment starts and ends with a dash, with the pattern scaled to fit.

Same pattern, different results.

The Problem

Skia's SkDashPathEffect treats the entire path as one continuous line. It measures the total path length and distributes the dash pattern evenly across everything.

For a path with 3 segments (50px, 80px, 30px):

auto effect = SkDashPathEffect::Make({20, 10}, 2, 0);
paint.setPathEffect(effect);
canvas.drawPath(path, paint);

The dashes flow:

Segment%201%3A%20%5B20%2C%20gap%2C%2010%5D%7C%20%20%u2190%20Pattern%20phase%20at%20end%3A%2030px%0ASegment%202%3A%20%5Bgap%2C%2020%2C%20gap%2C%2020%2C%20gap%2C%2020%5D%7C%20%20%u2190%20Continues%20from%2030px%0ASegment%203%3A%20%5B...continues...%5D

The | marks segment boundaries. Dashes don't align to them—the pattern is global, not per-segment.

Figma does it differently: each segment gets a fresh pattern distribution, scaled to fit exactly.

First Attempt: Per-Segment Path Effects

Maybe apply the dash effect to each segment individually?

for (auto& segment : segments) {
  SkPath segmentPath = createSegmentPath(segment);

  auto effect = SkDashPathEffect::Make({20, 10}, 2, 0);
  paint.setPathEffect(effect);

  canvas.drawPath(segmentPath, paint);
}

This created uniform dashes on each segment. A 50px segment got [20, gap, 10] regardless of what would fit well. An 80px segment also got [20, gap, 10], leaving 40px of waste.

The pattern doesn't adapt to the segment length. Figma's algorithm does.

Figma's Dash Distribution Algorithm

After reverse-engineering Figma's behavior and studying SkFigmaDashPathEffect.cpp in Skia's effects folder, here's the algorithm:

Concrete Example: 80px Segment with [20, 10] Pattern

Let's work through this step-by-step with an 80px segment and [20px dash, 10px gap] pattern.

Step 1: Calculate how many full pattern repeats fit:

patternLength = 20 + 10 = 30px  // One dash + one gap

// How many patterns fit? Divide and round
rawRepeats = 80 / 30 = 2.67
repeatCount = round(2.67) - 1 = 3 - 1 = 2

Why subtract 1? Because we'll add half-dashes at the ends. If we fit 2 full repeats + half-dashes on both ends, that's visually like 3 dashes total.

Step 2: Calculate scaling to fit exactly:

// We want to fit: half-dash + 2 repeats + half-dash = 3 dashes total
targetPatterns = repeatCount + 1 = 2 + 1 = 3

// What scale makes 3 patterns fit in 80px?
unscaledLength = 3 * 30 = 90px
scale = 80 / 90 = 0.889  (about 89% of original size)

Step 3: Check if scaling is reasonable:

if (scale < 0.75) {
  // Dashes would be too squished (< 75% of original)
  // Better to fit fewer dashes at a more reasonable scale
  repeatCount--;
}
else if (scale > 1.25) {
  // Dashes would be too stretched (> 125% of original)
  // Better to fit more dashes at a more reasonable scale
  repeatCount++;
}

// In our case: 0.889 is between 0.75 and 1.25, so keep repeatCount = 2

This prevents ridiculous-looking dashes—either too squished or too stretched.

Step 4: Build the actual pattern with scaled values:

scaledDash = 20 * 0.889 = 17.78px
scaledGap = 10 * 0.889 = 8.89px

pattern = [
  17.78 * 0.5,  // = 8.89px  (half-dash at start)
  8.89,         //           (gap)
  17.78,        //           (full dash)
  8.89,         //           (gap)
  17.78,        //           (full dash)
  8.89,         //           (gap)
  17.78 * 0.5   // = 8.89px  (half-dash at end)
]

// Total: 8.89 + 8.89 + 17.78 + 8.89 + 17.78 + 8.89 + 8.89 = 80px ✓

Visual result:

|====][  ][========][  ][========][  ][====|
 half  gap   dash    gap   dash    gap  half

When this segment connects to another, the half-dashes at the boundary combine:

Segment 1: ...][====|
                     |====][...  ← full dash formed!
Segment 2:

Why This Works

The algorithm ensures:

  1. Dashes fit the segment - no partial patterns cut off mid-way
  2. Reasonable scaling - never more than 25% distortion from original pattern
  3. Visual continuity - half-dashes create seamless joins between segments
  4. Predictable behavior - same-length segments get identical patterns

Implementation

The distribution algorithm (src/cf/model/CfVectorNetworkSegment.cpp:432):

void calculateFigmaSegmentDashPattern(const CfVectorNetwork* vn,
                                     SkScalar segmentLength,
                                     TArray* pattern) {
  SkScalar dashLength = vn->fDashArray[0];
  SkScalar gapLength = vn->fDashArray[1];

  SkScalar fullPatternLength = dashLength + gapLength;

  // Figma logic: round and subtract 1
  int repeatCount = SkScalarRoundToInt(segmentLength / fullPatternLength) - 1;
  if (repeatCount < 0) repeatCount = 0;

  // Scale to fit
  SkScalar scale = segmentLength / ((repeatCount + 1) * fullPatternLength);

  // Adaptive scaling
  if (repeatCount > 0) {
    if (scale < 0.75f) repeatCount--;
    else if (scale > 1.25f) repeatCount++;
    scale = segmentLength / ((repeatCount + 1) * fullPatternLength);
  }

  // Build pattern with half-dashes
  SkScalar scaledDash = dashLength * scale;
  SkScalar scaledGap = gapLength * scale;

  pattern->push_back(scaledDash * 0.5f);
  for (int i = 0; i < repeatCount; i++) {
    pattern->push_back(scaledGap);
    pattern->push_back(scaledDash);
  }
  pattern->push_back(scaledGap);
  pattern->push_back(scaledDash * 0.5f);
}

Each segment gets its own calculated pattern, then we pass it to SkDashPath::FilterDashPath() to generate the actual dashed geometry.

Why Half-Dashes?

Consider two segments meeting at a vertex:

Without half-dashes:

Segment 1: ========]  [gap at join]  [========
Segment 2:

Visual gap at the vertex.

With half-dashes:

Segment 1: ========][========  ← Half + half = full dash
Segment 2:

Seamless visual continuity.

The half-dashes are a visual trick. The math ensures they align at segment boundaries to create the appearance of continuous strokes despite per-segment pattern calculation.

The Bezier Complication

For bezier curves, there's a mismatch: SkPath::getLength() returns arc length (actual distance along curve), but dash patterns apply in parametric space (t parameter from 0 to 1).

For a curved segment, parametric distance ≠ arc length distance.

We compensate by scaling the pattern (src/cf/model/CfVectorNetworkSegment.cpp:396):

SkScalar straightDistance = SkPoint::Distance(startPoint, endPoint);
SkScalar curveLength = measure.getLength();

if (straightDistance > 0.1f) {
  SkScalar parametricScale = straightDistance / curveLength;

  // Scale down the pattern to compensate
  for (int i = 0; i < pattern.size(); i++) {
    pattern[i] *= parametricScale;
  }
}

This makes the dashes visually uniform along the curve, even though they're being applied in parametric space.

Results

Each segment gets an adapted dash pattern that:

Three segments (50px, 80px, 30px) with [20, 10] pattern:

Every segment gets dashes that fit its length. The pattern adapts, not the segments.

This is ~150 lines of dash distribution code that Skia's standard SkDashPathEffect can't provide, because it optimizes for continuous paths, not explicit segment-by-segment control.


Read next: LinearBurn: The Blend Mode That Wasn't There - Adding a missing blend mode to Skia's core.