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.
Previous: Overview Next: Implementation