Where Lines Meet
In the previous article, we drew lines. Now we need to make them look good.
When you stroke a path, three questions arise:
- What happens at the ends? (Caps)
- What happens at corners? (Joins)
- Can we make it dashed? (Dash patterns)
These seem simple. They're not.
Stroke Caps: The End of the Line
A cap is what you draw at an open endpoint. The three standard options:
var cfg = {
"id": "cap1",
"type": "cap-demo",
"bindings": {
"capType": "stroke.capType",
"strokeWidth": "stroke.width"
},
"width": 500,
"height": 150
}
Butt cap: Stops exactly at the endpoint. No extension.
Round cap: Adds a semicircle. Extends by half the stroke width.
Square cap: Adds a half-square. Also extends by half the stroke width.
Current: {{stroke.capType}} cap, {{stroke.width}}px stroke width.
The Figma Problem
Skia's round caps extend past the endpoint. Figma's don't.
Skia round cap: Figma round cap:
●────● ●────●
/ \ | |
( ── ) ( ── )
\ / | |
────── ──────
(extends) (contained)
Skia treats caps as decorations added to the stroke. Figma treats them as part of the stroke geometry.
Solution: We generate strokes manually, calculating cap geometry ourselves.
Stroke Joins: Where Paths Turn
When two segments meet at a corner, you need a join.
var cfg = {
"id": "join1",
"type": "join-demo",
"bindings": {
"joinType": "stroke.joinType",
"strokeWidth": "stroke.width",
"angle": "stroke.angle"
},
"width": 500,
"height": 200
}
Corner angle: {{stroke.angle}}°
Miter join: Extends the edges until they meet. Creates a sharp point.
Round join: Adds an arc at the corner.
Bevel join: Cuts off the corner with a straight line.
Miter Limit
Miter joins have a problem: at sharp angles, the point extends to infinity.
90° angle: 10° angle:
/\ /\
/ \ / \
/ \ / \
/______\ / \
/ \
/ !! \
(extends forever)
The miter limit cuts off miters that extend too far. When the miter length exceeds strokeWidth × miterLimit, it falls back to bevel.
Default miter limit is usually 4 or 10.
The Join Algorithm
Here's the actual geometry:
function calculateMiterJoin(p0, p1, p2, strokeWidth) {
// Vectors along each segment
const v1 = normalize(subtract(p1, p0));
const v2 = normalize(subtract(p2, p1));
// Perpendicular normals (rotate 90°)
const n1 = { x: -v1.y, y: v1.x };
const n2 = { x: -v2.y, y: v2.x };
// Miter direction = average of normals
const miter = normalize(add(n1, n2));
// Miter length = strokeWidth / sin(halfAngle)
const dot = n1.x * miter.x + n1.y * miter.y;
const miterLength = (strokeWidth / 2) / dot;
return {
point: add(p1, scale(miter, miterLength)),
length: miterLength
};
}
The key insight: the miter point lies along the bisector of the angle, at a distance determined by the angle's sine.
Dash Patterns
Dashes seem simple: draw some, skip some, repeat.
var cfg = {
"id": "dash1",
"type": "dash-demo",
"bindings": {
"dashLength": "dash.length",
"gapLength": "dash.gap",
"dashOffset": "dash.offset"
},
"width": 500,
"height": 150
}
Dash: {{dash.length}}px, Gap: {{dash.gap}}px, Offset: {{dash.offset}}px
Why Skia's Dashes Don't Work
Skia has a built-in dash effect: SkDashPathEffect. It works... but not for Figma-style rendering.
Problem 1: Global pattern
Skia applies dashes to the entire path. The pattern continues across segments.
Figma applies dashes per segment. Each segment starts fresh.
Skia (global):
──── ──── ──│── ──── ────
│
Figma (per-segment):
──── ──── ──│──── ──── ──
│
(pattern restarts at corner)
Problem 2: Distribution
Figma centers dashes on segments. Short segments get fewer dashes, but they're always centered.
Skia just starts at the beginning and goes.
Manual Dash Calculation
We calculate dashes per-segment:
function calculateDashes(segmentLength, dashLen, gapLen) {
const pattern = dashLen + gapLen;
const count = Math.floor(segmentLength / pattern);
// Center the pattern on the segment
const totalDashLen = count * pattern - gapLen;
const startOffset = (segmentLength - totalDashLen) / 2;
const dashes = [];
let pos = startOffset;
for (let i = 0; i < count; i++) {
dashes.push({ start: pos, end: pos + dashLen });
pos += pattern;
}
return dashes;
}
Putting It Together: Stroke Generation
The full stroke algorithm:
For each segment:
- Calculate offset curves (left and right edges)
- Apply dash pattern if needed
- Generate cap at open endpoints
- Generate join at corners
Combine into a single fill path
This is why we can't use Skia's stroke directly. We need control over every step.
The Code
Here's simplified stroke generation:
function generateStroke(segments, style) {
const { width, cap, join, dash } = style;
const halfWidth = width / 2;
const paths = [];
for (const segment of segments) {
// Get segment geometry
const { start, end, tangentStart, tangentEnd } = segment;
// Calculate perpendicular normals
const dir = normalize(subtract(end, start));
const normal = { x: -dir.y, y: dir.x };
// Offset points
const leftStart = add(start, scale(normal, halfWidth));
const leftEnd = add(end, scale(normal, halfWidth));
const rightStart = add(start, scale(normal, -halfWidth));
const rightEnd = add(end, scale(normal, -halfWidth));
// Build stroke path...
}
return paths;
}
Summary
- Caps close open endpoints (butt, round, square)
- Joins connect corners (miter, round, bevel)
- Dashes are per-segment, not global
- Figma's strokes require manual generation
- The math is geometry: perpendiculars, bisectors, arc lengths
The built-in stroke APIs are shortcuts. For full control, you calculate everything yourself.
Next: Loops and Fills →