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:
- ❌ Individual segment identity - can't reference "segment 3" anymore
- ❌ Vertex connectivity - can't query "which segments meet at vertex 5?"
- ❌ Per-segment effects - can't apply different strokes to different edges
- ❌ Multi-connection points - paths only support 2 connections per point (in-edge and out-edge), but vector networks support 3+ segments meeting at one vertex
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:
- Vertex positions (include/cf/CfVectorNetwork.h:72)
- Segment connectivity as vertex indices, not implicit chains
- Per-segment properties (width, dash pattern, caps)
- Vertex connectivity mapping vertices to their attached segments
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:
- Per-segment dashes: Segment 3 gets
[20,10,20,10]pattern, segment 7 stays solid - Variable stroke widths: Each segment stores its own width value
- Proper joins: Query vertex connectivity to find adjacent segment normals
- Corner radius: Detect corner vertices, shorten connecting segments, insert bezier arcs
- Multi-connection vertices: Three or more segments meeting at one point
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.