Approximating cubic nibs drawn along cubic strokes
2022/09/18 – 2023/04/23
The story of how I implemented calligraphy for cubic Bézier curves (the kind widely used in graphics programs, especially the common Scalable Vector Graphics (SVG) format).
The problem statement:
Given a pen nib of some shape, what composite shape is produced when that pen is drawn along any particular path?
If the inputs are cubic Bézier curves, is the output as well?
Is the Minkowski sum of two piecewise cubic Bézier hulls a piecewise cubic Bézier hull?
More specifically, is the the convolution of two cubic Bézier curves a cubic Bézier curve?
The catch? Itʼs mathematically impossible to model the output using cubic curves, as I determined after a bit calculus. In fact, it fails already for quadratic curves (the simpler companion to cubic curves, which would have simpler, more tractable solutions).
The cubic in “cubic Bézier curve” refers to the fact that they are parametric curves modeled by cubic polynomials (one polynomial for the coordinate and one polynomial for the coordinate, in terms of a shared time variable ). Simply put, the solution for how the curves of the pen and the curves of the path interact means that the solution wonʼt be a polynomial anymore, it would at least be a rational function, i.e. a polynomial divided by another polynomial.
However, that doesnʼt prevent us from getting pretty darn close. Let me show you how it works out.
Cubic polynomials have nothing to do with Cubism. At least, not that I know of.
You want to know how I got here? The story begins with a car trip. My customary activity while riding in the car is to invent new writing systems, drawing on my tablet to try different calligraphic curves to start establishing what shapes I want to represent various sounds. (I tend to develop featural writing systems based on sounds represented by the International Phonetic Alphabet.)
Of course, doing this in the car is hard mode already! The bumps of the car mean I have to use the tablets undo feature for more strokes than not. Plus, not only are there the mistakes of my hand being jostled by the car going over bumps, thereʼs also the mistakes of me just being mediocre at calligraphy, plus the fact that I have to teach myself the script as Iʼm inventing it! (I do love the creative interaction of drawing random shapes, seeing what feels good, and refining it both intentionally and through the natural iterations.)
Iʼve done this for many years, since before I could drive. As long as Iʼve done that, Iʼve also wanted to digitize the shapes, maybe to make them into a computer font so I donʼt have to manually write things out when I want to see how full sentences look. (ʼTwould also be a great way to explore ligatures and open type features to really make a natural flowing calligraphic font …)
As I mentioned above, the precisely stated mathematical problem says the curves we are looking for arenʼt the type of curves supported by graphics programs today. But why let the mathematical impossibilities get in the way of actually quite good enough? It took me until now to have the skills/insight/motivation to finally realize my dream, and I want to share the result and the process with you.
But first, always start with the demo! Here you can see the musculoskeletal anatomy of a Minkowski calligraphy stroke:
We can take apart the pen nib and pen path into a bunch of segments and compute the composite of each segment with each segment (Cartesian product). Each composite of individual segments produces a section of the result, resulting in a patchwork of sections that form the whole composite shape. Having a lot of overlapping sections is OK, since e.g. Inkscapeʼs builtin Boolean operations will simplify the shapes for us.It should be possible to only compute the outer segments and skip that step, but for now itʼs better to overapproximate it and optimize later.
In fact, we will end up subdividing the original segments a bunch to produce accurate results:
Then the task is to come up with the sections that form the patchwork:
But thatʼs the end result, letʼs see how I got to this algorithm.
The simplest approach is to paste each path on each segment, something like this:
,Qs) => Ps.flatMap(P => Qs.flatMap(Q => [shift(Q, P[0]), shift(P, Q[0])])) (Ps
Mathematically we would say weʼre taking the Cartesian product of the segments.
I was able to do this much in Inkscape directly: copy some segments, align them. You can even make linked clones so it updates altogether.
But there were problems: when the paths crossed over it got noticeably too thin. Even before then, the curves were trending too close, as you can see by double-clicking on it to reveal the red approximation. Basically if the pen nib wasnʼt made of perfectly straight lines, the composite stroke would be missing stuff.
Essentially this process is simulating what you would get by stamping the pen nib in certain points, and then drawing a rake through the path to connect them with curves. (The rake only touching the paper at the segmentation points of the pen nib.)
It appeared that anything more complex would require algorithmic help, and oh was I right … There were more issues lurking with curved segments.
Where tangents go wrong.
I sat down and tried to analyze where this occurred. My first thought was what I said above: itʼs where the crossovers happen, right? Right??
However, I realized that canʼt possibly be right: when the curves fail to cover the actual sweep of the pen, it has already separated by the time the curves actually cross over each other, and continues afterwards. That is, the cross-over is a symptom of the issue but not the part the delimits it.
Looking at it more closely (literally) I realized that the separation occurs precisely when the path of the pen parallels the endpoint tangent of one of the curvy segments of the pen nib.
My first thought was to stamp out the problem: insert more stamps of the pen nib at these problematic tangent points where it wants to detach from the real path. Little did I know this was only the start of unraveling a long thread … it was not enough! For longer curvy segments, it was clear that the extra stamps only masked the problem and did not account for what lay between them.
The main insight, which I have already spoiled for you, is that we need to find some composite of the curves of the pen nib with the curves in the pen path, a composite which is not identical to either curve.
To step back from calligraphy for a moment, consider a simpler example: drawing with a circular sponge, marker, whatever. Make it comically big in your mind.
If you draw a straight line, the composite path is very simple: the two endpoints are capped by a semicircle and are connected by a rectangle.
Now consider a curved path: you can quickly imagine that two semicircles joined by the exact path will not do the job. First of all the endpoints are wrong to connect with the endcaps, second of all the curve would look funny!
If you slow down and look at some point on the curve very closely, what points on the circle are actually doing the work of drawing? What part of the circle is extremally far from the curve at that point? The part that is tangent to the curve!
Thus we will end up offsetting each point on the curve by the radius perpendicular to the curveʼs tangent.
In fact, this offset curve is no longer a Bézier curve: it is an analytic curve of degree 10.
Funnily enough, although it is mathematically complicated, all graphics programs support approximating this cubic Bézier + circular pen combo: this is just the stroke width parameter of SVG Bézier curves.
As far as the main topic of this post goes, the underlying mathematic impossibility should not discourage us quite yet: circles cannot be exactly captured by Bézier curves either, so our focus on cubic Bézier pen nibs may still be okay. (Spoiler: it is not.)
This thought experiment shows that we really want to find the tangent point on the pen nib that corresponds with the tangent from the pen path. If we can correlate the two for each point in time, we would get a composite path that fills out the proper area, more than the rake and stamp method.
Now we can find a precise curve to work towards: given two “nice” curves, we add up all the points where their tangents are parallel, to obtain a new curve. (This is called the convolution of the two curves.)
We hope to solve this in the case of cubic curves in particular: given a tangent from one curve (the pen path), find the time when the other curve (the pen nib) has the same tangent, and add those points together.
Letʼs try a simpler thing first and see why it fails: we can keep the pen path as a cubic Bézier, but restrict the pen nib to being quadratic.
Taking the tangent vector of each curve decreases degree by one: the cubic Bézier has a quadratic tangent vector, and the quadratic Bézier has a linear tangent vector. This sounds okay so far, but recall that we want the tangents to have the same slope (to be parallel). This makes us take fractions (see below for more details in the cubic case).
So the solution is a rational function (ratio of two polynomials). Bézier curves are polynomials, not rational functions, so the result will not be a Bézier curve.
Dealing with cubic curves, their tangent vector (being the derivative of their position vector) is a quadratic function. We want the two tangent vectors to be parallel, so we end up with a quadratic equation of one in terms of the other. Solving the quadratic equation introduces radicals, so it is no longer even a rational function in the cubic case.
So we set about approximating the exact curve by a Bézier one.
The first question to ask ourselves is: what are the tangents of the curve at the endpoints? This is a simple question, actually: since we are picking points from the curves where the tangents match, the tangent is simply what it was for the base curve. (We will work through the math below to prove why this is the case.)
If we were approximating by a quadratic curve, this would be all we need to know: the last control point would be at the intersection of the tangents from the endpoints.
But since it is cubic, we have two more points to pick, which should correspond abstractly to another parameter to control at each endpoint, in addition to the tangent there.
Youʼd think this parameter would be curvature. Youʼd think!!
The obvious first parameter to control is the tangent angles. If we were approximating with quadratic curves, this would be all there is to it: two points and two tangents.
Working with cubic curves, however, we expect an extra degree of freedom: more control over how it matches our curve. The obvious place to look is curvature.
Curvature (often denoted ) is a geometric quantity that captures second-order behavior of a curve, regardless of parametrization. That is, regardless of how maps to actual points on the curve, if the curve looks the same, it will have the same curvature.
However, it is not so simple to map curvature onto the Bézier curve parameters, as weʼll see next.
For one thing, the formula involves a complex mix of components:
And then add in the complexities of the Bézier parameterization and you have a fun problem that yields non-unique solutions.
The proliferation of solutions is kind of problematic since it means we need to guess what is the right solution. At least we know we are looking for clean-looking solutions that do not deviate too much.
Itʼs funny: in order to display curvature in a geometrically meaningful way, you want it to be in units of distance, which means youʼd take its inverse . This inverse is the radius of the osculating circle that just barely touches the curve in the most graceful way. (Perhaps you know that you can form a circle passing through any three points, possibly a circle with infinite radius if the points are along the same line. This is why it is a second-order property.)
However, despite being in the right units, the radius of the osculating circle is poorly behaved because it can blow up to infinity when the curvature is near zero! (E.g. near inflection points.)
So people often resort to displaying curvature as a kind of vector field associated with the curve, with some implicit conversion of units from inverse distance to real distance.
There is a third-order analogue of curvature called aberrancy. It is related to the osculating parabola, since parabolas rotated in space can be fit to four points.
In which we work through the math to compute a cubic Bézier approximation to cubic Bézier convolution, based on matching the known curvature at the endpoints of the exact convolution.
If you want to dig into the details youʼll want some familiarity with vectors, calculus, and parametric curves.
Bézier curves are parametric curves based on some control points. Weʼll only be dealing with 2D cubic Bézier curves. Weʼll put the control points in boldface like , , and give the 2D vectors arrows over top like , .
We need the formula for the Bézier polynomial that results from the control points, and weʼll also need its first and second derivatives:
Naturally the first derivative of a cubic Bézier is quadratic, and the second derivative is linear.
Normally we think of it in 3D space, because the cross product of two 3D vectors is another 3D vector. But it also works in 2D space, it just produces a scalar (1D vector) instead! And it turns out to be a useful abstraction for a lot of our calculations.
The two curves are controlled by their own parameters that are independent of each otherʼs! We need to match them up somehow, and as discussed above, thereʼs a particular way to do that for our application: We need to find the times when they have parallel tangent lines, since that will tell us what is the furthest point of the pen nib () (locally) along any given point of the pen path ().
The slope of the tangent line at time is given by the ratio of the and components of the first derivative of the curve.
Weʼll start using to refer to the time along curve and to refer to the time along curve . Weʼll think of it as solving for in terms of ; is the input and the output. Our goal is to match them up, so the curves have the same slope at corresponding times!
Cross multiply
Or use the cross product:
(Recall that the cross product is a measure of how perpendicular two vectors are, so they are parallel exactly when their cross product is zero. This is true in 2D just like in 3D, itʼs just that the cross product is now a scalar quantity, not a vector quantity.)
What does this get us? Well, we can think of it either way, but letʼs assume that weʼre given , so we plug it in and obtain an equation to solve for . Since is quadratic, we get a quadratic equation to solve, with some nasty scalar coefficients , , and coming from the control points of our curves and , evaluated at :Even though weʼre putting a lot of vector information in from the control points of the curves, the coefficients are just scalars, and in fact are even quadratic polynomials in terms of ! Knowing that they are quadratic polynomials does not really help things at all.
Obviously it gets tedious to write all of that, so we omit the parameter and simply write:
Thereʼs a few issues we run into.
The first is that the solution doesnʼt necessarily lie on the actual Bézier segment drawn out by .
Second there might be two solutions, since weʼre solving a quadratic!
The solution to both is to split things up! We need to split up the pen path so it indexes the tangents at the end of the Bézier segments of the pen nib, after first splitting the pen nib at its inflection points.
Splitting at inflection points ensures that the tangent slope is always increasing or decreasing along the segment, making there only be a single solution. Actually this requires also knowing that the Bézier segment doesnʼt rotate 180°, so we need to split it if it reaches its original tangents again.
Solving these issues means we can think of the equation above as giving us a function for in terms of :
This puts the functions in lock-step in terms of their tangents, giving us what we need to calculate the outside of their sweep.
Weʼll need the derive of this equation soon, so letʼs calculate it while weʼre here.
My first thought was great, we have a quadratic equation, so we know the formula and can just take the derivative of it!
This was … naïve, oh so naïve. Letʼs see why.
We have our solution here:
So we can take its derivative , using the chain rule, quotient rule, product rule … oh Iʼll spare you the gory details.
(Recall that , , and are functions of , so they have derivatives , , and in terms of that variable.)
Notice any problems?
Well, first off, itʼs an ugly, messy formula! And thatʼs even with hiding the definitions of the coefficients , , and .
The biggest problem, though, is that everything is divided by or , which means it doesnʼt work when . That shouldnʼt be too surprising, given that the quadratic formula also fails in that case. (Itʼs the quadratic formula after all, not the quadratic-or-linear formula!)
I mean, we could solve the linear case separately:
But that also doesnʼt work; it omits the contribution of , which does in fact influence the result of the rate of change of the quadratic formula, even when .I first verified this numerically, but it makes a lot of sense if you think about it.
So I took a deep breath, started texting my math advisor, and I rubber ducked myself into a much much better solution.
You see, the quadratic formula is a lie. How did we define ? Certainly not as a complicated quadratic formula solution. It is really defined as the implicit solution to an equation (an equation which happens to be quadratic):
Look, we can just take the derivative of that whole equation, even before we attempt to solve it (only takes the product rule this time!):
And this, now this has a nicer solution:
I think itʼs cute how the numerator is another quadratic polynomial with the derivatives of the coefficients of the original polynomial. Itʼs also convenient how we have no square roots or plusminus signs anymore – instead we write the derivative in terms of the original solution .
We still have a denominator that can be zero, but this is for deeper reasons:
Obviously this is zero exactly when . This quantity is called the discriminant of the quadratic, and controls much of its behavior: the basic property of how many real-valued solutions it has, as well as deeper number-theoretic properties studied in Galois theory.
I was looking at this and seeing made me think that it could be rewritten a bit, since we can solve for in the defining equation:
With some work that gives us this formula: which is nice and symmetric (it is patterned a little like the cross product in the numerator) but not what I ended up going for, I think I was worried about floating-point precision but idk.
For fun we can see what the second derivative is, though we wonʼt end up using it! (I was scared we would need it at one point but that was caused by me misreading my code.)
In which I attempt to write out what , , and actually are. Wish me luck.
We take the standard quadratic form of the tangent of :
(Notice how there is a constant term, a term multiplied by , and a term with .)
We want to wrangle this cross product of that with into a quadratic equation of :
So by distributivity, each coefficient we saw above is cross-producted with to obtain our mystery coefficients:
You could expand it out more into the individual components, but it would be painful, not very insightful, and waste ink.
Note that this vector cross-product cannot be cancelled out as a common term, because the -vector coefficients control how the separate components of are mixed together to create the coefficients of , , and . However, it could be divided by its norm without a problem (that is, only the direction of matters, not is magnitude – and this is by design.)
Now that we have a formula for in terms of , we can just plug it in and get our whole curve.
Now for the main question: is this a Bézier curve? Nope!
Even if was a quadratic Bézier curve, the solution would still be a rational function, which is not compatible with the polynomial structure of Bézier curves.
That means we canʼt just stick the curve into an SVG file or similar graphics format, its true form is not natively supported by any graphics libraries. (And for good reason, because itʼs kind of a beast!)
However, we know a lot of information about the curve, and we can use it to reconstruct a decent approximation of its behavior, meaning all is not lost.
We now know an exact formula for the idealized . We can use this to get some key bits of information that will allow us to construct a good approximation to its behavior.
In particular, we want to know the slope at the endpoints and also the curvature at the endpoints. The curvature is the complicated part.
Itʼs going to get verbose very quickly, so letʼs trim down the notation a bit by leaving implicit, focusing on , and remove the extraneous parts of the Bézier notation:
By construction, the slope at the endpoints is just the slope of at the endpoints: since those vectors are parallel by the construction of :
The curvature is a bit complicated, but we can work through it and just requires applying the formulas, starting with this formula for curvature of a parametric curve:
We already computed above, so we just need to compute and compute the cross product.
However, I promised that we wouldnʼt need the second derivative , so letʼs see how it cancels out in the cross product. With some distributivity we can expand it:
Obviously , since those are parallel, being the same vector. But as well, since those are parallel by construction of . That means we do not need to deal with the second derivative .
Now we can get down to business. How do we find the best Bézier curve to fill in for the much more complicated curve that combines the two curves and ?
Weʼll take six (6) pieces of data from :
This should be enough to pin down a Bézier curve, and indeed there is a way to find cubic Bézier curves that match these parameters.
We will be following the paper High accuracy geometric Hermite interpolation by Carl de Boor, Klaus Höllig, and Malcolm Sabin to answer this question. The basic sketch of the math is pretty straightforward, but the authors have done the work to come up with the right parameterizations to make it easy to compute and reason about.
The bad news is we end up with a quartic (degree 4) equation, to solve the system of two quadratic equations. So we see that there can be up to 4 solutions. But we can narrow them down a bunch, like if there are solutions with loops (self-intersections) we can rule them out, or other outlandish solutions with far-flung control points.
For example, one can take these same datapoints from a real cubic Bézier curve and reconstruct its control points from those six pieces of information. In our case, we are hoping that the curves we come across, although not technically being of that form, are very close and will still produce a similar curve to the perfect idealized solution.Note that we arenʼt doing other methods of error reduction, like minimizing area.
In fact, one cool thing about this implementation is that we can use it to find the closest Bézier curve without a loop to one with a loop. (And the reverse, though I have not implemented that.)
Basically a lot of shuffling variables around.
We will be solving for and , which scale the control handles along the predefined tangents, giving these Bézier control points:
Now we compute the curvature at the endpoints for this Bézier curve:
And with these substitutions, we get a system of two quadratic equations for and :
Itʼs easy to deal with the case when (that is, when the starting and ending tangents are parallel). For the nonzero case, we reparameterize again according to:
Thus we end up with the very pretty system of quadratics:
We can solve for one of these variables, by substituting from the other equation,
This is a depressed quartic equation, with coefficients .
We want solutions with – that is, with the control handles pointing the correct way. We also want to generally minimize those variables too, otherwise there are outlandish solutions with huge coefficients (particularly ones with loops). Finally I also added a check that ensures we are getting the correct curvature out of them – for some reason I was getting solutions with flipped curvature.
Itʼs actually really pretty to see solutions with all signs of curvatures together:
I have an implementation in vanilla JavaScript of the algorithm described in this post.
Of course it needs some basic theory of vectors and polynomials and Bézier curves.
For example, bsplit(points, t0)
returns a vector of two new Bézier curves that cover intervals and of the input curve, respectively.
The important functions in calligraphy.js
are as follows:
compositeI(P,Q)
computes the approximate Bézier convolution of P
with Q
.PQ_CURVATURE(P,Q)(p,q=T_SOL(P,Q)(p))
computes the curvature of the exact convolution between P
and Q
at (p,q)
(where q
should be the point on Q
corresponding to p
on P
, i.e. parallel).INFLXNS(P)
computes the inflection points of P
.And the full algorithm is put together (with visualization) in minkowski.js
:
doTheThing(p1, p2)
does the thing, as it says.splitPathAtInflections(p2)
removes pathologies from the pen nib.splitBezierAtTangents(c1, getTangentsL(c2))
splits the pen path according to the points of interest of the pen nib.getTangentsL(c2)
includes both the control point tangents of the Bézier as well as the beginning-to-end tangent, to make the tangent function injective on the length of each segment.?? ??doTheTask(c1, c2)
creates a patch or two from two Bézier curves, which will either be the “parallelogram” formed by translating them, or will include their convolution if it exists.Uhhh … I allowed the control points to go backwards () and I perturbed the tangential splits due to numeric issues. The latter could be fixed by actually remembering those splits and not trying to solve there.
Throughout this post, Iʼve been emphasizing “the pen nib this” and “the pen path that” for the sake of giving you a concrete image in your mind. But the reality of the underlying math is that there is no fundamental distinction between the two curves. The convolution and Minkowski sum are commutative, and do not care which curve is which.
My primary sources/inspiration:
Other miscellanea on Bézier curves: