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 = V * U^T.
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] += v_final[r][k] * u[c][k];
141            }
142        }
143    }
144
145    // Reflection correction.
146    let det = det3(rot);
147    if det < 0.0 {
148        for r in 0..3 {
149            v_final[r][0] *= -1.0;
150        }
151        rot = [[0.0; 3]; 3];
152        for r in 0..3 {
153            for c in 0..3 {
154                for k in 0..3 {
155                    rot[r][c] += v_final[r][k] * u[c][k];
156                }
157            }
158        }
159    }
160
161    // RMSD after optimal rotation.
162    let mut sum_sq = 0.0f64;
163    for i in 0..n {
164        for row in 0..3 {
165            let rotated = (0..3).map(|k| rot[row][k] * q[i][k]).sum::<f64>();
166            let diff = p[i][row] - rotated;
167            sum_sq += diff * diff;
168        }
169    }
170    let rmsd = (sum_sq / nf).sqrt();
171
172    // Translation: after rotating mobile around its centroid, shift to reference centroid.
173    let translation = [cr[0] - cm[0], cr[1] - cm[1], cr[2] - cm[2]];
174
175    AlignResult {
176        rmsd,
177        rotation: rot,
178        translation,
179    }
180}
181
182/// Apply an [`AlignResult`] to transform `mobile` coordinates.
183///
184/// Returns a new `Vec` of aligned coordinates.
185pub fn apply_alignment(mobile: &[[f64; 3]], result: &AlignResult) -> Vec<[f64; 3]> {
186    // Compute mobile centroid.
187    let n = mobile.len();
188    if n == 0 {
189        return Vec::new();
190    }
191    let mut cm = [0.0f64; 3];
192    for v in mobile {
193        for k in 0..3 {
194            cm[k] += v[k];
195        }
196    }
197    for k in 0..3 {
198        cm[k] /= n as f64;
199    }
200
201    // Compute reference centroid from translation.
202    // translation = cr - cm_orig, so cr = cm_orig + translation.
203    // We need the reference centroid to shift into, but it's embedded in translation.
204    // Simpler: rotate (mobile - cm) then add (cm + translation).
205    let cr = [
206        cm[0] + result.translation[0],
207        cm[1] + result.translation[1],
208        cm[2] + result.translation[2],
209    ];
210
211    mobile
212        .iter()
213        .map(|v| {
214            let centered = [v[0] - cm[0], v[1] - cm[1], v[2] - cm[2]];
215            let mut out = [0.0f64; 3];
216            for row in 0..3 {
217                out[row] = (0..3)
218                    .map(|k| result.rotation[row][k] * centered[k])
219                    .sum::<f64>()
220                    + cr[row];
221            }
222            out
223        })
224        .collect()
225}
226
227fn det3(m: [[f64; 3]; 3]) -> f64 {
228    m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
229        - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
230        + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
231}
232
233// ---------------------------------------------------------------------------
234// Tests
235// ---------------------------------------------------------------------------
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
242        (a - b).abs() < tol
243    }
244
245    #[test]
246    fn test_rmsd_no_align_identical() {
247        let coords = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
248        assert!(approx_eq(rmsd_no_align(&coords, &coords), 0.0, 1e-10));
249    }
250
251    #[test]
252    fn test_rmsd_no_align_empty() {
253        assert_eq!(rmsd_no_align(&[], &[]), 0.0);
254    }
255
256    #[test]
257    fn test_rmsd_no_align_translated() {
258        let a = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
259        let b = vec![[1.0, 0.0, 0.0], [2.0, 0.0, 0.0]]; // translated by 1 Å along x
260        // RMSD without align = 1.0
261        assert!(approx_eq(rmsd_no_align(&a, &b), 1.0, 1e-9));
262    }
263
264    #[test]
265    fn test_align_identical() {
266        let coords = vec![[0.0, 0.0, 0.0], [1.5, 0.0, 0.0], [0.75, 1.3, 0.0]];
267        let result = align_coords(&coords, &coords);
268        assert!(
269            approx_eq(result.rmsd, 0.0, 1e-9),
270            "identical coords → RMSD 0"
271        );
272    }
273
274    #[test]
275    fn test_align_pure_translation() {
276        // Two identical shapes offset by a constant translation → RMSD after align = 0.
277        let reference = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]];
278        let mobile: Vec<[f64; 3]> = reference
279            .iter()
280            .map(|v| [v[0] + 3.0, v[1] - 2.0, v[2] + 1.0])
281            .collect();
282        let result = align_coords(&reference, &mobile);
283        assert!(
284            approx_eq(result.rmsd, 0.0, 1e-6),
285            "pure translation → RMSD 0 after Kabsch"
286        );
287    }
288
289    #[test]
290    fn test_align_different_shapes_nonzero_rmsd() {
291        let reference = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]];
292        let mobile = vec![[0.0, 0.0, 0.0], [1.0, 0.1, 0.0], [0.5, 1.1, 0.0]]; // perturbed
293        let result = align_coords(&reference, &mobile);
294        assert!(result.rmsd > 0.0, "different shapes → RMSD > 0");
295    }
296
297    #[test]
298    fn test_apply_alignment_reduces_rmsd() {
299        let reference = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]];
300        let mobile: Vec<[f64; 3]> = reference
301            .iter()
302            .map(|v| [v[0] + 2.0, v[1] + 2.0, v[2]])
303            .collect();
304        let result = align_coords(&reference, &mobile);
305        let aligned = apply_alignment(&mobile, &result);
306        let rmsd_after = rmsd_no_align(&reference, &aligned);
307        assert!(
308            approx_eq(rmsd_after, result.rmsd, 1e-6),
309            "apply_alignment should match reported RMSD"
310        );
311    }
312}