Skip to main content

chematic_mol/
cif.rs

1//! CIF (Crystallographic Information File) parser and writer.
2//!
3//! ## Scope
4//!
5//! Extracts **atomic positions** and converts fractional coordinates to
6//! orthogonal Ångström coordinates.  Symmetry expansion (space group /
7//! symmetry operations) is **not** performed — only atoms listed in the
8//! `_atom_site_*` loop are returned (effectively P1 treatment).
9//!
10//! ## CIF structure
11//!
12//! ```text
13//! data_NaCl
14//! _cell_length_a   5.6402
15//! _cell_length_b   5.6402
16//! _cell_length_c   5.6402
17//! _cell_angle_alpha  90.000
18//! _cell_angle_beta   90.000
19//! _cell_angle_gamma  90.000
20//!
21//! loop_
22//! _atom_site_label
23//! _atom_site_type_symbol
24//! _atom_site_fract_x
25//! _atom_site_fract_y
26//! _atom_site_fract_z
27//! Na1  Na  0.00000  0.00000  0.00000
28//! Cl1  Cl  0.50000  0.50000  0.50000
29//! ```
30//!
31//! ## Fractional → Orthogonal conversion (IUCr convention)
32//!
33//! ```text
34//! X = a·fx + b·cos(γ)·fy + c·cos(β)·fz
35//! Y =        b·sin(γ)·fy + c·(cos(α)−cos(β)cos(γ))/sin(γ)·fz
36//! Z =                      (V/(a·b·sin(γ)))·fz
37//! ```
38
39use chematic_core::{Atom, AtomIdx, Element, Molecule, MoleculeBuilder};
40
41// ---------------------------------------------------------------------------
42// Public types
43// ---------------------------------------------------------------------------
44
45/// Unit cell parameters (lengths in Å, angles in degrees).
46#[derive(Debug, Clone, PartialEq)]
47pub struct UnitCell {
48    pub a: f64,
49    pub b: f64,
50    pub c: f64,
51    pub alpha: f64,
52    pub beta: f64,
53    pub gamma: f64,
54}
55
56impl UnitCell {
57    /// Volume in ų.
58    pub fn volume(&self) -> f64 {
59        let (ca, cb, cg) = (
60            self.alpha.to_radians().cos(),
61            self.beta.to_radians().cos(),
62            self.gamma.to_radians().cos(),
63        );
64        self.a * self.b * self.c * (1.0 - ca * ca - cb * cb - cg * cg + 2.0 * ca * cb * cg).sqrt()
65    }
66
67    /// Convert fractional coordinates to orthogonal Å.
68    pub fn frac_to_cart(&self, fx: f64, fy: f64, fz: f64) -> (f64, f64, f64) {
69        let ca = self.alpha.to_radians().cos();
70        let cb = self.beta.to_radians().cos();
71        let sg = self.gamma.to_radians().sin();
72        let cg = self.gamma.to_radians().cos();
73        let x = self.a * fx + self.b * cg * fy + self.c * cb * fz;
74        let y = self.b * sg * fy + self.c * ((ca - cb * cg) / sg) * fz;
75        let z = (self.volume() / (self.a * self.b * sg)) * fz;
76        (x, y, z)
77    }
78}
79
80impl Default for UnitCell {
81    fn default() -> Self {
82        Self {
83            a: 1.0,
84            b: 1.0,
85            c: 1.0,
86            alpha: 90.0,
87            beta: 90.0,
88            gamma: 90.0,
89        }
90    }
91}
92
93/// Result of parsing a CIF file.
94pub struct CifResult {
95    /// Molecular topology (atoms; no bonds).
96    pub mol: Molecule,
97    /// Atomic coordinates in orthogonal Ångströms as `(x, y, z)` tuples.
98    pub coords: Vec<(f64, f64, f64)>,
99    /// Unit cell parameters, if present in the file.
100    pub cell: Option<UnitCell>,
101}
102
103// ---------------------------------------------------------------------------
104// Error type
105// ---------------------------------------------------------------------------
106
107/// Errors that can occur when parsing CIF files.
108#[derive(Debug, Clone, PartialEq)]
109pub enum CifError {
110    /// No `_atom_site_*` loop with coordinates was found.
111    NoAtomSiteLoop,
112    /// The atom site loop lacked at least one coordinate column.
113    MissingCoordinateColumns,
114    /// An element symbol or label could not be resolved.
115    UnknownElement(String),
116    /// A coordinate value could not be parsed as a float.
117    InvalidCoordinate(String),
118    /// The unit cell has degenerate angles (e.g. γ = 0° or 180°) that make
119    /// the fractional → Cartesian transformation undefined.
120    InvalidCellParameters(String),
121    /// The atom site loop uses fractional coordinates but the CIF contains no
122    /// `_cell_length_*` / `_cell_angle_*` parameters to convert them.
123    MissingCellParameters,
124}
125
126impl core::fmt::Display for CifError {
127    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
128        match self {
129            Self::NoAtomSiteLoop => write!(f, "no _atom_site_* loop found in CIF"),
130            Self::MissingCoordinateColumns => {
131                write!(f, "atom_site loop missing fract_x/y/z columns")
132            }
133            Self::UnknownElement(s) => write!(f, "unknown element '{s}' in CIF"),
134            Self::InvalidCoordinate(s) => write!(f, "invalid coordinate '{s}' in CIF"),
135            Self::InvalidCellParameters(s) => write!(f, "invalid cell parameters: {s}"),
136            Self::MissingCellParameters => write!(
137                f,
138                "fractional coordinates present but no _cell_length_*/_cell_angle_* parameters found"
139            ),
140        }
141    }
142}
143
144impl std::error::Error for CifError {}
145
146// ---------------------------------------------------------------------------
147// Parser
148// ---------------------------------------------------------------------------
149
150/// Parse a CIF file and return atoms with orthogonal coordinates.
151///
152/// Only the first `data_` block is parsed.  Symmetry expansion is **not**
153/// performed; returned atoms are exactly those listed in the `_atom_site_*`
154/// loop.
155/// Strip a CIF comment from one line, respecting single- and double-quoted
156/// strings (a `#` inside quotes is not a comment delimiter).
157fn strip_cif_comment(line: &str) -> &str {
158    let mut in_single = false;
159    let mut in_double = false;
160    for (i, c) in line.char_indices() {
161        match c {
162            '\'' if !in_double => in_single = !in_single,
163            '"' if !in_single => in_double = !in_double,
164            '#' if !in_single && !in_double => return &line[..i],
165            _ => {}
166        }
167    }
168    line
169}
170
171pub fn parse_cif(input: &str) -> Result<CifResult, CifError> {
172    // Strip CIF comments (# to end of line), but not '#' inside quoted strings.
173    let clean: String = input
174        .lines()
175        .map(strip_cif_comment)
176        .collect::<Vec<_>>()
177        .join("\n");
178
179    let tokens = tokenize_cif(&clean);
180
181    // --- Extract cell parameters ---
182    let mut cell = UnitCell::default();
183    let mut has_cell = false;
184    let mut i = 0;
185    while i + 1 < tokens.len() {
186        match tokens[i].to_ascii_lowercase().as_str() {
187            "_cell_length_a" => {
188                cell.a = parse_esd(&tokens[i + 1]).unwrap_or(cell.a);
189                has_cell = true;
190            }
191            "_cell_length_b" => {
192                cell.b = parse_esd(&tokens[i + 1]).unwrap_or(cell.b);
193            }
194            "_cell_length_c" => {
195                cell.c = parse_esd(&tokens[i + 1]).unwrap_or(cell.c);
196            }
197            "_cell_angle_alpha" => {
198                cell.alpha = parse_esd(&tokens[i + 1]).unwrap_or(cell.alpha);
199            }
200            "_cell_angle_beta" => {
201                cell.beta = parse_esd(&tokens[i + 1]).unwrap_or(cell.beta);
202            }
203            "_cell_angle_gamma" => {
204                cell.gamma = parse_esd(&tokens[i + 1]).unwrap_or(cell.gamma);
205            }
206            _ => {}
207        }
208        i += 1;
209    }
210
211    // --- Find _atom_site loop ---
212    i = 0;
213    let mut atom_site_data_start: Option<usize> = None;
214    let mut col_headers: Vec<String> = Vec::new();
215
216    while i < tokens.len() {
217        if tokens[i].as_str() == "loop_" {
218            let mut j = i + 1;
219            let mut headers: Vec<String> = Vec::new();
220            while j < tokens.len() && tokens[j].starts_with('_') {
221                headers.push(tokens[j].to_ascii_lowercase());
222                j += 1;
223            }
224            if headers.iter().any(|h| h.starts_with("_atom_site_")) {
225                col_headers = headers;
226                atom_site_data_start = Some(j);
227                break;
228            }
229            i = j;
230        } else {
231            i += 1;
232        }
233    }
234
235    let data_start = atom_site_data_start.ok_or(CifError::NoAtomSiteLoop)?;
236    let ncols = col_headers.len();
237    if ncols == 0 {
238        return Err(CifError::NoAtomSiteLoop);
239    }
240
241    let col = |name: &str| -> Option<usize> { col_headers.iter().position(|h| h.as_str() == name) };
242
243    let col_type = col("_atom_site_type_symbol");
244    let col_label = col("_atom_site_label");
245    // Prefer fractional; fall back to Cartesian.
246    let use_cartesian = col("_atom_site_fract_x").is_none();
247    let col_x = col("_atom_site_fract_x").or_else(|| col("_atom_site_cartn_x"));
248    let col_y = col("_atom_site_fract_y").or_else(|| col("_atom_site_cartn_y"));
249    let col_z = col("_atom_site_fract_z").or_else(|| col("_atom_site_cartn_z"));
250
251    let (col_x, col_y, col_z) = match (col_x, col_y, col_z) {
252        (Some(x), Some(y), Some(z)) => (x, y, z),
253        _ => return Err(CifError::MissingCoordinateColumns),
254    };
255
256    // Validate that fractional→Cartesian conversion is well-defined.
257    if !use_cartesian {
258        if !has_cell {
259            return Err(CifError::MissingCellParameters);
260        }
261        let sg = cell.gamma.to_radians().sin();
262        if sg.abs() < 1e-10 {
263            return Err(CifError::InvalidCellParameters(format!(
264                "_cell_angle_gamma = {} makes sin(γ) ≈ 0, transformation undefined",
265                cell.gamma
266            )));
267        }
268        // Also guard against a non-physical cell volume (≤ 0 or NaN).
269        let vol = cell.volume();
270        if !vol.is_finite() || vol <= 0.0 {
271            return Err(CifError::InvalidCellParameters(format!(
272                "unit cell volume is non-positive ({vol:.6} ų); check _cell_angle_* values"
273            )));
274        }
275    }
276
277    let mut builder = MoleculeBuilder::new();
278    let mut coords: Vec<(f64, f64, f64)> = Vec::new();
279    let data_tokens = &tokens[data_start..];
280
281    let mut row = 0;
282    while (row + 1) * ncols <= data_tokens.len() {
283        let base = row * ncols;
284        let tok = &data_tokens[base..base + ncols];
285
286        // Stop at next loop_ or data_ block.
287        if tok[0].as_str() == "loop_" || tok[0].starts_with("data_") {
288            break;
289        }
290
291        // Resolve element.
292        let elem_raw: &str = col_type
293            .and_then(|c| tok.get(c))
294            .map(|s| s.as_str())
295            .or_else(|| col_label.and_then(|c| tok.get(c)).map(|s| s.as_str()))
296            .unwrap_or("X");
297        // Strip trailing digits and oxidation-state signs from labels like
298        // "Na1", "Cu2+", "Fe3+", "O2-".
299        let elem_str =
300            elem_raw.trim_end_matches(|c: char| c.is_ascii_digit() || c == '+' || c == '-');
301        let elem = Element::from_symbol(elem_str)
302            .ok_or_else(|| CifError::UnknownElement(elem_str.to_string()))?;
303
304        let parse_coord = |s: &str| -> Result<f64, CifError> {
305            strip_esd(s)
306                .parse::<f64>()
307                .map_err(|_| CifError::InvalidCoordinate(s.to_string()))
308        };
309        let fx = parse_coord(&tok[col_x])?;
310        let fy = parse_coord(&tok[col_y])?;
311        let fz = parse_coord(&tok[col_z])?;
312
313        let (x, y, z) = if use_cartesian {
314            (fx, fy, fz)
315        } else {
316            cell.frac_to_cart(fx, fy, fz)
317        };
318
319        builder.add_atom(Atom::new(elem));
320        coords.push((x, y, z));
321        row += 1;
322    }
323
324    if coords.is_empty() {
325        return Err(CifError::NoAtomSiteLoop);
326    }
327
328    Ok(CifResult {
329        mol: builder.build(),
330        coords,
331        cell: if has_cell { Some(cell) } else { None },
332    })
333}
334
335// ---------------------------------------------------------------------------
336// Writer
337// ---------------------------------------------------------------------------
338
339/// Write a minimal CIF file (P1, no symmetry operators).
340///
341/// When `cell` is `None`, fractional coordinates are written as-is
342/// (the coordinates are treated as already orthogonal with a=b=c=1).
343pub fn write_cif(mol: &Molecule, coords: &[(f64, f64, f64)], cell: Option<&UnitCell>) -> String {
344    let mut out = String::from("data_chematic\n\n");
345
346    if let Some(c) = cell {
347        out.push_str(&format!(
348            "_cell_length_a   {:.4}\n\
349             _cell_length_b   {:.4}\n\
350             _cell_length_c   {:.4}\n\
351             _cell_angle_alpha  {:.3}\n\
352             _cell_angle_beta   {:.3}\n\
353             _cell_angle_gamma  {:.3}\n\n\
354             _symmetry_space_group_name_H-M  'P 1'\n\n",
355            c.a, c.b, c.c, c.alpha, c.beta, c.gamma
356        ));
357    }
358
359    out.push_str(
360        "loop_\n\
361         _atom_site_label\n\
362         _atom_site_type_symbol\n\
363         _atom_site_fract_x\n\
364         _atom_site_fract_y\n\
365         _atom_site_fract_z\n",
366    );
367
368    let fallback_cell = UnitCell::default();
369    let cell_ref = cell.unwrap_or(&fallback_cell);
370
371    for i in 0..mol.atom_count() {
372        let idx = AtomIdx(i as u32);
373        let sym = mol.atom(idx).element.symbol();
374        let (x, y, z) = coords.get(i).copied().unwrap_or((0.0, 0.0, 0.0));
375        let (fx, fy, fz) = cart_to_frac(cell_ref, x, y, z);
376        out.push_str(&format!(
377            "{sym}{} {sym}  {fx:.5}  {fy:.5}  {fz:.5}\n",
378            i + 1
379        ));
380    }
381    out
382}
383
384fn cart_to_frac(cell: &UnitCell, x: f64, y: f64, z: f64) -> (f64, f64, f64) {
385    // Exact inverse for orthogonal cells; approximate for triclinic.
386    let sg = cell.gamma.to_radians().sin();
387    let cg = cell.gamma.to_radians().cos();
388    let cb = cell.beta.to_radians().cos();
389    let ca = cell.alpha.to_radians().cos();
390    let fz = z * cell.a * cell.b * sg / cell.volume();
391    let fy = (y - cell.c * ((ca - cb * cg) / sg) * fz) / (cell.b * sg);
392    let fx = (x - cell.b * cg * fy - cell.c * cb * fz) / cell.a;
393    (fx, fy, fz)
394}
395
396// ---------------------------------------------------------------------------
397// CIF tokenizer
398// ---------------------------------------------------------------------------
399
400fn tokenize_cif(input: &str) -> Vec<String> {
401    let mut tokens: Vec<String> = Vec::new();
402    let chars: Vec<char> = input.chars().collect();
403    let len = chars.len();
404    let mut i = 0;
405
406    while i < len {
407        let ch = chars[i];
408        // Skip whitespace.
409        if ch.is_whitespace() {
410            i += 1;
411            continue;
412        }
413
414        // Semicolon text block (starts at column 0 on a new line).
415        if ch == ';' && (i == 0 || chars[i - 1] == '\n') {
416            i += 1;
417            let start = i;
418            while i < len {
419                if chars[i] == '\n' && i + 1 < len && chars[i + 1] == ';' {
420                    break;
421                }
422                i += 1;
423            }
424            // Skip the text content (we don't use it for atom sites).
425            tokens.push(chars[start..i].iter().collect());
426            i += 2; // skip '\n;'
427            continue;
428        }
429
430        // Single-quoted string.
431        if ch == '\'' {
432            i += 1;
433            let start = i;
434            while i < len && chars[i] != '\'' {
435                i += 1;
436            }
437            tokens.push(chars[start..i].iter().collect());
438            if i < len {
439                i += 1;
440            }
441            continue;
442        }
443
444        // Double-quoted string.
445        if ch == '"' {
446            i += 1;
447            let start = i;
448            while i < len && chars[i] != '"' {
449                i += 1;
450            }
451            tokens.push(chars[start..i].iter().collect());
452            if i < len {
453                i += 1;
454            }
455            continue;
456        }
457
458        // Regular token.
459        let start = i;
460        while i < len && !chars[i].is_whitespace() {
461            i += 1;
462        }
463        tokens.push(chars[start..i].iter().collect());
464    }
465    tokens
466}
467
468fn strip_esd(s: &str) -> &str {
469    s.find('(').map_or(s, |pos| &s[..pos])
470}
471
472fn parse_esd(s: &str) -> Option<f64> {
473    strip_esd(s).parse::<f64>().ok()
474}
475
476// ---------------------------------------------------------------------------
477// Tests
478// ---------------------------------------------------------------------------
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    const NACL_CIF: &str = r#"data_NaCl
485_cell_length_a   5.6402
486_cell_length_b   5.6402
487_cell_length_c   5.6402
488_cell_angle_alpha  90.000
489_cell_angle_beta   90.000
490_cell_angle_gamma  90.000
491
492loop_
493_atom_site_label
494_atom_site_type_symbol
495_atom_site_fract_x
496_atom_site_fract_y
497_atom_site_fract_z
498Na1  Na  0.00000  0.00000  0.00000
499Cl1  Cl  0.50000  0.50000  0.50000
500"#;
501
502    #[test]
503    fn parse_nacl_atom_count() {
504        let r = parse_cif(NACL_CIF).unwrap();
505        assert_eq!(r.mol.atom_count(), 2);
506        assert_eq!(r.coords.len(), 2);
507    }
508
509    #[test]
510    fn parse_nacl_cell_params() {
511        let r = parse_cif(NACL_CIF).unwrap();
512        let cell = r.cell.as_ref().unwrap();
513        assert!((cell.a - 5.6402).abs() < 1e-4);
514        assert!((cell.alpha - 90.0).abs() < 1e-4);
515    }
516
517    #[test]
518    fn parse_nacl_na_at_origin() {
519        let r = parse_cif(NACL_CIF).unwrap();
520        let (x, y, z) = r.coords[0];
521        assert!(
522            x.abs() < 1e-6 && y.abs() < 1e-6 && z.abs() < 1e-6,
523            "Na at origin: got ({x}, {y}, {z})"
524        );
525    }
526
527    #[test]
528    fn parse_nacl_cl_at_body_center() {
529        let r = parse_cif(NACL_CIF).unwrap();
530        let (x, y, z) = r.coords[1];
531        let half = 5.6402 / 2.0;
532        assert!((x - half).abs() < 1e-3, "Cl x: got {x}, expected ~{half}");
533        assert!((y - half).abs() < 1e-3);
534        assert!((z - half).abs() < 1e-3);
535    }
536
537    #[test]
538    fn unit_cell_volume_cubic() {
539        let cell = UnitCell {
540            a: 5.0,
541            b: 5.0,
542            c: 5.0,
543            alpha: 90.0,
544            beta: 90.0,
545            gamma: 90.0,
546        };
547        assert!((cell.volume() - 125.0).abs() < 1e-6);
548    }
549
550    #[test]
551    fn write_cif_roundtrip() {
552        let r = parse_cif(NACL_CIF).unwrap();
553        let out = write_cif(&r.mol, &r.coords, r.cell.as_ref());
554        assert!(out.contains("data_chematic"));
555        let r2 = parse_cif(&out).unwrap();
556        assert_eq!(r2.mol.atom_count(), 2);
557    }
558
559    #[test]
560    fn parse_esd_stripped() {
561        let cif = "data_x\n_cell_length_a 5.64(2)\n_cell_length_b 5.64\n_cell_length_c 5.64\n\
562                   _cell_angle_alpha 90\n_cell_angle_beta 90\n_cell_angle_gamma 90\n\
563                   loop_\n_atom_site_type_symbol\n_atom_site_fract_x\n_atom_site_fract_y\n_atom_site_fract_z\n\
564                   Na 0.0 0.0 0.0\n";
565        let r = parse_cif(cif).unwrap();
566        assert!((r.cell.unwrap().a - 5.64).abs() < 1e-3);
567    }
568
569    #[test]
570    fn frac_to_cart_cubic_roundtrip() {
571        let cell = UnitCell {
572            a: 5.0,
573            b: 5.0,
574            c: 5.0,
575            alpha: 90.0,
576            beta: 90.0,
577            gamma: 90.0,
578        };
579        let (x, y, z) = cell.frac_to_cart(0.3, 0.4, 0.5);
580        let (fx, fy, fz) = cart_to_frac(&cell, x, y, z);
581        assert!((fx - 0.3).abs() < 1e-10);
582        assert!((fy - 0.4).abs() < 1e-10);
583        assert!((fz - 0.5).abs() < 1e-10);
584    }
585}