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 H\mathbb H 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 H\mathbb H are the 4-dimensional real vector space with basis 1,i,j,k1, i, j, k,

q=w+x i+y j+z k,w,x,y,z∈R,q = w + x\,i + y\,j + z\,k, \qquad w, x, y, z \in \mathbb R,

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.

i2=j2=k2=ijk=−1.i^2 = j^2 = k^2 = ijk = -1 .

We write q=(w,u)q = (w, \mathbf u) with the scalar part ww and the vector part u=(x,y,z)\mathbf u = (x, y, z), and identify R3\mathbb R^3 with the pure quaternions {(0,u)}\{(0, \mathbf u)\}. Quaternion[T] stores exactly this pair: a field r for ww and a triple vec for u\mathbf u. Real numbers (s,0)(s, \mathbf 0) commute with everything, because the multiplication is R\mathbb R-bilinear.

Products of the units

Everything about the product follows from the four relations. Multiply ijk=−1ijk = -1 on the right by kk and use k2=−1k^2 = -1:

ijk k=−k−ij=−kij=k.\begin{aligned} ijk\,k &= -k \\ -ij &= -k \\ ij &= k . \end{aligned}

Multiply ijk=−1ijk = -1 on the left by ii and use i2=−1i^2 = -1: −jk=−i-jk = -i, so jk=ijk = i. The reversed products follow from these two:

ji=j (jk)=j2k=−k,kj=(ij) j=i j2=−i,ik=i (ij)=i2j=−j,ki=(ij) i=i (ji)=−ik=j.\begin{aligned} ji &= j\,(jk) = j^2 k = -k, \\ kj &= (ij)\,j = i\,j^2 = -i, \\ ik &= i\,(ij) = i^2 j = -j, \\ ki &= (ij)\,i = i\,(ji) = -ik = j . \end{aligned}

So the units multiply like the cross product of the standard basis, with an extra −1-1 on the diagonal:

⋅\cdotiijjkk
ii−1-1kk−j-j
jj−k-k−1-1ii
kkjj−i-i−1-1

The eight elements ±1,±i,±j,±k\pm 1, \pm i, \pm j, \pm k form the quaternion group Q8Q_8, which can be realized by 2×22\times 2 complex matrices.22 For example 1↦I1 \mapsto I, i↦(i00−i)i \mapsto \begin{pmatrix} i & 0 \\ 0 & -i\end{pmatrix}, j↦(01−10)j \mapsto \begin{pmatrix} 0 & 1 \\ -1 & 0\end{pmatrix}, k↦(0ii0)k \mapsto \begin{pmatrix} 0 & i \\ i & 0\end{pmatrix}. These matrices satisfy Hamilton’s relations, and matrix multiplication is associative. The multiplication of Q8Q_8 is associative, and by bilinearity so is the product of H\mathbb H.

The Hamilton product

Expanding p=w1+x1i+y1j+z1kp = w_1 + x_1 i + y_1 j + z_1 k times q=w2+x2i+y2j+z2kq = w_2 + x_2 i + y_2 j + z_2 k term by term with the table gives

pq=(w1w2−x1x2−y1y2−z1z2)+(w1x2+x1w2+y1z2−z1y2) i+(w1y2−x1z2+y1w2+z1x2) j+(w1z2+x1y2−y1x2+z1w2) k.\begin{aligned} pq ={} & (w_1 w_2 - x_1 x_2 - y_1 y_2 - z_1 z_2) \\ &+ (w_1 x_2 + x_1 w_2 + y_1 z_2 - z_1 y_2)\, i \\ &+ (w_1 y_2 - x_1 z_2 + y_1 w_2 + z_1 x_2)\, j \\ &+ (w_1 z_2 + x_1 y_2 - y_1 x_2 + z_1 w_2)\, k . \end{aligned}

The same product is clearer in scalar–vector form. For two pure quaternions a\mathbf a and b\mathbf b, the squares of the units contribute −a1b1−a2b2−a3b3-a_1 b_1 - a_2 b_2 - a_3 b_3, and the mixed terms pair up, for example a1b2 ij+a2b1 ji=(a1b2−a2b1)ka_1 b_2\, ij + a_2 b_1\, ji = (a_1 b_2 - a_2 b_1)k. Hence

a b=− a⋅b+a×b.\mathbf a\,\mathbf b = -\,\mathbf a\cdot\mathbf b + \mathbf a\times\mathbf b .

With bilinearity and the fact that scalars commute,

(s1,v1)(s2,v2)=s1s2+s1v2+s2v1+v1v2=(s1s2−v1⋅v2,  s1v2+s2v1+v1×v2).\begin{aligned} (s_1, \mathbf v_1)(s_2, \mathbf v_2) &= s_1 s_2 + s_1\mathbf v_2 + s_2\mathbf v_1 + \mathbf v_1\mathbf v_2 \\ &= \big(s_1 s_2 - \mathbf v_1\cdot\mathbf v_2,\ \ s_1\mathbf v_2 + s_2\mathbf v_1 + \mathbf v_1\times\mathbf v_2\big). \end{aligned}

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

pq−qp=2 v1×v2,pq - qp = 2\,\mathbf v_1 \times \mathbf v_2 ,

and two quaternions commute exactly when their vector parts are parallel. In particular H\mathbb H is not commutative: ij=kij = k but ji=−kji = -k.

The derivation used that the components commute with each other (s1v2=v2s1s_1\mathbf v_2 = \mathbf v_2 s_1 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 qˉ=(w,−u)\bar q = (w, -\mathbf u). Multiplying a quaternion by its conjugate leaves a real number, because the cross product of parallel vectors vanishes:

qqˉ=(w2+u⋅u, −wu+wu−u×u)=(w2+x2+y2+z2, 0)=∣q∣2,q\bar q = \big(w^2 + \mathbf u\cdot\mathbf u,\ -w\mathbf u + w\mathbf u - \mathbf u\times\mathbf u\big) = \big(w^2 + x^2 + y^2 + z^2,\ \mathbf 0\big) = |q|^2 ,

and in the same way qˉq=∣q∣2\bar q q = |q|^2. Quaternion::square_len computes this ∣q∣2|q|^2, and Quaternion::dot is the inner product of R4\mathbb R^4 whose norm it is.

Conjugation reverses products. With p=(s1,v1)p = (s_1, \mathbf v_1) and q=(s2,v2)q = (s_2, \mathbf v_2),

pq‾=(s1s2−v1⋅v2, −s1v2−s2v1−v1×v2),qˉ pˉ=(s2s1−v2⋅v1, −s2v1−s1v2+v2×v1),\begin{aligned} \overline{pq} &= \big(s_1 s_2 - \mathbf v_1\cdot\mathbf v_2,\ -s_1\mathbf v_2 - s_2\mathbf v_1 - \mathbf v_1\times\mathbf v_2\big), \\ \bar q\,\bar p &= \big(s_2 s_1 - \mathbf v_2\cdot\mathbf v_1,\ -s_2\mathbf v_1 - s_1\mathbf v_2 + \mathbf v_2\times\mathbf v_1\big), \end{aligned}

and the two agree because v2×v1=−v1×v2\mathbf v_2\times\mathbf v_1 = -\mathbf v_1\times\mathbf v_2. So pq‾=qˉ pˉ\overline{pq} = \bar q\,\bar p.

The norm is multiplicative. Using associativity, pq‾=qˉpˉ\overline{pq} = \bar q\bar p, and the fact that the real number ∣q∣2|q|^2 commutes with pp:

∣pq∣2=(pq) (pq)‾=p (qqˉ) pˉ=p ∣q∣2 pˉ=∣q∣2 ppˉ=∣p∣2 ∣q∣2.\begin{aligned} |pq|^2 &= (pq)\,\overline{(pq)} = p\,(q\bar q)\,\bar p \\ &= p\,|q|^2\,\bar p = |q|^2\,p\bar p = |p|^2\,|q|^2 . \end{aligned}

Over the integers this is Euler’s four-square identity, which the tutorial checks with Quaternion[Int].

Inverse and the two divisions

If qq is not zero, then ∣q∣2>0|q|^2 > 0, and qqˉ=qˉq=∣q∣2q\bar q = \bar q q = |q|^2 shows that

q−1=qˉ∣q∣2q^{-1} = \frac{\bar q}{|q|^2}

is a two-sided inverse. This is the Inverse implementation: the conjugate scaled by one() / square_len(). Every non-zero quaternion is invertible, so H\mathbb H is a division ring (a skew field). It is not a field, because it is not commutative.

Without commutativity, ”qq divided by rr” has two meanings, one for each side on which the unknown sits:

x r=q  ⟺  x=q r−1(right division, q/r),r x=q  ⟺  x=r−1q(left division, q.left_div(r)).\begin{aligned} x\,r = q &\iff x = q\,r^{-1} && \text{(right division, } q / r\text{)}, \\ r\,x = q &\iff x = r^{-1} q && \text{(left division, } q\text{.left\_div}(r)\text{)}. \end{aligned}

The implementation avoids forming r−1r^{-1} and divides once at the end. With q=(sq,vq)q = (s_q, \mathbf v_q), r=(sr,vr)r = (s_r, \mathbf v_r) and rˉ=(sr,−vr)\bar r = (s_r, -\mathbf v_r), the scalar–vector product gives

q r−1=q rˉ∣r∣2=(sqsr+vq⋅vr,  srvq−sqvr−vq×vr)∣r∣2,r−1q=rˉ q∣r∣2=(sqsr+vq⋅vr,  srvq−sqvr−vr×vq)∣r∣2,\begin{aligned} q\,r^{-1} &= \frac{q\,\bar r}{|r|^2} = \frac{\big(s_q s_r + \mathbf v_q\cdot\mathbf v_r,\ \ s_r\mathbf v_q - s_q\mathbf v_r - \mathbf v_q\times\mathbf v_r\big)}{|r|^2}, \\ r^{-1} q &= \frac{\bar r\,q}{|r|^2} = \frac{\big(s_q s_r + \mathbf v_q\cdot\mathbf v_r,\ \ s_r\mathbf v_q - s_q\mathbf v_r - \mathbf v_r\times\mathbf v_q\big)}{|r|^2}, \end{aligned}

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

q r−1−r−1q=−2 vq×vr∣r∣2.q\,r^{-1} - r^{-1} q = -\frac{2\,\mathbf v_q\times\mathbf v_r}{|r|^2}.

Example. Take q=1+2i+3j+4kq = 1 + 2i + 3j + 4k and r=5+6i+7j+8kr = 5 + 6i + 7j + 8k. Then ∣r∣2=174|r|^2 = 174, sqsr+vq⋅vr=5+12+21+32=70s_q s_r + \mathbf v_q\cdot\mathbf v_r = 5 + 12 + 21 + 32 = 70, srvq−sqvr=(4,8,12)s_r\mathbf v_q - s_q\mathbf v_r = (4, 8, 12) and vq×vr=(−4,8,−4)\mathbf v_q\times\mathbf v_r = (-4, 8, -4), so

q/r=1174(70+8i+0j+16k)≈0.4023+0.0460 i+0.0920 k,q.left_div(r)=1174(70+0i+16j+8k)≈0.4023+0.0920 j+0.0460 k.\begin{aligned} q / r &= \tfrac{1}{174}\big(70 + 8i + 0j + 16k\big) \approx 0.4023 + 0.0460\,i + 0.0920\,k, \\ q.\mathrm{left\_div}(r) &= \tfrac{1}{174}\big(70 + 0i + 16j + 8k\big) \approx 0.4023 + 0.0920\,j + 0.0460\,k . \end{aligned}

The API page checks these values and both defining equations.

Unit quaternions and rotations

A unit quaternion (∣q∣=1|q| = 1) can be written

q=(cos⁡θ2, sin⁡θ2 n),∣n∣=1,q = \Big(\cos\tfrac{\theta}{2},\ \sin\tfrac{\theta}{2}\,\mathbf n\Big), \qquad |\mathbf n| = 1 ,

which is exactly what from_axis_angle(axis, angle) returns, with n\mathbf n the normalized axis. For unit qq we have q−1=qˉq^{-1} = \bar q. Consider the map v↦q v qˉ\mathbf v \mapsto q\,\mathbf v\,\bar q on pure quaternions. Write q=(w,u)q = (w, \mathbf u). First,

q v=(−u⋅v, wv+u×v).q\,\mathbf v = \big(-\mathbf u\cdot\mathbf v,\ w\mathbf v + \mathbf u\times\mathbf v\big).

Multiplying on the right by qˉ=(w,−u)\bar q = (w, -\mathbf u), the scalar part is

−w u⋅v+(wv+u×v)⋅u=−w u⋅v+w v⋅u+0=0,-w\,\mathbf u\cdot\mathbf v + (w\mathbf v + \mathbf u\times\mathbf v)\cdot\mathbf u = -w\,\mathbf u\cdot\mathbf v + w\,\mathbf v\cdot\mathbf u + 0 = 0 ,

so the image is again a pure quaternion, and the vector part is

v′=(u⋅v) u+w (wv+u×v)−(wv+u×v)×u=(u⋅v) u+w2v+2w u×v+u×(u×v)=(w2−∣u∣2) v+2(u⋅v) u+2w u×v,\begin{aligned} \mathbf v' &= (\mathbf u\cdot\mathbf v)\,\mathbf u + w\,(w\mathbf v + \mathbf u\times\mathbf v) - (w\mathbf v + \mathbf u\times\mathbf v)\times\mathbf u \\ &= (\mathbf u\cdot\mathbf v)\,\mathbf u + w^2\mathbf v + 2w\,\mathbf u\times\mathbf v + \mathbf u\times(\mathbf u\times\mathbf v) \\ &= (w^2 - |\mathbf u|^2)\,\mathbf v + 2(\mathbf u\cdot\mathbf v)\,\mathbf u + 2w\,\mathbf u\times\mathbf v , \end{aligned}

using a×u=−u×a\mathbf a\times\mathbf u = -\mathbf u\times\mathbf a and u×(u×v)=(u⋅v)u−∣u∣2v\mathbf u\times(\mathbf u\times\mathbf v) = (\mathbf u\cdot\mathbf v)\mathbf u - |\mathbf u|^2\mathbf v. Substituting w=cos⁡θ2w = \cos\frac\theta2 and u=sin⁡θ2 n\mathbf u = \sin\frac\theta2\,\mathbf n, and the half-angle identities cos⁡2θ2−sin⁡2θ2=cos⁡θ\cos^2\frac\theta2 - \sin^2\frac\theta2 = \cos\theta, 2sin⁡2θ2=1−cos⁡θ2\sin^2\frac\theta2 = 1 - \cos\theta, 2sin⁡θ2cos⁡θ2=sin⁡θ2\sin\frac\theta2\cos\frac\theta2 = \sin\theta:

v′=cos⁡θ v+(1−cos⁡θ)(n⋅v) n+sin⁡θ (n×v).\mathbf v' = \cos\theta\,\mathbf v + (1 - \cos\theta)(\mathbf n\cdot\mathbf v)\,\mathbf n + \sin\theta\,(\mathbf n\times\mathbf v).

This is Rodrigues’ rotation formula: v↦qvq−1\mathbf v \mapsto q\mathbf v q^{-1} is the rotation by θ\theta about n\mathbf n, counter-clockwise by the right-hand rule. Since ∣qvqˉ∣=∣q∣∣v∣∣qˉ∣=∣v∣|q\mathbf v\bar q| = |q||\mathbf v||\bar q| = |\mathbf v|, the map is an isometry, as a rotation must be.

Three consequences shape the API:

  • Composition is multiplication. p (qvq−1) p−1=(pq) v (pq)−1p\,(q\mathbf v q^{-1})\,p^{-1} = (pq)\,\mathbf v\,(pq)^{-1}, so the rotation “first qq, then pp” is p * q.
  • Double cover. (−q) v (−q)−1=qvq−1(-q)\,\mathbf v\,(-q)^{-1} = q\mathbf v q^{-1}, so qq and −q-q are the same rotation. Indeed the angle θ+2π\theta + 2\pi gives −q-q. Every rotation has exactly two unit quaternions, which is why == does not compare rotations and why slerp flips the sign of one input.
  • Cheap evaluation. Quaternion::rotate evaluates t=2 u×v\mathbf t = 2\,\mathbf u\times\mathbf v and v′=v+w t+u×t\mathbf v' = \mathbf v + w\,\mathbf t + \mathbf u\times\mathbf t. Expanding, u×t=2(u⋅v)u−2∣u∣2v\mathbf u\times\mathbf t = 2(\mathbf u\cdot\mathbf v)\mathbf u - 2|\mathbf u|^2\mathbf v, so v+w t+u×t=(1−2∣u∣2) v+2(u⋅v) u+2w u×v,\mathbf v + w\,\mathbf t + \mathbf u\times\mathbf t = (1 - 2|\mathbf u|^2)\,\mathbf v + 2(\mathbf u\cdot\mathbf v)\,\mathbf u + 2w\,\mathbf u\times\mathbf v , which equals the formula above exactly when 1−2∣u∣2=w2−∣u∣21 - 2|\mathbf u|^2 = w^2 - |\mathbf u|^2, that is when w2+∣u∣2=1w^2 + |\mathbf u|^2 = 1. This is why rotate requires 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:

R(q)=(1−2(y2+z2)2(xy−wz)2(xz+wy)2(xy+wz)1−2(x2+z2)2(yz−wx)2(xz−wy)2(yz+wx)1−2(x2+y2)).R(q) = \begin{pmatrix} 1 - 2(y^2 + z^2) & 2(xy - wz) & 2(xz + wy) \\ 2(xy + wz) & 1 - 2(x^2 + z^2) & 2(yz - wx) \\ 2(xz - wy) & 2(yz + wx) & 1 - 2(x^2 + y^2) \end{pmatrix}.

The package does not return this matrix, but the Euler-angle extraction below reads its entries.

Euler angles

Write qX(α)q_X(\alpha), qY(α)q_Y(\alpha), qZ(α)q_Z(\alpha) for the rotations by α\alpha about the coordinate axes, and RX,RY,RZR_X, R_Y, R_Z for their matrices. A sequence of three rotations about axes A,B,CA, B, C with angles a,b,ca, b, c can be read in two ways:

  • extrinsic (external=true): about the fixed axes, first AA, then BB, then CC. Later rotations multiply on the left: q=qC(c) qB(b) qA(a)q = q_C(c)\,q_B(b)\,q_A(a).
  • intrinsic (external=false): about the axes of the moving body, first AA, then the moved BB, then the twice-moved CC: q=qA(a) qB(b) qC(c)q = q_A(a)\,q_B(b)\,q_C(c).

The same product read in both ways shows that intrinsic ABCABC with angles (a,b,c)(a, b, c) is extrinsic CBACBA with angles (c,b,a)(c, b, a). 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 R=RZ(c) RY(b) RX(a)R = R_Z(c)\,R_Y(b)\,R_X(a), multiplying out the elementary matrices gives

R=(cos⁡bcos⁡c⋯⋯cos⁡bsin⁡c⋯⋯−sin⁡bcos⁡bsin⁡acos⁡bcos⁡a).R = \begin{pmatrix} \cos b\cos c & \cdots & \cdots \\ \cos b\sin c & \cdots & \cdots \\ -\sin b & \cos b\sin a & \cos b\cos a \end{pmatrix}.

Comparing with R(q)R(q) entry by entry:

sin⁡b=−R31=2(wy−xz),a=atan2⁡(R32,R33)=atan2⁡(2(wx+yz), 1−2(x2+y2)),c=atan2⁡(R21,R11)=atan2⁡(2(wz+xy), 1−2(y2+z2)),\begin{aligned} \sin b &= -R_{31} = 2(wy - xz), \\ a &= \operatorname{atan2}(R_{32}, R_{33}) = \operatorname{atan2}\big(2(wx + yz),\ 1 - 2(x^2 + y^2)\big), \\ c &= \operatorname{atan2}(R_{21}, R_{11}) = \operatorname{atan2}\big(2(wz + xy),\ 1 - 2(y^2 + z^2)\big), \end{aligned}

valid while cos⁡b>0\cos b > 0 (dividing both atan2 arguments by cos⁡b\cos b does not change the angle). These are the expressions in to_euler_external_XYZ, with b=arcsin⁡(⋅)∈[−π/2,π/2]b = \arcsin(\cdot) \in [-\pi/2, \pi/2]. 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 w2+x2+y2+z2=1w^2 + x^2 + y^2 + z^2 = 1 used in the diagonal of R(q)R(q) hold up to rounding.

Gimbal lock. When cos⁡b=0\cos b = 0, the entries R32,R33,R21,R11R_{32}, R_{33}, R_{21}, R_{11} all vanish and the first and third rotations act about the same physical axis. For b=π/2b = \pi/2 in the XYZ case,

RZ(c) RY(π2) RX(a)=RY(π2) RX(a−c),R_Z(c)\,R_Y(\tfrac\pi2)\,R_X(a) = R_Y(\tfrac\pi2)\,R_X(a - c),

so only a−ca - c is determined (and a+ca + c for b=−π/2b = -\pi/2). A conventional fix sets c=0c = 0 and recovers aa from the entries that remain, here a=atan2⁡(R12,R22)a = \operatorname{atan2}(R_{12}, R_{22}). The implementation switches to a special branch when ∣sin⁡b∣≥0.9998|\sin b| \ge 0.9998 (about 88.85∘88.85^\circ): it prints a warning, sets b=±π/2b = \pm\pi/2 and c=0c = 0, but takes the first angle from atan2⁡(R13,R22)\operatorname{atan2}(R_{13}, R_{22}) for XYZ, and from the regular-branch formula (whose arguments are near zero) for the other orders. Neither isolates a∓ca \mp c, 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 qX(roll) qY(pitch) qZ(yaw)q_X(\text{roll})\,q_Y(\text{pitch})\,q_Z(\text{yaw}) in closed form. With cr=cos⁡roll2c_r = \cos\frac{\text{roll}}{2}, sr=sin⁡roll2s_r = \sin\frac{\text{roll}}{2} and so on, two applications of the Hamilton product give

qXqYqZ=(crcpcy−srspsy,  srcpcy+crspsy,  crspcy−srcpsy,  crcpsy+srspcy),q_X q_Y q_Z = \big(c_r c_p c_y - s_r s_p s_y,\ \ s_r c_p c_y + c_r s_p s_y,\ \ c_r s_p c_y - s_r c_p s_y,\ \ c_r c_p s_y + s_r s_p c_y\big),

the expression in the source. to_euler_internal_XYZ inverts it.

Spherical linear interpolation

Unit quaternions form the 3-sphere S3⊂R4S^3 \subset \mathbb R^4, and the shortest path between two orientations is a great-circle arc. Let q1,q2q_1, q_2 be unit with cos⁡Ω=q1⋅q2\cos\Omega = q_1\cdot q_2, 0<Ω<π0 < \Omega < \pi. The unit vector

q⊥=q2−cos⁡Ω q1sin⁡Ωq_\perp = \frac{q_2 - \cos\Omega\, q_1}{\sin\Omega}

is orthogonal to q1q_1, and the arc is γ(φ)=cos⁡φ q1+sin⁡φ q⊥\gamma(\varphi) = \cos\varphi\, q_1 + \sin\varphi\, q_\perp. At φ=tΩ\varphi = t\Omega,

γ(tΩ)=sin⁡Ωcos⁡tΩ−cos⁡Ωsin⁡tΩsin⁡Ω q1+sin⁡tΩsin⁡Ω q2=sin⁡((1−t)Ω)sin⁡Ω q1+sin⁡(tΩ)sin⁡Ω q2,\begin{aligned} \gamma(t\Omega) &= \frac{\sin\Omega\cos t\Omega - \cos\Omega\sin t\Omega}{\sin\Omega}\,q_1 + \frac{\sin t\Omega}{\sin\Omega}\,q_2 \\ &= \frac{\sin\big((1-t)\Omega\big)}{\sin\Omega}\,q_1 + \frac{\sin(t\Omega)}{\sin\Omega}\,q_2 , \end{aligned}

which is the formula slerp evaluates. It moves at constant angular speed, and the rotation angle grows linearly in tt. Because of the double cover, slerp first replaces q2q_2 by −q2-q_2 when q1⋅q2<0q_1\cdot q_2 < 0, so that Ω≤π/2\Omega \le \pi/2 and the rotation takes the short way around. It clamps cos⁡Ω\cos\Omega to [−1,1][-1, 1] before acos, since rounding can push the dot product of two unit quaternions slightly past 1.

Powers

pow_by_int uses q2m=(qm)2q^{2m} = (q^m)^2 and q2m+1=q (qm)2q^{2m+1} = q\,(q^m)^2, which needs only associativity; all factors are powers of the same qq, so they commute with each other. Negative exponents use q−n=(q−1)nq^{-n} = (q^{-1})^n.

pow_by_T uses the polar form. Every non-zero qq is q=∣q∣ (cos⁡φ+n^sin⁡φ)q = |q|\,(\cos\varphi + \hat{\mathbf n}\sin\varphi) with φ∈[0,π]\varphi \in [0, \pi] and a unit vector n^\hat{\mathbf n}, and the span of 11 and n^\hat{\mathbf n} is a copy of C\mathbb C (because n^2=−1\hat{\mathbf n}^2 = -1). De Moivre’s formula then defines

qt=∣q∣t (cos⁡tφ+n^sin⁡tφ).q^t = |q|^t\,\big(\cos t\varphi + \hat{\mathbf n}\sin t\varphi\big).

The implementation computes φ=arcsin⁡∣u∣/∣q∣\varphi = \arcsin\lvert\mathbf u\rvert/|q|, which is correct only for φ≤π/2\varphi \le \pi/2, 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 ab=baa b = b a or a/b⋅c=ac/ba / b \cdot c = a c / b. 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 H\mathbb H 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 r−1qr^{-1} q.

Choice. Since 0.2.0, q / r is q r−1q\,r^{-1}, the solution of xr=qx r = q. 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” (q∼−qq \sim -q) 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: (H,+,0)(\mathbb H, +, 0) is an abelian group, (H,⋅,1)(\mathbb H, \cdot, 1) a monoid, and ⋅\cdot distributes over ++ on both sides;
  • pq‾=qˉ pˉ\overline{pq} = \bar q\,\bar p, qˉˉ=q\bar{\bar q} = q, qqˉ=qˉq=∣q∣2q\bar q = \bar q q = |q|^2 and ∣pq∣2=∣p∣2∣q∣2|pq|^2 = |p|^2|q|^2;
  • for exact division (a field T such as rationals): q q−1=q−1q=1q\,q^{-1} = q^{-1}q = 1, (q/r) r=q(q / r)\,r = q and r (q.left_div(r))=qr\,(q.\mathrm{left\_div}(r)) = q.

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 pqpq is a sum of four products of a component of pp with a component of qq, 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: ∣fl(xTy)−xTy∣≤γn∣x∣T∣y∣\lvert\mathrm{fl}(x^{\mathsf T}y) - x^{\mathsf T}y\rvert \le \gamma_n \lvert x\rvert^{\mathsf T}\lvert y\rvert for any order of summation. gives, componentwise,

∣fl(pq)m−(pq)m∣≤γ4∑l=14∣pl∣ ∣qσm(l)∣≤γ4 ∣p∣ ∣q∣,γn=nu1−nu,\lvert \mathrm{fl}(pq)_m - (pq)_m \rvert \le \gamma_4 \sum_{l=1}^{4} \lvert p_l\rvert\,\lvert q_{\sigma_m(l)}\rvert \le \gamma_4\, |p|\,|q|, \qquad \gamma_n = \frac{n u}{1 - n u},

where σm\sigma_m is the permutation of qq‘s components that appears in component mm and the second inequality is Cauchy–Schwarz. Summing over the four components, ∥fl(pq)−pq∥≤2γ4∣p∣∣q∣\lVert\mathrm{fl}(pq) - pq\rVert \le 2\gamma_4 |p||q|, so by the multiplicativity of the norm

(1−2γ4) ∣p∣ ∣q∣≤∣fl(pq)∣≤(1+2γ4) ∣p∣ ∣q∣.(1 - 2\gamma_4)\,|p|\,|q| \le |\mathrm{fl}(pq)| \le (1 + 2\gamma_4)\,|p|\,|q| .

A product of nn unit quaternions therefore has norm 1+δ1 + \delta with ∣δ∣≲8nu|\delta| \lesssim 8nu to first order, where u=2−53≈1.1×10−16u = 2^{-53} \approx 1.1 \times 10^{-16}; in practice the errors partly cancel and the drift is smaller. Because rotate assumes ∣q∣=1|q| = 1, a drifted quaternion scales vectors by about 1+2δ1 + 2\delta. Long chains should call normalize periodically; its result has norm 11 within a few units of uu (for example 1+2i+3j+4k1 + 2i + 3j + 4k normalizes to a quaternion of computed norm 0.99999999999999990.9999999999999999).

Division by zero and degenerate inputs

The package does not check its inputs; degenerate cases follow the arithmetic of T:

InputDoubleInt
inv, /, left_div with a zero divisorNaN componentsruntime trap (integer division by zero)
normalize of zerozero returned unchangedzero returned unchanged
pow_by_T of zerozero returned unchangedzero returned unchanged
from_axis_angle with a zero axisNaN vector partruntime trap
slerp of a zero inputthe zero input is used unnormalizednot meaningful

Over Int, inv is zero unless ∣q∣2=1|q|^2 = 1, and every function that goes through DoubleConvert truncates.

Thresholds

slerp falls back to normalized linear interpolation when cos⁡Ω>0.9995\cos\Omega > 0.9995, that is Ω<0.0316\Omega < 0.0316 rad. There sin⁡Ω<0.032\sin\Omega < 0.032, 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 5.1×10−75.1 \times 10^{-7} rad on S3S^3 (about 10−610^{-6} rad of rotation angle) at the threshold, and less below it.

The Euler conversions treat ∣sin⁡b∣≥0.9998|\sin b| \ge 0.9998 as gimbal lock. Rounding bb to ±π/2\pm\pi/2 there costs up to π/2−arcsin⁡0.9998≈0.020\pi/2 - \arcsin 0.9998 \approx 0.020 rad in the middle angle.

Complexity

Every operation is O(1)O(1) on four components: a Hamilton product costs 16 multiplications and 12 additions, rotate 18 multiplications and 12 additions. pow_by_int(n) uses O(log⁡∣n∣)O(\log |n|) 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_euler with "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 therefore to_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_T computes φ\varphi with asin, which cannot exceed π/2\pi/2; atan2⁡(∣u∣,w)\operatorname{atan2}(\lvert\mathbf u\rvert, w) would cover [0,π][0, \pi].

Alternatives rejected

  • Implementing Field. Rejected because generic Field code may rely on commutativity (see above).
  • Keeping / as left division. It contradicted a / b == a * b.inv() and the usual reading of to / from for rotations.
  • A Double-only type. It would lose exact arithmetic over Int and 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 with Hash.

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 of T, and rotate trusts that its receiver is a unit quaternion;
  • implement luna-generic Field or Num, or make quaternions an ordered type;
  • depend on arithmetic, linear-algebra or luna-complex; its only Luna-Flow dependency is luna-generic;
  • choose a tolerance for comparing rotations; that is left to the caller.

Footnotes

  1. 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. ↩

  2. For example 1↦I1 \mapsto I, i↦(i00−i)i \mapsto \begin{pmatrix} i & 0 \\ 0 & -i\end{pmatrix}, j↦(01−10)j \mapsto \begin{pmatrix} 0 & 1 \\ -1 & 0\end{pmatrix}, k↦(0ii0)k \mapsto \begin{pmatrix} 0 & i \\ i & 0\end{pmatrix}. These matrices satisfy Hamilton’s relations, and matrix multiplication is associative. ↩

  3. N. J. Higham, Accuracy and Stability of Numerical Algorithms, 2nd ed., SIAM 2002, §3.1: ∣fl(xTy)−xTy∣≤γn∣x∣T∣y∣\lvert\mathrm{fl}(x^{\mathsf T}y) - x^{\mathsf T}y\rvert \le \gamma_n \lvert x\rvert^{\mathsf T}\lvert y\rvert for any order of summation. ↩