When Lines Close
We had lines. Users wanted to fill them.
"Just connect the endpoints," we thought. "How hard can closing a shape be?"
Turns out: harder than it looks.
The First Problem: What's Inside?
Draw a triangle. Three segments, three vertices. Now fill it.
Easy. The inside is... the inside.
var cfg = {
"id": "loop1",
"type": "loop-demo",
"bindings": {
"fillEnabled": "loop.fillEnabled"
},
"width": 500,
"height": 180
}
Toggle fill: {{loop.fillEnabled}}
But what about this?
┌────────────────┐
│ │
│ ┌────────┐ │
│ │ │ │
│ │ ??? │ │
│ │ │ │
│ └────────┘ │
│ │
└────────────────┘
Two nested squares. Is the inner area filled or not?
The answer: it depends on which direction you draw them.
Winding: The Hidden Property
Every loop has a direction. Clockwise or counter-clockwise.
var cfg = {
"id": "winding1",
"type": "winding-demo",
"bindings": {
"direction": "winding.direction"
},
"width": 500,
"height": 180
}
Current direction: {{winding.direction}}. Click to reverse.
We didn't realize this mattered. It does.
The Non-Zero Rule
The default fill rule counts crossings. Cast a ray from any point. Count +1 for clockwise crossings, -1 for counter-clockwise.
function isInsideNonZero(point, loops) {
let winding = 0;
for (const loop of loops) {
for (const segment of loop.segments) {
if (crossesRay(point, segment)) {
winding += segment.direction;
}
}
}
return winding !== 0;
}
If the final count is zero, you're outside. Any other number, you're inside.
Nested shapes with same direction: Both count positive. Inner area has winding = 2. Still filled.
Nested shapes with opposite direction: They cancel. Inner area has winding = 0. A hole.
var cfg = {
"id": "fillrule1",
"type": "fillrule-demo",
"bindings": {
"fillRule": "fill.rule"
},
"width": 500,
"height": 180
}
Fill rule: {{fill.rule}}
Toggle to see the difference. With even-odd, only crossing count matters, not direction.
The Even-Odd Alternative
Even-odd is simpler. Count crossings. Ignore direction.
Odd number? Filled. Even? Empty.
This gives the "obvious" nested-square result without worrying about winding. But it breaks for self-intersecting paths in ways that non-zero doesn't.
We use non-zero. Figma uses non-zero. Most professional tools use non-zero.
Finding Loops in a Vector Network
Here's what we missed: loops aren't stored. They're discovered.
A vector network is vertices and segments. That's it. No explicit "this is a closed shape."
function findLoops(segments) {
const graph = buildAdjacency(segments);
const loops = [];
for (const start of graph.keys()) {
const path = walkUntilClosed(graph, start);
if (path) loops.push(path);
}
return loops;
}
Walk the graph. When you revisit a vertex, you've found a loop.
The tricky part: a single vertex can belong to multiple loops. The letter "B" has two loops sharing the spine. A figure-eight has two loops sharing the center.
Stroke Position
With fills working, users wanted more control: "Can I stroke inside the shape?"
var cfg = {
"id": "strokepos1",
"type": "stroke-position-demo",
"bindings": {
"position": "stroke.position",
"width": "stroke.width"
},
"width": 500,
"height": 180
}
Position: {{stroke.position}}, Width: {{stroke.width}}px
Three options:
- Center: Stroke straddles the path edge
- Inside: Stroke stays within the filled area
- Outside: Stroke extends beyond the fill
This requires knowing winding direction. "Inside" means toward the filled region.
First attempt: offset the path. Didn't work—you can't just move vertices, the corners distort.
Second attempt: clip the stroke. Works, but slow for complex shapes.
What worked: calculate proper offset curves, accounting for joins and corners. More work upfront, faster rendering.
The Data Structure
const loop = {
segments: [0, 1, 2, 3], // Indices into segment array
direction: 1, // 1 = CW, -1 = CCW
fillRule: 'nonzero'
};
Loops reference segments by index. They don't duplicate geometry. When a segment moves, every loop using it updates automatically.
What We Learned
Direction matters. A loop drawn clockwise is different from the same loop drawn counter-clockwise.
Loops are computed, not stored. The vector network doesn't know about fills. It just knows connectivity.
Fill rules aren't arbitrary. Non-zero and even-odd exist because there's no single "correct" answer for overlapping regions.
Stroke position is a fill operation. You need to know what's inside before you can stroke inside.
The simplest question—"is this point inside the shape?"—requires understanding winding, fill rules, and graph topology.
A line is just A to B. A shape is a statement about inside and outside.
Next: Primitive Shapes →