Skip to main content

molframe_cif/write/
projection.rs

1//! Allocation-free typed projection shared by canonical format writers.
2//!
3//! Validation is linear in emitted atom rows. A subsequent visit is also
4//! linear and allocates nothing, so a columnar writer can replay one column at
5//! a time without retaining a row DOM.
6
7use super::options::{CifWriteError, CifWriteOptions, valid_block_id};
8use molframe_core::element::Element;
9use molframe_core::index::ModelIndex;
10use molframe_core::structure::{
11    AtomRef, ChainRef, ResidueRef, SEQUENCE_REFERENCES_EXTENSION, SequenceReferences, Structure,
12};
13
14/// A typed canonical value with CIF's two distinct missing-value states.
15#[derive(Clone, Copy, Debug, PartialEq)]
16pub enum CanonicalValue<T> {
17    /// A recorded value.
18    Present(T),
19    /// The item does not apply to this row.
20    Inapplicable,
21    /// The item applies but was not recorded.
22    Unknown,
23}
24
25/// A validated semantic projection that can be consumed without text or a DOM.
26#[derive(Clone, Copy, Debug)]
27pub struct CanonicalProjection<'a> {
28    structure: &'a Structure,
29    options: &'a CifWriteOptions,
30    block_id: &'a str,
31}
32
33/// Validates and borrows the canonical structure projection.
34///
35/// # Errors
36///
37/// Returns a canonical write refusal before a format writer allocates its
38/// output when a required model, atom, bond endpoint, or identifier is absent.
39pub fn canonical_projection<'a>(
40    structure: &'a Structure,
41    options: &'a CifWriteOptions,
42) -> Result<CanonicalProjection<'a>, CifWriteError> {
43    let Some(block_id) = options.block_id().or(structure.data().entry.id.as_deref()) else {
44        return Err(CifWriteError::MissingBlockId);
45    };
46    if !valid_block_id(block_id) {
47        return Err(CifWriteError::InvalidBlockId(block_id.to_owned()));
48    }
49    let projection = CanonicalProjection {
50        structure,
51        options,
52        block_id,
53    };
54    projection.atom_site_row_count()?;
55    projection.visit_atom_rows(|_| {})?;
56    super::bonds::preflight(structure, options)?;
57    super::secondary::preflight(structure)?;
58    Ok(projection)
59}
60
61impl<'a> CanonicalProjection<'a> {
62    /// The validated data-block identifier.
63    #[must_use]
64    pub const fn block_id(self) -> &'a str {
65        self.block_id
66    }
67
68    /// The immutable semantic structure behind this projection.
69    #[must_use]
70    pub const fn structure(self) -> &'a Structure {
71        self.structure
72    }
73
74    /// The explicit canonical decisions used for this projection.
75    #[must_use]
76    pub const fn options(self) -> &'a CifWriteOptions {
77        self.options
78    }
79
80    /// Database sequence references retained by the structure.
81    #[must_use]
82    pub fn sequence_references(self) -> Option<&'a SequenceReferences> {
83        self.structure
84            .extensions()
85            .get::<SequenceReferences>(SEQUENCE_REFERENCES_EXTENSION)
86    }
87
88    /// Number of rows emitted by the atom-site category.
89    ///
90    /// # Errors
91    ///
92    /// Returns a range refusal if the product or ragged-model sum exceeds
93    /// `usize`.
94    pub fn atom_site_row_count(self) -> Result<usize, CifWriteError> {
95        if let Some(models) = self.structure.ragged_models() {
96            return models.iter().try_fold(0usize, |total, model| {
97                let rows = usize::try_from(model.atom_count())
98                    .map_err(|_| CifWriteError::AtomRowCountOverflow)?;
99                total
100                    .checked_add(rows)
101                    .ok_or(CifWriteError::AtomRowCountOverflow)
102            });
103        }
104        let atoms = usize::try_from(self.structure.atom_count())
105            .map_err(|_| CifWriteError::AtomRowCountOverflow)?;
106        atoms
107            .checked_mul(self.structure.model_count())
108            .ok_or(CifWriteError::AtomRowCountOverflow)
109    }
110
111    /// Whether any atom carries a stated formal charge.
112    #[must_use]
113    pub fn carries_formal_charge(self) -> bool {
114        let mut found = false;
115        // A refusal here is reported again, with its cause, by the row pass
116        // that follows; absence of a charge is the safe answer meanwhile.
117        let _ = self.visit_atom_rows(|row| {
118            found |= matches!(row.formal_charge(), CanonicalValue::Present(_));
119        });
120        found
121    }
122
123    /// Visits canonical atom-site rows in deposition and model order.
124    ///
125    /// The row borrows a model snapshot and is valid only for the synchronous
126    /// callback. Replaying the visit is allocation-free and supports encoders
127    /// that materialise only one output column at a time.
128    ///
129    /// # Errors
130    ///
131    /// Returns a canonical write refusal if the immutable structure cannot
132    /// supply a required row identity.
133    pub fn visit_atom_rows(
134        self,
135        mut visit: impl for<'row> FnMut(CanonicalAtomRow<'row>),
136    ) -> Result<(), CifWriteError> {
137        for position in 0..self.structure.model_count() {
138            let model = model_index(position)?;
139            let Some((snapshot, local_model)) = self.structure.model_snapshot(model) else {
140                return Err(CifWriteError::MissingModelNumber { model: model.get() });
141            };
142            let Some(model_number) = snapshot
143                .model(local_model)
144                .and_then(molframe_core::structure::ModelRef::number)
145            else {
146                return Err(CifWriteError::MissingModelNumber { model: model.get() });
147            };
148            visit_model(&snapshot, local_model, model_number, &mut visit)?;
149        }
150        Ok(())
151    }
152}
153
154fn visit_model(
155    structure: &Structure,
156    model: ModelIndex,
157    model_number: i32,
158    visit: &mut impl for<'row> FnMut(CanonicalAtomRow<'row>),
159) -> Result<(), CifWriteError> {
160    for chain in structure.data().chains() {
161        let Some(chain_label) = chain.label().filter(|text| !text.is_empty()) else {
162            let atom = chain
163                .residues()
164                .next()
165                .and_then(|residue| residue.atoms().next());
166            let position = atom.map_or(0, |value| value.index().get());
167            return Err(CifWriteError::MissingAtomField {
168                atom: position,
169                field: "label_asym_id",
170            });
171        };
172        let positions = structure.model_positions(model);
173        for residue in chain.residues() {
174            for atom in residue.atoms() {
175                let row = atom_row(
176                    structure,
177                    atom,
178                    residue,
179                    chain,
180                    chain_label,
181                    positions,
182                    model_number,
183                )?;
184                visit(row);
185            }
186        }
187    }
188    Ok(())
189}
190
191fn atom_row<'a>(
192    structure: &'a Structure,
193    atom: AtomRef<'a>,
194    residue: ResidueRef<'a>,
195    chain: ChainRef<'a>,
196    chain_label: &'a str,
197    positions: Option<&'a [[f32; 3]]>,
198    model_number: i32,
199) -> Result<CanonicalAtomRow<'a>, CifWriteError> {
200    let atom_index = atom.index().get();
201    let Some(atom_site_id) = atom.atom_site_id().filter(|value| *value != 0) else {
202        return Err(CifWriteError::MissingAtomSiteId { atom: atom_index });
203    };
204    let Some(atom_name) = atom.name().filter(|text| !text.is_empty()) else {
205        return Err(CifWriteError::MissingAtomField {
206            atom: atom_index,
207            field: "label_atom_id",
208        });
209    };
210    let Some(component_name) = atom.component_name().filter(|text| !text.is_empty()) else {
211        return Err(CifWriteError::MissingAtomField {
212            atom: atom_index,
213            field: "label_comp_id",
214        });
215    };
216    let position = positions.and_then(|values| values.get(atom.index().as_usize()).copied());
217    Ok(CanonicalAtomRow {
218        structure,
219        atom,
220        residue,
221        chain,
222        atom_site_id,
223        atom_name,
224        component_name,
225        chain_label,
226        position,
227        model_number,
228    })
229}
230
231fn model_index(position: usize) -> Result<ModelIndex, CifWriteError> {
232    u32::try_from(position)
233        .map(ModelIndex::new)
234        .map_err(|_| CifWriteError::ModelIndexOverflow { model: position })
235}
236
237/// One allocation-free atom-site row over a borrowed model snapshot.
238#[derive(Clone, Copy, Debug)]
239pub struct CanonicalAtomRow<'a> {
240    structure: &'a Structure,
241    atom: AtomRef<'a>,
242    residue: ResidueRef<'a>,
243    chain: ChainRef<'a>,
244    atom_site_id: u32,
245    atom_name: &'a str,
246    component_name: &'a str,
247    chain_label: &'a str,
248    position: Option<[f32; 3]>,
249    model_number: i32,
250}
251
252impl<'a> CanonicalAtomRow<'a> {
253    /// PDB record family implied by the residue kind.
254    #[must_use]
255    pub fn group(self) -> &'static str {
256        if self.residue.is_het() {
257            "HETATM"
258        } else {
259            "ATOM"
260        }
261    }
262
263    /// Deposited atom-site identifier.
264    #[must_use]
265    pub const fn atom_site_id(self) -> i64 {
266        self.atom_site_id as i64
267    }
268
269    /// Element or an explicit unknown value.
270    #[must_use]
271    pub fn element(self) -> CanonicalValue<Element> {
272        match self.atom.element() {
273            Some(value) => CanonicalValue::Present(value),
274            None => CanonicalValue::Unknown,
275        }
276    }
277
278    /// Normalised atom identifier.
279    #[must_use]
280    pub const fn label_atom_id(self) -> &'a str {
281        self.atom_name
282    }
283
284    /// Alternate-location identifier, inapplicable for the shared position.
285    #[must_use]
286    pub fn label_alt_id(self) -> CanonicalValue<&'a str> {
287        inapplicable_text(self.atom.alt_label())
288    }
289
290    /// Effective component identifier.
291    #[must_use]
292    pub const fn label_comp_id(self) -> &'a str {
293        self.component_name
294    }
295
296    /// Normalised chain identifier.
297    #[must_use]
298    pub const fn label_asym_id(self) -> &'a str {
299        self.chain_label
300    }
301
302    /// Entity identifier, inapplicable when the chain has none.
303    #[must_use]
304    pub fn label_entity_id(self) -> CanonicalValue<&'a str> {
305        let value = self
306            .chain
307            .entity()
308            .and_then(|entity| self.structure.data().topology.entities.id(entity))
309            .and_then(|symbol| self.structure.resolve(symbol));
310        inapplicable_text(value)
311    }
312
313    /// Canonical sequence position.
314    #[must_use]
315    pub fn label_seq_id(self) -> CanonicalValue<i64> {
316        inapplicable_integer(self.residue.label_seq_id())
317    }
318
319    /// Depositor insertion code.
320    #[must_use]
321    pub fn insertion_code(self) -> CanonicalValue<&'a str> {
322        unknown_text(self.residue.ins_code())
323    }
324
325    /// Cartesian coordinate at stored precision; text writers choose their own precision.
326    #[must_use]
327    pub fn coordinate(self, axis: usize) -> CanonicalValue<f64> {
328        match self.position.and_then(|point| point.get(axis).copied()) {
329            Some(value) => CanonicalValue::Present(f64::from(value)),
330            None => CanonicalValue::Unknown,
331        }
332    }
333
334    /// Occupancy at stored precision.
335    #[must_use]
336    pub fn occupancy(self) -> CanonicalValue<f64> {
337        optional_measurement(self.atom.occupancy())
338    }
339
340    /// Isotropic temperature factor at stored precision.
341    #[must_use]
342    pub fn b_factor(self) -> CanonicalValue<f64> {
343        optional_measurement(self.atom.b_factor())
344    }
345
346    /// Formal charge, unknown when the atom carries none.
347    #[must_use]
348    pub fn formal_charge(self) -> CanonicalValue<i64> {
349        match self.atom.formal_charge() {
350            Some(charge) => CanonicalValue::Present(i64::from(charge)),
351            None => CanonicalValue::Unknown,
352        }
353    }
354
355    /// Depositor sequence position.
356    #[must_use]
357    pub fn auth_seq_id(self) -> CanonicalValue<i64> {
358        inapplicable_integer(self.residue.auth_seq_id())
359    }
360
361    /// Depositor component identifier.
362    #[must_use]
363    pub fn auth_comp_id(self) -> CanonicalValue<&'a str> {
364        unknown_text(self.residue.auth_name())
365    }
366
367    /// Depositor chain identifier.
368    #[must_use]
369    pub fn auth_asym_id(self) -> CanonicalValue<&'a str> {
370        match self.chain.auth_label() {
371            Some(label) => CanonicalValue::Present(label),
372            None => CanonicalValue::Unknown,
373        }
374    }
375
376    /// Depositor atom identifier.
377    #[must_use]
378    pub fn auth_atom_id(self) -> CanonicalValue<&'a str> {
379        unknown_text(self.atom.auth_name())
380    }
381
382    /// Deposited model number.
383    #[must_use]
384    pub const fn model_number(self) -> i64 {
385        self.model_number as i64
386    }
387}
388
389fn inapplicable_text(value: Option<&str>) -> CanonicalValue<&str> {
390    match value.filter(|text| !text.is_empty()) {
391        Some(text) => CanonicalValue::Present(text),
392        None => CanonicalValue::Inapplicable,
393    }
394}
395
396fn unknown_text(value: Option<&str>) -> CanonicalValue<&str> {
397    match value.filter(|text| !text.is_empty()) {
398        Some(text) => CanonicalValue::Present(text),
399        None => CanonicalValue::Unknown,
400    }
401}
402
403fn inapplicable_integer(value: Option<i32>) -> CanonicalValue<i64> {
404    match value {
405        Some(number) => CanonicalValue::Present(i64::from(number)),
406        None => CanonicalValue::Inapplicable,
407    }
408}
409
410fn optional_measurement(value: Option<f32>) -> CanonicalValue<f64> {
411    match value {
412        Some(number) => CanonicalValue::Present(f64::from(number)),
413        None => CanonicalValue::Unknown,
414    }
415}