Skip to main content

oxiphysics_core/
dual_quaternion.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Dual quaternions for rigid body transformations and screw motions.
5//!
6//! Dual quaternions combine a rotation quaternion (real part) with a
7//! translation-encoding quaternion (dual part) to represent general rigid
8//! body motions in a single algebraic object.  They are particularly well
9//! suited for screw-motion interpolation (ScLERP).
10
11#![allow(dead_code)]
12
13// ─────────────────────────────────────────────────────────────────────────────
14// Quaternion
15// ─────────────────────────────────────────────────────────────────────────────
16
17/// A unit or general quaternion `w + xi + yj + zk`.
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub struct Quaternion {
20    /// Scalar (real) component.
21    pub w: f64,
22    /// `i` component.
23    pub x: f64,
24    /// `j` component.
25    pub y: f64,
26    /// `k` component.
27    pub z: f64,
28}
29
30impl Quaternion {
31    /// Creates a new quaternion from its four components.
32    pub fn new(w: f64, x: f64, y: f64, z: f64) -> Self {
33        Self { w, x, y, z }
34    }
35
36    /// Returns the multiplicative identity quaternion `(1, 0, 0, 0)`.
37    pub fn identity() -> Self {
38        Self::new(1.0, 0.0, 0.0, 0.0)
39    }
40
41    /// Constructs a unit quaternion representing a rotation of `angle` radians
42    /// around `axis`.  `axis` need not be normalised; it is normalised
43    /// internally.
44    ///
45    /// # Panics
46    /// Panics (debug) if `axis` has zero length.
47    pub fn from_axis_angle(axis: [f64; 3], angle: f64) -> Self {
48        let len = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
49        let inv = 1.0 / len;
50        let nx = axis[0] * inv;
51        let ny = axis[1] * inv;
52        let nz = axis[2] * inv;
53        let half = angle * 0.5;
54        let s = half.sin();
55        Self::new(half.cos(), nx * s, ny * s, nz * s)
56    }
57
58    /// Hamilton product `self * other`.
59    pub fn mul(&self, other: &Quaternion) -> Quaternion {
60        Quaternion::new(
61            self.w * other.w - self.x * other.x - self.y * other.y - self.z * other.z,
62            self.w * other.x + self.x * other.w + self.y * other.z - self.z * other.y,
63            self.w * other.y - self.x * other.z + self.y * other.w + self.z * other.x,
64            self.w * other.z + self.x * other.y - self.y * other.x + self.z * other.w,
65        )
66    }
67
68    /// Returns the conjugate `(w, -x, -y, -z)`.
69    pub fn conjugate(&self) -> Quaternion {
70        Quaternion::new(self.w, -self.x, -self.y, -self.z)
71    }
72
73    /// Returns the Euclidean norm.
74    pub fn norm(&self) -> f64 {
75        (self.w * self.w + self.x * self.x + self.y * self.y + self.z * self.z).sqrt()
76    }
77
78    /// Returns a normalised copy.  If the norm is zero the result is
79    /// `identity`.
80    pub fn normalize(&self) -> Quaternion {
81        let n = self.norm();
82        if n < f64::EPSILON {
83            return Quaternion::identity();
84        }
85        let inv = 1.0 / n;
86        Quaternion::new(self.w * inv, self.x * inv, self.y * inv, self.z * inv)
87    }
88
89    /// Rotates a 3-D vector by the unit quaternion using `q v q*`.
90    pub fn rotate_vector(&self, v: [f64; 3]) -> [f64; 3] {
91        let qv = Quaternion::new(0.0, v[0], v[1], v[2]);
92        let res = self.mul(&qv).mul(&self.conjugate());
93        [res.x, res.y, res.z]
94    }
95
96    /// Euclidean dot product in 4-D.
97    pub fn dot(&self, other: &Quaternion) -> f64 {
98        self.w * other.w + self.x * other.x + self.y * other.y + self.z * other.z
99    }
100
101    /// Spherical linear interpolation between `self` and `other` at parameter
102    /// `t ∈ [0, 1]`.  Both quaternions should be unit quaternions.
103    pub fn slerp(&self, other: &Quaternion, t: f64) -> Quaternion {
104        let mut d = self.dot(other);
105        // Take the shorter arc.
106        let other_adj = if d < 0.0 {
107            d = -d;
108            Quaternion::new(-other.w, -other.x, -other.y, -other.z)
109        } else {
110            *other
111        };
112
113        if d > 1.0 - 1e-10 {
114            // Nearly identical — linear blend and renormalize.
115            let w = self.w + t * (other_adj.w - self.w);
116            let x = self.x + t * (other_adj.x - self.x);
117            let y = self.y + t * (other_adj.y - self.y);
118            let z = self.z + t * (other_adj.z - self.z);
119            return Quaternion::new(w, x, y, z).normalize();
120        }
121
122        let theta = d.acos();
123        let sin_theta = theta.sin();
124        let s0 = ((1.0 - t) * theta).sin() / sin_theta;
125        let s1 = (t * theta).sin() / sin_theta;
126        Quaternion::new(
127            s0 * self.w + s1 * other_adj.w,
128            s0 * self.x + s1 * other_adj.x,
129            s0 * self.y + s1 * other_adj.y,
130            s0 * self.z + s1 * other_adj.z,
131        )
132    }
133
134    /// Converts to a 3×3 rotation matrix.
135    pub fn to_rotation_matrix(&self) -> [[f64; 3]; 3] {
136        let q = self.normalize();
137        let (w, x, y, z) = (q.w, q.x, q.y, q.z);
138        [
139            [
140                1.0 - 2.0 * (y * y + z * z),
141                2.0 * (x * y - w * z),
142                2.0 * (x * z + w * y),
143            ],
144            [
145                2.0 * (x * y + w * z),
146                1.0 - 2.0 * (x * x + z * z),
147                2.0 * (y * z - w * x),
148            ],
149            [
150                2.0 * (x * z - w * y),
151                2.0 * (y * z + w * x),
152                1.0 - 2.0 * (x * x + y * y),
153            ],
154        ]
155    }
156
157    /// Constructs a quaternion from a 3×3 rotation matrix using Shepperd's
158    /// method.
159    pub fn from_rotation_matrix(m: &[[f64; 3]; 3]) -> Quaternion {
160        let trace = m[0][0] + m[1][1] + m[2][2];
161        if trace > 0.0 {
162            let s = 0.5 / (trace + 1.0).sqrt();
163            Quaternion::new(
164                0.25 / s,
165                (m[2][1] - m[1][2]) * s,
166                (m[0][2] - m[2][0]) * s,
167                (m[1][0] - m[0][1]) * s,
168            )
169        } else if m[0][0] > m[1][1] && m[0][0] > m[2][2] {
170            let s = 2.0 * (1.0 + m[0][0] - m[1][1] - m[2][2]).sqrt();
171            Quaternion::new(
172                (m[2][1] - m[1][2]) / s,
173                0.25 * s,
174                (m[0][1] + m[1][0]) / s,
175                (m[0][2] + m[2][0]) / s,
176            )
177        } else if m[1][1] > m[2][2] {
178            let s = 2.0 * (1.0 + m[1][1] - m[0][0] - m[2][2]).sqrt();
179            Quaternion::new(
180                (m[0][2] - m[2][0]) / s,
181                (m[0][1] + m[1][0]) / s,
182                0.25 * s,
183                (m[1][2] + m[2][1]) / s,
184            )
185        } else {
186            let s = 2.0 * (1.0 + m[2][2] - m[0][0] - m[1][1]).sqrt();
187            Quaternion::new(
188                (m[1][0] - m[0][1]) / s,
189                (m[0][2] + m[2][0]) / s,
190                (m[1][2] + m[2][1]) / s,
191                0.25 * s,
192            )
193        }
194    }
195
196    /// Quaternion exponential.  For a pure quaternion `(0, v)` this produces
197    /// `cos(|v|) + sin(|v|)/|v| * v`.
198    pub fn exp(&self) -> Quaternion {
199        let v_norm = (self.x * self.x + self.y * self.y + self.z * self.z).sqrt();
200        let ew = self.w.exp();
201        if v_norm < f64::EPSILON {
202            return Quaternion::new(ew, 0.0, 0.0, 0.0);
203        }
204        let s = ew * v_norm.sin() / v_norm;
205        Quaternion::new(ew * v_norm.cos(), self.x * s, self.y * s, self.z * s)
206    }
207
208    /// Quaternion natural logarithm.  For a unit quaternion this produces a
209    /// pure quaternion encoding the rotation axis and half-angle.
210    pub fn ln(&self) -> Quaternion {
211        let n = self.norm();
212        let v_norm = (self.x * self.x + self.y * self.y + self.z * self.z).sqrt();
213        if v_norm < f64::EPSILON {
214            return Quaternion::new(n.ln(), 0.0, 0.0, 0.0);
215        }
216        let theta = (self.w / n).acos();
217        let s = theta / v_norm;
218        Quaternion::new(n.ln(), self.x * s, self.y * s, self.z * s)
219    }
220}
221
222// ─────────────────────────────────────────────────────────────────────────────
223// DualNumber
224// ─────────────────────────────────────────────────────────────────────────────
225
226/// A dual number `a + ε b` where `ε² = 0`.
227#[derive(Debug, Clone, Copy, PartialEq)]
228pub struct DualNumber {
229    /// Real part.
230    pub real: f64,
231    /// Dual (infinitesimal) part.
232    pub dual: f64,
233}
234
235impl DualNumber {
236    /// Creates a new dual number.
237    pub fn new(real: f64, dual: f64) -> Self {
238        Self { real, dual }
239    }
240
241    /// Component-wise addition.
242    pub fn add(&self, other: &DualNumber) -> DualNumber {
243        DualNumber::new(self.real + other.real, self.dual + other.dual)
244    }
245
246    /// Dual-number multiplication: `(a + εb)(c + εd) = ac + ε(ad + bc)`.
247    pub fn mul(&self, other: &DualNumber) -> DualNumber {
248        DualNumber::new(
249            self.real * other.real,
250            self.real * other.dual + self.dual * other.real,
251        )
252    }
253
254    /// Dual conjugate `(a, -b)`.
255    pub fn conjugate(&self) -> DualNumber {
256        DualNumber::new(self.real, -self.dual)
257    }
258}
259
260// ─────────────────────────────────────────────────────────────────────────────
261// DualQuaternion
262// ─────────────────────────────────────────────────────────────────────────────
263
264/// A dual quaternion `q_r + ε q_d` encoding a rigid body transformation.
265///
266/// The *real* part `q_r` is a unit quaternion encoding the rotation.  The
267/// *dual* part `q_d = ½ t̂ q_r` encodes the translation, where `t̂` is the
268/// pure quaternion formed from the translation vector.
269#[derive(Debug, Clone, Copy, PartialEq)]
270pub struct DualQuaternion {
271    /// Rotation part (unit quaternion).
272    pub real: Quaternion,
273    /// Translation-encoding part.
274    pub dual: Quaternion,
275}
276
277impl DualQuaternion {
278    /// Returns the identity dual quaternion (no rotation, no translation).
279    pub fn identity() -> Self {
280        Self {
281            real: Quaternion::identity(),
282            dual: Quaternion::new(0.0, 0.0, 0.0, 0.0),
283        }
284    }
285
286    /// Constructs a dual quaternion from a unit rotation quaternion `q` and a
287    /// translation vector `t`.
288    ///
289    /// The dual part is `½ * (0, t) * q`.
290    pub fn from_rotation_translation(q: Quaternion, t: [f64; 3]) -> Self {
291        let t_quat = Quaternion::new(0.0, t[0], t[1], t[2]);
292        let dual = t_quat.mul(&q).scale(0.5);
293        Self { real: q, dual }
294    }
295
296    /// Dual-quaternion product `self * other`.
297    pub fn mul(&self, other: &DualQuaternion) -> DualQuaternion {
298        DualQuaternion {
299            real: self.real.mul(&other.real),
300            dual: self.real.mul(&other.dual).add(&self.dual.mul(&other.real)),
301        }
302    }
303
304    /// Dual-quaternion conjugate: conjugate both the real and dual parts.
305    pub fn conjugate(&self) -> DualQuaternion {
306        DualQuaternion {
307            real: self.real.conjugate(),
308            dual: self.dual.conjugate(),
309        }
310    }
311
312    /// Normalises so that the real part has unit norm.
313    pub fn normalize(&self) -> DualQuaternion {
314        let n = self.real.norm();
315        if n < f64::EPSILON {
316            return DualQuaternion::identity();
317        }
318        let inv = 1.0 / n;
319        DualQuaternion {
320            real: self.real.scale(inv),
321            dual: self.dual.scale(inv),
322        }
323    }
324
325    /// Returns the norm (magnitude) of the real part.
326    pub fn norm(&self) -> f64 {
327        self.real.norm()
328    }
329
330    /// Transforms a 3-D point using the rigid body motion encoded in `self`.
331    ///
332    /// Equivalent to `dq * (1 + ε p̂) * dq*` in the dual-quaternion algebra.
333    pub fn transform_point(&self, p: [f64; 3]) -> [f64; 3] {
334        let dq = self.normalize();
335        // Rotation
336        let rotated = dq.real.rotate_vector(p);
337        // Translation: t = 2 * dual * real_conjugate  (imaginary parts)
338        let t = dq.to_translation();
339        [rotated[0] + t[0], rotated[1] + t[1], rotated[2] + t[2]]
340    }
341
342    /// Returns the rotation as a normalised quaternion.
343    pub fn to_rotation(&self) -> Quaternion {
344        self.real.normalize()
345    }
346
347    /// Recovers the translation vector from the dual quaternion.
348    ///
349    /// `t = 2 * q_d * q_r*` (imaginary parts only).
350    pub fn to_translation(&self) -> [f64; 3] {
351        let dq = self.normalize();
352        let t = dq.dual.mul(&dq.real.conjugate()).scale(2.0);
353        [t.x, t.y, t.z]
354    }
355
356    /// Screw linear interpolation (ScLERP) between `self` and `other` at `t`.
357    ///
358    /// Uses the formula `(other * self⁻¹)^t * self`.
359    pub fn sclerp(&self, other: &DualQuaternion, t: f64) -> DualQuaternion {
360        let self_inv = self.conjugate();
361        let diff = self_inv.mul(other).normalize();
362        self.mul(&diff.pow(t))
363    }
364
365    /// Constructs a dual quaternion from a general screw motion.
366    ///
367    /// # Parameters
368    /// * `axis`  – unit direction of the screw axis (must be unit length).
369    /// * `angle` – rotation angle in radians.
370    /// * `pitch` – translation per radian along the axis.
371    /// * `point` – a point on the screw axis.
372    ///
373    /// The resulting dual quaternion encodes the rigid body transformation:
374    /// rotate by `angle` around the axis through `point`, then translate
375    /// `pitch * angle` along the axis.
376    ///
377    /// Dual part formula: `q_d = 0.5 * pure_quat(full_translation) * q_r`
378    /// where `full_translation = d * axis + (I - R) * point` and `d = pitch * angle`.
379    pub fn from_twist(axis: [f64; 3], angle: f64, pitch: f64, point: [f64; 3]) -> DualQuaternion {
380        // Rotation quaternion for the given axis and angle.
381        let q_rot = Quaternion::from_axis_angle(axis, angle);
382
383        // Scalar translation along the screw axis.
384        let d = pitch * angle;
385
386        // Translation component from screw (along axis).
387        let t_vec = [axis[0] * d, axis[1] * d, axis[2] * d];
388
389        // Point-on-axis correction: (I - R) * point.
390        // The rotation moves `point` to `q_rot.rotate_vector(point)`, so the
391        // net translation required to keep the axis in place is `point - R*point`.
392        let rotated_point = q_rot.rotate_vector(point);
393        let correction = [
394            point[0] - rotated_point[0],
395            point[1] - rotated_point[1],
396            point[2] - rotated_point[2],
397        ];
398
399        // Total translation vector: screw advance plus off-origin-axis correction.
400        let full_t = [
401            t_vec[0] + correction[0],
402            t_vec[1] + correction[1],
403            t_vec[2] + correction[2],
404        ];
405
406        DualQuaternion::from_rotation_translation(q_rot, full_t)
407    }
408
409    // ── Internal helpers ──────────────────────────────────────────────────────
410
411    /// Raises a dual quaternion to the real power `t` via the ScLERP formula.
412    fn pow(&self, t: f64) -> DualQuaternion {
413        // Decompose into screw parameters and re-compose.
414        let sm = ScrewMotion::from_dual_quaternion(self);
415        DualQuaternion::from_twist(sm.axis_dir, sm.angle * t, sm.pitch, sm.axis_point)
416    }
417}
418
419// ── Private arithmetic helpers on Quaternion ─────────────────────────────────
420
421impl Quaternion {
422    fn scale(&self, s: f64) -> Quaternion {
423        Quaternion::new(self.w * s, self.x * s, self.y * s, self.z * s)
424    }
425
426    fn add(&self, other: &Quaternion) -> Quaternion {
427        Quaternion::new(
428            self.w + other.w,
429            self.x + other.x,
430            self.y + other.y,
431            self.z + other.z,
432        )
433    }
434}
435
436// ─────────────────────────────────────────────────────────────────────────────
437// ScrewMotion
438// ─────────────────────────────────────────────────────────────────────────────
439
440/// A rigid body screw motion described by a Chasles-style parameterisation.
441///
442/// Every rigid body motion (up to pure translation) can be represented as a
443/// rotation by `angle` radians around an axis, combined with a translation
444/// of `pitch * angle` metres along that same axis.
445#[derive(Debug, Clone, Copy)]
446pub struct ScrewMotion {
447    /// Unit direction vector of the screw axis.
448    pub axis_dir: [f64; 3],
449    /// A point on the screw axis.
450    pub axis_point: [f64; 3],
451    /// Rotation angle in radians.
452    pub angle: f64,
453    /// Translation per radian along the screw axis.
454    pub pitch: f64,
455}
456
457impl ScrewMotion {
458    /// Converts this screw motion to its dual-quaternion representation.
459    pub fn to_dual_quaternion(&self) -> DualQuaternion {
460        DualQuaternion::from_twist(self.axis_dir, self.angle, self.pitch, self.axis_point)
461    }
462
463    /// Extracts the screw-motion parameters from a (normalised) dual
464    /// quaternion.
465    pub fn from_dual_quaternion(dq: &DualQuaternion) -> ScrewMotion {
466        let dq = dq.normalize();
467        // Real part → rotation axis & angle.
468        let q = dq.real;
469        // Clamp for numerical safety.
470        let w_clamped = q.w.clamp(-1.0, 1.0);
471        let angle = 2.0 * w_clamped.acos();
472        let sin_half = (1.0 - w_clamped * w_clamped).sqrt();
473
474        let (axis_dir, axis_point, pitch) = if sin_half < 1e-10 {
475            // Pure translation (no rotation).
476            let t = dq.to_translation();
477            let t_norm = (t[0] * t[0] + t[1] * t[1] + t[2] * t[2]).sqrt();
478            let dir = if t_norm < f64::EPSILON {
479                [0.0, 0.0, 1.0]
480            } else {
481                [t[0] / t_norm, t[1] / t_norm, t[2] / t_norm]
482            };
483            ([dir[0], dir[1], dir[2]], [0.0_f64, 0.0, 0.0], t_norm)
484        } else {
485            let inv = 1.0 / sin_half;
486            let axis = [q.x * inv, q.y * inv, q.z * inv];
487
488            // Dual part → moment and pitch.
489            // d = 2 * q_d * q_r* (imaginary parts = translation).
490            let t = dq.to_translation();
491            // Pitch = translation along axis.
492            let pitch_val = t[0] * axis[0] + t[1] * axis[1] + t[2] * axis[2];
493            // Moment = axis × translation / |axis|² (moment of the screw).
494            // axis_point = axis × (t - pitch*axis) / angle (simplified).
495            let t_perp = [
496                t[0] - pitch_val * axis[0],
497                t[1] - pitch_val * axis[1],
498                t[2] - pitch_val * axis[2],
499            ];
500            // axis_point from (I-R)*p = t_perp:
501            // p = [(1-cos)*t_perp + sin*cross] / (2*(1-cos))
502            // where cross = axis × t_perp, 1-cos = 2*sin²(θ/2)
503            let cross = [
504                axis[1] * t_perp[2] - axis[2] * t_perp[1],
505                axis[2] * t_perp[0] - axis[0] * t_perp[2],
506                axis[0] * t_perp[1] - axis[1] * t_perp[0],
507            ];
508            let one_minus_cos = 2.0 * sin_half * sin_half; // 1 - cos(θ)
509            let sin_theta = 2.0 * sin_half * w_clamped; // sin(θ)
510            let denom = 2.0 * one_minus_cos;
511            let p = if denom.abs() < 1e-10 {
512                [0.0, 0.0, 0.0]
513            } else {
514                [
515                    (one_minus_cos * t_perp[0] + sin_theta * cross[0]) / denom,
516                    (one_minus_cos * t_perp[1] + sin_theta * cross[1]) / denom,
517                    (one_minus_cos * t_perp[2] + sin_theta * cross[2]) / denom,
518                ]
519            };
520            let pitch_per_rad = if angle.abs() < 1e-10 {
521                0.0
522            } else {
523                pitch_val / angle
524            };
525            (axis, p, pitch_per_rad)
526        };
527
528        ScrewMotion {
529            axis_dir,
530            axis_point,
531            angle,
532            pitch,
533        }
534    }
535}
536
537// ─────────────────────────────────────────────────────────────────────────────
538// DLB — Dual Quaternion Linear Blending
539// ─────────────────────────────────────────────────────────────────────────────
540
541/// Dual Quaternion Linear Blending (DLB) — blend N dual quaternions with given weights.
542///
543/// The result is normalised so the real part has unit norm.
544/// This is the standard blending used in dual quaternion skinning (GPU-friendly).
545///
546/// If all weights sum to zero the identity dual quaternion is returned.
547#[allow(dead_code)]
548pub fn dual_quaternion_blend(dqs: &[DualQuaternion], weights: &[f64]) -> DualQuaternion {
549    let n = dqs.len().min(weights.len());
550    if n == 0 {
551        return DualQuaternion::identity();
552    }
553    let mut sum_real = Quaternion::new(0.0, 0.0, 0.0, 0.0);
554    let mut sum_dual = Quaternion::new(0.0, 0.0, 0.0, 0.0);
555    let first_real = dqs[0].real;
556    for i in 0..n {
557        // Ensure the real part is on the same hemisphere as the first quaternion
558        let dq = if dqs[i].real.dot(&first_real) < 0.0 {
559            DualQuaternion {
560                real: dqs[i].real.scale(-1.0),
561                dual: dqs[i].dual.scale(-1.0),
562            }
563        } else {
564            dqs[i]
565        };
566        let w = weights[i];
567        sum_real = sum_real.add(&dq.real.scale(w));
568        sum_dual = sum_dual.add(&dq.dual.scale(w));
569    }
570    DualQuaternion {
571        real: sum_real,
572        dual: sum_dual,
573    }
574    .normalize()
575}
576
577// ─────────────────────────────────────────────────────────────────────────────
578// Dual quaternion interpolation helpers
579// ─────────────────────────────────────────────────────────────────────────────
580
581impl DualQuaternion {
582    /// Linear interpolation (nLERP) between two dual quaternions.
583    ///
584    /// Cheaper than ScLERP; less accurate for large rotations.
585    /// Both dual quaternions are normalised before blending.
586    #[allow(dead_code)]
587    pub fn nlerp(&self, other: &DualQuaternion, t: f64) -> DualQuaternion {
588        let a = self.normalize();
589        let b = other.normalize();
590        // Ensure shortest path on real part
591        let b = if a.real.dot(&b.real) < 0.0 {
592            DualQuaternion {
593                real: b.real.scale(-1.0),
594                dual: b.dual.scale(-1.0),
595            }
596        } else {
597            b
598        };
599        let real = a.real.scale(1.0 - t).add(&b.real.scale(t));
600        let dual = a.dual.scale(1.0 - t).add(&b.dual.scale(t));
601        DualQuaternion { real, dual }.normalize()
602    }
603
604    /// Rigid transform composition: compose `self` with `other`.
605    ///
606    /// The resulting transformation first applies `self`, then `other`.
607    /// Equivalent to `other.mul(self)`.
608    #[allow(dead_code)]
609    pub fn compose_transforms(&self, other: &DualQuaternion) -> DualQuaternion {
610        other.mul(self)
611    }
612
613    /// Invert a unit dual quaternion (conjugate of the real and dual parts).
614    #[allow(dead_code)]
615    pub fn invert(&self) -> DualQuaternion {
616        self.conjugate()
617    }
618
619    /// Apply this rigid transform to a list of points.
620    #[allow(dead_code)]
621    pub fn transform_points(&self, points: &[[f64; 3]]) -> Vec<[f64; 3]> {
622        points.iter().map(|&p| self.transform_point(p)).collect()
623    }
624
625    /// Extract the rotation angle (in radians) from the real quaternion.
626    #[allow(dead_code)]
627    pub fn rotation_angle(&self) -> f64 {
628        let q = self.real.normalize();
629        2.0 * q.w.clamp(-1.0, 1.0).acos()
630    }
631
632    /// Extract the rotation axis (unit vector) from the real quaternion.
633    ///
634    /// Returns \[0, 0, 1\] for the identity rotation.
635    #[allow(dead_code)]
636    pub fn rotation_axis(&self) -> [f64; 3] {
637        let q = self.real.normalize();
638        let s = (1.0 - q.w * q.w).sqrt();
639        if s < 1e-12 {
640            [0.0, 0.0, 1.0]
641        } else {
642            [q.x / s, q.y / s, q.z / s]
643        }
644    }
645}
646
647// ─────────────────────────────────────────────────────────────────────────────
648// Screw interpolation (ScLERP) utilities
649// ─────────────────────────────────────────────────────────────────────────────
650
651/// Interpolate a sequence of `n` dual quaternion poses evenly spaced between
652/// `start` and `end` (including both endpoints).
653///
654/// Uses ScLERP for each intermediate pose.
655#[allow(dead_code)]
656pub fn sclerp_sequence(
657    start: &DualQuaternion,
658    end: &DualQuaternion,
659    n: usize,
660) -> Vec<DualQuaternion> {
661    if n == 0 {
662        return Vec::new();
663    }
664    if n == 1 {
665        return vec![*start];
666    }
667    (0..n)
668        .map(|i| {
669            let t = i as f64 / (n - 1) as f64;
670            start.sclerp(end, t)
671        })
672        .collect()
673}
674
675// ─────────────────────────────────────────────────────────────────────────────
676// Plücker Coordinates
677// ─────────────────────────────────────────────────────────────────────────────
678
679/// A line in 3D represented by Plücker coordinates (direction, moment).
680///
681/// The direction vector `l` points along the line, and the moment `m = p × l`
682/// where `p` is any point on the line.  Together `(l, m)` uniquely describe
683/// a 3D line up to scale.
684#[derive(Debug, Clone, Copy)]
685pub struct PluckerLine {
686    /// Unit direction vector of the line.
687    pub direction: [f64; 3],
688    /// Moment vector `m = p × direction` for any point `p` on the line.
689    pub moment: [f64; 3],
690}
691
692impl PluckerLine {
693    /// Construct a Plücker line passing through two distinct points.
694    pub fn from_two_points(p: [f64; 3], q: [f64; 3]) -> Self {
695        let d = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
696        let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
697        let direction = if len < f64::EPSILON {
698            [0.0, 0.0, 1.0]
699        } else {
700            [d[0] / len, d[1] / len, d[2] / len]
701        };
702        // Moment = p × direction
703        let moment = [
704            p[1] * direction[2] - p[2] * direction[1],
705            p[2] * direction[0] - p[0] * direction[2],
706            p[0] * direction[1] - p[1] * direction[0],
707        ];
708        Self { direction, moment }
709    }
710
711    /// Construct from a point on the line and a direction vector.
712    pub fn from_point_direction(p: [f64; 3], d: [f64; 3]) -> Self {
713        let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
714        let direction = if len < f64::EPSILON {
715            [0.0, 0.0, 1.0]
716        } else {
717            [d[0] / len, d[1] / len, d[2] / len]
718        };
719        let moment = [
720            p[1] * direction[2] - p[2] * direction[1],
721            p[2] * direction[0] - p[0] * direction[2],
722            p[0] * direction[1] - p[1] * direction[0],
723        ];
724        Self { direction, moment }
725    }
726
727    /// Returns the shortest distance between two skew lines (or 0 if they intersect).
728    pub fn distance_to_line(&self, other: &PluckerLine) -> f64 {
729        // For two lines l1=(d1,m1), l2=(d2,m2):
730        // distance = |d1·m2 + d2·m1| / |d1 × d2|
731        let cross_d = [
732            self.direction[1] * other.direction[2] - self.direction[2] * other.direction[1],
733            self.direction[2] * other.direction[0] - self.direction[0] * other.direction[2],
734            self.direction[0] * other.direction[1] - self.direction[1] * other.direction[0],
735        ];
736        let cross_len =
737            (cross_d[0] * cross_d[0] + cross_d[1] * cross_d[1] + cross_d[2] * cross_d[2]).sqrt();
738
739        let dot1 = self.direction[0] * other.moment[0]
740            + self.direction[1] * other.moment[1]
741            + self.direction[2] * other.moment[2];
742        let dot2 = other.direction[0] * self.moment[0]
743            + other.direction[1] * self.moment[1]
744            + other.direction[2] * self.moment[2];
745
746        if cross_len < 1e-12 {
747            // Parallel lines: pick points on each line (d × m for unit direction)
748            // and compute |(p1 - p2) × d| / |d|
749            let p1 = [
750                self.direction[1] * self.moment[2] - self.direction[2] * self.moment[1],
751                self.direction[2] * self.moment[0] - self.direction[0] * self.moment[2],
752                self.direction[0] * self.moment[1] - self.direction[1] * self.moment[0],
753            ];
754            let p2 = [
755                other.direction[1] * other.moment[2] - other.direction[2] * other.moment[1],
756                other.direction[2] * other.moment[0] - other.direction[0] * other.moment[2],
757                other.direction[0] * other.moment[1] - other.direction[1] * other.moment[0],
758            ];
759            let dp = [p1[0] - p2[0], p1[1] - p2[1], p1[2] - p2[2]];
760            // cross dp × self.direction
761            let cx = dp[1] * self.direction[2] - dp[2] * self.direction[1];
762            let cy = dp[2] * self.direction[0] - dp[0] * self.direction[2];
763            let cz = dp[0] * self.direction[1] - dp[1] * self.direction[0];
764            (cx * cx + cy * cy + cz * cz).sqrt()
765        } else {
766            (dot1 + dot2).abs() / cross_len
767        }
768    }
769
770    /// Returns the foot of the perpendicular from a point to the line.
771    pub fn closest_point_to(&self, p: [f64; 3]) -> [f64; 3] {
772        // Project p onto the line: p - ((p - q) · d_hat) * d_hat + q_on_line
773        // Reconstruct a point on the line: q = d × m / |d|² (for unit d, q = d × m)
774        let q_on_line = [
775            self.direction[1] * self.moment[2] - self.direction[2] * self.moment[1],
776            self.direction[2] * self.moment[0] - self.direction[0] * self.moment[2],
777            self.direction[0] * self.moment[1] - self.direction[1] * self.moment[0],
778        ];
779        let ap = [
780            p[0] - q_on_line[0],
781            p[1] - q_on_line[1],
782            p[2] - q_on_line[2],
783        ];
784        let t = ap[0] * self.direction[0] + ap[1] * self.direction[1] + ap[2] * self.direction[2];
785        [
786            q_on_line[0] + t * self.direction[0],
787            q_on_line[1] + t * self.direction[1],
788            q_on_line[2] + t * self.direction[2],
789        ]
790    }
791
792    /// Converts this Plücker line to a dual quaternion representation.
793    ///
794    /// The DQ encodes the line as a pure rotation at the moment direction.
795    pub fn to_dual_quaternion_rotation(&self, angle: f64) -> DualQuaternion {
796        // Rotate around the direction axis by `angle`, with the axis passing through
797        // the point closest to the origin (given by d × m for unit d)
798        let axis_point = [
799            self.direction[1] * self.moment[2] - self.direction[2] * self.moment[1],
800            self.direction[2] * self.moment[0] - self.direction[0] * self.moment[2],
801            self.direction[0] * self.moment[1] - self.direction[1] * self.moment[0],
802        ];
803        DualQuaternion::from_twist(self.direction, angle, 0.0, axis_point)
804    }
805}
806
807// ─────────────────────────────────────────────────────────────────────────────
808// Velocity Screw
809// ─────────────────────────────────────────────────────────────────────────────
810
811/// A velocity screw in body or spatial form, combining angular and linear velocity.
812///
813/// In screw theory, any instantaneous rigid body motion can be described by a
814/// screw velocity: an angular velocity `ω` and a linear velocity `v` of the origin.
815/// The velocity of a point `p` is `v + ω × p`.
816#[derive(Debug, Clone, Copy)]
817pub struct VelocityScrew {
818    /// Angular velocity vector (ω).
819    pub angular: [f64; 3],
820    /// Linear velocity of the reference point (v).
821    pub linear: [f64; 3],
822}
823
824impl VelocityScrew {
825    /// Returns the zero velocity screw.
826    pub fn zero() -> Self {
827        Self {
828            angular: [0.0; 3],
829            linear: [0.0; 3],
830        }
831    }
832
833    /// Computes the velocity of a point `p` attached to the body.
834    ///
835    /// `vel(p) = v + ω × p`
836    pub fn velocity_at_point(&self, p: [f64; 3]) -> [f64; 3] {
837        let w = self.angular;
838        let v = self.linear;
839        // ω × p
840        let cross = [
841            w[1] * p[2] - w[2] * p[1],
842            w[2] * p[0] - w[0] * p[2],
843            w[0] * p[1] - w[1] * p[0],
844        ];
845        [v[0] + cross[0], v[1] + cross[1], v[2] + cross[2]]
846    }
847
848    /// Returns the screw pitch: v · ω / |ω|² (translation per radian along axis).
849    ///
850    /// Returns 0 if angular velocity is zero.
851    pub fn pitch(&self) -> f64 {
852        let w_sq = self.angular[0] * self.angular[0]
853            + self.angular[1] * self.angular[1]
854            + self.angular[2] * self.angular[2];
855        if w_sq < f64::EPSILON {
856            return 0.0;
857        }
858        let v_dot_w = self.linear[0] * self.angular[0]
859            + self.linear[1] * self.angular[1]
860            + self.linear[2] * self.angular[2];
861        v_dot_w / w_sq
862    }
863
864    /// Returns the screw axis direction (unit angular velocity), or z-axis if zero.
865    pub fn axis_direction(&self) -> [f64; 3] {
866        let len = (self.angular[0] * self.angular[0]
867            + self.angular[1] * self.angular[1]
868            + self.angular[2] * self.angular[2])
869            .sqrt();
870        if len < f64::EPSILON {
871            [0.0, 0.0, 1.0]
872        } else {
873            [
874                self.angular[0] / len,
875                self.angular[1] / len,
876                self.angular[2] / len,
877            ]
878        }
879    }
880
881    /// Adds two velocity screws component-wise.
882    pub fn add(&self, other: &VelocityScrew) -> VelocityScrew {
883        VelocityScrew {
884            angular: [
885                self.angular[0] + other.angular[0],
886                self.angular[1] + other.angular[1],
887                self.angular[2] + other.angular[2],
888            ],
889            linear: [
890                self.linear[0] + other.linear[0],
891                self.linear[1] + other.linear[1],
892                self.linear[2] + other.linear[2],
893            ],
894        }
895    }
896
897    /// Scales the velocity screw by a scalar.
898    pub fn scale(&self, s: f64) -> VelocityScrew {
899        VelocityScrew {
900            angular: [
901                self.angular[0] * s,
902                self.angular[1] * s,
903                self.angular[2] * s,
904            ],
905            linear: [self.linear[0] * s, self.linear[1] * s, self.linear[2] * s],
906        }
907    }
908
909    /// Converts this instantaneous velocity screw to a dual quaternion
910    /// for the given time step `dt` (first-order integration).
911    pub fn to_dual_quaternion_step(&self, dt: f64) -> DualQuaternion {
912        let angle = {
913            let w = self.angular;
914            (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt() * dt
915        };
916        let axis = self.axis_direction();
917        let pitch = self.pitch();
918        DualQuaternion::from_twist(axis, angle, pitch, [0.0; 3])
919    }
920}
921
922// ─────────────────────────────────────────────────────────────────────────────
923// Denavit-Hartenberg Transform
924// ─────────────────────────────────────────────────────────────────────────────
925
926/// Computes the Denavit-Hartenberg (DH) rigid body transform as a dual quaternion.
927///
928/// The standard DH convention applies four elementary transformations:
929/// 1. Rotate `theta` about z-axis
930/// 2. Translate `d` along z-axis
931/// 3. Translate `a` along x-axis
932/// 4. Rotate `alpha` about x-axis
933///
934/// # Arguments
935/// * `a`     – link length (translation along x)
936/// * `alpha` – link twist (rotation about x)
937/// * `d`     – link offset (translation along z)
938/// * `theta` – joint angle (rotation about z)
939///
940/// # Returns
941/// A dual quaternion representing the combined rigid transform.
942#[allow(clippy::too_many_arguments)]
943pub fn dh_transform(a: f64, alpha: f64, d: f64, theta: f64) -> DualQuaternion {
944    // Step 1+2: rotate theta about z then translate d along z
945    let q_theta = Quaternion::from_axis_angle([0.0, 0.0, 1.0], theta);
946    let dq_theta = DualQuaternion::from_rotation_translation(q_theta, [0.0, 0.0, d]);
947
948    // Step 3+4: translate a along x then rotate alpha about x
949    let q_alpha = Quaternion::from_axis_angle([1.0, 0.0, 0.0], alpha);
950    let dq_alpha = DualQuaternion::from_rotation_translation(q_alpha, [a, 0.0, 0.0]);
951
952    dq_theta.mul(&dq_alpha)
953}
954
955/// Builds a full DH kinematic chain from a list of DH parameter sets.
956///
957/// Each entry is `(a, alpha, d, theta)`.  The transforms are composed in order:
958/// `T_total = T_0 * T_1 * ... * T_{n-1}`.
959pub fn dh_chain(params: &[(f64, f64, f64, f64)]) -> DualQuaternion {
960    params
961        .iter()
962        .fold(DualQuaternion::identity(), |acc, &(a, al, d, th)| {
963            acc.mul(&dh_transform(a, al, d, th))
964        })
965}
966
967// ─────────────────────────────────────────────────────────────────────────────
968// Rigid body pose blending
969// ─────────────────────────────────────────────────────────────────────────────
970
971/// Blend N rigid body poses (dual quaternions) with given weights using DLB.
972///
973/// This is a wrapper around `dual_quaternion_blend` with a more descriptive name
974/// for the rigid body pose blending use case (e.g. character skinning, IK blending).
975pub fn rigid_body_pose_blend(poses: &[DualQuaternion], weights: &[f64]) -> DualQuaternion {
976    dual_quaternion_blend(poses, weights)
977}
978
979/// Geodesic interpolation between two poses using ScLERP.
980///
981/// Equivalent to `start.sclerp(end, t)` but exposed as a free function.
982pub fn geodesic_interpolate(
983    start: &DualQuaternion,
984    end: &DualQuaternion,
985    t: f64,
986) -> DualQuaternion {
987    start.sclerp(end, t)
988}
989
990/// Compute the relative transform from `from` to `to`: `from⁻¹ * to`.
991pub fn relative_transform(from: &DualQuaternion, to: &DualQuaternion) -> DualQuaternion {
992    from.invert().mul(to)
993}
994
995/// Extract angular velocity from two adjacent dual quaternions separated by time `dt`.
996///
997/// Uses finite differences on the rotation part: `ω ≈ 2 * ln(q1* q2).xyz / dt`.
998pub fn finite_difference_angular_velocity(
999    dq1: &DualQuaternion,
1000    dq2: &DualQuaternion,
1001    dt: f64,
1002) -> [f64; 3] {
1003    if dt.abs() < f64::EPSILON {
1004        return [0.0; 3];
1005    }
1006    let q1 = dq1.to_rotation();
1007    let q2 = dq2.to_rotation();
1008    let dq = q1.conjugate().mul(&q2);
1009    let log = dq.ln();
1010    // log of unit quaternion: (0, θ/2 * axis_xyz)
1011    let scale = 2.0 / dt;
1012    [log.x * scale, log.y * scale, log.z * scale]
1013}
1014
1015// ─────────────────────────────────────────────────────────────────────────────
1016// Tests
1017// ─────────────────────────────────────────────────────────────────────────────
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022    use std::f64::consts::PI;
1023
1024    fn approx_eq(a: f64, b: f64, eps: f64) -> bool {
1025        (a - b).abs() < eps
1026    }
1027
1028    fn vec3_approx_eq(a: [f64; 3], b: [f64; 3], eps: f64) -> bool {
1029        approx_eq(a[0], b[0], eps) && approx_eq(a[1], b[1], eps) && approx_eq(a[2], b[2], eps)
1030    }
1031
1032    fn quat_approx_eq(a: Quaternion, b: Quaternion, eps: f64) -> bool {
1033        // Account for double-cover: q and -q represent the same rotation.
1034        let direct = approx_eq(a.w, b.w, eps)
1035            && approx_eq(a.x, b.x, eps)
1036            && approx_eq(a.y, b.y, eps)
1037            && approx_eq(a.z, b.z, eps);
1038        let negated = approx_eq(a.w, -b.w, eps)
1039            && approx_eq(a.x, -b.x, eps)
1040            && approx_eq(a.y, -b.y, eps)
1041            && approx_eq(a.z, -b.z, eps);
1042        direct || negated
1043    }
1044
1045    fn dq_approx_eq(a: DualQuaternion, b: DualQuaternion, eps: f64) -> bool {
1046        quat_approx_eq(a.real, b.real, eps) && quat_approx_eq(a.dual, b.dual, eps)
1047    }
1048
1049    // ── Quaternion tests ──────────────────────────────────────────────────────
1050
1051    #[test]
1052    fn test_rotate_z90() {
1053        let q = Quaternion::from_axis_angle([0.0, 0.0, 1.0], PI / 2.0);
1054        let v = q.rotate_vector([1.0, 0.0, 0.0]);
1055        assert!(vec3_approx_eq(v, [0.0, 1.0, 0.0], 1e-12));
1056    }
1057
1058    #[test]
1059    fn test_slerp_endpoints() {
1060        let q1 = Quaternion::from_axis_angle([0.0, 0.0, 1.0], 0.0);
1061        let q2 = Quaternion::from_axis_angle([0.0, 0.0, 1.0], PI / 2.0);
1062        let s0 = q1.slerp(&q2, 0.0);
1063        let s1 = q1.slerp(&q2, 1.0);
1064        assert!(quat_approx_eq(s0, q1, 1e-10));
1065        assert!(quat_approx_eq(s1, q2, 1e-10));
1066    }
1067
1068    #[test]
1069    fn test_to_rotation_matrix_orthogonal() {
1070        let q = Quaternion::from_axis_angle([1.0, 1.0, 0.0], PI / 3.0);
1071        let m = q.to_rotation_matrix();
1072        // R^T R = I
1073        for i in 0..3 {
1074            for j in 0..3 {
1075                let dot: f64 = (0..3).map(|k| m[k][i] * m[k][j]).sum();
1076                let expected = if i == j { 1.0 } else { 0.0 };
1077                assert!(approx_eq(dot, expected, 1e-12), "R^T R [{i}][{j}] = {dot}");
1078            }
1079        }
1080    }
1081
1082    #[test]
1083    fn test_from_rotation_matrix_round_trip() {
1084        let q0 = Quaternion::from_axis_angle([0.0, 1.0, 0.0], PI / 4.0);
1085        let m = q0.to_rotation_matrix();
1086        let q1 = Quaternion::from_rotation_matrix(&m);
1087        assert!(quat_approx_eq(q0, q1, 1e-12));
1088    }
1089
1090    #[test]
1091    fn test_exp_ln_round_trip() {
1092        let q = Quaternion::new(0.0, 0.5, 0.3, 0.1); // pure quaternion
1093        let recovered = q.exp().ln();
1094        assert!(quat_approx_eq(q, recovered, 1e-12));
1095    }
1096
1097    // ── DualNumber tests ──────────────────────────────────────────────────────
1098
1099    #[test]
1100    fn test_dual_number_mul() {
1101        let a = DualNumber::new(2.0, 3.0);
1102        let b = DualNumber::new(4.0, 5.0);
1103        let c = a.mul(&b);
1104        assert!(approx_eq(c.real, 8.0, 1e-15));
1105        assert!(approx_eq(c.dual, 2.0 * 5.0 + 3.0 * 4.0, 1e-15));
1106    }
1107
1108    // ── DualQuaternion tests ──────────────────────────────────────────────────
1109
1110    #[test]
1111    fn test_identity_transforms_point() {
1112        let dq = DualQuaternion::identity();
1113        let p = [1.0, 2.0, 3.0];
1114        let tp = dq.transform_point(p);
1115        assert!(vec3_approx_eq(tp, p, 1e-12));
1116    }
1117
1118    #[test]
1119    fn test_from_rotation_translation_recovers_translation() {
1120        let q = Quaternion::identity();
1121        let t = [3.0, -1.0, 2.0];
1122        let dq = DualQuaternion::from_rotation_translation(q, t);
1123        let tr = dq.to_translation();
1124        assert!(vec3_approx_eq(tr, t, 1e-12), "got {tr:?}");
1125    }
1126
1127    #[test]
1128    fn test_pure_translation_transform() {
1129        let q = Quaternion::identity();
1130        let t = [5.0, 0.0, 0.0];
1131        let dq = DualQuaternion::from_rotation_translation(q, t);
1132        let p = [1.0, 2.0, 3.0];
1133        let tp = dq.transform_point(p);
1134        assert!(vec3_approx_eq(tp, [6.0, 2.0, 3.0], 1e-12), "got {tp:?}");
1135    }
1136
1137    #[test]
1138    fn test_mul_identity_left() {
1139        let id = DualQuaternion::identity();
1140        let q = Quaternion::from_axis_angle([0.0, 1.0, 0.0], PI / 3.0);
1141        let dq = DualQuaternion::from_rotation_translation(q, [1.0, 2.0, 3.0]);
1142        let result = id.mul(&dq);
1143        assert!(dq_approx_eq(result.normalize(), dq.normalize(), 1e-12));
1144    }
1145
1146    #[test]
1147    fn test_sclerp_endpoints() {
1148        let q1 = Quaternion::from_axis_angle([0.0, 0.0, 1.0], 0.0);
1149        let dq1 = DualQuaternion::from_rotation_translation(q1, [0.0, 0.0, 0.0]);
1150        let q2 = Quaternion::from_axis_angle([0.0, 0.0, 1.0], PI / 2.0);
1151        let dq2 = DualQuaternion::from_rotation_translation(q2, [1.0, 0.0, 0.0]);
1152
1153        let s0 = dq1.sclerp(&dq2, 0.0).normalize();
1154        let s1 = dq1.sclerp(&dq2, 1.0).normalize();
1155
1156        // t=0 should recover dq1, t=1 should recover dq2.
1157        assert!(
1158            vec3_approx_eq(s0.to_translation(), dq1.to_translation(), 1e-8),
1159            "sclerp(0) translation: {:?}",
1160            s0.to_translation()
1161        );
1162        assert!(
1163            vec3_approx_eq(s1.to_translation(), dq2.to_translation(), 1e-8),
1164            "sclerp(1) translation: {:?}",
1165            s1.to_translation()
1166        );
1167    }
1168
1169    #[test]
1170    fn test_rotation_translation_composition() {
1171        let q = Quaternion::from_axis_angle([0.0, 0.0, 1.0], PI / 2.0);
1172        let t = [0.0, 0.0, 5.0];
1173        let dq = DualQuaternion::from_rotation_translation(q, t);
1174        let p = [1.0, 0.0, 0.0];
1175        let tp = dq.transform_point(p);
1176        // Rotate [1,0,0] by 90° around Z → [0,1,0], then translate by [0,0,5].
1177        assert!(vec3_approx_eq(tp, [0.0, 1.0, 5.0], 1e-12), "got {tp:?}");
1178    }
1179
1180    // ── DLB (dual quaternion linear blending) ─────────────────────────────────
1181
1182    #[test]
1183    fn test_dlb_single_quaternion() {
1184        let q = Quaternion::from_axis_angle([0.0, 0.0, 1.0], PI / 4.0);
1185        let dq = DualQuaternion::from_rotation_translation(q, [1.0, 0.0, 0.0]);
1186        let blended = dual_quaternion_blend(&[dq], &[1.0]);
1187        // Blending a single quaternion with weight=1 should return itself
1188        let p = [0.0, 0.0, 0.0];
1189        let tp1 = dq.transform_point(p);
1190        let tp2 = blended.transform_point(p);
1191        assert!(
1192            vec3_approx_eq(tp1, tp2, 1e-10),
1193            "DLB of single DQ should be identity, got {:?}",
1194            tp2
1195        );
1196    }
1197
1198    #[test]
1199    fn test_dlb_two_equal_weights() {
1200        // Blending two identical DQs with equal weights should give the same DQ
1201        let q = Quaternion::from_axis_angle([0.0, 1.0, 0.0], PI / 6.0);
1202        let dq = DualQuaternion::from_rotation_translation(q, [0.0, 0.0, 0.0]);
1203        let blended = dual_quaternion_blend(&[dq, dq], &[0.5, 0.5]);
1204        let p = [1.0, 0.0, 0.0];
1205        let tp1 = dq.transform_point(p);
1206        let tp2 = blended.transform_point(p);
1207        assert!(
1208            vec3_approx_eq(tp1, tp2, 1e-10),
1209            "DLB of equal pair should equal original"
1210        );
1211    }
1212
1213    #[test]
1214    fn test_dlb_empty_returns_identity() {
1215        let blended = dual_quaternion_blend(&[], &[]);
1216        let id = DualQuaternion::identity();
1217        let p = [1.0, 2.0, 3.0];
1218        assert!(vec3_approx_eq(
1219            blended.transform_point(p),
1220            id.transform_point(p),
1221            1e-12
1222        ));
1223    }
1224
1225    // ── nLERP tests ────────────────────────────────────────────────────────────
1226
1227    #[test]
1228    fn test_nlerp_endpoints() {
1229        let q1 = Quaternion::identity();
1230        let dq1 = DualQuaternion::from_rotation_translation(q1, [0.0, 0.0, 0.0]);
1231        let q2 = Quaternion::from_axis_angle([0.0, 0.0, 1.0], PI / 2.0);
1232        let dq2 = DualQuaternion::from_rotation_translation(q2, [2.0, 0.0, 0.0]);
1233        let s0 = dq1.nlerp(&dq2, 0.0);
1234        let s1 = dq1.nlerp(&dq2, 1.0);
1235        let p = [0.0, 0.0, 0.0];
1236        assert!(
1237            vec3_approx_eq(s0.transform_point(p), dq1.transform_point(p), 1e-10),
1238            "nlerp t=0 should recover dq1"
1239        );
1240        assert!(
1241            vec3_approx_eq(s1.transform_point(p), dq2.transform_point(p), 1e-10),
1242            "nlerp t=1 should recover dq2"
1243        );
1244    }
1245
1246    // ── compose_transforms tests ───────────────────────────────────────────────
1247
1248    #[test]
1249    fn test_compose_transforms() {
1250        // Compose: first translate by [1,0,0], then rotate 90° around Z.
1251        let t = DualQuaternion::from_rotation_translation(Quaternion::identity(), [1.0, 0.0, 0.0]);
1252        let r = DualQuaternion::from_rotation_translation(
1253            Quaternion::from_axis_angle([0.0, 0.0, 1.0], PI / 2.0),
1254            [0.0, 0.0, 0.0],
1255        );
1256        let combined = t.compose_transforms(&r);
1257        let p = [0.0, 0.0, 0.0];
1258        // translate [0,0,0] → [1,0,0], then rotate 90° → [0,1,0]
1259        let result = combined.transform_point(p);
1260        assert!(
1261            vec3_approx_eq(result, [0.0, 1.0, 0.0], 1e-10),
1262            "composed transform: expected [0,1,0], got {:?}",
1263            result
1264        );
1265    }
1266
1267    // ── rotation_angle / rotation_axis tests ──────────────────────────────────
1268
1269    #[test]
1270    fn test_rotation_angle_pi_over_3() {
1271        let angle = PI / 3.0;
1272        let q = Quaternion::from_axis_angle([0.0, 1.0, 0.0], angle);
1273        let dq = DualQuaternion::from_rotation_translation(q, [0.0, 0.0, 0.0]);
1274        let extracted = dq.rotation_angle();
1275        assert!(
1276            approx_eq(extracted, angle, 1e-12),
1277            "Expected angle {angle}, got {extracted}"
1278        );
1279    }
1280
1281    #[test]
1282    fn test_rotation_axis_z() {
1283        let q = Quaternion::from_axis_angle([0.0, 0.0, 1.0], PI / 4.0);
1284        let dq = DualQuaternion::from_rotation_translation(q, [0.0, 0.0, 0.0]);
1285        let axis = dq.rotation_axis();
1286        assert!(
1287            vec3_approx_eq(axis, [0.0, 0.0, 1.0], 1e-10),
1288            "Axis should be [0,0,1], got {:?}",
1289            axis
1290        );
1291    }
1292
1293    #[test]
1294    fn test_rotation_axis_identity() {
1295        let dq = DualQuaternion::identity();
1296        let axis = dq.rotation_axis();
1297        // Identity should return the default axis [0,0,1]
1298        assert!(vec3_approx_eq(axis, [0.0, 0.0, 1.0], 1e-10));
1299    }
1300
1301    // ── sclerp_sequence tests ─────────────────────────────────────────────────
1302
1303    #[test]
1304    fn test_sclerp_sequence_length() {
1305        let dq1 = DualQuaternion::identity();
1306        let q2 = Quaternion::from_axis_angle([0.0, 0.0, 1.0], PI / 2.0);
1307        let dq2 = DualQuaternion::from_rotation_translation(q2, [0.0, 0.0, 0.0]);
1308        let seq = sclerp_sequence(&dq1, &dq2, 5);
1309        assert_eq!(seq.len(), 5, "sequence should have 5 poses");
1310    }
1311
1312    #[test]
1313    fn test_sclerp_sequence_endpoints() {
1314        let dq1 = DualQuaternion::identity();
1315        let q2 = Quaternion::from_axis_angle([0.0, 0.0, 1.0], PI / 2.0);
1316        let dq2 = DualQuaternion::from_rotation_translation(q2, [1.0, 0.0, 0.0]);
1317        let seq = sclerp_sequence(&dq1, &dq2, 10);
1318        let p = [1.0, 0.0, 0.0];
1319        let t0 = seq[0].transform_point(p);
1320        let t9 = seq[9].transform_point(p);
1321        let expected0 = dq1.transform_point(p);
1322        let expected9 = dq2.transform_point(p);
1323        assert!(
1324            vec3_approx_eq(t0, expected0, 1e-8),
1325            "seq[0] should match start"
1326        );
1327        assert!(
1328            vec3_approx_eq(t9, expected9, 1e-8),
1329            "seq[n-1] should match end"
1330        );
1331    }
1332
1333    #[test]
1334    fn test_sclerp_sequence_empty() {
1335        let dq = DualQuaternion::identity();
1336        let seq = sclerp_sequence(&dq, &dq, 0);
1337        assert!(seq.is_empty());
1338    }
1339
1340    // ── invert tests ──────────────────────────────────────────────────────────
1341
1342    #[test]
1343    fn test_dq_invert_composition_is_identity() {
1344        let q = Quaternion::from_axis_angle([0.0, 1.0, 0.0], PI / 3.0);
1345        let dq = DualQuaternion::from_rotation_translation(q, [1.0, 2.0, 3.0]);
1346        let inv = dq.invert();
1347        let product = dq.mul(&inv).normalize();
1348        let id = DualQuaternion::identity();
1349        let p = [5.0, -2.0, 1.0];
1350        let tp1 = product.transform_point(p);
1351        let tp2 = id.transform_point(p);
1352        assert!(
1353            vec3_approx_eq(tp1, tp2, 1e-10),
1354            "dq * inv(dq) should be identity transform, got {:?}",
1355            tp1
1356        );
1357    }
1358
1359    // ── transform_points tests ────────────────────────────────────────────────
1360
1361    #[test]
1362    fn test_transform_points_batch() {
1363        let q = Quaternion::identity();
1364        let dq = DualQuaternion::from_rotation_translation(q, [1.0, 0.0, 0.0]);
1365        let points = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1366        let transformed = dq.transform_points(&points);
1367        assert_eq!(transformed.len(), 3);
1368        // Each point should be shifted by [1,0,0]
1369        for (orig, trans) in points.iter().zip(transformed.iter()) {
1370            assert!((trans[0] - orig[0] - 1.0).abs() < 1e-12);
1371            assert!((trans[1] - orig[1]).abs() < 1e-12);
1372        }
1373    }
1374
1375    // ── Plücker coordinate tests ──────────────────────────────────────────────
1376
1377    #[test]
1378    fn test_plucker_from_points() {
1379        let p = [0.0, 0.0, 0.0];
1380        let q = [1.0, 0.0, 0.0];
1381        let pl = PluckerLine::from_two_points(p, q);
1382        // Direction should be unit x
1383        assert!((pl.direction[0] - 1.0).abs() < 1e-12);
1384        assert!(pl.direction[1].abs() < 1e-12);
1385        assert!(pl.direction[2].abs() < 1e-12);
1386    }
1387
1388    #[test]
1389    fn test_plucker_moment_through_origin() {
1390        // A line through the origin has zero moment
1391        let p = [0.0, 0.0, 0.0];
1392        let q = [0.0, 1.0, 0.0];
1393        let pl = PluckerLine::from_two_points(p, q);
1394        let mom_sq = pl.moment[0].powi(2) + pl.moment[1].powi(2) + pl.moment[2].powi(2);
1395        assert!(mom_sq.sqrt() < 1e-12, "moment={:?}", pl.moment);
1396    }
1397
1398    #[test]
1399    fn test_plucker_distance_parallel_lines() {
1400        // Two parallel lines along X, separated by 1 in Y
1401        let l1 = PluckerLine::from_two_points([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
1402        let l2 = PluckerLine::from_two_points([0.0, 1.0, 0.0], [1.0, 1.0, 0.0]);
1403        let d = l1.distance_to_line(&l2);
1404        assert!((d - 1.0).abs() < 1e-10, "distance={}", d);
1405    }
1406
1407    // ── DH transform tests ────────────────────────────────────────────────────
1408
1409    #[test]
1410    fn test_dh_transform_zero_params() {
1411        // All-zero DH params → identity
1412        let dq = dh_transform(0.0, 0.0, 0.0, 0.0);
1413        let p = [1.0, 0.0, 0.0];
1414        let tp = dq.transform_point(p);
1415        assert!(vec3_approx_eq(tp, p, 1e-10), "got {:?}", tp);
1416    }
1417
1418    #[test]
1419    fn test_dh_transform_pure_translation_d() {
1420        // d=2, others=0 → pure translation along z by 2
1421        let dq = dh_transform(0.0, 0.0, 2.0, 0.0);
1422        let p = [0.0, 0.0, 0.0];
1423        let tp = dq.transform_point(p);
1424        assert!(vec3_approx_eq(tp, [0.0, 0.0, 2.0], 1e-10), "got {:?}", tp);
1425    }
1426
1427    #[test]
1428    fn test_dh_transform_theta_rotation() {
1429        // theta=PI/2 around z, all others 0 → rotate x to y
1430        let dq = dh_transform(0.0, 0.0, 0.0, PI / 2.0);
1431        let p = [1.0, 0.0, 0.0];
1432        let tp = dq.transform_point(p);
1433        assert!(vec3_approx_eq(tp, [0.0, 1.0, 0.0], 1e-10), "got {:?}", tp);
1434    }
1435
1436    // ── Velocity screw tests ──────────────────────────────────────────────────
1437
1438    #[test]
1439    fn test_velocity_screw_zero() {
1440        let vs = VelocityScrew::zero();
1441        let v = vs.velocity_at_point([1.0, 0.0, 0.0]);
1442        assert!(vec3_approx_eq(v, [0.0, 0.0, 0.0], 1e-12));
1443    }
1444
1445    #[test]
1446    fn test_velocity_screw_pure_translation() {
1447        let vs = VelocityScrew {
1448            angular: [0.0; 3],
1449            linear: [1.0, 0.0, 0.0],
1450        };
1451        let v = vs.velocity_at_point([0.0, 5.0, 0.0]);
1452        // Pure translation: velocity = linear everywhere
1453        assert!(vec3_approx_eq(v, [1.0, 0.0, 0.0], 1e-12), "got {:?}", v);
1454    }
1455
1456    #[test]
1457    fn test_velocity_screw_pure_rotation_z() {
1458        // Rotation around z at 1 rad/s: point (1,0,0) should have vel (0,1,0)
1459        let vs = VelocityScrew {
1460            angular: [0.0, 0.0, 1.0],
1461            linear: [0.0, 0.0, 0.0],
1462        };
1463        let v = vs.velocity_at_point([1.0, 0.0, 0.0]);
1464        assert!(vec3_approx_eq(v, [0.0, 1.0, 0.0], 1e-12), "got {:?}", v);
1465    }
1466
1467    #[test]
1468    fn test_velocity_screw_addition() {
1469        let a = VelocityScrew {
1470            angular: [1.0, 0.0, 0.0],
1471            linear: [0.0, 1.0, 0.0],
1472        };
1473        let b = VelocityScrew {
1474            angular: [0.0, 1.0, 0.0],
1475            linear: [0.0, 0.0, 1.0],
1476        };
1477        let s = a.add(&b);
1478        assert!(vec3_approx_eq(s.angular, [1.0, 1.0, 0.0], 1e-12));
1479        assert!(vec3_approx_eq(s.linear, [0.0, 1.0, 1.0], 1e-12));
1480    }
1481
1482    // ── Rigid body pose blending tests ────────────────────────────────────────
1483
1484    #[test]
1485    fn test_pose_blend_two_equal() {
1486        let q = Quaternion::from_axis_angle([0.0, 1.0, 0.0], PI / 4.0);
1487        let dq = DualQuaternion::from_rotation_translation(q, [1.0, 2.0, 3.0]);
1488        let blended = rigid_body_pose_blend(&[dq, dq], &[0.5, 0.5]);
1489        let p = [1.0, 0.0, 0.0];
1490        let tp1 = dq.transform_point(p);
1491        let tp2 = blended.transform_point(p);
1492        assert!(vec3_approx_eq(tp1, tp2, 1e-8), "blend of equal = original");
1493    }
1494
1495    #[test]
1496    fn test_pose_blend_weighted_translation() {
1497        let dq1 =
1498            DualQuaternion::from_rotation_translation(Quaternion::identity(), [0.0, 0.0, 0.0]);
1499        let dq2 =
1500            DualQuaternion::from_rotation_translation(Quaternion::identity(), [2.0, 0.0, 0.0]);
1501        // 50/50 blend should give translation [1, 0, 0]
1502        let blended = rigid_body_pose_blend(&[dq1, dq2], &[0.5, 0.5]);
1503        let t = blended.to_translation();
1504        assert!((t[0] - 1.0).abs() < 1e-8, "t[0]={}", t[0]);
1505    }
1506
1507    // ── Screw interpolation chain tests ──────────────────────────────────────
1508
1509    #[test]
1510    fn test_sclerp_sequence_midpoint() {
1511        let dq1 = DualQuaternion::identity();
1512        let q2 = Quaternion::from_axis_angle([0.0, 0.0, 1.0], PI / 2.0);
1513        let dq2 = DualQuaternion::from_rotation_translation(q2, [0.0, 0.0, 0.0]);
1514        let seq = sclerp_sequence(&dq1, &dq2, 3);
1515        // seq[1] at t=0.5 should have angle ~ PI/4
1516        let angle = seq[1].rotation_angle();
1517        assert!((angle - PI / 4.0).abs() < 1e-6, "midpoint angle={}", angle);
1518    }
1519
1520    #[test]
1521    fn test_dq_mul_sequence() {
1522        // Apply same rotation 4 times → 4x rotation
1523        let q = Quaternion::from_axis_angle([0.0, 0.0, 1.0], PI / 8.0);
1524        let dq = DualQuaternion::from_rotation_translation(q, [0.0, 0.0, 0.0]);
1525        let dq4 = dq.mul(&dq).mul(&dq).mul(&dq);
1526        let angle = dq4.rotation_angle();
1527        assert!(
1528            (angle - PI / 2.0).abs() < 1e-10,
1529            "4x rotation angle={}",
1530            angle
1531        );
1532    }
1533
1534    // ── from_twist tests ──────────────────────────────────────────────────────
1535
1536    #[test]
1537    fn test_from_twist_pure_rotation_origin() {
1538        // Rotation of PI/2 around z-axis through origin, no pitch, no translation.
1539        // Point [1,0,0] should map to [0,1,0].
1540        let dq = DualQuaternion::from_twist([0.0, 0.0, 1.0], PI / 2.0, 0.0, [0.0, 0.0, 0.0]);
1541        let result = dq.transform_point([1.0, 0.0, 0.0]);
1542        assert!(
1543            vec3_approx_eq(result, [0.0, 1.0, 0.0], 1e-10),
1544            "got {:?}",
1545            result
1546        );
1547    }
1548
1549    #[test]
1550    fn test_from_twist_pure_translation_z() {
1551        // Zero rotation, pitch=1.0, angle=2.0 → translate 2 along z.
1552        let dq = DualQuaternion::from_twist([0.0, 0.0, 1.0], 0.0, 1.0, [0.0, 0.0, 0.0]);
1553        let result = dq.transform_point([0.0, 0.0, 0.0]);
1554        // pitch * angle = 0 → no translation; just identity.
1555        assert!(vec3_approx_eq(result, [0.0, 0.0, 0.0], 1e-10));
1556    }
1557
1558    #[test]
1559    fn test_from_twist_screw_with_pitch() {
1560        // Rotation PI/2 around z with pitch=1.0 → translate 1*(PI/2) along z.
1561        let dq = DualQuaternion::from_twist([0.0, 0.0, 1.0], PI / 2.0, 1.0, [0.0, 0.0, 0.0]);
1562        let t = dq.to_translation();
1563        // Translation along z should be pitch * angle = PI/2.
1564        assert!((t[2] - PI / 2.0).abs() < 1e-10, "t_z={}", t[2]);
1565        // XY components of translation should be near zero.
1566        assert!(t[0].abs() < 1e-10 && t[1].abs() < 1e-10);
1567    }
1568
1569    #[test]
1570    fn test_from_twist_off_origin_axis() {
1571        // Rotation of PI/2 around the z-axis through point [1,0,0].
1572        // The origin [0,0,0] is rotated 90° around z through [1,0,0]:
1573        // translate to origin: [-1,0,0], rotate: [0,-1,0], translate back: [1,-1,0].
1574        let dq = DualQuaternion::from_twist([0.0, 0.0, 1.0], PI / 2.0, 0.0, [1.0, 0.0, 0.0]);
1575        let result = dq.transform_point([0.0, 0.0, 0.0]);
1576        assert!(
1577            vec3_approx_eq(result, [1.0, -1.0, 0.0], 1e-10),
1578            "got {:?}",
1579            result
1580        );
1581    }
1582
1583    #[test]
1584    fn test_from_twist_transform_point_roundtrip() {
1585        // axis=[0,0,1], angle=PI/2, pure translation=[1,0,0] via point on axis.
1586        // Rotation around z through origin, no pitch, then manually verify:
1587        // transforming [0,0,0] with rotation only gives [0,0,0].
1588        // With pitch=0 and point=[0,0,0], this is just rotation.
1589        let dq = DualQuaternion::from_twist([0.0, 0.0, 1.0], PI / 2.0, 0.0, [0.0, 0.0, 0.0]);
1590        // [1,0,0] rotated 90° around z → [0,1,0]
1591        let p = dq.transform_point([1.0, 0.0, 0.0]);
1592        assert!(vec3_approx_eq(p, [0.0, 1.0, 0.0], 1e-10), "got {:?}", p);
1593        // [0,1,0] rotated 90° around z → [-1,0,0]
1594        let q_pt = dq.transform_point([0.0, 1.0, 0.0]);
1595        assert!(
1596            vec3_approx_eq(q_pt, [-1.0, 0.0, 0.0], 1e-10),
1597            "got {:?}",
1598            q_pt
1599        );
1600    }
1601}