Initial Research

As mentioned in the overview, this project was largely inspired by https://getwaves.io. So, as any thief software developer worth their salt, the first thing to do is steal borrow. Most of this project is setting up the React logic for the input forms. Given that these projects are built to be added to real front-end projects, this makes sense. My needs fortunately, do not require any additional framework overhead.

In this project, the input form values applied a small amount of randomness before using d3 to handle the curve rendering. At this point, I realized that I could do something almost exactly similar but I already knew that this would not scratch my itch. In the normal course of events, it would be the eminently reasonable thing to do to rely on a tool used by thousands (millions?) to render graphics. This however, was not what I set out to do. Instead, I proceeded further in my burglary research by digging into the d3 implementation.

The d3 code

The high level of what I was interested in was figuring out how to draw Bézier curves through a collection of provided points. Bézier curves are the most complex and most powerful part of the SVG path specification.

The d3 code in question is the d3-shape curves: https://github.com/d3/d3-shape#curves. If you continue following the source, you will arrive at https://github.com/d3/d3-shape/blob/main/src/curve/basis.js. Fortunately, the logic for how to construct Bézier curves from a collection of points is right at the top of the file.

export function point(that, x, y) {
    that._context.bezierCurveTo(
      (2 * that._x0 + that._x1) / 3,
      (2 * that._y0 + that._y1) / 3,
      (that._x0 + 2 * that._x1) / 3,
      (that._y0 + 2 * that._y1) / 3,
      (that._x0 + 4 * that._x1 + x) / 6,
      (that._y0 + 4 * that._y1 + y) / 6
    );
  }

This is, unfortunately, clear as mud. Clearly we’re going to have to do more spelunking to figure out how this all works.

Figuring out how this all works

The main background that we’ll need prior to implementing this logic is what a Bézier curve is. The one sentence answer is “parametric curves that use control points to define a smooth curve”. If you’re like me, that means about as much as “a monad is an endofunction over the set of monoids”.

I will attempt to explain these curves in a way that helped me understand them. That said, I am not a mathematician. I am a software developer who has a passing interest in making pretty shapes and colors and needs to learn some math to do so. If you are a mathematician, I apologize for the butchering of the these beautiful machinations.

A “better” Bézier description

One of the first things you will learn playing around with Bézier curves is that they have “control points”. The second thing you will learn is that “control point” does not mean “the curve will go through this point”. But they must mean something like that because when you define straight lines with this syntax, it actually does go through both control points.

What made it click for me, and how I will attempt to describe it, is that just like how polynomial equations can be defined by the points they go through, Bézier curves do the same for certain (cubic) parametric equations.

Consider the quadratic

A quadratic equation is most commonly defined as:

y = f(x) = a*x^2 + b*x + c

For every x value we provide to the function, exactly one y value will result. This however is not the only way to specify a quadratic equation. Another form of the equation can help young algebra practioners when they are posited: "for what values of x is the value of y zero?". We can rephrase the equation to answer that.

y = f(x) = a * (x - r)*(x - s)
a = a
b = -a * (r+s)
c = a * r * s

This form includes the fun result that when x=r or x=s the function must be zero.

Another form is to phrase a function as not two points and a scalar, but 3 points?

y_0 = f(x_0) = a*x_0^2 + b*x_0 + c
y_1 = f(x_1) = a*x_1^2 + b*x_1 + c
y_2 = f(x_2) = a*x_2^2 + b*x_2 + c

This is a pretty simple system of equations because each of the values (x_0, x_1, x_2) are concrete. The simplest method to solve for this equation to turn it into the other forms would probably to use an inverse Matrix. I'll leave that as an exercise for the reader.

Next, the parametric

If you have not yet worked with parametric functions, they are very fun to play with. Rather than defining a curve as f(x) = y where each input x has exactly one output y (no, I am not going to bother to get into asymptotes, disjuncts, or anthing of that sort) we introduce a time component t and define the equation as f(t) = (x, y). This means we can define shapes like swirls, rectangles, doodles; really anything continuous (again, yes, things are being glossed over. Remember I said that I’m not a mathematician?).

We are not going to try and handle the general case of parametric curves. We have a specific target, Bézier curves. Just like with the quadratic curves earlier, we can make certain assumptions that make the whole flow work.

Bèzier curves in SVG paths

Even MDN agrees, paths are the MOST POWERFUL element in the SVG library (…. of basic shapes). See https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths for proof. Straight lines exist within SVG paths, but they are boring. Curves are where the fun is at.

// Lines
M x y // Move to x,y
m dx dy // Move with delta x and delta y
L x y // Line to x,y
l dx dy // Line with delta x and delta y
H x // Horizontal line to x
h dx // Horizontal line with delta x
V y // Vertical line to y
v dy // Vertical line with delta y

// Curves - Bezier
C x1 y1, x2 y2, x y // Cubic Bezier curve
c dx1 dy1, dx2 dy2, dx dy // Cubic Bezier curve with deltas
S x2 y2, x y // Smooth cubic Bezier curve
s dx2 dy2, dx dy // Smooth cubic Bezier curve with deltas
Q x1 y1, x y // Quadratic Bezier curve
q dx1 dy1, dx dy // Quadratic Bezier curve with deltas
T x y // Smooth quadratic Bezier curve
t dx dy // Smooth quadratic Bezier curve with deltas

// Curves - Arcs
A rx ry x-axis-rotation large-arc-flag sweep-flag x y // Elliptical arc
a rx ry x-axis-rotation large-arc-flag sweep-flag dx dy // Elliptical arc with deltas

Z // Close path
z // Close path

The specific syntax for defining Bézier curves is not particularly useful for constructing them. That said, this will be critical to putting all of the pieces together at the end.

Back to understanding the d3 code

So, we know that the d3 code is essentially using the Bèzier definition to solve a system of equations to ensure that the defined points are where the curve actually goes through. What we don’t know yet is what system of equations is actually getting solved. But, if there is any word that is used alongside these curves it is “smooth”. What are smooth curves? Well, they are ones where not just the points are equal, but their slopes and second-order slopes (and so on). For cubic curves therefore, we have a system of equation that will require that at each of these connection points, 3 values of each segment are the same. That is, the curve itself, the slope, and the change in the slope (acceleration). Or in other words, the function, derivative, and second derivative.

Lets say that (x, y) = f(t) as a single segment of the entire curve. Bèzier curves consists of multiple segments, each of which is their own cubic parametric function. The d3 code is solving for the following system of equations:

x_0 = fx(t_0) = ax_0 * t_0^3 + bx_0 * t_0^2 + cx_0 * t_0 + dx_0
x_0 = gx(t_0) = ex_0 * t_0^3 + fx_0 * t_0^2 + gx_0 * t_0 + hx_0

y_0 = fy(t_0) = ay_0 * t_0^3 + by_0 * t_0^2 + cy_0 * t_0 + dy_0
y_0 = gy(t_0) = ey_0 * t_0^3 + fy_0 * t_0^2 + gy_0 * t_0 + hy_0

## Collapsing the above x,y equations into a single line for convenience
(x_0, y_0)' = f'(t_0) = 3 * a_0 * t_0^2 + 2 * b_0 * t_0 + c_0
(x_0, y_0)' = g'(t_0) = 3 * e_0 * t_0^2 + 2 * f_0 * t_0 + g_0
(x_0, y_0)'' = f''(t_0) = 6 * a_0 * t_0 + 2 * b_0
(x_0, y_0)'' = g''(t_0) = 6 * e_0 * t_0 + 2 * f_0

Fortunately, we don’t have to worry about actually solving this system of equations, that is what the d3 code is already doing!

As a reminder, here is what the code for adding the next defined x,y point is in d3.

export function point(that, x, y) {
  that._context.bezierCurveTo(
    (2 * that._x0 + that._x1) / 3,
    (2 * that._y0 + that._y1) / 3,
    (that._x0 + 2 * that._x1) / 3,
    (that._y0 + 2 * that._y1) / 3,
    (that._x0 + 4 * that._x1 + x) / 6,
    (that._y0 + 4 * that._y1 + y) / 6
  );
}

We have two previous points defined as the that variable. The next point that we are attempting to add is the x, y point. The part that was confusing, is that this is essentially solving the system of equations for the (that._x0, that._y0) and (that._x1, that.y1) points. It still needs to reference that upcoming point to get the slope correct, which is why the second control point uses the x and y values.

The clincher for this to work is the secret buffering. Here is the code that is used to specify the first few points within the definition. _point is essentially the buffer for the first few points to have different behavior than the nth point. The first point moves the cursor, the second point does nothing, and the third takes a kinda average of the first two points before beginning the actual cubic curve definitions (note the lack of a break which causes the fall through on the case 2).

switch (this._point) {
  case 0: this._point = 1; this._line ? this._context.lineTo(x, y) : this._context.moveTo(x, y); break;
  case 1: this._point = 2; break;
  case 2: this._point = 3; this._context.lineTo((5 * this._x0 + this._x1) / 6, (5 * this._y0 + this._y1) / 6); // falls through
  default: point(this, x, y); break;
}

Wrapping up the research

If we’re being honest with ourselves, after getting to this point I still can’t say I understand Bezier curves. That’s okay though, because a tenuous grasp of the concept is all we really need to get into the code and start playing around. The next phase of the project is re-implementing the curves with an eye towards using them within a page background.