Skip to main content

molframe_core/structure/
overlay.rs

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