core design
This page explains the mathematics that Luna-Flow/quaternion implements and why its API has the shape it has. Every formula below is the one the code in src/quaternion.mbt evaluates; where the implementation deviates from the mathematics, the deviation is stated.
Design goal
The package gives MoonBit one quaternion type, Quaternion[T], that serves two audiences:
- algebraic code, which wants as a ring over an exact or approximate scalar type and uses it through the luna-generic traits;
- geometric code, which wants unit quaternions as 3D rotations: build them from an axis and an angle or from Euler angles, compose, apply, interpolate and decompose them.
Both are served by the same generic type. Each operation asks only for the traits of T it needs, so ring arithmetic works over Int exactly, while the operations that need square roots or trigonometry go through Double.
Mathematical background
Quaternions as a real algebra
The quaternions are the 4-dimensional real vector space with basis ,
with a bilinear multiplication fixed by Hamilton’s relations11 W. R. Hamilton, “On quaternions; or on a new system of imaginaries in algebra”, 1843. The relations are the ones he carved into Brougham Bridge in Dublin.
We write with the scalar part and the vector part , and identify with the pure quaternions . Quaternion[T] stores exactly this pair: a field r for and a triple vec for . Real numbers commute with everything, because the multiplication is -bilinear.
Products of the units
Everything about the product follows from the four relations. Multiply on the right by and use :
Multiply on the left by and use : , so . The reversed products follow from these two:
So the units multiply like the cross product of the standard basis, with an extra on the diagonal:
The eight elements form the quaternion group , which can be realized by complex matrices.22 For example , , , . These matrices satisfy Hamilton’s relations, and matrix multiplication is associative. The multiplication of is associative, and by bilinearity so is the product of .
The Hamilton product
Expanding times term by term with the table gives
The same product is clearer in scalar–vector form. For two pure quaternions and , the squares of the units contribute , and the mixed terms pair up, for example . Hence
With bilinearity and the fact that scalars commute,
This is literally the Mul implementation: the scalar part is self.r * other.r - dot(self.vec, other.vec) and the vector part is cross(self.vec, other.vec) plus the two scaled vectors.
Swapping the factors only flips the sign of the cross product, so
and two quaternions commute exactly when their vector parts are parallel. In particular is not commutative: but .
The derivation used that the components commute with each other ( and so on). The generic implementation therefore assumes that T has a commutative multiplication, which holds for every numeric type luna-generic provides.
Conjugate and norm
The conjugate is . Multiplying a quaternion by its conjugate leaves a real number, because the cross product of parallel vectors vanishes:
and in the same way . Quaternion::square_len computes this , and Quaternion::dot is the inner product of whose norm it is.
Conjugation reverses products. With and ,
and the two agree because . So .
The norm is multiplicative. Using associativity, , and the fact that the real number commutes with :
Over the integers this is Euler’s four-square identity, which the tutorial checks with Quaternion[Int].
Inverse and the two divisions
If is not zero, then , and shows that
is a two-sided inverse. This is the Inverse implementation: the conjugate scaled by one() / square_len(). Every non-zero quaternion is invertible, so is a division ring (a skew field). It is not a field, because it is not commutative.
Without commutativity, ” divided by ” has two meanings, one for each side on which the unknown sits:
The implementation avoids forming and divides once at the end. With , and , the scalar–vector product gives
which are the Div implementation (with c = cross(q.vec, r.vec)) and Quaternion::left_div (with c = cross(r.vec, self.vec)). The quotients have the same scalar part and differ by
Example. Take and . Then , , and , so
The API page checks these values and both defining equations.
Unit quaternions and rotations
A unit quaternion () can be written
which is exactly what from_axis_angle(axis, angle) returns, with the normalized axis. For unit we have . Consider the map on pure quaternions. Write . First,
Multiplying on the right by , the scalar part is
so the image is again a pure quaternion, and the vector part is
using and . Substituting and , and the half-angle identities , , :
This is Rodrigues’ rotation formula: is the rotation by about , counter-clockwise by the right-hand rule. Since , the map is an isometry, as a rotation must be.
Three consequences shape the API:
- Composition is multiplication. , so the rotation “first , then ” is
p * q. - Double cover. , so and are the same rotation. Indeed the angle gives . Every rotation has exactly two unit quaternions, which is why
==does not compare rotations and whyslerpflips the sign of one input. - Cheap evaluation.
Quaternion::rotateevaluates and . Expanding, , so which equals the formula above exactly when , that is when . This is whyrotaterequires a unit quaternion: it needs no division, but it relies on the norm being 1.
The same formula, applied to the basis vectors, gives the rotation matrix of a unit quaternion:
The package does not return this matrix, but the Euler-angle extraction below reads its entries.
Euler angles
Write , , for the rotations by about the coordinate axes, and for their matrices. A sequence of three rotations about axes with angles can be read in two ways:
- extrinsic (
external=true): about the fixed axes, first , then , then . Later rotations multiply on the left: . - intrinsic (
external=false): about the axes of the moving body, first , then the moved , then the twice-moved : .
The same product read in both ways shows that intrinsic with angles is extrinsic with angles . The implementation uses exactly this: every to_euler_internal_* function calls the extrinsic function of the reversed order and reverses the triple.
Extraction, extrinsic XYZ. For , multiplying out the elementary matrices gives
Comparing with entry by entry:
valid while (dividing both atan2 arguments by does not change the angle). These are the expressions in to_euler_external_XYZ, with . The functions to_euler_external_YZX and to_euler_external_ZYX are the same derivation with the axes permuted; a permutation that is not cyclic flips the sign inside the arcsine and the atan2 numerators. The module normalizes q before extracting, so the identities used in the diagonal of hold up to rounding.
Gimbal lock. When , the entries all vanish and the first and third rotations act about the same physical axis. For in the XYZ case,
so only is determined (and for ). A conventional fix sets and recovers from the entries that remain, here . The implementation switches to a special branch when (about ): it prints a warning, sets and , but takes the first angle from for XYZ, and from the regular-branch formula (whose arguments are near zero) for the other orders. Neither isolates , so the branch currently returns angles that do not reproduce the input; see known deviations.
Construction. from_euler(roll, pitch, yaw) with the default "XYZ" multiplies out in closed form. With , and so on, two applications of the Hamilton product give
the expression in the source. to_euler_internal_XYZ inverts it.
Spherical linear interpolation
Unit quaternions form the 3-sphere , and the shortest path between two orientations is a great-circle arc. Let be unit with , . The unit vector
is orthogonal to , and the arc is . At ,
which is the formula slerp evaluates. It moves at constant angular speed, and the rotation angle grows linearly in . Because of the double cover, slerp first replaces by when , so that and the rotation takes the short way around. It clamps to before acos, since rounding can push the dot product of two unit quaternions slightly past 1.
Powers
pow_by_int uses and , which needs only associativity; all factors are powers of the same , so they commute with each other. Negative exponents use .
pow_by_T uses the polar form. Every non-zero is with and a unit vector , and the span of and is a copy of (because ). De Moivre’s formula then defines
The implementation computes , which is correct only for , that is for a non-negative scalar part; see known deviations.
Design decisions
Generic components with per-function bounds
Problem. Quaternions are useful over exact integers (number theory, testing identities) and over Double (geometry), and the luna-generic ecosystem wants one type that fits both.
Options. A Double-only type; a type parameterized by a single “real number” trait; or a type with no bound on T whose functions each require only what they use.
Choice. The last. Quaternion[T] has no bound; + needs Add, * needs Mul + Sub + Add, square_len needs Mul + Add, and only functions with square roots or trigonometry ask for DoubleConvert. This follows the Luna-Flow principle that code depends on the smallest trait composition that states its needs, and it lets Quaternion[Int] check algebraic identities exactly.
Scalar–vector storage
The type stores r : T and vec : (T, T, T) rather than four separate fields. The scalar–vector form is how the product, the conjugate, the inverse and the rotation are derived above, so the code reads like the derivations (dot, cross, scale on vec). The type is abstract, which keeps the representation free to change; the cost is that there are no component accessors yet.
Why Ring and not Field
luna-generic’s Field is Ring + Inverse + Div, and generic code written against it is entitled to the laws of a commutative field, such as or . Quaternions satisfy every ring law and have inverses, but not commutativity, so claiming Field would let such code compute wrong answers silently. The package therefore implements Zero, One, AddMonoid, MulMonoid, Semiring and Ring (all lawful for over a commutative T), plus the operation traits Inverse and Conjugate, and stops there. Division is still available through Div and left_div, but generic code has to ask for it explicitly.
/ is right division
Problem. With non-commuting multiplication, q / r must pick a side. Before 0.2.0 it computed .
Choice. Since 0.2.0, q / r is , the solution of . This is the usual convention for a division ring and keeps Div consistent with Inverse in the way it is for the luna-generic scalar types: a / b == a * b.inv(). It also reads naturally for rotations: to / from is the rotation that, applied after from, gives to. The other quotient stays available as left_div, with a name that says which side the inverse is on. The change is breaking and is recorded in the changelog.
The Double bridge
Square roots and trigonometric functions exist for Double only, and the package depends on nothing but luna-generic, which has no analytic traits. DoubleConvert is a two-method trait that converts a component to Double and back, so magnitude, normalize, slerp, pow_by_T and the Euler conversions stay generic in signature while computing in Double. It is implemented for Double (as the identity) and Int (truncating from_double). The trait is declared pub rather than pub(open), so other packages cannot implement it for their own types.
Exact structural equality
Eq and Hash are derived and compare components exactly. Equality “as rotations” () or “within a tolerance” depends on the application, so it is left to the caller (the tutorial shows one way). Exact equality also keeps Eq consistent with Hash.
Explicit method promotion
MoonBit 0.10 no longer turns trait implementations into methods implicitly. src/extends.mbt promotes the operators, equal, hash, to_string, conjugate, zero, one and inv, which are natural operations on a quaternion. The method forms not_equal, hash_combine, output, default and to_repr are kept, deprecated and hidden, for compatibility.
Correctness and invariants
Algebraic laws
Over a commutative ring T with exact arithmetic (Int, BigInt), the implementation satisfies:
- the ring laws: is an abelian group, a monoid, and distributes over on both sides;
- , , and ;
- for exact division (a field
Tsuch as rationals): , and .
The package tests check the ring laws on integer samples, non-commutativity, and the two division identities.
Floating-point error
Over Double the laws hold up to rounding. Each component of is a sum of four products of a component of with a component of , evaluated with four roundings of products and three of additions. The standard bound for such an inner product33 N. J. Higham, Accuracy and Stability of Numerical Algorithms, 2nd ed., SIAM 2002, §3.1: for any order of summation. gives, componentwise,
where is the permutation of ‘s components that appears in component and the second inequality is Cauchy–Schwarz. Summing over the four components, , so by the multiplicativity of the norm
A product of unit quaternions therefore has norm with to first order, where ; in practice the errors partly cancel and the drift is smaller. Because rotate assumes , a drifted quaternion scales vectors by about . Long chains should call normalize periodically; its result has norm within a few units of (for example normalizes to a quaternion of computed norm ).
Division by zero and degenerate inputs
The package does not check its inputs; degenerate cases follow the arithmetic of T:
| Input | Double | Int |
|---|---|---|
inv, /, left_div with a zero divisor | NaN components | runtime trap (integer division by zero) |
normalize of zero | zero returned unchanged | zero returned unchanged |
pow_by_T of zero | zero returned unchanged | zero returned unchanged |
from_axis_angle with a zero axis | NaN vector part | runtime trap |
slerp of a zero input | the zero input is used unnormalized | not meaningful |
Over Int, inv is zero unless , and every function that goes through DoubleConvert truncates.
Thresholds
slerp falls back to normalized linear interpolation when , that is rad. There , and dividing by it would amplify rounding errors in the weights; the linear path, on the other hand, deviates from the arc by less than rad on (about rad of rotation angle) at the threshold, and less below it.
The Euler conversions treat as gimbal lock. Rounding to there costs up to rad in the middle angle.
Complexity
Every operation is on four components: a Hamilton product costs 16 multiplications and 12 additions, rotate 18 multiplications and 12 additions. pow_by_int(n) uses products and recursion depth.
Known deviations
These are deviations of the current implementation from the mathematics above. They are documented here rather than hidden, and are reported for fixing:
from_eulerwith"ZYX"or"YZX"does not evaluate a product of axis rotations; the results are not unit quaternions."ZXY"takes its angles by position while"YXZ"takes them by axis.to_euler_external_XZY(and thereforeto_euler_internal_YZX) evaluates the intrinsic X-Y-Z extraction instead of the named sequence.- The gimbal-lock branch of the Euler extraction does not isolate the free angle (see above), and it reports the warning with
println. pow_by_Tcomputes withasin, which cannot exceed ; would cover .
Alternatives rejected
- Implementing
Field. Rejected because genericFieldcode may rely on commutativity (see above). - Keeping
/as left division. It contradicteda / b == a * b.inv()and the usual reading ofto / fromfor rotations. - A
Double-only type. It would lose exact arithmetic overIntand the luna-generic ring structure for other scalar types. - Storing four named fields or a rotation matrix. Four fields hide the scalar–vector structure the formulas use; a matrix has nine entries, needs re-orthogonalization instead of a cheap normalization, and cannot be interpolated as simply.
- Comparing with a tolerance in
Eq. A tolerance-based==is not transitive and cannot agree withHash.
Boundaries
The package deliberately does not:
- provide rotation matrices, exponential and logarithm maps, or quaternion calculus (dual quaternions, derivatives of orientations);
- check its inputs or return
Result: degenerate inputs follow the arithmetic ofT, androtatetrusts that its receiver is a unit quaternion; - implement luna-generic
FieldorNum, or make quaternions an ordered type; - depend on
arithmetic,linear-algebraorluna-complex; its only Luna-Flow dependency is luna-generic; - choose a tolerance for comparing rotations; that is left to the caller.
Footnotes
-
W. R. Hamilton, “On quaternions; or on a new system of imaginaries in algebra”, 1843. The relations are the ones he carved into Brougham Bridge in Dublin. ↩
-
For example , , , . These matrices satisfy Hamilton’s relations, and matrix multiplication is associative. ↩
-
N. J. Higham, Accuracy and Stability of Numerical Algorithms, 2nd ed., SIAM 2002, §3.1: for any order of summation. ↩