Look, Ma, No Trigonometry!
Torus knots without sines and cosines.
The PGA ($\mathbb R_{3,0,1}$) exponential function!
In our previous writeup "Look, Ma, No Matrices!", we focused on replacing matrices, normal vectors and tangents with PGA motors, resulting in some unexpected savings. Today, we'll turn our attention to another aspect of computer graphics, and again wonder how the plane-based geometric algebra can make a difference.
Specifically, we will be looking into PGA motor orbits, and how they can be used to approach a number of constructive geometry tasks in quite a different way. As a reference this time, we are using three.js, and specifically we'll be looking at its "TorusKnotGeometry" implementation [1]. It's a great example to contrast the two approaches, and well, torus knots are pretty!
Introduction.
It is probably fair to say that many graphics programmers associate geometries like circles, spheres, toruses and torus knots with an abundance of sine and cosine calls. For the graphics engineer, these objects are often a far cry from the elegant manifestions of topology that mathematicians make them out to be. Instead they are a tedious manipulation of coordinate coefficients, often not to be touched, or even read after initial development.
We get a similar vibe from the TorusKnotGeometry implementation in three.js. Skipping over the boilerplate, there are three main pieces of code involved in constructing the desired geometry. The first one is the torus-knot curve. For this the three.js implementation reads:
function calculatePositionOnCurve( u, p, q, radius, position ) {
const cu = Math.cos( u );
const su = Math.sin( u );
const quOverP = q / p * u;
const cs = Math.cos( quOverP );
position.x = radius * ( 2 + cs ) * 0.5 * cu;
position.y = radius * ( 2 + cs ) * su * 0.5;
position.z = radius * Math.sin( quOverP ) * 0.5;
}
Would you be able to figure out what this snippet is doing if presented with it out of context? Would you be able to modify it, for example to construct a torus knot around a torus that is not axis aligned? Did you notice there is only one radius parameter, while a torus clearly has two radii? Can you tell me what these radii are from this block? Can you modify the code to add in that extra parameter?
None of these tasks, including simply reading and understanding, are obvious, even though we are looking at just 7 lines of code. In the next section, we will look at the PGA alternative, and ask exactly the same questions. But constructing the curve at the center of the torus knot is not quite the full story. The next part of the task consists of extruding this curve to create the actual 'tube' geometry. For this the three.js implementation starts with the following block:
// now we calculate two points. P1 is our current position on the curve, P2 is a little farther ahead.
// these points are used to create a special "coordinate space", which is necessary to calculate the correct vertex positions
calculatePositionOnCurve( u, p, q, radius, P1 );
calculatePositionOnCurve( u + 0.01, p, q, radius, P2 );
// calculate orthonormal basis
T.subVectors( P2, P1 );
N.addVectors( P2, P1 );
B.crossVectors( T, N );
N.crossVectors( B, T );
// normalize B, N. T can be ignored, we don't use it
B.normalize();
N.normalize();
Here we see finite differences is used to explicitly construct an orthogonal frame along the curve. Was this a choice for finite differences because the explicit differential of the calculatePositionOnCurve function was to hard? Either way, for our PGA approach we'll be able to use techniques from our previous article to help us avoid this extra work. But before we do, the final piece of the three.js implementation constructs the actual vertex positions, and reads:
const v = j / radialSegments * Math.PI * 2;
const cx = - tube * Math.cos( v );
const cy = tube * Math.sin( v );
// now calculate the final vertex position.
// first we orient the extrusion with our basis vectors, then we add it to the current position on the curve
vertex.x = P1.x + ( cx * N.x + cy * B.x );
vertex.y = P1.y + ( cx * N.y + cy * B.y );
vertex.z = P1.z + ( cx * N.z + cy * B.z );
Here, the final 'tube' around the torus knot is constructed in 2 dimensions, and explicitly multiplied with the local basis vectors constructed in the previous block.
// normal (P1 is always the center/origin of the extrusion, thus we can use it to calculate the normal)
normal.subVectors( vertex, P1 ).normalize();
Finally, vertex normals are calculated as the difference between the vertex position and the current position on the torus knot curve. Note they do not calculate tangent vectors.
I would like to stress that this entire construction is not an example of bad engineering. It is clean, well structured, and written by someone with knowledge of linear algebra. Just a few years back I would have approached this problem very similarly.
So how exactly are we going to improve on this? What does PGA bring that can fundamentally change what we are doing here? After all ... the very task is exactly constructing these vertex positions and normals!
The answer here is easy. The exponential function. Now as a graphics programmer, in contrast with for example a physicist, the exponential function is not typically in our day to day toolbox. Especially not in 'the matrix', as matrix exponential and logarithmic functions are involved, difficult and generally expensive.
The PGA Torus Knot
On my SIGGRAPH talk in 2019, where the ideas behind this technique were used as a motivational example, I called the exponential function my favorite function. As such, I'm eager to go into some of its details, but let us first keep our focus on the torus knot example, and tackle the engineering problem first.
Generating Motions
In PGA, your main use of the exponential function will be to generate rotations, translations and screw motions. In this geometric context, the input to the exponential function is going to be a (sum of) scaled line(s). The following table summarizes how the type of line determines the output.
Input | Result | |
---|---|---|
A scaled Euclidean Line | $\Large e^{- \frac \alpha 2 \bar L}$ | A rotation of $\alpha$ around the line $L$. |
A scaled Infinite Line | $\Large e^{- \frac \alpha 2 \bar L}$ | A translation of $\alpha$ around/orthogonal to the line $L$. |
A sum of scaled lines | $\Large e^{B}$ | A screw motion. |
So in practice, to generate a rotation, you start from the line $L$ you want to rotate around, scale it with (minus half of) the angle you want to rotate, and exponentiate it. And similarly, to generate a translation, you start from a line at infinity $L$, scale it with (minus half of) the distance you want to translate, and exponentiate it. This is a very deep connection between the elements of the group (i.e. your motors, quaternions, dual quaternions), and the lines (3D) or points (2D) of the geometry. I'm sure your favorite engine has a quaternion type, and a line type, but does the quaternion logarithm function output a line?
Motor Orbits - Circle
We will use this exponential function to describe what is called a developable surface. Such a surface is a function of a set of parameters $t_1, t_2, ...$ with typically $0 < t_i < 1$. It is perhaps easiest to start with the trivial example. In PGA, we can describe a circle using
$$\large C(t) = e^{t\pi \mathbf e_{12}} e^{r \mathbf e_{01}} $$ The first (from the right!) exponential function generates a motion using the line $\mathbf e_{01}$, this is the line at the horizon of the $x=0$ or $yz$ plane, and so the resulting motor will move us in the $x$ direction. The second exponential instead generates a motion using the Euclidean line $\mathbf e_{12}$, the intersection of the $x=0$ and $y=0$ planes, aka around the $z$ axis.
Once you know how, it is easy to read this as move $r$, or radius, to the right, then turn $t$ around. To actually see the shape, we evaluate this function with $t$ from $0$ to $1$, and apply the resulting motor to the origin. The object $C(t)$ is called a motor orbit, and as we will see, it can be used to describe a very wide variety of shapes.
Motor Orbits - Torus
So why is it so interesting to use orbits to describe shapes? Well, the motor orbit that describes the torus demonstrates exactly why:
$$\large T(t_1, t_2) = e^{t_2\pi \mathbf e_{13}} e^{r_2 \mathbf e_{01}}e^{t_1\pi \mathbf e_{12}} e^{r_1 \mathbf e_{01}} $$ The two first exponentials, on the right, are literally simply a circle around $\mathbf e_{12}$, aka the $z$ axis, while the next two exponentials on the left, are also a circle, this time around $\mathbf e_{13}$, aka the $y$ axis. You're reading that correctly, in PGA a torus in fact is simply the geometric product of two circles!
$$\large T(t_1, t_2) = C_1(t_1) * C_2(t_2)$$
It does not seem unreasonable to say there are few on which this mathematical elegance is lost.
What about normal vectors?
So far, we've been applying our motor objects to the origin, producing the vertex positions. But for rendering purposes, knowing the vertex positions is not enough, as for shading purposes we also need to know the local orientation of the manifold at each vertex.
For this, we typically use normal vectors, and optionally tangent vectors. How do we get those from our motor representation? Since we start our construction moving to the right, turns out all we need to do is apply our motor to the x-direction instead of the origin. $$ v_i = T \mathbf e_0^* \widetilde {T},\qquad n_i = T \mathbf e_1^* \widetilde {T},\qquad b_i = T \mathbf e_2^* \widetilde {T},\qquad t_i = T \mathbf e_3^* \widetilde {T} $$ Hence, our motor orbits are not just descriptions of vertex positions, but they indeed continuously represent the oriented shape manifold, and computing positions ($v_i$), normals ($n_i$), tangents ($t_i$) and bitangents ($b_i$) is all done simply using the GA sandwich product. (also recall from our previous writeup that the extraction of a normal and tangent vector from a motor requires just 9 multiplications and 8 additions.)
Motor Orbits - Torus Knot Curve
To construct the actual torus knot curve, recall that a torus knot is determined by two numbers $p,q$, where $p$ is the number of windings around the hole and $q$ is the number of windings through the hole. Equipped with our torus orbit, the $p,q$ knot curve is given simply by:
$$\large K(t) = T(tp, tq) = e^{tp\pi \mathbf e_{13}} e^{r_2 \mathbf e_{01}}e^{tq\pi \mathbf e_{12}} e^{r_1 \mathbf e_{01}}$$
This completes the first part of our task, the construction of the torus knot curve. This simple formula translates to an equally simple implementation:
motor circle ( float t, float radius, line axis ) {
return gp_rt( exp_r(t * PI, axis), exp_t(radius, e01) );
}
motor knot ( float t ) {
return gp_mm( circle( t * P, r2, e12 ), circle( t * Q, r1, e31 ) );
}
At this point, we've completely replaced the initial block of the three.js implementation. We have a working function that constructs motors along the curve in the center of the torus knot. We've used no sine or cosine functions, or thought about coordinates. Both the process of getting there and the implementation are entirely geometric.
Motor Orbits - Oriented Curves
Aligned to torusTo construct our torus knot curve, what we are doing is sampling the torus motor manifold. That means that the orientation of each of these points aligns to the torus, as we have seen in 2.3. If we are tasked with aligning elements to the torus, then this may very well be the orientation we are looking for.
However for our extrusion today, we need the orientation over the curve also to be along the curve, so that our extruded tube does not squish along the way. To do this, the original three.js implentation explicitly constructs a local frame based on the derivative of the positition along the torus knot.
We could take the easy way out, and opt to use finite differences to calculate the needed derivatives. However, we just reduced the entire torus knot expression to the product of just four exponential functions, of which only two are dependent on t.
Surely this derivative can not be too difficult?
Derivatives of circles and torus knots.
Let us begin with the derivative of a circle. It involves just two exponential functions, of which only one is dependent on $t$. Recall that the exponential function has an extraordinarily easy derivative $\frac {\delta}{\delta t} e^{xt} = x e^{xt}$. That's all we need to find
$$C = e^{t\pi \mathbf e_{12}} e^{r \mathbf e_{01}} $$ $$\dot C = \pi \mathbf e_{12} e^{t\pi \mathbf e_{12}} e^{r \mathbf e_{01}} = \pi \mathbf e_{12} C$$
Hence the derivative of a circle is just that circle premultiplied with the orthogonal line through its center. Similarly, for the torusknot, all we need is the above result, the chain rule $\dot{a(b)} = \dot a(b) \dot b$, and the product rule $\dot{(ab)} = \dot a b + a \dot b$, so we trivially find :
$$K(t) = C_1(pt)C_2(qt) $$ $$\dot K(t) = p\dot C_1(pt) C_2(qt) + q C_1(pt) \dot C_2(qt)$$
This gives us the derivative of the torus knot motor, but for our purposes we need the derivative of the origin, as it gets moved around by the torus knot motor. This is the sandwich product $p = K \mathbf e_0^* \widetilde K$, with three factors, but only two depend on $t$, so the same product rule as above yields:
$$p = K \mathbf e_0^* \widetilde K $$ $$\dot p = \dot K \mathbf e_0^* \widetilde K + K \mathbf e_0^* \dot{\widetilde K}$$
Orienting along the curve.
After this short detour, we have the derivative we need, without having to resort to an approximation like finite differences. Now we will certainly not use it to construct a local matrix, but instead simply require our local z-axis and the derivative of our position to align.
Recall that in geometric algebra a single unified formula exists to calculate the rotor between two elements. To find the transformation that takes $a$ to $b$ we use : $$\sqrt{ {b} {a^{-1}}} $$ Where we remember to first move our derivative $\dot p$ to the origin, dualize it to a vector and normalize it with $b = \overline{{(\widetilde K \dot p K)}^*}$. We also use that $a = \mathbf e_3$ is its own inverse and write the final correction as :
Aligned to curve$$ \sqrt{ \overline{{(\widetilde K \dot p K)}^*} \mathbf e_3 } $$
Where we take the derivative of our position along the knot $\dot p$, move it back to the origin by multiplying it with our
reverse knot, dualizing that to turn it into a vector, and multiplying the result with $\mathbf e_3$. This produces twice the transformation
from our local frame to the desired direction, so the square root finally produces the correction we need.
Putting all this together, we can write the formula for our rectified torus knot motor:
$$ K_r = K \sqrt{ \overline{{(\widetilde K \dot p K)}^*} \mathbf e_3 } $$
At this point, our boxes nicely align to the curve, and at the same time, they still sit 'flat' on the surface of the torus. To actually extrude our tube, we simply multiply our rectified torus knot with another circle, this time with the desired tube radius.
$$ TK(t_1, t_2) = K_r(t_1)C(t_2) $$
To see why we are now both aligned to the curve and the torus, remember that the formula we started from produced a motor perfectly aligned to the torus. If one considers the $x=0$ plane in that frame, it will be tangent to the torus at that point. At the same time the torusknot curve derivative is tangent to the torus at that point, and so by definition lies in that plane. The correction motor we calculated is thus always a rotation around the normal, and it will keep the $x=0$ plane tangent to the torus.
Minimal TorsionThis specific orientation, both aligned to the curve and the torus, is not the one used by three.js, but it is another valid option that naturally rolls out of this procedure. The third interesting orientation for motors along our curve is the one that minimizes the torsion, and thus minimizes the distortion of any texture mapped onto the torusknot. To find this orientation, note that when we stay flat to the torus as before, we in fact end up twisting $q$ times around over the entire path. $$ TK(t_1, t_2) = K_r(t_1)C(t_2 - t_1 * q) $$
Hence with this simple final tweak, just twisting q times back along the knot, we produce the distortion-free geometry we are looking for.
In code
Putting it all together, the final code block starts:
/***** TORUS KNOT CURVE **********/
// Circle Orbit. Rotation after Translation
motor circle ( float t, float radius, line axis ) {
return gp_rt( exp_r(t * PI, axis), exp_t(radius, e01) );
}
// Torus Knot. Product of two circles.
motor knot ( float t ) {
return gp_mm( circle( t * P, r2, e12 ), circle( t * Q, r1, e31 ) );
}
Compare this to the initial block we started from. Not only is the geometry obvious, it is clear a torus indeed has two radii, and we have full control over the lines our torus is constructed around. Furthermore, it is obvious that P and Q represent the windings around each of the circles. None of this holds for the coordinate manipulations we started from ...
For the next block, we replaced the finite differences approach from three.js with the analytic derivative we just calculated. One could skip this step and stay closer to the reference, but the point is of course that it was very easy to calculate.
/***** DERIVATIVE OF TORUS KNOT CURVE **********/
// Derivative of circle. Axis * PI * Circle.
motor dCircle ( float t, float radius, line axis ) {
return gp_rm( motor( 0.0, axis[0] * PI, 0.0, 0.0, 0.0, 0.0), circle(t, radius, axis) );
}
// Derivative of torus knot.
motor dKnot ( float t ) {
return gp_mm( P * dCircle( t * P, r2, e12 ), circle( t * Q, r1, e31 ) )
+ gp_mm( circle( t * P, r2, e12 ), Q * dCircle( t * Q, r1, e31 ) );
}
// Derivative of origin as moved by torus knot.
// k' * e123 * ~k + k * e123 * ~k'
direction dOrig ( float t ) {
return gp_mom_mom( dKnot(t), knot(t) );
}
The last block replaces the explicit construction of the TBN matrix with a premultiplication with a correction rotor. We simply transform the derivative of the torus knot to the identity frame, and rotate our z-axis to match the direction of the curve. This gives us a clean rectified torus knot, an object that does not exist in the three.js implentation at all. Yet, it is a very useful object, for example, you can apply it directly, without modification, to a camera to fly through the center of your torusknot.
Armed with this clean motor curve, the final step of constructing the desired geometry, complete with normals and tangents, is again simply the multiplication of this curve with a simple circle.
/***** RECTIFIED TORUS KNOT CURVE **********/
// Rectified torus curve.
motor rKnot( float t ) {
motor M = knot(t);
direction to = sw_md(reverse_m(M), dOrig(t));
motor corr = sqrt_m(gp(normalize(to), e021));
return gp(M, corr);
}
// Final extruded torus knot.
motor tube( float s, float t ) {
return gp_mm( rKnot(s), circle(t - s + 0.125, r3, e12) );
}
More orbits
Hopefully this short introduction has shown you the advantages of using motor orbits to describe developable surfaces. It certainly requires some practice to read, but the resulting code retains its editability and extendability and made it easier to provide an analytically correct derivative. In fact a large number of the geometries provided by the three.js library could be substituted by motor orbits.
Note that the torus knot is by far the most involved, and all of the other geometries in this table require only the very first code block we wrote. Remember that for any of the $M$ above, we can easily extract any desired vertex property.
The Exponential Function
At this point I hope to have convinced you that the exponential function is worth knowing about. And now that we've gotten all of this blue collar graphineering out of the way, perhaps we can take a minute to better understand this extraordinary map.
Groups and Algebras
So, a group is a monoid with inverses. Oh no! Don't worry - we're not going to take that approach. Instead we'll try to build the intuition we need to effectively use this powerhouse of a function, and so before we even attempt a definition, let us start with a guiding example. The groups we will be interested in are so called transformation or symmetry groups. They are the things like the rigid body transformations (PGA), or the conformal transformations (CGA), and they are quite complex high dimensional beasts.
Take the important group of the rigid body motions as an example. It is called $\mathfrak {SE}(3)$ and it contains all rotations, translations and their combinations. It turns out this group is a 6-dimensional smooth manifold, and well, that's a few dimensions more than our intuition can handle.
Our first example instead will focus on a much smaller group, namely the transformations of a point on a sphere. It is a great group, that we all have experience with, when we think of the points on the sphere as cities, so that the group elements become direct flights between cities. Our group is now nothing more than the bag of all possible flights. And that sets the scene for us to think about the logarithmic and exponential maps. Now that we know the group is the bag of all possible flights, what is then the algebra? The algebra is a collection of charts, a book of maps, an atlas. Let's call it Lie's atlas and start by constructing it together.
Lie's Atlas
Let us consider three cities, Amsterdam (A), Bornem (B) and Cambridge (C). The group elements drawn are not the cities, but instead the paths AB, BA, AC, CA, BC, CB. Recall that the earth is not flat, so the distance traveled by a group element is in fact the length of a great circular arc. Recall also that such distances are substantially more annoying to compute than the standard Euclidean square root of sum of squares that you'd be able to use on a flat surface. $$ d(a,b) = r \arccos {\cfrac {a \cdot b} {r^2}} \qquad \stackrel {{\text{annoying}}} {>} \qquad d(a,b) = \lVert a-b \rVert $$
Lastly, recall that it is physically impossible to gift wrap a soccer ball in any presentable way, or equivalently to unwrap the globe into a flat map while preserving distances. Apparently it took very smart mathematicians to figure out you can't flatten a ball. There is however a next best thing, and that thing is called, you've guessed it, the exponential map. Let us run through the motions to construct this map. First thing we have to do is pick a specific point to be the origin of our map. We'll just go with $A$, and we'll put that at the center of our new, flat map.
Next, we'll draw a circle of radius $1$ (in your preferred units) around this point A, and mark on it all the cities on the globe that are a flight of $1$ (in your preferred units) away from Amsterdam. We then repeat this procedure for $2, 3, ...$ units. This construction, where we take flights around the globe, and map them into straight lines on our map, is called the logarithmic function. And it does something quite magical.
When we are done constructing our map, we get a map where we can measure the distance from 'A' to any other city using the standard Euclidean distance formula! So we can't have a map that preserves distances between any two cities, but we can make one that perserves distance between one specific city, and any other city.
In blue, you will see another copy of this map, this time picking C as the center. As before this blue map will allow us to measure the distance from C to any other point using the Euclidean distance function.
So note here, and this is important, we have as many of these maps as there are points, these are all different maps even though we created them using the exact same procedure, i.e. using a single logarithmic function.
On the orange map, we can not measure the distance between $b$ and $c$, and on the blue map we can not measure the distance between $a$ and $b$. The distance between $a$ and $c$ however can be read from both the orange and blue maps.
Different maps from one logarithm?
Easy! The city where you currently are becomes the center of the map. In other words the identity element in the group gets mapped to the origin of the algebra. So in practice, if you want to have the map that belongs to the city $A$, you have to first transform your elements so that $A$ becomes the identity, i.e. multiply it with $\widetilde A$ first.
This is the source of the single most made mistake among CG practitioners when working with Lie algebras. Up to the point where I've often considered making this second 'hidden' argument of the exp aand log functions, namely which city you are starting from, an explicit argument instead. This could be done with the definition $$ \ln_A B = \ln (B \tilde A) $$ $$ \exp_A b = (\exp b) A $$
While there is no real algebraic reason for this alternate notation, as the structure of the maps is always the same, in my experience it makes things easier to work with and reason about. For example, certain fundamental relations become quite readable : $$\begin{aligned} \ln_{A} B &= -\ln_{B} A \\ \exp_{A} b &= \widetilde {\exp_{B} a} \end{aligned}$$ This simply expresses that the shortest path from a to b is the same (up to direction) as the shortest path from b to a. Similarly, we can use these to eloquently write the 'slerp' (for non graphics programmers, this stands for 'spherical linear interpolation') as: $$ \text{slerp}(A,B,t) = \exp_A( t\, ln_A B) $$ Which now clearly reads, lookup $B$ in the map of $A$, scale it with $t$, and turn it back into a transformation using the same map, namely that of $A$.
Averaging group elements.
Let us see if we can use our new notation and understanding to better grok the interesting problem of averaging and interpolating transformations. From what we have seen so far, it is clear that we can easily take the average of two group elements. To do so we can use the algebra map located at either of those elements, for indeed : $$\exp_A( t\, ln_A B) = \exp_B( (1-t)\, ln_B A) $$ Or in other words, the maps $\ln_A$ and $\ln_B$ have a single line on which they agree about all distances. When flying from $A$ to $B$, every city you fly over will simultaniously be $t$ away from $A$ and $(1-t)$ from $B$.
When taking the average of two transformations, you can indeed simply pick the correct map, and take the standard Euclidean flat average there. Note that the correct map here is not just that of $A$ or $B$ but indeed the map of any city on the great arc that has $A$ and $B$. For example, lets stop exactly halfway, and call that city $AB$. What is special about $AB$, is that on $AB$'s map, and only on $AB$'s map, the sum of the map elements of $A$ and $B$ will be zero. Or in other words, if we draw $AB$'s map, the arrow to $A$ will be equal but opposite to the arrow to $B$. $$ \ln_{AB}A + \ln_{AB}B = 0$$ In fact, we could use this as the definition of the average position between two group elements. It is the group element $AB$ for which equation 23 holds.
The motivation for us to do this should become clear when we want to consider the average of more than two group elements. For three elements $A$, $B$, $C$, the maps $\ln_A, \ln_B, \ln_C$ will only agree on distances between all three cities when all three cities are on the same great circular arc.
But for three general cities, it should now be clear that picking the correct map is not obvious. The map of $A$ can't measure between $B$ and $C$, and similar for the maps at $B$ and $C$. So how do we find the log space average of three transformations? Well, in our analogy, the average city is that one unique city that is closest to $A, B$ and $C$, or in other words, the average $ABC$ is the one that satisfies : $$ \ln_{ABC}A + \ln_{ABC}B + \ln_{ABC}C = 0 $$ And while we could directly compute AB from either the map of $A$ or that of $B$ or any map inbetween, computing $ABC$ is not as obvious. One popular iterative approach is to start with any map, taking the average on that map, flying to that city, then repeat the entire procedure. This technique is called log space averaging, and it typically converges quite quickly to a solution. Starting from a guess $G_0$, we can find the average of $n$ elements $R_j$ with: $$ G_{i+1} = \exp_{G_{i}} \frac 1 n \sum \limits_{j=1}^{n} \ln_{G_{i}}R_j$$ Where again, our new notation makes this procedure quite easy to read. We pick the map from our current guess, and use the average there to find our next guess.
Conclusion
Torus knots are pretty.
References
- Three.js TorusKnotGeometry.js. https://github.com/mrdoob/three.js/blob/dev/src/geometries/TorusKnotGeometry.js.
- Geometric Algebra and Computer Graphics. Charles Gunn & Steven De Keninck. https://dl.acm.org/doi/10.1145/3305366.3328099
- Plane-based Geometric Algebra for Computer Science. Leo Dorst & Steven De Keninck. https://bivector.net/PGA4CS.html
- May the Forque be with you. Leo Dorst & Steven De Keninck. https://bivector.net/PGADYN.html
- Normalization, square roots, and the exponential and logarithmic maps in geometric algebras of less than 6D. Steven De Keninck & Martin Roelfs. http://dx.doi.org/10.1002/mma.8639