Skip to main content

ferritin_core/
atomcollection.rs

1//! AtomCollection
2//!
3//! An AtomCollection is primarily a group of atoms with some atomic properties like coordinates, element type
4//! and residue information. Additional data like bonds can be added post-instantiation.
5//! The data for residues within this collection can be iterated through. Other useful queries like inter-atomic
6//! distances are supported.
7use std::sync::Arc;
8use super::bonds::{Bond, BondOrder};
9use super::info::constants::get_bonds_canonical20;
10use super::views::chain::ChainView;
11use super::views::residue::ResidueView;
12use crate::data::Segmentation;
13use crate::info::elements::Element;
14use crate::model::{AtomicConformation, AtomicHierarchy, Bonds, Model};
15use crate::model::tables::{AtomsTable, ChainsTable, ResidueGroup, ResiduesTable};
16use itertools::{Itertools, izip};
17
18/// Atom Collection
19///
20/// The core data structure of ferritin-core.
21///
22/// it strives to be simple, high performance, and extensible using
23/// traits.
24#[derive(Clone)]
25pub struct AtomCollection {
26    size: usize,
27    coords: Vec<[f32; 3]>,
28    res_ids: Vec<i32>,
29    res_names: Vec<String>,
30    is_hetero: Vec<bool>,
31    elements: Vec<Element>,
32    atom_names: Vec<String>,
33    chain_ids: Vec<String>,
34    bonds: Option<Vec<Bond>>,
35    residue_start_indices: Option<Vec<usize>>,
36    chain_start_indices: Option<Vec<usize>>,
37}
38
39impl AtomCollection {
40    pub fn new(
41        size: usize,
42        coords: Vec<[f32; 3]>,
43        res_ids: Vec<i32>,
44        res_names: Vec<String>,
45        is_hetero: Vec<bool>,
46        elements: Vec<Element>,
47        atom_names: Vec<String>,
48        chain_ids: Vec<String>,
49        bonds: Option<Vec<Bond>>,
50    ) -> Self {
51        let mut ac = AtomCollection {
52            size,
53            coords,
54            res_ids,
55            res_names,
56            is_hetero,
57            elements,
58            atom_names,
59            chain_ids,
60            bonds,
61            residue_start_indices: None,
62            chain_start_indices: None,
63        };
64        ac.calculate_chain_indices();
65        ac
66    }
67    // Calculate and cache chain start indices
68    pub fn calculate_chain_indices(&mut self) {
69        if self.chain_start_indices.is_none() {
70            if self.residue_start_indices.is_none() {
71                let residue_starts = self.get_residue_starts();
72                self.residue_start_indices = Some(residue_starts);
73            }
74
75            // Get chain starts as residue indices
76            let residue_starts = self.residue_start_indices.as_ref().unwrap();
77            let chain_starts: Vec<usize> = self
78                .get_chain_starts()
79                .iter()
80                .map(|&atom_idx| {
81                    // Find the residue index that contains this atom
82                    let residue_idx = residue_starts
83                        .iter()
84                        .enumerate()
85                        .filter(|&(_, &res_start)| res_start <= atom_idx)
86                        .last()
87                        .map(|(i, _)| i)
88                        .unwrap_or(0);
89                    residue_idx
90                })
91                .collect();
92
93            self.chain_start_indices = Some(chain_starts);
94        }
95    }
96    pub fn connect_via_residue_names(&mut self) {
97        if self.bonds.is_some() {
98            println!("Bonds already in place. Not overwriting.");
99            return;
100        }
101        let aa_bond_info = get_bonds_canonical20();
102        let residue_starts = self.get_residue_starts();
103        let n_atoms = self.size;
104        let mut bonds = Vec::new();
105        for res_i in 0..residue_starts.len() - 1 {
106            let curr_start_i = residue_starts[res_i];
107            let next_start_i = residue_starts[res_i + 1];
108            if let Some(bond_dict_for_res) =
109                aa_bond_info.get(&self.res_names[curr_start_i].as_str())
110            {
111                for &(atom_name1, atom_name2, bond_type) in bond_dict_for_res {
112                    let atom_indices1: Vec<usize> = (curr_start_i..next_start_i)
113                        .filter(|&i| self.atom_names[i] == atom_name1)
114                        .collect();
115                    let atom_indices2: Vec<usize> = (curr_start_i..next_start_i)
116                        .filter(|&i| self.atom_names[i] == atom_name2)
117                        .collect();
118                    for &i in &atom_indices1 {
119                        for &j in &atom_indices2 {
120                            bonds.push(Bond::new(i as i32, j as i32, bond_type));
121                        }
122                    }
123                }
124            }
125        }
126        // Backbone C→N peptide bonds between consecutive residues on the same chain
127        for res_i in 0..residue_starts.len() - 1 {
128            let curr_start_i = residue_starts[res_i];
129            let next_start_i = residue_starts[res_i + 1];
130            // Skip if these residues are on different chains
131            if self.chain_ids[curr_start_i] != self.chain_ids[next_start_i] {
132                continue;
133            }
134            // Skip hetero residues (ligands, solvent)
135            if self.is_hetero[curr_start_i] || self.is_hetero[next_start_i] {
136                continue;
137            }
138            let next_end_i = residue_starts
139                .get(res_i + 2)
140                .copied()
141                .unwrap_or(n_atoms);
142            let c_idx = (curr_start_i..next_start_i).find(|&i| self.atom_names[i] == "C");
143            let n_idx = (next_start_i..next_end_i).find(|&i| self.atom_names[i] == "N");
144            if let (Some(c), Some(n)) = (c_idx, n_idx) {
145                bonds.push(Bond::new(c as i32, n as i32, BondOrder::Single));
146            }
147        }
148        self.bonds = Some(bonds);
149    }
150    pub fn get_size(&self) -> usize {
151        self.size
152    }
153    pub fn get_atom_name(&self, idx: usize) -> &String {
154        &self.atom_names[idx]
155    }
156    pub fn get_bonds(&self) -> Option<&Vec<Bond>> {
157        self.bonds.as_ref()
158    }
159    pub fn get_chain_id(&self, idx: usize) -> &String {
160        &self.chain_ids[idx]
161    }
162    pub fn get_coord(&self, idx: usize) -> &[f32; 3] {
163        &self.coords[idx]
164    }
165    pub fn get_coords(&self) -> &Vec<[f32; 3]> {
166        self.coords.as_ref()
167    }
168    pub fn get_element(&self, idx: usize) -> &Element {
169        &self.elements[idx]
170    }
171    pub fn get_elements(&self) -> &Vec<Element> {
172        self.elements.as_ref()
173    }
174    pub fn get_is_hetero(&self, idx: usize) -> bool {
175        self.is_hetero[idx]
176    }
177    pub fn get_resnames(&self) -> &Vec<String> {
178        self.res_names.as_ref()
179    }
180    pub fn get_res_id(&self, idx: usize) -> &i32 {
181        &self.res_ids[idx]
182    }
183    pub fn get_resids(&self) -> &Vec<i32> {
184        self.res_ids.as_ref()
185    }
186    pub fn get_res_name(&self, idx: usize) -> &String {
187        &self.res_names[idx]
188    }
189    /// A new residue starts, either when the chain ID, residue ID,
190    /// insertion code or residue name changes from one to the next atom.
191    fn get_residue_starts(&self) -> Vec<usize> {
192        let mut starts = vec![0];
193
194        starts.extend(
195            izip!(&self.res_ids, &self.res_names, &self.chain_ids)
196                .tuple_windows()
197                .enumerate()
198                .filter_map(
199                    |(i, ((res_id1, name1, chain1), (res_id2, name2, chain2)))| {
200                        if res_id1 != res_id2 || name1 != name2 || chain1 != chain2 {
201                            Some(i + 1)
202                        } else {
203                            None
204                        }
205                    },
206                ),
207        );
208        starts
209    }
210    pub fn get_residue_start_indices(&self) -> Option<&Vec<usize>> {
211        self.residue_start_indices.as_ref()
212    }
213    /// A new chain starts when the chain ID changes from one atom to the next.
214    fn get_chain_starts(&self) -> Vec<usize> {
215        let mut starts = vec![0];
216        starts.extend(
217            self.chain_ids
218                .iter()
219                .tuple_windows()
220                .enumerate()
221                .filter_map(
222                    |(i, (chain1, chain2))| {
223                        if chain1 != chain2 { Some(i + 1) } else { None }
224                    },
225                ),
226        );
227        starts
228    }
229
230    /// Filter atoms using a boolean mask, returning a new `AtomCollection`.
231    ///
232    /// Bonds are remapped — only bonds where both endpoints survive the mask are kept,
233    /// with indices adjusted to the new compact numbering.
234    ///
235    /// # Panics
236    /// Panics if `mask.len() != self.size`.
237    pub fn filter(&self, mask: &[bool]) -> AtomCollection {
238        assert_eq!(mask.len(), self.size, "mask length must equal atom count");
239
240        // Build old-index → new-index map in one pass.
241        let mut next = 0usize;
242        let remap: Vec<Option<usize>> = mask
243            .iter()
244            .map(|&keep| {
245                if keep {
246                    let idx = next;
247                    next += 1;
248                    Some(idx)
249                } else {
250                    None
251                }
252            })
253            .collect();
254
255        let selected: Vec<usize> = remap.iter().enumerate().filter_map(|(i, r)| r.map(|_| i)).collect();
256
257        let coords: Vec<[f32; 3]> = selected.iter().map(|&i| self.coords[i]).collect();
258        let res_ids: Vec<i32> = selected.iter().map(|&i| self.res_ids[i]).collect();
259        let res_names: Vec<String> = selected.iter().map(|&i| self.res_names[i].clone()).collect();
260        let is_hetero: Vec<bool> = selected.iter().map(|&i| self.is_hetero[i]).collect();
261        let elements: Vec<Element> = selected.iter().map(|&i| self.elements[i].clone()).collect();
262        let atom_names: Vec<String> = selected.iter().map(|&i| self.atom_names[i].clone()).collect();
263        let chain_ids: Vec<String> = selected.iter().map(|&i| self.chain_ids[i].clone()).collect();
264
265        let bonds: Option<Vec<Bond>> = self.bonds.as_ref().map(|bonds| {
266            bonds
267                .iter()
268                .filter_map(|b| {
269                    let (a, b_idx) = b.get_atom_indices();
270                    match (remap[a as usize], remap[b_idx as usize]) {
271                        (Some(new_a), Some(new_b)) => {
272                            Some(Bond::new(new_a as i32, new_b as i32, b.get_order()))
273                        }
274                        _ => None,
275                    }
276                })
277                .collect()
278        });
279
280        AtomCollection::new(next, coords, res_ids, res_names, is_hetero, elements, atom_names, chain_ids, bonds)
281    }
282
283    /// Build a boolean mask selecting atoms belonging to `chain_id`.
284    pub fn select_chain(&self, chain_id: &str) -> Vec<bool> {
285        self.chain_ids.iter().map(|c| c == chain_id).collect()
286    }
287
288    /// Build a boolean mask selecting hetero atoms (ligands, solvent, etc.).
289    pub fn select_hetero(&self) -> Vec<bool> {
290        self.is_hetero.clone()
291    }
292
293    /// Build a boolean mask selecting backbone atoms (N, CA, C, O).
294    pub fn select_backbone(&self) -> Vec<bool> {
295        const BACKBONE: [&str; 4] = ["N", "CA", "C", "O"];
296        self.atom_names.iter().map(|n| BACKBONE.contains(&n.as_str())).collect()
297    }
298
299    /// Build a boolean mask selecting atoms with `atom_name`.
300    pub fn select_atom_name(&self, atom_name: &str) -> Vec<bool> {
301        self.atom_names.iter().map(|n| n == atom_name).collect()
302    }
303
304    /// Build a boolean mask selecting residues with `res_name` (e.g. `"ALA"`).
305    pub fn select_residue_name(&self, res_name: &str) -> Vec<bool> {
306        self.res_names.iter().map(|n| n == res_name).collect()
307    }
308
309    pub fn iter_coords_and_elements(&self) -> impl Iterator<Item = (&[f32; 3], &Element)> {
310        izip!(&self.coords, &self.elements)
311    }
312
313    pub fn iter_chains(&self) -> impl Iterator<Item = ChainView<'_>> {
314        // Make sure indices are calculated
315        let chain_starts = match &self.chain_start_indices {
316            Some(indices) => indices.clone(),
317            None => Vec::new(),
318        };
319
320        (0..chain_starts.len()).map(move |i| {
321            let start_residue_idx = chain_starts[i];
322            let end_residue_idx = if i + 1 < chain_starts.len() {
323                chain_starts[i + 1]
324            } else {
325                // If it's the last chain, go to the end of the structure
326                match &self.residue_start_indices {
327                    Some(indices) => indices.len(),
328                    None => self.size,
329                }
330            };
331
332            ChainView {
333                data: self,
334                start_residue_idx,
335                end_residue_idx,
336            }
337        })
338    }
339    pub fn iter_residues(&self) -> impl Iterator<Item = ResidueView<'_>> {
340        let residue_starts = self.get_residue_starts();
341        let atom_size = self.get_size();
342        // Create a copy of the last element if it exists
343        // Generate pairs for all residues
344        let last_atom_idx = residue_starts.last().copied();
345        (0..residue_starts.len().saturating_sub(1))
346            .map(move |i| ResidueView::new(self, residue_starts[i], residue_starts[i + 1]))
347            .chain(
348                last_atom_idx
349                    .map(|idx| ResidueView::new(self, idx, atom_size))
350                    .into_iter(),
351            )
352    }
353    /// Iterates over amino acid residues in the collection
354    ///
355    /// Returns a filtered iterator that only includes standard amino acid residues
356    pub fn iter_residues_aminoacid(&self) -> impl Iterator<Item = ResidueView<'_>> {
357        self.iter_residues()
358            .filter(|residue| residue.is_amino_acid())
359    }
360
361    /// Convert this AtomCollection to a Model representation.
362    ///
363    /// Lifts the flat per-atom vectors into the hierarchical Model structure
364    /// (AtomicHierarchy + AtomicConformation).
365    pub fn to_model(&self) -> Model {
366        let n_atoms = self.size;
367
368        let atoms = AtomsTable {
369            atom_name: self.atom_names.clone(),
370            element: self.elements.iter().map(|e| e.symbol().to_string()).collect(),
371            alt_loc: vec![None; n_atoms],
372            formal_charge: vec![None; n_atoms],
373        };
374
375        let residue_starts = self.get_residue_starts();
376        let n_residues = residue_starts.len();
377        let mut comp_id = Vec::with_capacity(n_residues);
378        let mut label_seq_id = Vec::with_capacity(n_residues);
379        let mut auth_seq_id = Vec::with_capacity(n_residues);
380        let mut ins_code = Vec::with_capacity(n_residues);
381        let mut group = Vec::with_capacity(n_residues);
382
383        for &start in &residue_starts {
384            comp_id.push(self.res_names[start].clone());
385            let res_id = self.res_ids[start];
386            label_seq_id.push(res_id);
387            auth_seq_id.push(res_id);
388            ins_code.push(None);
389            group.push(if self.is_hetero[start] {
390                ResidueGroup::NonPolymer
391            } else {
392                ResidueGroup::Polymer
393            });
394        }
395
396        let residues = ResiduesTable {
397            comp_id,
398            label_seq_id,
399            auth_seq_id,
400            ins_code,
401            group,
402        };
403
404        let chain_starts = self.get_chain_starts();
405        let n_chains = chain_starts.len();
406        let mut label_asym_id = Vec::with_capacity(n_chains);
407        let mut auth_asym_id = Vec::with_capacity(n_chains);
408        let mut entity_id = Vec::with_capacity(n_chains);
409
410        for &start in &chain_starts {
411            let chain_id = self.chain_ids[start].clone();
412            label_asym_id.push(chain_id.clone());
413            auth_asym_id.push(chain_id.clone());
414            entity_id.push(chain_id);
415        }
416
417        let chains = ChainsTable {
418            label_asym_id,
419            auth_asym_id,
420            entity_id,
421        };
422
423        let mut atom_offsets: Vec<u32> = residue_starts.iter().map(|&s| s as u32).collect();
424        atom_offsets.push(n_atoms as u32);
425        let atom_to_residue = Segmentation::from_offsets(atom_offsets);
426
427        let residue_offsets: Vec<u32> = chain_starts
428            .iter()
429            .map(|&atom_start| {
430                residue_starts
431                    .iter()
432                    .position(|&r| r == atom_start)
433                    .unwrap_or(0) as u32
434            })
435            .chain(std::iter::once(n_residues as u32))
436            .collect();
437        let residue_to_chain = Segmentation::from_offsets(residue_offsets);
438
439        let bonds = Bonds::from_unsorted(vec![], vec![], vec![], n_atoms);
440
441        let hierarchy = Arc::new(AtomicHierarchy {
442            atoms,
443            residues,
444            chains,
445            atom_to_residue,
446            residue_to_chain,
447            bonds,
448        });
449
450        let x: Vec<f32> = self.coords.iter().map(|c| c[0]).collect();
451        let y: Vec<f32> = self.coords.iter().map(|c| c[1]).collect();
452        let z: Vec<f32> = self.coords.iter().map(|c| c[2]).collect();
453
454        let conformation = AtomicConformation {
455            x,
456            y,
457            z,
458            occupancy: None,
459            b_iso: None,
460            confidence: None,
461        };
462
463        Model::new(hierarchy, conformation)
464    }
465}
466
467impl From<&Model> for AtomCollection {
468    /// Convert a Model to an AtomCollection.
469    ///
470    /// Regenerates per-atom vectors from the hierarchy and conformation.
471    fn from(model: &Model) -> Self {
472        let n_atoms = model.n_atoms();
473        let hierarchy = &model.hierarchy;
474        let conformation = &model.conformation;
475
476        let coords: Vec<[f32; 3]> = (0..n_atoms)
477            .map(|i| [conformation.x[i], conformation.y[i], conformation.z[i]])
478            .collect();
479
480        let mut res_ids = Vec::with_capacity(n_atoms);
481        let mut res_names = Vec::with_capacity(n_atoms);
482        let mut is_hetero = Vec::with_capacity(n_atoms);
483        let mut chain_ids = Vec::with_capacity(n_atoms);
484
485        for atom_idx in 0..n_atoms {
486            let res_idx = hierarchy.residue_of_atom(atom_idx);
487            let chain_idx = hierarchy.chain_of_residue(res_idx);
488
489            res_ids.push(hierarchy.residues.auth_seq_id[res_idx]);
490            res_names.push(hierarchy.residues.comp_id[res_idx].clone());
491            is_hetero.push(hierarchy.residues.group[res_idx] == ResidueGroup::NonPolymer);
492            chain_ids.push(hierarchy.chains.auth_asym_id[chain_idx].clone());
493        }
494
495        let elements: Vec<Element> = hierarchy
496            .atoms
497            .element
498            .iter()
499            .map(|s| Element::from_symbol(s).unwrap_or(Element::C))
500            .collect();
501
502        let atom_names = hierarchy.atoms.atom_name.clone();
503
504        AtomCollection::new(
505            n_atoms,
506            coords,
507            res_ids,
508            res_names,
509            is_hetero,
510            elements,
511            atom_names,
512            chain_ids,
513            None,
514        )
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    fn make_test_atom_collection() -> AtomCollection {
523        let coords = vec![
524            [1.0, 2.0, 3.0],
525            [4.0, 5.0, 6.0],
526            [7.0, 8.0, 9.0],
527            [10.0, 11.0, 12.0],
528            [13.0, 14.0, 15.0],
529        ];
530        let res_ids = vec![1, 1, 1, 2, 2];
531        let res_names = vec![
532            "ALA".into(), "ALA".into(), "ALA".into(),
533            "GLY".into(), "GLY".into(),
534        ];
535        let is_hetero = vec![false, false, false, false, false];
536        let elements = vec![Element::N, Element::C, Element::C, Element::N, Element::C];
537        let atom_names = vec![
538            "N".into(), "CA".into(), "C".into(),
539            "N".into(), "CA".into(),
540        ];
541        let chain_ids = vec![
542            "A".into(), "A".into(), "A".into(),
543            "A".into(), "A".into(),
544        ];
545
546        AtomCollection::new(
547            5,
548            coords,
549            res_ids,
550            res_names,
551            is_hetero,
552            elements,
553            atom_names,
554            chain_ids,
555            None,
556        )
557    }
558
559    #[test]
560    fn test_atomcollection_to_model_roundtrip() {
561        let original = make_test_atom_collection();
562
563        let model = original.to_model();
564        let restored = AtomCollection::from(&model);
565
566        assert_eq!(original.get_size(), restored.get_size());
567
568        for i in 0..original.get_size() {
569            assert_eq!(original.get_coord(i), restored.get_coord(i), "coord mismatch at {}", i);
570            assert_eq!(original.get_res_id(i), restored.get_res_id(i), "res_id mismatch at {}", i);
571            assert_eq!(original.get_res_name(i), restored.get_res_name(i), "res_name mismatch at {}", i);
572            assert_eq!(original.get_atom_name(i), restored.get_atom_name(i), "atom_name mismatch at {}", i);
573            assert_eq!(original.get_chain_id(i), restored.get_chain_id(i), "chain_id mismatch at {}", i);
574            assert_eq!(original.get_is_hetero(i), restored.get_is_hetero(i), "is_hetero mismatch at {}", i);
575        }
576    }
577
578    #[test]
579    fn test_model_to_atomcollection_coords() {
580        let ac = make_test_atom_collection();
581        let model = ac.to_model();
582
583        let model_coords = model.coords_as_slice();
584        assert_eq!(model_coords.len(), ac.get_size());
585        for i in 0..ac.get_size() {
586            assert_eq!(model_coords[i], *ac.get_coord(i), "coord mismatch at {}", i);
587        }
588
589        assert_eq!(model.x(), &[1.0, 4.0, 7.0, 10.0, 13.0]);
590        assert_eq!(model.y(), &[2.0, 5.0, 8.0, 11.0, 14.0]);
591        assert_eq!(model.z(), &[3.0, 6.0, 9.0, 12.0, 15.0]);
592    }
593
594    #[test]
595    fn test_model_hierarchy_structure() {
596        let ac = make_test_atom_collection();
597        let model = ac.to_model();
598
599        assert_eq!(model.n_atoms(), 5);
600        assert_eq!(model.n_residues(), 2);
601        assert_eq!(model.n_chains(), 1);
602
603        assert_eq!(model.hierarchy.atoms_in_residue(0), 0..3);
604        assert_eq!(model.hierarchy.atoms_in_residue(1), 3..5);
605
606        assert_eq!(model.hierarchy.residues_in_chain(0), 0..2);
607    }
608
609    #[test]
610    fn test_multi_chain_roundtrip() {
611        let coords = vec![
612            [1.0, 1.0, 1.0],
613            [2.0, 2.0, 2.0],
614            [3.0, 3.0, 3.0],
615        ];
616        let res_ids = vec![1, 1, 10];
617        let res_names = vec!["ALA".into(), "ALA".into(), "GLY".into()];
618        let is_hetero = vec![false, false, true];
619        let elements = vec![Element::N, Element::C, Element::N];
620        let atom_names = vec!["N".into(), "CA".into(), "N".into()];
621        let chain_ids = vec!["A".into(), "A".into(), "B".into()];
622
623        let original = AtomCollection::new(
624            3, coords, res_ids, res_names, is_hetero, elements, atom_names, chain_ids, None,
625        );
626
627        let model = original.to_model();
628        assert_eq!(model.n_chains(), 2);
629        assert_eq!(model.n_residues(), 2);
630
631        let restored = AtomCollection::from(&model);
632        assert_eq!(restored.get_chain_id(0), "A");
633        assert_eq!(restored.get_chain_id(2), "B");
634        assert!(restored.get_is_hetero(2));
635        assert!(!restored.get_is_hetero(0));
636    }
637
638    fn make_two_chain_collection() -> AtomCollection {
639        // Chain A: 3 atoms (N, CA, C), res 1 ALA
640        // Chain B: 2 atoms (N, CA), res 2 GLY
641        // Atom order: 0=A/N, 1=A/CA, 2=A/C, 3=B/N, 4=B/CA
642        let coords = vec![[1.,0.,0.],[2.,0.,0.],[3.,0.,0.],[4.,0.,0.],[5.,0.,0.]];
643        let res_ids = vec![1, 1, 1, 2, 2];
644        let res_names: Vec<String> = ["ALA","ALA","ALA","GLY","GLY"].iter().map(|s| s.to_string()).collect();
645        let is_hetero = vec![false, false, false, false, false];
646        let elements = vec![Element::N, Element::C, Element::C, Element::N, Element::C];
647        let atom_names: Vec<String> = ["N","CA","C","N","CA"].iter().map(|s| s.to_string()).collect();
648        let chain_ids: Vec<String> = ["A","A","A","B","B"].iter().map(|s| s.to_string()).collect();
649        let bonds = vec![
650            Bond::new(0, 1, BondOrder::Single),
651            Bond::new(1, 2, BondOrder::Single),
652            Bond::new(3, 4, BondOrder::Single),
653        ];
654        AtomCollection::new(5, coords, res_ids, res_names, is_hetero, elements, atom_names, chain_ids, Some(bonds))
655    }
656
657    #[test]
658    fn test_filter_keeps_selected_atoms() {
659        let ac = make_two_chain_collection();
660        let mask = vec![true, false, true, false, true];
661        let filtered = ac.filter(&mask);
662        assert_eq!(filtered.get_size(), 3);
663        assert_eq!(filtered.get_coord(0), &[1., 0., 0.]);
664        assert_eq!(filtered.get_coord(1), &[3., 0., 0.]);
665        assert_eq!(filtered.get_coord(2), &[5., 0., 0.]);
666    }
667
668    #[test]
669    fn test_filter_remaps_bonds() {
670        let ac = make_two_chain_collection();
671        // Keep atoms 0,1,2 (chain A). Bond 0-1 and 1-2 survive; bond 3-4 is dropped.
672        let mask = vec![true, true, true, false, false];
673        let filtered = ac.filter(&mask);
674        let bonds = filtered.get_bonds().unwrap();
675        assert_eq!(bonds.len(), 2);
676        let indices: Vec<(i32, i32)> = bonds.iter().map(|b| b.get_atom_indices()).collect();
677        assert!(indices.contains(&(0, 1)));
678        assert!(indices.contains(&(1, 2)));
679    }
680
681    #[test]
682    fn test_filter_drops_cross_boundary_bonds() {
683        let ac = make_two_chain_collection();
684        // Keep only atoms 0 and 4 — bond 0-1 and 3-4 are cut; bond 3-4's 3 is missing.
685        let mask = vec![true, false, false, false, true];
686        let filtered = ac.filter(&mask);
687        assert_eq!(filtered.get_size(), 2);
688        let bonds = filtered.get_bonds().unwrap();
689        assert!(bonds.is_empty());
690    }
691
692    #[test]
693    fn test_select_chain() {
694        let ac = make_two_chain_collection();
695        let mask = ac.select_chain("B");
696        assert_eq!(mask, vec![false, false, false, true, true]);
697        let filtered = ac.filter(&mask);
698        assert_eq!(filtered.get_size(), 2);
699    }
700
701    #[test]
702    fn test_select_backbone() {
703        let ac = make_two_chain_collection();
704        let mask = ac.select_backbone();
705        // N, CA, C, N, CA — all 5 are backbone
706        assert_eq!(mask, vec![true, true, true, true, true]);
707    }
708
709    #[test]
710    fn test_select_atom_name() {
711        let ac = make_two_chain_collection();
712        let mask = ac.select_atom_name("CA");
713        assert_eq!(mask, vec![false, true, false, false, true]);
714    }
715
716    #[test]
717    fn test_select_residue_name() {
718        let ac = make_two_chain_collection();
719        let mask = ac.select_residue_name("ALA");
720        assert_eq!(mask, vec![true, true, true, false, false]);
721    }
722
723    #[test]
724    #[should_panic(expected = "mask length must equal atom count")]
725    fn test_filter_wrong_mask_length() {
726        let ac = make_test_atom_collection();
727        ac.filter(&[true, false]);
728    }
729}