bio_forge/model/
atom.rs

1//! Fundamental atom representation comprising name, chemical element, and Cartesian position.
2//!
3//! This module defines the smallest structural unit used throughout `bio-forge`. Atoms are
4//! instantiated by IO readers, manipulated by topology operations, and rendered back into
5//! biomolecular formats. Distance helpers and translation utilities keep vector math inside
6//! the type, ensuring consistent use of the chosen coordinate system.
7
8use super::types::{Element, Point};
9use std::fmt;
10
11/// Labeled atom with immutable element identity and mutable position.
12///
13/// The struct is shared across residue, chain, and structure builders. Keeping the element
14/// metadata close to the coordinate allows downstream algorithms (e.g., heavy-atom filters
15/// or hydrogen placement) to reason locally without traversing additional tables.
16#[derive(Debug, Clone, PartialEq)]
17pub struct Atom {
18    /// Atom name as it appears in crystallographic or modeling files (e.g., `CA`).
19    pub name: String,
20    /// Chemical element derived from the periodic table definitions.
21    pub element: Element,
22    /// Cartesian coordinates measured in ångströms.
23    pub pos: Point,
24}
25
26impl Atom {
27    /// Creates a new atom from a name, element, and position.
28    ///
29    /// Caller controls ownership of the label string while the element enforces chemical
30    /// consistency. The position is copied as-is; no normalization is performed.
31    ///
32    /// # Arguments
33    ///
34    /// * `name` - Atom label such as `"CA"` or `"OXT"`.
35    /// * `element` - `Element` variant describing the chemical identity.
36    /// * `pos` - `Point` describing the Cartesian coordinates in ångströms.
37    ///
38    /// # Returns
39    ///
40    /// A fully initialized `Atom` instance.
41    pub fn new(name: &str, element: Element, pos: Point) -> Self {
42        Self {
43            name: name.to_string(),
44            element,
45            pos,
46        }
47    }
48
49    /// Computes the squared Euclidean distance to another atom.
50    ///
51    /// Prefer this when comparing relative distances or feeding cutoffs, as it avoids the
52    /// costly square-root step while remaining in ångström squared units.
53    ///
54    /// # Arguments
55    ///
56    /// * `other` - Reference atom to measure against.
57    ///
58    /// # Returns
59    ///
60    /// The squared distance as `f64`.
61    pub fn distance_squared(&self, other: &Atom) -> f64 {
62        nalgebra::distance_squared(&self.pos, &other.pos)
63    }
64
65    /// Computes the Euclidean distance to another atom.
66    ///
67    /// This is the fully realized length in ångströms and is suitable for reporting or
68    /// geometry calculations that require actual bond lengths.
69    ///
70    /// # Arguments
71    ///
72    /// * `other` - Reference atom to measure against.
73    ///
74    /// # Returns
75    ///
76    /// The distance in ångströms as `f64`.
77    pub fn distance(&self, other: &Atom) -> f64 {
78        nalgebra::distance(&self.pos, &other.pos)
79    }
80
81    /// Translates the atom by an arbitrary vector.
82    ///
83    /// The operation mutates the underlying position and is commonly used during rigid-body
84    /// transforms or when applying simulation displacements.
85    ///
86    /// # Arguments
87    ///
88    /// * `vector` - Translation expressed as a `nalgebra::Vector3<f64>` in ångströms.
89    pub fn translate_by(&mut self, vector: &nalgebra::Vector3<f64>) {
90        self.pos += vector;
91    }
92}
93
94impl fmt::Display for Atom {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        write!(
97            f,
98            "Atom {{ name: \"{}\", element: {}, pos: [{:.3}, {:.3}, {:.3}] }}",
99            self.name, self.element, self.pos.x, self.pos.y, self.pos.z
100        )
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn atom_new_creates_correct_atom() {
110        let pos = Point::new(1.0, 2.0, 3.0);
111        let atom = Atom::new("C1", Element::C, pos);
112
113        assert_eq!(atom.name, "C1");
114        assert_eq!(atom.element, Element::C);
115        assert_eq!(atom.pos, pos);
116    }
117
118    #[test]
119    fn atom_distance_squared_calculates_correctly() {
120        let atom1 = Atom::new("A", Element::H, Point::new(0.0, 0.0, 0.0));
121        let atom2 = Atom::new("B", Element::H, Point::new(3.0, 4.0, 0.0));
122
123        let dist_sq = atom1.distance_squared(&atom2);
124        assert!((dist_sq - 25.0).abs() < 1e-10);
125    }
126
127    #[test]
128    fn atom_distance_calculates_correctly() {
129        let atom1 = Atom::new("A", Element::H, Point::new(0.0, 0.0, 0.0));
130        let atom2 = Atom::new("B", Element::H, Point::new(3.0, 4.0, 0.0));
131
132        let dist = atom1.distance(&atom2);
133        assert!((dist - 5.0).abs() < 1e-10);
134    }
135
136    #[test]
137    fn atom_distance_squared_zero_for_same_position() {
138        let pos = Point::new(1.5, -2.3, 4.7);
139        let atom1 = Atom::new("A", Element::O, pos);
140        let atom2 = Atom::new("B", Element::O, pos);
141
142        let dist_sq = atom1.distance_squared(&atom2);
143        assert!((dist_sq - 0.0).abs() < 1e-10);
144    }
145
146    #[test]
147    fn atom_distance_zero_for_same_position() {
148        let pos = Point::new(1.5, -2.3, 4.7);
149        let atom1 = Atom::new("A", Element::O, pos);
150        let atom2 = Atom::new("B", Element::O, pos);
151
152        let dist = atom1.distance(&atom2);
153        assert!((dist - 0.0).abs() < 1e-10);
154    }
155
156    #[test]
157    fn atom_translate_by_updates_position_correctly() {
158        let mut atom = Atom::new("Test", Element::N, Point::new(1.0, 2.0, 3.0));
159        let vector = nalgebra::Vector3::new(0.5, -1.0, 2.5);
160
161        atom.translate_by(&vector);
162
163        assert!((atom.pos.x - 1.5).abs() < 1e-10);
164        assert!((atom.pos.y - 1.0).abs() < 1e-10);
165        assert!((atom.pos.z - 5.5).abs() < 1e-10);
166    }
167
168    #[test]
169    fn atom_translate_by_with_zero_vector_no_change() {
170        let mut atom = Atom::new("Test", Element::N, Point::new(1.0, 2.0, 3.0));
171        let original_pos = atom.pos;
172        let vector = nalgebra::Vector3::new(0.0, 0.0, 0.0);
173
174        atom.translate_by(&vector);
175
176        assert_eq!(atom.pos, original_pos);
177    }
178
179    #[test]
180    fn atom_display_formats_correctly() {
181        let atom = Atom::new("CA", Element::C, Point::new(1.234, -5.678, 9.012));
182
183        let display = format!("{}", atom);
184        let expected = "Atom { name: \"CA\", element: C, pos: [1.234, -5.678, 9.012] }";
185
186        assert_eq!(display, expected);
187    }
188
189    #[test]
190    fn atom_display_with_unknown_element() {
191        let atom = Atom::new("UNK", Element::Unknown, Point::new(0.0, 0.0, 0.0));
192
193        let display = format!("{}", atom);
194        let expected = "Atom { name: \"UNK\", element: Unknown, pos: [0.000, 0.000, 0.000] }";
195
196        assert_eq!(display, expected);
197    }
198
199    #[test]
200    fn atom_clone_creates_identical_copy() {
201        let atom = Atom::new("CloneTest", Element::Fe, Point::new(7.89, -1.23, 4.56));
202        let cloned = atom.clone();
203
204        assert_eq!(atom, cloned);
205        assert_eq!(atom.name, cloned.name);
206        assert_eq!(atom.element, cloned.element);
207        assert_eq!(atom.pos, cloned.pos);
208    }
209
210    #[test]
211    fn atom_partial_eq_compares_correctly() {
212        let atom1 = Atom::new("Test", Element::O, Point::new(1.0, 2.0, 3.0));
213        let atom2 = Atom::new("Test", Element::O, Point::new(1.0, 2.0, 3.0));
214        let atom3 = Atom::new("Different", Element::O, Point::new(1.0, 2.0, 3.0));
215        let atom4 = Atom::new("Test", Element::N, Point::new(1.0, 2.0, 3.0));
216        let atom5 = Atom::new("Test", Element::O, Point::new(1.1, 2.0, 3.0));
217
218        assert_eq!(atom1, atom2);
219        assert_ne!(atom1, atom3);
220        assert_ne!(atom1, atom4);
221        assert_ne!(atom1, atom5);
222    }
223
224    #[test]
225    fn atom_distance_with_negative_coordinates() {
226        let atom1 = Atom::new("A", Element::H, Point::new(-1.0, -2.0, -3.0));
227        let atom2 = Atom::new("B", Element::H, Point::new(1.0, 2.0, 3.0));
228
229        let dist_sq = atom1.distance_squared(&atom2);
230        assert!((dist_sq - 56.0).abs() < 1e-10);
231
232        let dist = atom1.distance(&atom2);
233        assert!((dist - (56.0_f64).sqrt()).abs() < 1e-10);
234    }
235
236    #[test]
237    fn atom_translate_by_multiple_times_accumulates() {
238        let mut atom = Atom::new("Test", Element::C, Point::new(0.0, 0.0, 0.0));
239
240        atom.translate_by(&nalgebra::Vector3::new(1.0, 0.0, 0.0));
241        atom.translate_by(&nalgebra::Vector3::new(0.0, 2.0, 0.0));
242        atom.translate_by(&nalgebra::Vector3::new(0.0, 0.0, 3.0));
243
244        assert!((atom.pos.x - 1.0).abs() < 1e-10);
245        assert!((atom.pos.y - 2.0).abs() < 1e-10);
246        assert!((atom.pos.z - 3.0).abs() < 1e-10);
247    }
248}