Separating the intersections of an edge from the edges that created those intersections makes it easier to think about. It alleviates some of the complexity that might arise from multiple edges intersecting with each other.

Self-intersection

Cubic beziers can self-intersect.

This, unfortunately, means that every single cubic bezier edge has to be checked for self-intersection. It’s an interesting problem that involves finding the two different t values that the bezier intersects itself at, but I won’t be covering how to find those values here.

Once you have the t values, a self-intersecting bezier can be expanded like so:

The blue node should be invisible to the user

We insert n3 since having a node with an edge that has itself on both ends of the edge is problematic, but it should be hidden from the user.

Intersecting the loop of a self-intersecting bezier

Removing n3 at the first opportunity

Curvy edges

Earlier we covered the CW - CCW graph traversal algorithm to find the minimal cycle basis (small areas).

Finding the better (counter clockwise most) point adjacent to curr

But the algorithm described in the paper was designed to work with nodes connected by straight lines that don’t intersect. Introducing edges defined by cubic beziers introduces significant complexity.

Which edge to choose, blue or green?

In the example above, we can find out that the blue edge is better than the green one by using the determinant. We are stilling defining better to mean the CCW most edge.

When working with cubic bezier curves, the naive solution would be to just convert the bezier to a line defined by the points at the start and end of the curve.

But that idea breaks down as soon as one edge curves over the other.

Oops

Let’s take a fresh look at a bezier curves and try to work from there.

Looking at this, we notice that the tangent at the start of the curve, n0 , is parallel to the line from n0 to cp0 . So to get the direction at the start of the edge we can use the line (n0, cp0) .

For clarity, the start of our edge, n0 , is the same node as curr .

So by converting edges defined cubic bezier into a line defined by (n0, cp0) , we get the initial angle of the curve.

This seems like a good solution when looking at the “curve around” case.

Looks like we’ve solved the problem. Right?

No intersections

Before we move on to further edge cases, it helps to understand that any solutions assume that no two edges may intersect when deciding which edge to travel.

This is not allowed

The edges of the graph we’re traversing must not have any intersections when we compute the cycles (minimal cycle basis) of the graph.

We can only operate on an expanded graph.

Like we covered earlier, an expanded graph is a graph that has replaced all intersections with new nodes and edges. So if the original, user-defined graph has any intersections, they would have to be expanded before we can find the graph’s cycles.

The same edges as above, but expanded

Going forward, we will assume that all graphs are expanded unless stated otherwise.

Parallel edges

The next edge case is two edges being parallel (pointing in the same direction).

If the lines go in the same direction, determining which is better is impossible without more information.

Here are a few possible solutions for the cases where the control points of the curves are parallel.

Point at t

What if we just take the point on the curve at, for example, t = 0.1 ?

This produces the correct result for curves of a similar length, but we can easily break this with one curve being significantly bigger than the other.

This is effectively the same problem as the “curve around” case we saw earlier.

Point at length

Instead of taking a point at a fixed t value, we could take a point at some length along the curve. The length would be determined by some point on the smaller curve, e.g. at t=0.1 .

I have not tried implementing this since I have another working solution, but this could possibly be a viable and performant solution if it works for all edge cases.

Lasers!

The next solution is a bit esoteric but produces the correct result. This is the solution I’m currently using.

We begin by splitting each bezier at t = 0.05 (image above is exaggerated). We then tesselate each part into n points.

Then, for each point of the tesselated bezier, we check whether a line from n0 to that point intersects the other edge.

It’s pretty hard to see what’s going on at this scale, so let’s zoom in a bit.

When a point intersects the other edge, we use the point before it.

Found an intersection

The intersection close up

For the other edge, we have no intersection.

In that case, we just use the end of the edge as the direction line.

With this method we’ve produced lines that seem to represent their respective curves.

And this also works for the “curve around” case.

But it fails for a “curve behind” case.

This would produce the green edge as the more CCW edge, which is wrong.

My solution to this problem is to shoot an infinite laser in the direction of the previous edge.

We then check whether the points of the tesselated bezier intersect this laser.

But a line from n0 to the points would never intersect the laser.

Passes right through

Instead, we can create a line from the current point to the previous point and use that for the intersection test.

When we intersect the laser, we use the previous point. The previous point will always be on the correct side of the laser.

The point we use

And like that, we have a solution.

Parallel, but in reverse!

It could also be the case that the blue or green edges, a and b respectively, could be parallel to the edge from curr to prev .

a is parallel to prev

The process for finding the better edge follows a process similar to the one described above so we will cover this very quickly.

There are two cases:

A or B are parallel to Prev , but not both

If either a or b , but not both, are parallel to prev , we can simply compare the parallel edge to prev .

If the parallel edge is CW of prev , the parallel edge is better.

If the parallel edge is CCW of prev , the other edge is better.

Think a bit about why this is true.

If one edge is parallel to prev and curves CW, and the other is not parallel to prev , then the parallel edge is as CCW as can be. This means that the green zone for the other edge is completely empty.

The reverse is true if the parallel edge curves CCW, since it would be as CW as possible. This means that the green zone for the other edge is the whole circle.

Both A and B are parallel to Prev

Using the same laser solution as before, this case is covered.

Cycles inside of cycles

Now we’re going to look at fills for a bit.

Let’s take a look at a basic example of a graph with a cycle inside of another cycle.

You would expect the graph’s areas to be defined like so:

But as it stands, if you hover over the outer area you get a different, unsatisfactory result.

But this makes sense. Let’s take a look at the nodes of the graph.

The cycle (0, 1, 2, 3) describes the outer boundary of the area we want, but we current aren’t describing the “inner boundary” of the area.

Let’s take a look at how we can do that.

Even-odd rule

Telling a computer to draw the outline of a 2D shape is simple enough. But if you want to fill that shape, how do you tell the computer what is “inside” and what is “outside”?

One way of finding out whether a point is inside of a shape or not is by shooting an infinite laser in any direction from that point and counting how many “walls” it passes through.

If the laser intersects an odd number of walls, it’s inside of the shape. Otherwise it is outside of the shape.

Intersects 1 wall, we’re inside of the shape

Intersects 4 walls, we’re outside of the shape

This works for any 2D shape, no matter which point you choose and which direction you shoot the laser in.

This also helps in the case of nested paths.

This gives us an idea for how we can define the “inner boundary” of a shape.

Reducing closed walks

Let’s look at a graph with a cycle nested inside of another cycle, but with an edge connecting two nodes of the cycles.

This will cycle back (no pun intended) to how we can think about nested cycles and give us a deeper understanding on how to think about them.

Let’s find the cycles. We use the same CW-CCW method as usual.

With this method, we go on what looks like a small detour around the inner cycle.

When we reach the node we started at, this is what the cycle looks like.

This is the first cycle we’ve seen where we cross the same node twice ( n3 and n4 ). And something interesting appears when we take a look at the direction that the cycle takes throughout the graph.

We start off traveling CCW, but when we cross the edge from the outer cycle to the inner cycle the orientation we travel seems to flip.

I will state for now that we want to separate the outer cycle from the inner cycle and treat the edge between them as if it didn’t exist. I will go into the why later and explain the how here.

We take all repeated nodes, in this case n3 , and remove them from the cycle. We also remove any nodes that are between the two repeated nodes.

You might notice that n4 is also repeated, but since it’s “inside” of the part of the cycle that n3 removes, we can ignore it.

We leave one instance of the repeated node, and then we have the cycle that would have been found if the crossing didn’t exist.

We then mark the edge that connected the outer cycle from the inner cycle. I call these marked edges crossings.

It could also be the case that an outer-inner cycle combo has multiple crossings.

In that case, we mark all edges adjacent to the node connected to the outer cycle as a crossing.

And after all this is done, our cycles look like so:

Rendering implications

This is because the conversion from vectors to pixels on a canvas uses anti aliasing to make curves and lines appear smoother.

Example of rasterization

Separating the outer and inner cycles also simplifies the render operation.

Without separating them we have to draw 14 different lines to draw a square, and 10 different lines to draw the square with a square hole.

By separating the cycles, we can draw the square with 4 lines. And we can add the square hole with 4 additional lines.

Subcycles

Instead of referring to an “inner” and “outer” cycle, I will refer to these as subcycles and parent cycles respectively. This will make it easier to think about multiple cycles relative to each other.

With that, let’s introduce a third cycle.

Now when we hover over the outer-most cycle, what do you expect to happen?

Because of the even-odd rule, the inner-most cycle is filled too!

To fix this, we can introduce the concept of direct subcycles.

Parent cycles (blue) and their direct subcycles (green)

A parent cycle may have multiple direct subcycles. But due to the non-intersection rule, a subcycle may only have a single parent cycle.

Let’s take a look at how this works.

This graph has a a rectangle, our outer most cycle. That square has two direct subcycles, a diamond and an hourglass. The diamond has two direct subcycles in the shape of triangles, and the hourglass has three direct subcycles in some funky shapes.

We will begin with the rectangle and its direct subcycles. We will name them, c0 , c1 and c2 .

The user has decided to fill some of these cycles, and leave some of them empty.

c0 and c1 are filled, and c2 is empty

Let’s draw the graph without a stroke and with a gray fill. When drawing this graph we start with the outer-most cycle, c0 .

The graph to the left with the render to the right

Since c0 is filled, we draw it. If it were not filled we could skip drawing it. We can shoot a laser out of the rectangle and see that it intersects the walls of the rectangle once, so we can expect it to be filled considering the even-odd rule.

This may seem really obvious, but it’s good to have the rules of the game laid out clearly before we move on.

Next we want to draw c1 , the diamond in our graph. It was filled, just like the rectangle so we should draw it as well. But if we try to draw the diamond as well, we get the wrong result.

Our laser is intersecting two walls as a result of drawing both of the shapes when the have the same fill setting.

We intersect an even number of walls, so we’re “outside” of the shape

So to draw the image the user wanted we can simply skip drawing the diamond since the parent cycle implicitly draws direct subcycles with the same fill setting.

Now the hourglass, c2 , is supposed to be empty. With that being the case, just not drawing it seems like a reasonable conclusion. But since the parent cycle, the rectangle, has already drawn the hourglass as if it was filled we need to “flip” the fill by drawing the hourglass.

And again, if we try to use the laser intersection method we see that the number of intersections is 2, an even number. And with the even-odd rule, an even number of walls means you’re “outside” of the shape.

Now that we’ve drawn the rectangle and its direct subcycles, we can move onto the direct subcycles of those.

When working with c3 and c4 , the direct subcycles of c1 , we can treat them as if they’re direct subcycles of c0 since c1 had the same fill setting.

For c3 , we want to “flip” the fill setting so we draw it. But c4 has the same fill setting as its parent cycle so we don’t draw it.

Even number of intersections so we’re outside of the shape

And we can think of c5 , c6 and c7 in the same way. We don’t care whether they’re filled or empty when rendering them. We care whether or not they have the same fill as their parent cycle.

We only need to draw cycles if their parent cycle has the opposite “fill setting” as themselves. If they have the same fill setting, we don’t have to draw them.

This means that when drawing cycles, start by drawing the outer-most non-empty cycle. We then recursively operate on that cycle’s direct subcycles. If a subcycle has the same fill setting (filled or empty) as their parent cycle, they can be omitted from the drawing operation.

Contiguous cycles

A graph may have multiple “clusters” of adjacent cycles.

I use the phrase contiguous cycles to describe the “togetherness of the cycles”, if you will. I often think of these contiguous groups of cycles as being in different colors.

Finding these contiguous cycles can be done with a depth-first traversal:

Start at the first node of the cycle

Color each node you find

But remember the crossings? In the search, you may not crawl to adjacent nodes by edges marked as crossings.

So in the end, our colors actually look like this:

Imagine one of these groups is nested inside of another group of contiguous cycles.

Because of the non-intersection rule we know that if one of the nodes in this contiguous cycle is inside of another cycle, all of them are.

This “contiguous cycles” idea is maybe not the most interesting part of this post on the surface, but I’ve found it to be useful when working on Vector Networks.

Partial expansion

When hovering an area defined by intersections, we are showing a cycle of the expanded graph.

Take this triangle as an example.

If we hover over one of its areas, we see an area defined by nodes that don’t exist yet.

What the blue striped area represents is the area whose fill state would be “toggled” if the user clicks the left mouse button. This area does not exist on the graph as the user defined it. It exists as a cycle on the expanded version of the original graph.

The expanded graph

When the user clicks to toggle the fill state of the area, we would first have to expand the graph for the nodes and edges that make up that area to exist.

The expanded graph

But by doing that we’ve expanded two intersections that we didn’t need to expand to be able to describe the area. These expansions are destructive in nature and should be avoided when possible.

Instead, we can partially expand the graph by only expanding the intersections that define the selected cycle.

Partially expanded graph

This allows us to maintain as much of the original graph as possible while still being able to define the fill.

Implementing partial expansions

The basic implementation is reasonably simple. When you create the expanded graph, just add a little metadata to each expanded node that tells you which two edges of the original graph were used to create it and at what t values those intersections occurred.

Then when the cycle is clicked, iterate over each node. If the node exists in the expanded graph but not the original graph, add it to a new partially expanded graph.

There are edge cases, but I will not be covering them here.

Omitted topics

Here are some of the topics that I decided to omit for this post. Go have a stab at them yourself!

Parallel line edges

If you have two parallel line edges that overlap each other, you will run into some problems when trying to find the CCW-most edge. Can you find out how to expand them?

Joins

Figma offers three types of joins. Round, pointy and square. How could these different types of joins be implemented?

Stroke align

Figma also offers three ways to align the stroke of a graph: center, inside and outside.

How do you determine inside- or outside-ness and what happens when the graph has no cycles?

Boolean operations

Figma, like most vector graphics tools, offers boolean operations. How could those be implemented?

Paper.js is open source and has boolean operations for paths, maybe you can start there?

Future topics

These are some of the more open-ended features and ideas I want to explore in the future.

A different way of working with fills

There are alternatives to how Figma allows the user to work with fills.

One possible solution I’m interested in exploring is multiple different “fill layers” that use one vector object as a reference. This would solve the “one graph, multiple colors” problem without having to duplicate the layer and keep multiple vector objects in sync if you want to make changes later on.

Animating the graph

Given an expression and reference based system similar to After Effects, what could you achieve when you combine it with Vector Networks?

Or maybe we could make use of a node editor similar to Blender’s shader editor or Fusion’s node based workflow?

There’s a lot of exploration to be done here and I’m really excited to dive into this topic.

In closing

Thanks for reading this post! I hope it served as a good introduction to what I think is a really interesting problem space. I’ve been working on this problem alongside school and work for a good while. It’s part of an animation editor plus runtime for the web I’m working on. I intend for a modified version of Vector Networks to be the core of a few features.

I’ve been working on implementing Vector Networks for a bit over half a year now. The vector editor is pretty robust when it comes to creating, modifying and expanding the graph. But the edge cases when modifying the fill state have been stumping me for quite a while now.

I wanted to have a fully working demo before publishing this post, but it’s going to be a few months until it’s stable enough for it to be usable for people that are not me.

The big idea behind the project is to be a piece of animation software that’s tailor-made for creating and running dynamic animations on the web. I’ll share more about this project at a later date.

I also just think that Figma’s Vector Networks are super cool and it’s really hard to find material about it online. I hope this post helps fix the lack of information that I encountered when attempting to find information about Vector Networks.

About me

My name is Alex Harri Jónsson. I’m 22 and I like programming, design, drinking water, and playing badminton.

I live in Iceland and work at Taktikal. I do a lot of frontend work there.