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