Skip to main content

chematic_3d/
lib.rs

1//! `chematic-3d` — 3D coordinate generation and file formats for chematic.
2//!
3//! Provides:
4//! - [`generate_coords`]: rule-based 3D coordinate builder.
5//! - [`parse_pdb_atoms`], [`pdb_to_molecule`], [`write_pdb`]: PDB file support.
6//! - [`parse_xyz`], [`write_xyz`]: XYZ file support.
7
8#![forbid(unsafe_code)]
9
10pub mod conformer;
11pub mod coords;
12pub mod dg;
13pub mod minimize;
14pub mod pdb;
15pub mod shape_descriptors;
16pub mod stereo3d;
17pub mod xyz;
18
19pub use conformer::{ConformerEnsemble, ConformerError};
20pub use coords::{Coords3D, Point3};
21pub use dg::generate_coords;
22pub use minimize::{MinimizeConfig, minimize, minimize_with_config};
23pub use pdb::{PdbAtom, parse_pdb_atoms, pdb_to_molecule, write_pdb};
24pub use shape_descriptors::{
25    asphericity, eccentricity, npr1, npr2, plane_of_best_fit,
26    pmi, pmi1, pmi2, pmi3, radius_of_gyration,
27};
28pub use stereo3d::{StereoAssignment3D, assign_stereo_from_3d};
29pub use xyz::{XyzError, parse_xyz, write_xyz};
30
31// ---------------------------------------------------------------------------
32// Tests
33// ---------------------------------------------------------------------------
34
35#[cfg(test)]
36mod tests {
37    use chematic_core::AtomIdx;
38    use chematic_smiles::parse;
39
40    use crate::{
41        coords::Point3,
42        dg::generate_coords,
43        pdb::{parse_pdb_atoms, pdb_to_molecule, write_pdb},
44        xyz::{XyzError, parse_xyz, write_xyz},
45    };
46
47    // -----------------------------------------------------------------------
48    // Coords / Point3 tests
49    // -----------------------------------------------------------------------
50
51    /// Test 1: Point3 distance.
52    #[test]
53    fn test_point3_distance() {
54        let a = Point3::new(3.0, 4.0, 0.0);
55        let b = Point3::zero();
56        let d = a.distance(&b);
57        assert!((d - 5.0).abs() < 1e-10, "expected 5.0, got {d}");
58    }
59
60    /// Test 2: Point3 cross product — (1,0,0) × (0,1,0) = (0,0,1).
61    #[test]
62    fn test_point3_cross_product() {
63        let x = Point3::new(1.0, 0.0, 0.0);
64        let y = Point3::new(0.0, 1.0, 0.0);
65        let z = x.cross(&y);
66        assert!((z.x - 0.0).abs() < 1e-10);
67        assert!((z.y - 0.0).abs() < 1e-10);
68        assert!((z.z - 1.0).abs() < 1e-10);
69    }
70
71    // -----------------------------------------------------------------------
72    // DG / generate_coords tests
73    // -----------------------------------------------------------------------
74
75    /// Test 3: Single atom placed at origin.
76    #[test]
77    fn test_single_atom_at_origin() {
78        let mol = parse("O").expect("oxygen SMILES");
79        let coords = generate_coords(&mol);
80        assert_eq!(coords.atom_count(), 1);
81        let p = coords.get(AtomIdx(0));
82        assert!((p.x).abs() < 1e-10 && (p.y).abs() < 1e-10 && (p.z).abs() < 1e-10);
83    }
84
85    /// Test 4: Ethane — 2 distinct atoms, distance ≈ 1.54 Å (±0.1).
86    #[test]
87    fn test_ethane_bond_length() {
88        let mol = parse("CC").expect("ethane SMILES");
89        let coords = generate_coords(&mol);
90        assert_eq!(coords.atom_count(), 2);
91        let p0 = coords.get(AtomIdx(0));
92        let p1 = coords.get(AtomIdx(1));
93        let d = p0.distance(&p1);
94        assert!(
95            (d - 1.54).abs() < 0.1,
96            "ethane C-C distance expected ~1.54, got {d}"
97        );
98    }
99
100    /// Test 5: Propane — 3 distinct atoms, no two identical.
101    #[test]
102    fn test_propane_distinct_atoms() {
103        let mol = parse("CCC").expect("propane SMILES");
104        let coords = generate_coords(&mol);
105        assert_eq!(coords.atom_count(), 3);
106        let positions: Vec<_> = (0..3).map(|i| coords.get(AtomIdx(i))).collect();
107        for i in 0..3 {
108            for j in (i + 1)..3 {
109                let d = positions[i].distance(&positions[j]);
110                assert!(d > 0.1, "atoms {i} and {j} are too close (d={d:.4})");
111            }
112        }
113    }
114
115    /// Test 6: Benzene — 6 distinct atoms, all within 2.0 Å of centroid.
116    #[test]
117    fn test_benzene_ring() {
118        let mol = parse("c1ccccc1").expect("benzene SMILES");
119        let coords = generate_coords(&mol);
120        assert_eq!(coords.atom_count(), 6);
121
122        // Compute centroid.
123        let cx = (0..6).map(|i| coords.get(AtomIdx(i)).x).sum::<f64>() / 6.0;
124        let cy = (0..6).map(|i| coords.get(AtomIdx(i)).y).sum::<f64>() / 6.0;
125        let cz = (0..6).map(|i| coords.get(AtomIdx(i)).z).sum::<f64>() / 6.0;
126        let centroid = Point3::new(cx, cy, cz);
127
128        for i in 0..6 {
129            let p = coords.get(AtomIdx(i));
130            let d = p.distance(&centroid);
131            assert!(
132                d < 2.0,
133                "benzene atom {i} is {d:.3} Å from centroid, expected < 2.0"
134            );
135        }
136    }
137
138    /// Test 7: Water — 1 heavy atom at origin (H are implicit).
139    #[test]
140    fn test_water_single_atom() {
141        let mol = parse("O").expect("water SMILES");
142        assert_eq!(mol.atom_count(), 1, "water has 1 heavy atom");
143        let coords = generate_coords(&mol);
144        assert_eq!(coords.atom_count(), 1);
145        let p = coords.get(AtomIdx(0));
146        assert!((p.x).abs() < 1e-10 && (p.y).abs() < 1e-10 && (p.z).abs() < 1e-10);
147    }
148
149    /// Test 8: Disconnected "CC.CC" — 4 distinct atoms.
150    #[test]
151    fn test_disconnected_four_atoms() {
152        let mol = parse("CC.CC").expect("disconnected ethane SMILES");
153        assert_eq!(mol.atom_count(), 4);
154        let coords = generate_coords(&mol);
155        assert_eq!(coords.atom_count(), 4);
156
157        // All four positions must be distinct.
158        let positions: Vec<_> = (0..4).map(|i| coords.get(AtomIdx(i))).collect();
159        for i in 0..4 {
160            for j in (i + 1)..4 {
161                let d = positions[i].distance(&positions[j]);
162                assert!(d > 0.1, "atoms {i} and {j} overlap (d={d:.4})");
163            }
164        }
165    }
166
167    // -----------------------------------------------------------------------
168    // XYZ tests
169    // -----------------------------------------------------------------------
170
171    /// Test 9: Write then parse roundtrip for methane — 1 atom, coord ≈ (0,0,0).
172    #[test]
173    fn test_xyz_roundtrip_methane() {
174        let mol = parse("C").expect("methane SMILES");
175        let coords = generate_coords(&mol);
176        let xyz_str = write_xyz(&mol, &coords, "methane");
177
178        let (mol2, coords2) = parse_xyz(&xyz_str).expect("roundtrip parse");
179        assert_eq!(mol2.atom_count(), 1);
180        let p = coords2.get(AtomIdx(0));
181        assert!((p.x).abs() < 1e-6 && (p.y).abs() < 1e-6 && (p.z).abs() < 1e-6);
182    }
183
184    /// Test 10: Write ethane, parse back — 2 atoms, distance preserved (±0.01).
185    #[test]
186    fn test_xyz_ethane_roundtrip_distance() {
187        let mol = parse("CC").expect("ethane SMILES");
188        let coords = generate_coords(&mol);
189        let orig_dist = coords.get(AtomIdx(0)).distance(&coords.get(AtomIdx(1)));
190
191        let xyz_str = write_xyz(&mol, &coords, "ethane");
192        let (mol2, coords2) = parse_xyz(&xyz_str).expect("roundtrip parse");
193        assert_eq!(mol2.atom_count(), 2);
194
195        let parsed_dist = coords2.get(AtomIdx(0)).distance(&coords2.get(AtomIdx(1)));
196        assert!(
197            (parsed_dist - orig_dist).abs() < 0.01,
198            "distance changed: orig={orig_dist:.6}, parsed={parsed_dist:.6}"
199        );
200    }
201
202    /// Test 11: parse_xyz returns error on invalid atom count line.
203    #[test]
204    fn test_xyz_invalid_atom_count() {
205        let bad = "not_a_number\ncomment\n";
206        let result = parse_xyz(bad);
207        assert!(
208            matches!(result, Err(XyzError::InvalidAtomCount)),
209            "expected InvalidAtomCount error, got {:?}",
210            result.err()
211        );
212    }
213
214    /// Test 12: write_xyz first line is the atom count as a string.
215    #[test]
216    fn test_xyz_first_line_is_count() {
217        let mol = parse("CCC").expect("propane SMILES");
218        let coords = generate_coords(&mol);
219        let xyz_str = write_xyz(&mol, &coords, "propane");
220        let first_line = xyz_str.lines().next().unwrap();
221        assert_eq!(first_line.trim(), "3");
222    }
223
224    // -----------------------------------------------------------------------
225    // PDB tests
226    // -----------------------------------------------------------------------
227
228    /// Test 13: parse_pdb_atoms on a minimal HETATM record.
229    #[test]
230    fn test_pdb_parse_minimal_hetatm() {
231        // Standard 80-column PDB HETATM line with known values.
232        let pdb_line = "HETATM    1  C   LIG A   1       1.000   2.000   3.000  1.00  0.00           C\n";
233        let atoms = parse_pdb_atoms(pdb_line);
234        assert_eq!(atoms.len(), 1);
235        let a = &atoms[0];
236        assert_eq!(a.serial, 1);
237        assert!((a.x - 1.0).abs() < 1e-3, "x={}", a.x);
238        assert!((a.y - 2.0).abs() < 1e-3, "y={}", a.y);
239        assert!((a.z - 3.0).abs() < 1e-3, "z={}", a.z);
240        assert_eq!(a.element.trim(), "C");
241    }
242
243    /// Test 14: write_pdb then parse_pdb_atoms roundtrip preserves count and coords.
244    #[test]
245    fn test_pdb_write_parse_roundtrip() {
246        let mol = parse("CCO").expect("ethanol SMILES");
247        let coords = generate_coords(&mol);
248
249        let pdb_str = write_pdb(&mol, &coords);
250        let parsed = parse_pdb_atoms(&pdb_str);
251
252        assert_eq!(parsed.len(), mol.atom_count());
253
254        // Compare coordinates to within 0.001 Å.
255        for i in 0..mol.atom_count() {
256            let orig = coords.get(AtomIdx(i as u32));
257            let p = &parsed[i];
258            assert!(
259                (p.x - orig.x).abs() < 0.001,
260                "atom {i} x mismatch: orig={:.3} parsed={:.3}",
261                orig.x,
262                p.x
263            );
264            assert!(
265                (p.y - orig.y).abs() < 0.001,
266                "atom {i} y mismatch: orig={:.3} parsed={:.3}",
267                orig.y,
268                p.y
269            );
270            assert!(
271                (p.z - orig.z).abs() < 0.001,
272                "atom {i} z mismatch: orig={:.3} parsed={:.3}",
273                orig.z,
274                p.z
275            );
276        }
277    }
278
279    /// Test 15: pdb_to_molecule for two C atoms 1.54 Å apart — 2 atoms, 1 bond.
280    #[test]
281    fn test_pdb_to_molecule_bonding() {
282        let pdb = "HETATM    1  C   LIG A   1       0.000   0.000   0.000  1.00  0.00           C\n\
283                   HETATM    2  C   LIG A   1       1.540   0.000   0.000  1.00  0.00           C\n\
284                   END\n";
285        let atoms = parse_pdb_atoms(pdb);
286        let (mol, _coords) = pdb_to_molecule(&atoms);
287        assert_eq!(mol.atom_count(), 2);
288        assert_eq!(mol.bond_count(), 1);
289    }
290}