bio_forge/model/
structure.rs

1//! Representation of multi-chain biomolecular assemblies with geometric helpers.
2//!
3//! The `Structure` type aggregates polymer chains, provides fast lookup utilities, and
4//! offers derived properties such as geometric centers or mass-weighted centroids. It is
5//! the central container consumed by IO readers, cleaning operations, and solvation tools.
6
7use super::chain::Chain;
8use super::residue::Residue;
9use super::types::Point;
10use std::fmt;
11
12/// High-level biomolecular assembly composed of zero or more chains.
13///
14/// A `Structure` wraps individual chains, tracks optional periodic box vectors, and offers
15/// convenience iterators for traversing chains, residues, and atoms alongside contextual
16/// metadata. Builders and operations mutate the structure to clean, solvate, or analyze
17/// biological systems.
18#[derive(Debug, Clone, Default)]
19pub struct Structure {
20    /// Internal collection of polymer chains preserving insertion order.
21    chains: Vec<Chain>,
22    /// Optional periodic box represented as crystallographic basis vectors.
23    pub box_vectors: Option<[[f64; 3]; 3]>,
24}
25
26impl Structure {
27    /// Creates an empty structure with no chains or box vectors.
28    ///
29    /// # Returns
30    ///
31    /// A new `Structure` identical to `Structure::default()`.
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Appends a chain to the structure, asserting unique chain IDs in debug builds.
37    ///
38    /// The chain is inserted at the end of the current collection and becomes visible to
39    /// iterator methods immediately.
40    ///
41    /// # Arguments
42    ///
43    /// * `chain` - Chain instance whose `id` must be unique within the structure.
44    pub fn add_chain(&mut self, chain: Chain) {
45        debug_assert!(
46            self.chain(&chain.id).is_none(),
47            "Attempted to add a duplicate chain ID '{}'",
48            chain.id
49        );
50        self.chains.push(chain);
51    }
52
53    /// Removes and returns a chain by identifier if it exists.
54    ///
55    /// # Arguments
56    ///
57    /// * `id` - Chain identifier to search for.
58    ///
59    /// # Returns
60    ///
61    /// `Some(Chain)` when a chain with the provided ID is present, otherwise `None`.
62    pub fn remove_chain(&mut self, id: &str) -> Option<Chain> {
63        if let Some(index) = self.chains.iter().position(|c| c.id == id) {
64            Some(self.chains.remove(index))
65        } else {
66            None
67        }
68    }
69
70    /// Drops every chain from the structure, leaving box vectors untouched.
71    pub fn clear(&mut self) {
72        self.chains.clear();
73    }
74
75    /// Retrieves an immutable chain by identifier.
76    ///
77    /// # Arguments
78    ///
79    /// * `id` - Chain identifier to search for.
80    ///
81    /// # Returns
82    ///
83    /// `Some(&Chain)` if found, otherwise `None`.
84    pub fn chain(&self, id: &str) -> Option<&Chain> {
85        self.chains.iter().find(|c| c.id == id)
86    }
87
88    /// Retrieves a mutable chain by identifier.
89    ///
90    /// # Arguments
91    ///
92    /// * `id` - Chain identifier to search for.
93    ///
94    /// # Returns
95    ///
96    /// `Some(&mut Chain)` if found, otherwise `None`.
97    pub fn chain_mut(&mut self, id: &str) -> Option<&mut Chain> {
98        self.chains.iter_mut().find(|c| c.id == id)
99    }
100
101    /// Finds a residue using chain ID, residue number, and optional insertion code.
102    ///
103    /// # Arguments
104    ///
105    /// * `chain_id` - Identifier of the chain to search.
106    /// * `residue_id` - Numeric residue index (typically PDB `resSeq`).
107    /// * `insertion_code` - Optional insertion code differentiating duplicate IDs.
108    ///
109    /// # Returns
110    ///
111    /// `Some(&Residue)` when the residue is located, otherwise `None`.
112    pub fn find_residue(
113        &self,
114        chain_id: &str,
115        residue_id: i32,
116        insertion_code: Option<char>,
117    ) -> Option<&Residue> {
118        self.chain(chain_id)
119            .and_then(|c| c.residue(residue_id, insertion_code))
120    }
121
122    /// Finds a mutable residue reference using chain and residue identifiers.
123    ///
124    /// # Arguments
125    ///
126    /// * `chain_id` - Identifier of the chain to search.
127    /// * `residue_id` - Numeric residue index.
128    /// * `insertion_code` - Optional insertion code to disambiguate residues.
129    ///
130    /// # Returns
131    ///
132    /// `Some(&mut Residue)` when located, otherwise `None`.
133    pub fn find_residue_mut(
134        &mut self,
135        chain_id: &str,
136        residue_id: i32,
137        insertion_code: Option<char>,
138    ) -> Option<&mut Residue> {
139        self.chain_mut(chain_id)
140            .and_then(|c| c.residue_mut(residue_id, insertion_code))
141    }
142
143    /// Sorts chains lexicographically by their identifier.
144    pub fn sort_chains_by_id(&mut self) {
145        self.chains.sort_by(|a, b| a.id.cmp(&b.id));
146    }
147
148    /// Returns the number of chains currently stored.
149    ///
150    /// # Returns
151    ///
152    /// Chain count as `usize`.
153    pub fn chain_count(&self) -> usize {
154        self.chains.len()
155    }
156
157    /// Counts all residues across every chain.
158    ///
159    /// # Returns
160    ///
161    /// Total residue count as `usize`.
162    pub fn residue_count(&self) -> usize {
163        self.chains.iter().map(|c| c.residue_count()).sum()
164    }
165
166    /// Counts all atoms across every chain.
167    ///
168    /// # Returns
169    ///
170    /// Total atom count as `usize`.
171    pub fn atom_count(&self) -> usize {
172        self.chains.iter().map(|c| c.iter_atoms().count()).sum()
173    }
174
175    /// Indicates whether the structure contains zero chains.
176    ///
177    /// # Returns
178    ///
179    /// `true` if no chains are present.
180    pub fn is_empty(&self) -> bool {
181        self.chains.is_empty()
182    }
183
184    /// Provides an iterator over immutable chains.
185    ///
186    /// # Returns
187    ///
188    /// `std::slice::Iter<'_, Chain>` spanning all chains in insertion order.
189    pub fn iter_chains(&self) -> std::slice::Iter<'_, Chain> {
190        self.chains.iter()
191    }
192
193    /// Provides an iterator over mutable chains.
194    ///
195    /// # Returns
196    ///
197    /// `std::slice::IterMut<'_, Chain>` for in-place modification of chains.
198    pub fn iter_chains_mut(&mut self) -> std::slice::IterMut<'_, Chain> {
199        self.chains.iter_mut()
200    }
201
202    /// Iterates over immutable atoms across all chains.
203    ///
204    /// # Returns
205    ///
206    /// An iterator yielding `&Atom` in chain/residue order.
207    pub fn iter_atoms(&self) -> impl Iterator<Item = &super::atom::Atom> {
208        self.chains.iter().flat_map(|c| c.iter_atoms())
209    }
210
211    /// Iterates over mutable atoms across all chains.
212    ///
213    /// # Returns
214    ///
215    /// An iterator yielding `&mut Atom` for direct coordinate editing.
216    pub fn iter_atoms_mut(&mut self) -> impl Iterator<Item = &mut super::atom::Atom> {
217        self.chains.iter_mut().flat_map(|c| c.iter_atoms_mut())
218    }
219
220    /// Retains residues that satisfy a predicate, removing all others.
221    ///
222    /// The predicate receives the chain ID and a residue reference, enabling
223    /// context-sensitive filtering.
224    ///
225    /// # Arguments
226    ///
227    /// * `f` - Closure returning `true` to keep the residue.
228    pub fn retain_residues<F>(&mut self, mut f: F)
229    where
230        F: FnMut(&str, &Residue) -> bool,
231    {
232        for chain in &mut self.chains {
233            let chain_id = chain.id.clone();
234            chain.retain_residues(|residue| f(&chain_id, residue));
235        }
236    }
237
238    /// Removes any chain that became empty after residue pruning.
239    pub fn prune_empty_chains(&mut self) {
240        self.chains.retain(|chain| !chain.is_empty());
241    }
242
243    /// Iterates over atoms while including chain and residue context.
244    ///
245    /// # Returns
246    ///
247    /// An iterator yielding triples `(&Chain, &Residue, &Atom)` for every atom.
248    pub fn iter_atoms_with_context(
249        &self,
250    ) -> impl Iterator<Item = (&Chain, &Residue, &super::atom::Atom)> {
251        self.chains.iter().flat_map(|chain| {
252            chain.iter_residues().flat_map(move |residue| {
253                residue.iter_atoms().map(move |atom| (chain, residue, atom))
254            })
255        })
256    }
257
258    /// Computes the geometric center of all atom coordinates.
259    ///
260    /// Falls back to the origin when the structure contains no atoms.
261    ///
262    /// # Returns
263    ///
264    /// A `Point` located at the unweighted centroid.
265    pub fn geometric_center(&self) -> Point {
266        let mut sum = nalgebra::Vector3::zeros();
267        let mut count = 0;
268
269        for atom in self.iter_atoms() {
270            sum += atom.pos.coords;
271            count += 1;
272        }
273
274        if count > 0 {
275            Point::from(sum / (count as f64))
276        } else {
277            Point::origin()
278        }
279    }
280
281    /// Computes the mass-weighted center of all atoms.
282    ///
283    /// Uses element atomic masses and returns the origin when the total mass is below
284    /// numerical tolerance.
285    ///
286    /// # Returns
287    ///
288    /// A `Point` representing the center of mass.
289    pub fn center_of_mass(&self) -> Point {
290        let mut total_mass = 0.0;
291        let mut weighted_sum = nalgebra::Vector3::zeros();
292
293        for atom in self.iter_atoms() {
294            let mass = atom.element.atomic_mass();
295            weighted_sum += atom.pos.coords * mass;
296            total_mass += mass;
297        }
298
299        if total_mass > 1e-9 {
300            Point::from(weighted_sum / total_mass)
301        } else {
302            Point::origin()
303        }
304    }
305}
306
307impl fmt::Display for Structure {
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        write!(
310            f,
311            "Structure {{ chains: {}, residues: {}, atoms: {} }}",
312            self.chain_count(),
313            self.residue_count(),
314            self.atom_count()
315        )
316    }
317}
318
319impl FromIterator<Chain> for Structure {
320    fn from_iter<T: IntoIterator<Item = Chain>>(iter: T) -> Self {
321        Self {
322            chains: iter.into_iter().collect(),
323            box_vectors: None,
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::model::atom::Atom;
332    use crate::model::types::{Element, ResidueCategory, StandardResidue};
333
334    fn make_residue(id: i32, name: &str) -> Residue {
335        Residue::new(
336            id,
337            None,
338            name,
339            Some(StandardResidue::ALA),
340            ResidueCategory::Standard,
341        )
342    }
343
344    #[test]
345    fn structure_new_creates_empty_structure() {
346        let structure = Structure::new();
347
348        assert!(structure.is_empty());
349        assert_eq!(structure.chain_count(), 0);
350        assert_eq!(structure.residue_count(), 0);
351        assert_eq!(structure.atom_count(), 0);
352        assert!(structure.box_vectors.is_none());
353    }
354
355    #[test]
356    fn structure_default_creates_empty_structure() {
357        let structure = Structure::default();
358
359        assert!(structure.is_empty());
360        assert!(structure.box_vectors.is_none());
361    }
362
363    #[test]
364    fn structure_add_chain_adds_chain_correctly() {
365        let mut structure = Structure::new();
366        let chain = Chain::new("A");
367
368        structure.add_chain(chain);
369
370        assert_eq!(structure.chain_count(), 1);
371        assert!(structure.chain("A").is_some());
372        assert_eq!(structure.chain("A").unwrap().id, "A");
373    }
374
375    #[test]
376    fn structure_remove_chain_removes_existing_chain() {
377        let mut structure = Structure::new();
378        let chain = Chain::new("A");
379        structure.add_chain(chain);
380
381        let removed = structure.remove_chain("A");
382
383        assert!(removed.is_some());
384        assert_eq!(removed.unwrap().id, "A");
385        assert_eq!(structure.chain_count(), 0);
386        assert!(structure.chain("A").is_none());
387    }
388
389    #[test]
390    fn structure_remove_chain_returns_none_for_nonexistent_chain() {
391        let mut structure = Structure::new();
392
393        let removed = structure.remove_chain("NONEXISTENT");
394
395        assert!(removed.is_none());
396    }
397
398    #[test]
399    fn structure_clear_removes_all_chains() {
400        let mut structure = Structure::new();
401        structure.add_chain(Chain::new("A"));
402        structure.add_chain(Chain::new("B"));
403
404        structure.clear();
405
406        assert!(structure.is_empty());
407        assert_eq!(structure.chain_count(), 0);
408    }
409
410    #[test]
411    fn structure_chain_returns_correct_chain() {
412        let mut structure = Structure::new();
413        let chain = Chain::new("A");
414        structure.add_chain(chain);
415
416        let retrieved = structure.chain("A");
417
418        assert!(retrieved.is_some());
419        assert_eq!(retrieved.unwrap().id, "A");
420    }
421
422    #[test]
423    fn structure_chain_returns_none_for_nonexistent_chain() {
424        let structure = Structure::new();
425
426        let retrieved = structure.chain("NONEXISTENT");
427
428        assert!(retrieved.is_none());
429    }
430
431    #[test]
432    fn structure_chain_mut_returns_correct_mutable_chain() {
433        let mut structure = Structure::new();
434        let chain = Chain::new("A");
435        structure.add_chain(chain);
436
437        let retrieved = structure.chain_mut("A");
438
439        assert!(retrieved.is_some());
440        assert_eq!(retrieved.unwrap().id, "A");
441    }
442
443    #[test]
444    fn structure_chain_mut_returns_none_for_nonexistent_chain() {
445        let mut structure = Structure::new();
446
447        let retrieved = structure.chain_mut("NONEXISTENT");
448
449        assert!(retrieved.is_none());
450    }
451
452    #[test]
453    fn structure_find_residue_finds_correct_residue() {
454        let mut structure = Structure::new();
455        let mut chain = Chain::new("A");
456        let residue = Residue::new(
457            1,
458            None,
459            "ALA",
460            Some(StandardResidue::ALA),
461            ResidueCategory::Standard,
462        );
463        chain.add_residue(residue);
464        structure.add_chain(chain);
465
466        let found = structure.find_residue("A", 1, None);
467
468        assert!(found.is_some());
469        assert_eq!(found.unwrap().id, 1);
470        assert_eq!(found.unwrap().name, "ALA");
471    }
472
473    #[test]
474    fn structure_find_residue_returns_none_for_nonexistent_chain() {
475        let structure = Structure::new();
476
477        let found = structure.find_residue("NONEXISTENT", 1, None);
478
479        assert!(found.is_none());
480    }
481
482    #[test]
483    fn structure_find_residue_returns_none_for_nonexistent_residue() {
484        let mut structure = Structure::new();
485        let chain = Chain::new("A");
486        structure.add_chain(chain);
487
488        let found = structure.find_residue("A", 999, None);
489
490        assert!(found.is_none());
491    }
492
493    #[test]
494    fn structure_find_residue_mut_finds_correct_mutable_residue() {
495        let mut structure = Structure::new();
496        let mut chain = Chain::new("A");
497        let residue = Residue::new(
498            1,
499            None,
500            "ALA",
501            Some(StandardResidue::ALA),
502            ResidueCategory::Standard,
503        );
504        chain.add_residue(residue);
505        structure.add_chain(chain);
506
507        let found = structure.find_residue_mut("A", 1, None);
508
509        assert!(found.is_some());
510        assert_eq!(found.unwrap().id, 1);
511    }
512
513    #[test]
514    fn structure_sort_chains_by_id_sorts_correctly() {
515        let mut structure = Structure::new();
516        structure.add_chain(Chain::new("C"));
517        structure.add_chain(Chain::new("A"));
518        structure.add_chain(Chain::new("B"));
519
520        structure.sort_chains_by_id();
521
522        let ids: Vec<&str> = structure.iter_chains().map(|c| c.id.as_str()).collect();
523        assert_eq!(ids, vec!["A", "B", "C"]);
524    }
525
526    #[test]
527    fn structure_chain_count_returns_correct_count() {
528        let mut structure = Structure::new();
529
530        assert_eq!(structure.chain_count(), 0);
531
532        structure.add_chain(Chain::new("A"));
533        assert_eq!(structure.chain_count(), 1);
534
535        structure.add_chain(Chain::new("B"));
536        assert_eq!(structure.chain_count(), 2);
537    }
538
539    #[test]
540    fn structure_residue_count_returns_correct_count() {
541        let mut structure = Structure::new();
542        let mut chain = Chain::new("A");
543        chain.add_residue(Residue::new(
544            1,
545            None,
546            "ALA",
547            Some(StandardResidue::ALA),
548            ResidueCategory::Standard,
549        ));
550        chain.add_residue(Residue::new(
551            2,
552            None,
553            "GLY",
554            Some(StandardResidue::GLY),
555            ResidueCategory::Standard,
556        ));
557        structure.add_chain(chain);
558
559        assert_eq!(structure.residue_count(), 2);
560    }
561
562    #[test]
563    fn structure_atom_count_returns_correct_count() {
564        let mut structure = Structure::new();
565        let mut chain = Chain::new("A");
566        let mut residue = Residue::new(
567            1,
568            None,
569            "ALA",
570            Some(StandardResidue::ALA),
571            ResidueCategory::Standard,
572        );
573        residue.add_atom(Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0)));
574        residue.add_atom(Atom::new("CB", Element::C, Point::new(1.0, 0.0, 0.0)));
575        chain.add_residue(residue);
576        structure.add_chain(chain);
577
578        assert_eq!(structure.atom_count(), 2);
579    }
580
581    #[test]
582    fn structure_is_empty_returns_true_for_empty_structure() {
583        let structure = Structure::new();
584
585        assert!(structure.is_empty());
586    }
587
588    #[test]
589    fn structure_is_empty_returns_false_for_non_empty_structure() {
590        let mut structure = Structure::new();
591        structure.add_chain(Chain::new("A"));
592
593        assert!(!structure.is_empty());
594    }
595
596    #[test]
597    fn structure_iter_chains_iterates_correctly() {
598        let mut structure = Structure::new();
599        structure.add_chain(Chain::new("A"));
600        structure.add_chain(Chain::new("B"));
601
602        let mut ids = Vec::new();
603        for chain in structure.iter_chains() {
604            ids.push(chain.id.clone());
605        }
606
607        assert_eq!(ids, vec!["A", "B"]);
608    }
609
610    #[test]
611    fn structure_iter_chains_mut_iterates_correctly() {
612        let mut structure = Structure::new();
613        structure.add_chain(Chain::new("A"));
614
615        for chain in structure.iter_chains_mut() {
616            chain.id = "MODIFIED".to_string();
617        }
618
619        assert_eq!(structure.chain("MODIFIED").unwrap().id, "MODIFIED");
620    }
621
622    #[test]
623    fn structure_iter_atoms_iterates_over_all_atoms() {
624        let mut structure = Structure::new();
625        let mut chain = Chain::new("A");
626        let mut residue = Residue::new(
627            1,
628            None,
629            "ALA",
630            Some(StandardResidue::ALA),
631            ResidueCategory::Standard,
632        );
633        residue.add_atom(Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0)));
634        residue.add_atom(Atom::new("CB", Element::C, Point::new(1.0, 0.0, 0.0)));
635        chain.add_residue(residue);
636        structure.add_chain(chain);
637
638        let mut atom_names = Vec::new();
639        for atom in structure.iter_atoms() {
640            atom_names.push(atom.name.clone());
641        }
642
643        assert_eq!(atom_names, vec!["CA", "CB"]);
644    }
645
646    #[test]
647    fn structure_iter_atoms_mut_iterates_over_all_atoms_mutably() {
648        let mut structure = Structure::new();
649        let mut chain = Chain::new("A");
650        let mut residue = Residue::new(
651            1,
652            None,
653            "ALA",
654            Some(StandardResidue::ALA),
655            ResidueCategory::Standard,
656        );
657        residue.add_atom(Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0)));
658        chain.add_residue(residue);
659        structure.add_chain(chain);
660
661        for atom in structure.iter_atoms_mut() {
662            atom.translate_by(&nalgebra::Vector3::new(1.0, 0.0, 0.0));
663        }
664
665        let translated_atom = structure
666            .find_residue("A", 1, None)
667            .unwrap()
668            .atom("CA")
669            .unwrap();
670        assert!((translated_atom.pos.x - 1.0).abs() < 1e-10);
671    }
672
673    #[test]
674    fn structure_retain_residues_filters_using_chain_context() {
675        let mut structure = Structure::new();
676        let mut chain_a = Chain::new("A");
677        chain_a.add_residue(make_residue(1, "ALA"));
678        chain_a.add_residue(make_residue(2, "GLY"));
679        let mut chain_b = Chain::new("B");
680        chain_b.add_residue(make_residue(3, "SER"));
681        structure.add_chain(chain_a);
682        structure.add_chain(chain_b);
683
684        structure.retain_residues(|chain_id, residue| chain_id == "A" && residue.id == 1);
685
686        let chain_a = structure.chain("A").unwrap();
687        assert_eq!(chain_a.residue_count(), 1);
688        assert!(chain_a.residue(1, None).is_some());
689        assert!(structure.chain("B").unwrap().is_empty());
690    }
691
692    #[test]
693    fn structure_prune_empty_chains_removes_them() {
694        let mut structure = Structure::new();
695        let mut chain_a = Chain::new("A");
696        chain_a.add_residue(make_residue(1, "ALA"));
697        let chain_b = Chain::new("B");
698        structure.add_chain(chain_a);
699        structure.add_chain(chain_b);
700
701        structure.prune_empty_chains();
702
703        assert!(structure.chain("A").is_some());
704        assert!(structure.chain("B").is_none());
705        assert_eq!(structure.chain_count(), 1);
706    }
707
708    #[test]
709    fn structure_iter_atoms_with_context_provides_correct_context() {
710        let mut structure = Structure::new();
711        let mut chain = Chain::new("A");
712        let mut residue = Residue::new(
713            1,
714            None,
715            "ALA",
716            Some(StandardResidue::ALA),
717            ResidueCategory::Standard,
718        );
719        residue.add_atom(Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0)));
720        chain.add_residue(residue);
721        structure.add_chain(chain);
722
723        let mut contexts = Vec::new();
724        for (chain, residue, atom) in structure.iter_atoms_with_context() {
725            contexts.push((chain.id.clone(), residue.id, atom.name.clone()));
726        }
727
728        assert_eq!(contexts, vec![("A".to_string(), 1, "CA".to_string())]);
729    }
730
731    #[test]
732    fn structure_geometric_center_calculates_correctly() {
733        let mut structure = Structure::new();
734        let mut chain = Chain::new("A");
735        let mut residue = Residue::new(
736            1,
737            None,
738            "ALA",
739            Some(StandardResidue::ALA),
740            ResidueCategory::Standard,
741        );
742        residue.add_atom(Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0)));
743        residue.add_atom(Atom::new("CB", Element::C, Point::new(2.0, 0.0, 0.0)));
744        chain.add_residue(residue);
745        structure.add_chain(chain);
746
747        let center = structure.geometric_center();
748
749        assert!((center.x - 1.0).abs() < 1e-10);
750        assert!((center.y - 0.0).abs() < 1e-10);
751        assert!((center.z - 0.0).abs() < 1e-10);
752    }
753
754    #[test]
755    fn structure_geometric_center_returns_origin_for_empty_structure() {
756        let structure = Structure::new();
757
758        let center = structure.geometric_center();
759
760        assert_eq!(center, Point::origin());
761    }
762
763    #[test]
764    fn structure_center_of_mass_calculates_correctly() {
765        let mut structure = Structure::new();
766        let mut chain = Chain::new("A");
767        let mut residue = Residue::new(
768            1,
769            None,
770            "ALA",
771            Some(StandardResidue::ALA),
772            ResidueCategory::Standard,
773        );
774        residue.add_atom(Atom::new("H", Element::H, Point::new(0.0, 0.0, 0.0)));
775        residue.add_atom(Atom::new("C", Element::C, Point::new(2.0, 0.0, 0.0)));
776        chain.add_residue(residue);
777        structure.add_chain(chain);
778
779        let com = structure.center_of_mass();
780
781        let expected_x = (0.0 * 1.00794 + 2.0 * 12.0107) / (1.00794 + 12.0107);
782        assert!((com.x - expected_x).abs() < 1e-3);
783        assert!((com.y - 0.0).abs() < 1e-10);
784        assert!((com.z - 0.0).abs() < 1e-10);
785    }
786
787    #[test]
788    fn structure_center_of_mass_returns_origin_for_empty_structure() {
789        let structure = Structure::new();
790
791        let com = structure.center_of_mass();
792
793        assert_eq!(com, Point::origin());
794    }
795
796    #[test]
797    fn structure_display_formats_correctly() {
798        let mut structure = Structure::new();
799        let mut chain = Chain::new("A");
800        let mut residue = Residue::new(
801            1,
802            None,
803            "ALA",
804            Some(StandardResidue::ALA),
805            ResidueCategory::Standard,
806        );
807        residue.add_atom(Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0)));
808        chain.add_residue(residue);
809        structure.add_chain(chain);
810
811        let display = format!("{}", structure);
812        let expected = "Structure { chains: 1, residues: 1, atoms: 1 }";
813
814        assert_eq!(display, expected);
815    }
816
817    #[test]
818    fn structure_display_formats_empty_structure_correctly() {
819        let structure = Structure::new();
820
821        let display = format!("{}", structure);
822        let expected = "Structure { chains: 0, residues: 0, atoms: 0 }";
823
824        assert_eq!(display, expected);
825    }
826
827    #[test]
828    fn structure_from_iterator_creates_structure_correctly() {
829        let chains = vec![Chain::new("A"), Chain::new("B")];
830        let structure: Structure = chains.into_iter().collect();
831
832        assert_eq!(structure.chain_count(), 2);
833        assert!(structure.chain("A").is_some());
834        assert!(structure.chain("B").is_some());
835        assert!(structure.box_vectors.is_none());
836    }
837
838    #[test]
839    fn structure_clone_creates_identical_copy() {
840        let mut structure = Structure::new();
841        structure.add_chain(Chain::new("A"));
842        structure.box_vectors = Some([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
843
844        let cloned = structure.clone();
845
846        assert_eq!(structure.chain_count(), cloned.chain_count());
847        assert_eq!(structure.box_vectors, cloned.box_vectors);
848    }
849}