Skip to main content

ferritin_core/model/
symmetry.rs

1//! Symmetry operators and zero-copy assembly definitions.
2
3/// Row-major homogeneous 4×4 affine matrix.
4pub type Mat4 = [[f32; 4]; 4];
5
6/// Identity affine transform.
7pub const IDENTITY_MAT4: Mat4 = [
8    [1.0, 0.0, 0.0, 0.0],
9    [0.0, 1.0, 0.0, 0.0],
10    [0.0, 0.0, 1.0, 0.0],
11    [0.0, 0.0, 0.0, 1.0],
12];
13
14/// A named affine transform applied lazily to model coordinates.
15#[derive(Clone, Debug, PartialEq)]
16pub struct SymmetryOperator {
17    pub label: String,
18    pub matrix: Mat4,
19}
20
21impl SymmetryOperator {
22    pub fn identity() -> Self {
23        Self {
24            label: "identity".to_string(),
25            matrix: IDENTITY_MAT4,
26        }
27    }
28
29    pub fn new(label: impl Into<String>, matrix: Mat4) -> Self {
30        Self {
31            label: label.into(),
32            matrix,
33        }
34    }
35
36    /// Apply the affine transform to a Cartesian point.
37    pub fn apply(&self, point: [f32; 3]) -> [f32; 3] {
38        let [x, y, z] = point;
39        [
40            self.matrix[0][0] * x
41                + self.matrix[0][1] * y
42                + self.matrix[0][2] * z
43                + self.matrix[0][3],
44            self.matrix[1][0] * x
45                + self.matrix[1][1] * y
46                + self.matrix[1][2] * z
47                + self.matrix[1][3],
48            self.matrix[2][0] * x
49                + self.matrix[2][1] * y
50                + self.matrix[2][2] * z
51                + self.matrix[2][3],
52        ]
53    }
54
55    /// Compose two operators. The returned operator applies `other` first,
56    /// followed by `self`.
57    pub fn compose(&self, other: &Self, label: impl Into<String>) -> Self {
58        let mut matrix = [[0.0; 4]; 4];
59        for (row, values) in matrix.iter_mut().enumerate() {
60            for (column, value) in values.iter_mut().enumerate() {
61                *value = (0..4)
62                    .map(|k| self.matrix[row][k] * other.matrix[k][column])
63                    .sum();
64            }
65        }
66        Self::new(label, matrix)
67    }
68}
69
70impl Default for SymmetryOperator {
71    fn default() -> Self {
72        Self::identity()
73    }
74}
75
76/// One transformed subset in a biological or crystallographic assembly.
77#[derive(Clone, Debug, PartialEq)]
78pub struct AssemblyUnit {
79    /// Canonical `label_asym_id` values included in this unit.
80    pub asym_ids: Vec<String>,
81    pub operator: SymmetryOperator,
82}
83
84/// A named collection of transformed model subsets.
85#[derive(Clone, Debug, PartialEq)]
86pub struct Assembly {
87    pub id: String,
88    pub details: Option<String>,
89    pub units: Vec<AssemblyUnit>,
90}
91
92/// Crystallographic metadata from the mmCIF `_cell` and `_symmetry` categories.
93#[derive(Clone, Debug, Default, PartialEq)]
94pub struct CrystalSymmetry {
95    pub space_group_name: Option<String>,
96    pub cell: Option<[f32; 6]>,
97    pub operators: Vec<SymmetryOperator>,
98}
99
100/// Symmetry and assembly metadata associated with a model topology.
101#[derive(Clone, Debug, Default, PartialEq)]
102pub struct SymmetryData {
103    pub assemblies: Vec<Assembly>,
104    pub crystal: CrystalSymmetry,
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn operator_applies_translation() {
113        let mut matrix = IDENTITY_MAT4;
114        matrix[0][3] = 10.0;
115        matrix[2][3] = -2.0;
116        let operator = SymmetryOperator::new("translated", matrix);
117        assert_eq!(operator.apply([1.0, 2.0, 3.0]), [11.0, 2.0, 1.0]);
118    }
119
120    #[test]
121    fn operator_composition_preserves_application_order() {
122        let mut translate = IDENTITY_MAT4;
123        translate[0][3] = 2.0;
124        let mut scale = IDENTITY_MAT4;
125        scale[0][0] = 3.0;
126        let combined = SymmetryOperator::new("scale", scale)
127            .compose(&SymmetryOperator::new("translate", translate), "combined");
128        assert_eq!(combined.apply([1.0, 0.0, 0.0]), [9.0, 0.0, 0.0]);
129    }
130}