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...%5DThe | 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:
- Dashes fit the segment - no partial patterns cut off mid-way
- Reasonable scaling - never more than 25% distortion from original pattern
- Visual continuity - half-dashes create seamless joins between segments
- 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:
- Starts and ends with half-dashes for visual continuity
- Scales to fit the segment length
- Adjusts repeat count if scaling would be too extreme
- Compensates for bezier curve parametric distortion
Three segments (50px, 80px, 30px) with [20, 10] pattern:
- Segment 1 (50px):
[8.3, 16.7, 8.3](scaled 0.556×) - Segment 2 (80px):
[8.9, 17.8, 8.9, 17.8, 8.9, 17.8, 8.9](scaled 0.889×, 3 repeats) - Segment 3 (30px):
[10, 10, 10](scaled 0.667×, simplified to fit)
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.