Skip to main content

molframe_core/structure/
overlay.rs

1//! Transactional copy-on-write edits over immutable structure snapshots.
2
3mod helpers;
4
5use super::materialize::compact_hierarchy;
6use super::{Structure, StructureData, validate, validate_coordinate_edit};
7use crate::annotation::AtomAnnotation;
8#[cfg(test)]
9use crate::bond::{BondRecord, BondTableBuilder};
10use crate::diagnostic::{Code, Diagnostic};
11use crate::index::ChainIndex;
12#[cfg(test)]
13use crate::index::{AtomIndex, ResidueIndex};
14use crate::selection::AtomSelection;
15use helpers::{delete_from, require_no_extensions, transform_store, validate_selection};
16
17const DELETED_ATOM: u32 = u32::MAX;
18
19/// A private copy-on-write snapshot that becomes visible only on commit.
20#[derive(Debug)]
21pub struct StructureEditor {
22    data: StructureData,
23    coordinates_changed: bool,
24    requires_full_validation: bool,
25}
26
27impl Structure {
28    /// Starts a transactional structural edit.
29    #[must_use]
30    pub fn edit(&self) -> StructureEditor {
31        StructureEditor {
32            data: self.data().clone(),
33            coordinates_changed: false,
34            requires_full_validation: false,
35        }
36    }
37
38    /// Copies a selected atom set into a compact, independent structure.
39    ///
40    /// Empty residues, chains and unreferenced entities are removed. Domain
41    /// extensions are deliberately discarded because their topology-aligned
42    /// contents cannot remain valid after materialisation.
43    ///
44    /// # Errors
45    ///
46    /// Returns diagnostics for an out-of-range atom, a ragged ensemble, or an
47    /// invalid source hierarchy.
48    pub fn materialize(&self, selection: &AtomSelection) -> Result<Structure, Vec<Diagnostic>> {
49        if let Err(error) = validate_selection(selection, self.data().atom_count()) {
50            return Err(vec![error]);
51        }
52
53        let deleted = AtomSelection::All(self.atom_count()).difference(selection);
54        let mut editor = self.edit();
55        editor.clear_extensions();
56        if let Err(error) = editor.delete_atoms(&deleted) {
57            return Err(vec![error]);
58        }
59        if let Err(error) = compact_hierarchy(&mut editor.data) {
60            return Err(vec![error]);
61        }
62        editor.commit()
63    }
64}
65
66impl StructureEditor {
67    /// Adds or replaces a typed per-atom annotation column.
68    ///
69    /// # Errors
70    ///
71    /// Returns a diagnostic when the name is empty or the column length differs
72    /// from the structure's atom count.
73    pub fn set_annotation(&mut self, name: &str, column: AtomAnnotation) -> Result<(), Diagnostic> {
74        if name.is_empty() {
75            return Err(Diagnostic::new(Code::E6007));
76        }
77        let atom_count = self.data.atom_count();
78        if column.len() != atom_count {
79            return Err(Diagnostic::new(Code::E3011)
80                .with_context("annotation", name)
81                .with_context("column rows", column.len().to_string())
82                .with_context("atoms", atom_count.to_string()));
83        }
84        let _ = self.data.annotations.insert(name, column);
85        self.requires_full_validation = true;
86        Ok(())
87    }
88
89    /// Removes a custom annotation, returning whether it existed.
90    pub fn remove_annotation(&mut self, name: &str) -> bool {
91        let removed = self.data.annotations.remove(name).is_some();
92        self.requires_full_validation |= removed;
93        removed
94    }
95
96    /// Explicitly discards domain extensions before a topology-changing edit.
97    pub fn clear_extensions(&mut self) {
98        self.data.extensions.clear();
99        self.requires_full_validation = true;
100    }
101
102    /// Renames a chain in both identifier namespaces.
103    ///
104    /// # Errors
105    ///
106    /// Returns a diagnostic when the chain does not exist, the new label is
107    /// empty, or the bounded identifier dictionary cannot accept it.
108    pub fn rename_chain(&mut self, chain: ChainIndex, label: &str) -> Result<(), Diagnostic> {
109        if self.data.topology.chains.label_asym_id(chain).is_none() {
110            return Err(Diagnostic::new(Code::E6006).with_context("chain", chain.to_string()));
111        }
112        if label.is_empty() {
113            return Err(Diagnostic::new(Code::E6007));
114        }
115        require_no_extensions(&self.data)?;
116        let symbol = self.data.dictionary.intern(label).map_err(|_| {
117            Diagnostic::new(Code::E1901)
118                .with_context("limit", "dictionary entries")
119                .with_context("identifier", label)
120        })?;
121        let result = self
122            .data
123            .topology
124            .chains
125            .rename(chain, symbol)
126            .map_err(|_| Diagnostic::new(Code::E6006).with_context("chain", chain.to_string()));
127        if result.is_ok() {
128            self.requires_full_validation = true;
129        }
130        result
131    }
132
133    /// Applies one coordinate transform to selected atoms in every dense frame.
134    ///
135    /// # Errors
136    ///
137    /// Returns a diagnostic for an out-of-range atom, a non-finite result, or a
138    /// ragged ensemble whose models must be edited as independent snapshots.
139    pub fn transform<F>(
140        &mut self,
141        selection: &AtomSelection,
142        transform: F,
143    ) -> Result<(), Diagnostic>
144    where
145        F: Fn([f32; 3]) -> [f32; 3],
146    {
147        validate_selection(selection, self.data.atom_count())?;
148        if selection.is_empty() {
149            return Ok(());
150        }
151        let mut coords = self.data.coords.clone();
152        let changed = transform_store(&mut coords, selection, &transform)?;
153        if changed {
154            self.data.coords = coords;
155            self.coordinates_changed = true;
156        }
157        Ok(())
158    }
159
160    /// Deletes selected atom rows and remaps residues, coordinates and bonds.
161    ///
162    /// # Errors
163    ///
164    /// Returns a diagnostic for an out-of-range atom or a ragged ensemble.
165    pub fn delete_atoms(&mut self, selection: &AtomSelection) -> Result<(), Diagnostic> {
166        validate_selection(selection, self.data.atom_count())?;
167        if selection.is_empty() {
168            return Ok(());
169        }
170        if matches!(&self.data.coords, super::CoordinateStore::Ragged { .. }) {
171            return Err(Diagnostic::new(Code::E6003));
172        }
173        require_no_extensions(&self.data)?;
174        self.data = delete_from(&self.data, selection)?;
175        self.coordinates_changed = true;
176        self.requires_full_validation = true;
177        Ok(())
178    }
179
180    /// Validates and publishes the edited snapshot.
181    ///
182    /// # Errors
183    ///
184    /// Returns every violated structural invariant. The original structure is
185    /// unaffected whether commit succeeds or fails.
186    pub fn commit(mut self) -> Result<Structure, Vec<Diagnostic>> {
187        if self.coordinates_changed {
188            let Some(generation) = self.data.generation.next() else {
189                return Err(vec![
190                    Diagnostic::new(Code::E6003).with_context("coordinate_generation", "exhausted"),
191                ]);
192            };
193            self.data.generation = generation;
194        }
195        let findings = if self.requires_full_validation {
196            validate(&self.data)
197        } else {
198            validate_coordinate_edit(&self.data)
199        };
200        if findings.is_empty() {
201            Ok(Structure::new(self.data))
202        } else {
203            Err(findings)
204        }
205    }
206}
207
208#[cfg(test)]
209#[path = "overlay_tests.rs"]
210mod tests;