Skip to main content

chematic_3d/
align.rs

1//! Standalone 3D coordinate alignment using the Kabsch algorithm.
2//!
3//! [`align_coords`] finds the rotation and translation that minimises the RMSD
4//! between two sets of paired 3D coordinates.  The implementation reuses the
5//! Jacobi eigensolver from [`crate::shape_descriptors`].
6//!
7//! [`rmsd_no_align`] computes the raw RMSD without any superposition.
8
9use crate::shape_descriptors::jacobi3;
10
11// ---------------------------------------------------------------------------
12// Public types
13// ---------------------------------------------------------------------------
14
15/// Result of a Kabsch alignment.
16#[derive(Debug, Clone)]
17pub struct AlignResult {
18    /// RMSD after optimal superposition (Å or whatever unit `reference` uses).
19    pub rmsd: f64,
20    /// 3×3 rotation matrix R such that `mobile_centred @ R ≈ reference_centred`.
21    pub rotation: [[f64; 3]; 3],
22    /// Translation to apply to mobile *after* rotation to match reference centroid.
23    pub translation: [f64; 3],
24}
25
26// ---------------------------------------------------------------------------
27// Public API
28// ---------------------------------------------------------------------------
29
30/// Compute the RMSD between two sets of paired coordinates **without** any
31/// rotation or translation.
32///
33/// `a` and `b` must have the same length.  Returns `0.0` for empty slices.
34pub fn rmsd_no_align(a: &[[f64; 3]], b: &[[f64; 3]]) -> f64 {
35    let n = a.len().min(b.len());
36    if n == 0 {
37        return 0.0;
38    }
39    let sum_sq: f64 = a
40        .iter()
41        .zip(b.iter())
42        .map(|(pa, pb)| (0..3).map(|i| (pa[i] - pb[i]).powi(2)).sum::<f64>())
43        .sum();
44    (sum_sq / n as f64).sqrt()
45}
46
47/// Find the optimal superposition of `mobile` onto `reference` using the
48/// Kabsch algorithm and return the RMSD plus the rotation matrix and
49/// translation vector.
50///
51/// The two slices must have the same length (atom-correspondence is assumed).
52/// Returns an [`AlignResult`] with `rmsd = 0.0` for empty or single-atom inputs.
53///
54/// To obtain the aligned coordinates, apply:
55/// ```text
56/// aligned[i][j] = Σ_k rotation[j][k] * (mobile[i][k] - mobile_centroid[k])
57///                + reference_centroid[j]
58/// ```
59/// Or use [`apply_alignment`].
60pub fn align_coords(reference: &[[f64; 3]], mobile: &[[f64; 3]]) -> AlignResult {
61    let n = reference.len().min(mobile.len());
62    if n == 0 {
63        return AlignResult {
64            rmsd: 0.0,
65            rotation: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
66            translation: [0.0, 0.0, 0.0],
67        };
68    }
69
70    let nf = n as f64;
71
72    // Centroids.
73    let mut cr = [0.0f64; 3];
74    let mut cm = [0.0f64; 3];
75    for i in 0..n {
76        for k in 0..3 {
77            cr[k] += reference[i][k];
78            cm[k] += mobile[i][k];
79        }
80    }
81    for k in 0..3 {
82        cr[k] /= nf;
83        cm[k] /= nf;
84    }
85
86    // Centered coordinates.
87    let p: Vec<[f64; 3]> = reference
88        .iter()
89        .map(|v| [v[0] - cr[0], v[1] - cr[1], v[2] - cr[2]])
90        .collect();
91    let q: Vec<[f64; 3]> = mobile
92        .iter()
93        .map(|v| [v[0] - cm[0], v[1] - cm[1], v[2] - cm[2]])
94        .collect();
95
96    // H = P^T * Q  (3×3 cross-covariance).
97    let mut h = [[0.0f64; 3]; 3];
98    for i in 0..n {
99        for r in 0..3 {
100            for c in 0..3 {
101                h[r][c] += p[i][r] * q[i][c];
102            }
103        }
104    }
105
106    // SVD via eigendecomposition of H^T * H.
107    let mut hth = [[0.0f64; 3]; 3];
108    for r in 0..3 {
109        for c in 0..3 {
110            for k in 0..3 {
111                hth[r][c] += h[k][r] * h[k][c];
112            }
113        }
114    }
115    let (evals, v) = jacobi3(hth);
116
117    // U = H * V * diag(1/σ).
118    let mut hv = [[0.0f64; 3]; 3];
119    for r in 0..3 {
120        for c in 0..3 {
121            for k in 0..3 {
122                hv[r][c] += h[r][k] * v[k][c];
123            }
124        }
125    }
126    let mut u = [[0.0f64; 3]; 3];
127    for j in 0..3 {
128        let sigma = evals[j].max(0.0).sqrt();
129        for r in 0..3 {
130            u[r][j] = if sigma > 1e-10 { hv[r][j] / sigma } else { 0.0 };
131        }
132    }
133
134    // R = U * V^T (maximises trace(H^T R), the Kabsch objective).
135    let mut rot = [[0.0f64; 3]; 3];
136    let mut v_final = v;
137    for r in 0..3 {
138        for c in 0..3 {
139            for k in 0..3 {
140                rot[r][c] += u[r][k] * v_final[c][k];
141            }
142        }
143    }
144
145    // Reflection correction: flip the column of V paired with the smallest
146    // singular value (index 0, ascending order) when det(R) < 0.
147    let det = det3(rot);
148    if det < 0.0 {
149        for r in 0..3 {
150            v_final[r][0] *= -1.0;
151        }
152        rot = [[0.0; 3]; 3];
153        for r in 0..3 {
154            for c in 0..3 {
155                for k in 0..3 {
156                    rot[r][c] += u[r][k] * v_final[c][k];
157                }
158            }
159        }
160    }
161
162    // RMSD after optimal rotation.
163    let mut sum_sq = 0.0f64;
164    for i in 0..n {
165        for row in 0..3 {
166            let rotated = (0..3).map(|k| rot[row][k] * q[i][k]).sum::<f64>();
167            let diff = p[i][row] - rotated;
168            sum_sq += diff * diff;
169        }
170    }
171    let rmsd = (sum_sq / nf).sqrt();
172
173    // Translation: after rotating mobile around its centroid, shift to reference centroid.
174    let translation = [cr[0] - cm[0], cr[1] - cm[1], cr[2] - cm[2]];
175
176    AlignResult {
177        rmsd,
178        rotation: rot,
179        translation,
180    }
181}
182
183/// Apply an [`AlignResult`] to transform `mobile` coordinates.
184///
185/// Returns a new `Vec` of aligned coordinates.
186pub fn apply_alignment(mobile: &[[f64; 3]], result: &AlignResult) -> Vec<[f64; 3]> {
187    // Compute mobile centroid.
188    let n = mobile.len();
189    if n == 0 {
190        return Vec::new();
191    }
192    let mut cm = [0.0f64; 3];
193    for v in mobile {
194        for k in 0..3 {
195            cm[k] += v[k];
196        }
197    }
198    for k in 0..3 {
199        cm[k] /= n as f64;
200    }
201
202    // Compute reference centroid from translation.
203    // translation = cr - cm_orig, so cr = cm_orig + translation.
204    // We need the reference centroid to shift into, but it's embedded in translation.
205    // Simpler: rotate (mobile - cm) then add (cm + translation).
206    let cr = [
207        cm[0] + result.translation[0],
208        cm[1] + result.translation[1],
209        cm[2] + result.translation[2],
210    ];
211
212    mobile
213        .iter()
214        .map(|v| {
215            let centered = [v[0] - cm[0], v[1] - cm[1], v[2] - cm[2]];
216            let mut out = [0.0f64; 3];
217            for row in 0..3 {
218                out[row] = (0..3)
219                    .map(|k| result.rotation[row][k] * centered[k])
220                    .sum::<f64>()
221                    + cr[row];
222            }
223            out
224        })
225        .collect()
226}
227
228fn det3(m: [[f64; 3]; 3]) -> f64 {
229    m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
230        - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
231        + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
232}
233
234// ---------------------------------------------------------------------------
235// Tests
236// ---------------------------------------------------------------------------
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
243        (a - b).abs() < tol
244    }
245
246    #[test]
247    fn test_rmsd_no_align_identical() {
248        let coords = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
249        assert!(approx_eq(rmsd_no_align(&coords, &coords), 0.0, 1e-10));
250    }
251
252    #[test]
253    fn test_rmsd_no_align_empty() {
254        assert_eq!(rmsd_no_align(&[], &[]), 0.0);
255    }
256
257    #[test]
258    fn test_rmsd_no_align_translated() {
259        let a = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
260        let b = vec![[1.0, 0.0, 0.0], [2.0, 0.0, 0.0]]; // translated by 1 Å along x
261        // RMSD without align = 1.0
262        assert!(approx_eq(rmsd_no_align(&a, &b), 1.0, 1e-9));
263    }
264
265    #[test]
266    fn test_align_identical() {
267        let coords = vec![[0.0, 0.0, 0.0], [1.5, 0.0, 0.0], [0.75, 1.3, 0.0]];
268        let result = align_coords(&coords, &coords);
269        assert!(
270            approx_eq(result.rmsd, 0.0, 1e-9),
271            "identical coords → RMSD 0"
272        );
273    }
274
275    #[test]
276    fn test_align_pure_translation() {
277        // Two identical shapes offset by a constant translation → RMSD after align = 0.
278        let reference = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]];
279        let mobile: Vec<[f64; 3]> = reference
280            .iter()
281            .map(|v| [v[0] + 3.0, v[1] - 2.0, v[2] + 1.0])
282            .collect();
283        let result = align_coords(&reference, &mobile);
284        assert!(
285            approx_eq(result.rmsd, 0.0, 1e-6),
286            "pure translation → RMSD 0 after Kabsch"
287        );
288    }
289
290    #[test]
291    fn test_align_different_shapes_nonzero_rmsd() {
292        let reference = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]];
293        let mobile = vec![[0.0, 0.0, 0.0], [1.0, 0.1, 0.0], [0.5, 1.1, 0.0]]; // perturbed
294        let result = align_coords(&reference, &mobile);
295        assert!(result.rmsd > 0.0, "different shapes → RMSD > 0");
296    }
297
298    #[test]
299    fn test_apply_alignment_reduces_rmsd() {
300        let reference = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]];
301        let mobile: Vec<[f64; 3]> = reference
302            .iter()
303            .map(|v| [v[0] + 2.0, v[1] + 2.0, v[2]])
304            .collect();
305        let result = align_coords(&reference, &mobile);
306        let aligned = apply_alignment(&mobile, &result);
307        let rmsd_after = rmsd_no_align(&reference, &aligned);
308        assert!(
309            approx_eq(rmsd_after, result.rmsd, 1e-6),
310            "apply_alignment should match reported RMSD"
311        );
312    }
313
314    /// Regression test for a rotation-direction bug: `align_coords` once
315    /// returned the reference→mobile rotation instead of mobile→reference,
316    /// which is invisible under pure translation (H is symmetric there, so
317    /// U == V and the two directions coincide) but produces a large bogus
318    /// RMSD once an actual rotation is involved.
319    #[test]
320    fn test_align_pure_rotation_recovers_zero_rmsd() {
321        let reference = vec![
322            [0.0, 0.0, 0.0],
323            [2.0, 0.0, 0.0],
324            [0.0, 3.0, 0.0],
325            [1.0, 1.0, 4.0],
326        ];
327        // 90° rotation about z (x,y,z) -> (-y,x,z), plus a translation.
328        let mobile: Vec<[f64; 3]> = reference
329            .iter()
330            .map(|p| [-p[1] + 5.0, p[0] - 3.0, p[2] + 2.0])
331            .collect();
332        let result = align_coords(&reference, &mobile);
333        assert!(
334            approx_eq(result.rmsd, 0.0, 1e-6),
335            "pure rotation + translation → RMSD ~0 after Kabsch, got {}",
336            result.rmsd
337        );
338    }
339
340    #[test]
341    fn test_apply_alignment_under_rotation_matches_reference_pointwise() {
342        let reference = vec![
343            [0.0, 0.0, 0.0],
344            [2.0, 0.0, 0.0],
345            [0.0, 3.0, 0.0],
346            [1.0, 1.0, 4.0],
347        ];
348        let mobile: Vec<[f64; 3]> = reference
349            .iter()
350            .map(|p| [-p[1] + 5.0, p[0] - 3.0, p[2] + 2.0])
351            .collect();
352        let result = align_coords(&reference, &mobile);
353        let aligned = apply_alignment(&mobile, &result);
354        for (r, a) in reference.iter().zip(aligned.iter()) {
355            for k in 0..3 {
356                assert!(
357                    approx_eq(r[k], a[k], 1e-6),
358                    "aligned point {a:?} should match reference point {r:?}"
359                );
360            }
361        }
362    }
363
364    /// Forces the det<0 reflection-correction branch: a mirror image of a
365    /// genuinely non-planar (chiral) point set cannot be superposed by any
366    /// proper rotation, so the corrected result must still have det(R) = +1
367    /// and a nonzero residual RMSD (never a silent improper "rotation").
368    #[test]
369    fn test_align_mirror_image_forces_reflection_correction() {
370        let reference = vec![
371            [0.0, 0.0, 0.0],
372            [2.0, 0.0, 0.0],
373            [0.0, 3.0, 0.0],
374            [1.0, 1.0, 4.0],
375        ];
376        // Reflect through the xy-plane (z -> -z): improper for a non-planar set.
377        let mobile: Vec<[f64; 3]> = reference.iter().map(|p| [p[0], p[1], -p[2]]).collect();
378        let result = align_coords(&reference, &mobile);
379        assert!(
380            approx_eq(det3(result.rotation), 1.0, 1e-6),
381            "corrected rotation must be proper (det=+1), got det={}",
382            det3(result.rotation)
383        );
384        assert!(
385            result.rmsd > 0.5,
386            "a chiral point set can't be superposed onto its mirror image by any rotation, rmsd={}",
387            result.rmsd
388        );
389    }
390}