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:

  1. What happens at the ends? (Caps)
  2. What happens at corners? (Joins)
  3. 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:

  1. For each segment:

    • Calculate offset curves (left and right edges)
    • Apply dash pattern if needed
    • Generate cap at open endpoints
    • Generate join at corners
  2. 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

The built-in stroke APIs are shortcuts. For full control, you calculate everything yourself.

Next: Loops and Fills →