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:

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

  1. Direction matters. A loop drawn clockwise is different from the same loop drawn counter-clockwise.

  2. Loops are computed, not stored. The vector network doesn't know about fills. It just knows connectivity.

  3. Fill rules aren't arbitrary. Non-zero and even-odd exist because there's no single "correct" answer for overlapping regions.

  4. 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 →