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