Skip to main content

chematic_depict/
lib.rs

1//! `chematic-depict` — 2D SVG depiction engine for chematic.
2//!
3//! Entry point: `depict_svg(mol)` returns an SVG string.
4
5#![forbid(unsafe_code)]
6
7pub mod layout;
8pub mod svg;
9
10use chematic_core::Molecule;
11
12pub use layout::{Layout, Point, compute_layout};
13pub use svg::render_svg;
14
15/// Compute a 2D layout and render it as an SVG string.
16pub fn depict_svg(mol: &Molecule) -> String {
17    let layout = compute_layout(mol);
18    render_svg(mol, &layout)
19}
20
21// ---------------------------------------------------------------------------
22// Tests
23// ---------------------------------------------------------------------------
24
25#[cfg(test)]
26mod tests {
27    use super::*;
28    use crate::layout::BOND_LEN;
29
30    use chematic_smiles::parse;
31
32    // Helper: parse SMILES, panic with a helpful message on failure.
33    fn mol(smiles: &str) -> Molecule {
34        parse(smiles).unwrap_or_else(|e| panic!("Failed to parse '{}': {:?}", smiles, e))
35    }
36
37    // -------------------------------------------------------------------
38    // 1. compute_layout — benzene: 6 atoms, all coords distinct,
39    //    no two atoms closer than BOND_LEN/2.
40    // -------------------------------------------------------------------
41    #[test]
42    fn test_layout_benzene_six_distinct_coords() {
43        let m = mol("c1ccccc1");
44        assert_eq!(m.atom_count(), 6);
45        let layout = compute_layout(&m);
46        assert_eq!(layout.coords.len(), 6);
47
48        // All coordinates must be distinct.
49        for i in 0..6 {
50            for j in (i + 1)..6 {
51                let d = layout.coords[i].dist(&layout.coords[j]);
52                assert!(
53                    d > BOND_LEN / 2.0,
54                    "Atoms {} and {} are too close: {:.2} < {:.2}",
55                    i,
56                    j,
57                    d,
58                    BOND_LEN / 2.0
59                );
60            }
61        }
62    }
63
64    // -------------------------------------------------------------------
65    // 2. compute_layout — single atom: one coord near origin.
66    // -------------------------------------------------------------------
67    #[test]
68    fn test_layout_single_atom() {
69        let m = mol("[C]");
70        assert_eq!(m.atom_count(), 1);
71        let layout = compute_layout(&m);
72        assert_eq!(layout.coords.len(), 1);
73        // Single atom should be placed at (0, 0).
74        let p = layout.coords[0];
75        assert!((p.x).abs() < 1.0 && (p.y).abs() < 1.0, "Single atom not near origin: {:?}", p);
76    }
77
78    // -------------------------------------------------------------------
79    // 3. compute_layout — ethane (CC): 2 atoms, distance ~= BOND_LEN (±1%).
80    // -------------------------------------------------------------------
81    #[test]
82    fn test_layout_ethane_bond_length() {
83        let m = mol("CC");
84        assert_eq!(m.atom_count(), 2);
85        let layout = compute_layout(&m);
86        assert_eq!(layout.coords.len(), 2);
87        let d = layout.coords[0].dist(&layout.coords[1]);
88        let tolerance = BOND_LEN * 0.01;
89        assert!(
90            (d - BOND_LEN).abs() < tolerance,
91            "Ethane bond distance {:.4} != BOND_LEN {:.4} (±{:.4})",
92            d,
93            BOND_LEN,
94            tolerance
95        );
96    }
97
98    // -------------------------------------------------------------------
99    // 4. compute_layout — naphthalene: 10 distinct coords, reasonable bbox.
100    // -------------------------------------------------------------------
101    #[test]
102    fn test_layout_naphthalene_ten_coords() {
103        let m = mol("c1ccc2ccccc2c1");
104        assert_eq!(m.atom_count(), 10);
105        let layout = compute_layout(&m);
106        assert_eq!(layout.coords.len(), 10);
107
108        // All coords distinct.
109        for i in 0..10 {
110            for j in (i + 1)..10 {
111                let d = layout.coords[i].dist(&layout.coords[j]);
112                assert!(
113                    d > BOND_LEN / 2.0,
114                    "Naphthalene atoms {} and {} too close: {:.2}",
115                    i,
116                    j,
117                    d
118                );
119            }
120        }
121
122        // Reasonable bounding box: no wider/taller than 6 * BOND_LEN.
123        let (min_x, min_y, max_x, max_y) = layout.bounding_box();
124        assert!(max_x - min_x < 6.0 * BOND_LEN, "Naphthalene too wide");
125        assert!(max_y - min_y < 6.0 * BOND_LEN, "Naphthalene too tall");
126    }
127
128    // -------------------------------------------------------------------
129    // 5. compute_layout — disconnected mol ("CC.CC"): atoms from different
130    //    fragments are farther than BOND_LEN apart.
131    // -------------------------------------------------------------------
132    #[test]
133    fn test_layout_disconnected_fragments_no_overlap() {
134        let m = mol("CC.CC");
135        assert_eq!(m.atom_count(), 4);
136        let layout = compute_layout(&m);
137
138        // Fragment 0 = atoms 0,1; fragment 1 = atoms 2,3.
139        // No atom from fragment 0 should be within BOND_LEN of any atom in fragment 1.
140        for i in 0..2 {
141            for j in 2..4 {
142                let d = layout.coords[i].dist(&layout.coords[j]);
143                assert!(
144                    d >= BOND_LEN,
145                    "Atoms from different fragments too close: atoms {} and {}, dist {:.2}",
146                    i,
147                    j,
148                    d
149                );
150            }
151        }
152    }
153
154    // -------------------------------------------------------------------
155    // 6. render_svg — benzene: SVG contains <svg and <line but no <text.
156    // -------------------------------------------------------------------
157    #[test]
158    fn test_svg_benzene_no_text() {
159        let m = mol("c1ccccc1");
160        let layout = compute_layout(&m);
161        let svg = render_svg(&m, &layout);
162        assert!(svg.contains("<svg"), "SVG must start with <svg");
163        assert!(svg.contains("<line"), "SVG must have bond lines");
164        assert!(!svg.contains("<text"), "Benzene SVG must have no atom text labels");
165    }
166
167    // -------------------------------------------------------------------
168    // 7. render_svg — pyridine: SVG contains <text with "N".
169    // -------------------------------------------------------------------
170    #[test]
171    fn test_svg_pyridine_contains_nitrogen_label() {
172        let m = mol("c1ccncc1");
173        let layout = compute_layout(&m);
174        let svg = render_svg(&m, &layout);
175        assert!(svg.contains("<text"), "Pyridine SVG must have a text label");
176        assert!(svg.contains('N'), "Pyridine SVG must contain 'N' label");
177    }
178
179    // -------------------------------------------------------------------
180    // 8. render_svg — aspirin: non-empty, contains <line.
181    // -------------------------------------------------------------------
182    #[test]
183    fn test_svg_aspirin_non_empty() {
184        let m = mol("CC(=O)Oc1ccccc1C(=O)O");
185        let layout = compute_layout(&m);
186        let svg = render_svg(&m, &layout);
187        assert!(!svg.is_empty(), "Aspirin SVG must be non-empty");
188        assert!(svg.contains("<line"), "Aspirin SVG must contain line elements");
189    }
190
191    // -------------------------------------------------------------------
192    // 9. render_svg — double bond (C=C): SVG contains two <line elements.
193    // -------------------------------------------------------------------
194    #[test]
195    fn test_svg_double_bond_two_lines() {
196        let m = mol("C=C");
197        let layout = compute_layout(&m);
198        let svg = render_svg(&m, &layout);
199        let count = svg.matches("<line").count();
200        assert!(count >= 2, "C=C SVG should have >= 2 <line elements, got {}", count);
201    }
202
203    // -------------------------------------------------------------------
204    // 10. depict_svg — caffeine: produces valid SVG.
205    // -------------------------------------------------------------------
206    #[test]
207    fn test_depict_svg_caffeine_valid() {
208        let m = mol("Cn1cnc2c1c(=O)n(c(=O)n2C)C");
209        let svg = depict_svg(&m);
210        assert!(svg.starts_with("<svg"), "Caffeine SVG must start with <svg");
211        assert!(svg.ends_with("</svg>"), "Caffeine SVG must end with </svg>");
212    }
213
214    // -------------------------------------------------------------------
215    // 11. depict_svg — water ([OH2]): SVG contains "O" label.
216    // -------------------------------------------------------------------
217    #[test]
218    fn test_depict_svg_water_contains_o() {
219        let m = mol("[OH2]");
220        let svg = depict_svg(&m);
221        assert!(svg.contains('O'), "Water SVG must contain 'O'");
222    }
223
224    // -------------------------------------------------------------------
225    // 12. depict_svg — single carbon (C): SVG produced without error,
226    //     no label for plain C.
227    // -------------------------------------------------------------------
228    #[test]
229    fn test_depict_svg_single_carbon_no_label() {
230        let m = mol("C");
231        let svg = depict_svg(&m);
232        assert!(svg.starts_with("<svg"), "Single C SVG must start with <svg");
233        assert!(svg.ends_with("</svg>"), "Single C SVG must end with </svg>");
234        // Plain carbon has no text label.
235        assert!(!svg.contains("<text"), "Single C SVG should have no text label");
236    }
237}