1use chematic_core::{Atom, AtomIdx, Element, Molecule, MoleculeBuilder};
40
41#[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 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 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
93pub struct CifResult {
95 pub mol: Molecule,
97 pub coords: Vec<(f64, f64, f64)>,
99 pub cell: Option<UnitCell>,
101}
102
103#[derive(Debug, Clone, PartialEq)]
109pub enum CifError {
110 NoAtomSiteLoop,
112 MissingCoordinateColumns,
114 UnknownElement(String),
116 InvalidCoordinate(String),
118 InvalidCellParameters(String),
121 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
146fn 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 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 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 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 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 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 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 if tok[0].as_str() == "loop_" || tok[0].starts_with("data_") {
288 break;
289 }
290
291 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 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
335pub 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 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
396fn 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 if ch.is_whitespace() {
410 i += 1;
411 continue;
412 }
413
414 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 tokens.push(chars[start..i].iter().collect());
426 i += 2; continue;
428 }
429
430 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 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 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#[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}