Why We Can't Use Skia's Path API

Skia has paths. We have vector networks. They're both used to draw shapes. Why can't we just convert one to the other?

We tried. It didn't work.

The Problem

Figma lets you apply effects to individual segments—one edge of a shape gets dashed strokes, another stays solid. Put a corner radius on this vertex but not that one. Different stroke widths per segment.

Skia's SkPath can't do this. It represents shapes as a continuous chain of drawing instructions: "move here, line to there, curve to there, close the path." Once you've built that chain, the individual pieces are gone. You've got geometry, but you've lost topology.

First Attempt: Just Convert It

The obvious approach seemed simple enough:

SkPath convertToPath(const VectorNetwork& vn) {
  SkPath path;
  for (const auto& segment : vn.segments) {
    SkPoint start = vn.vertices[segment.fromVertex];
    SkPoint end = vn.vertices[segment.toVertex];

    if (segment.hasTangents) {
      path.cubicTo(segment.tangent1, segment.tangent2, end);
    } else {
      path.lineTo(end);
    }
  }
  return path;
}

Clean code. Straightforward conversion. Ship it.

Except now you try to render dashes on segment 3 but not segment 7. Can't do it—the path is one continuous thing. The segments are gone.

Why It Failed: Lists vs. Graphs

Think of the difference like this:

A path is like GPS driving directions:

"Start at Main St.
Turn right onto Oak Ave.
Continue for 2 blocks.
Turn left onto Elm St.
..."

It's a sequence of instructions. You can follow them to draw the shape, but once you're done, there's no way to ask "which roads connect to the intersection at Oak and Elm?" The instructions don't store that information.

A vector network is like a map:

Intersection 1: (Main St, Oak Ave)
Intersection 2: (Oak Ave, Elm St)
Road A: Intersection 1 → Intersection 2
Road B: Intersection 2 → Intersection 3

It's a graph of relationships. You can query "what roads connect to Intersection 2?" and get back: Road A and Road B.

Technical Breakdown

Paths are lists of verbs (SkPath.h):

enum Verb { kMove, kLine, kQuad, kCubic, kClose };

// Stored as: [Move (0,0), Line (100,0), Line (100,100), Close]

They're optimized for rendering, not for maintaining structure.

Vector networks are graphs:

struct CfVectorNetwork {
  TArray fVertices;     // ID-indexed vertices
  TArray fSegments;     // Edges with fromVertex/toVertex
};

// Stored as relationships:
// Vertex 0 at (0,0)
// Vertex 1 at (100,0)
// Vertex 2 at (100,100)
// Segment A: connects Vertex 0 → Vertex 1
// Segment B: connects Vertex 1 → Vertex 2

What You Lose

When you convert a vector network to a path:

Figma's plugin API documentation puts it directly: "Vector networks are a superset of paths. They can represent everything paths can represent, but paths cannot represent everything a vector network represents."

Second Attempt: Path Operations

Maybe we could work around it. Generate separate paths for each segment, then combine them with path operations?

SkPath buildStrokedShape(const VectorNetwork& vn) {
  SkPath result;
  for (const auto& segment : vn.segments) {
    SkPath segmentPath = createSegmentPath(vn, segment);

    if (segment.needsDashes) {
      // Apply dash effect
    }

    Op(result, segmentPath, kUnion_SkPathOp, &result);
  }
  return result;
}

This... kind of worked. For straight lines with no joins.

Then we tried it on shapes with corners. The joins between segments didn't connect properly—gaps appeared where segments met at vertices. We were computing strokes independently without knowing about their neighbors.

We'd lost the connectivity graph. Can't compute proper stroke joins when you don't know which segments share vertices.

The Solution

Build a custom data structure that preserves the topology:

class CfVectorNetwork {
  skia_private::TArray fVertices;           // Vertex positions
  skia_private::TArray fSegments;           // fromVertex, toVertex
  skia_private::TArray fTangents;   // Bezier controls
  skia_private::TArray fSegmentWidths;        // Per-segment widths

  // Connectivity tracking
  skia_private::THashMap> fVertexToSegments;
};

Now when we stroke segment 5, we can query: "Which other segments connect at this vertex? What are their tangent directions? What join style should we use?"

The data structure explicitly maintains:

The Cost

This means we can't use Skia's built-in stroking. We had to implement manual stroke generation, per-segment dash calculation, custom join geometry, and contained stroke caps.

Every feature Skia's SkPaint gives you for free with paths, we build manually for vector networks. Hundreds of lines of stroke math that SkStroke.cpp already had.

But there's no other way. You can't apply per-segment effects to a path that doesn't remember its segments.

What This Enabled

With explicit topology:

This is why Figma uses vector networks instead of SVG paths. The topology isn't optional—it's the foundation that makes per-segment effects possible.

The Irony

Skia has all this math already. SkStroke.cpp has miter join calculations, round cap geometry, dash distribution algorithms. We couldn't use any of it because it operates on SkPath objects that have already lost the segment boundaries.

We ended up porting the algorithms to work with explicit segment topology:

// Can't use this (works on paths):
paint.getFillPath(path, &strokedPath);

// Had to write this (works on segments):
CfVectorNetworkSegment::generateStrokeGeometry(vectorNetwork);

Same math, different data structure. The topology preservation is the only difference.

Results

Our custom vector network implementation: ~2000 lines of C++ across CfVectorNetwork.cpp, CfVectorNetworkSegment.cpp, and stroke helpers.

Skia's path-based stroke system we couldn't use: Also ~2000 lines in SkStroke.cpp.

Sometimes you can't reuse the existing solution. The data model determines what's possible.


Read next: 240MB Per Frame: The saveLayer() Trap - How coordinate space confusion led to massive memory over-allocation.