#![forbid(unsafe_code)]
pub mod grid;
pub mod layout;
pub mod png;
pub mod svg;
use chematic_core::{AtomIdx, BondIdx, BondOrder, Element, Molecule};
pub use grid::{depict_svg_grid, depict_svg_grid_with_opts};
pub use layout::{
BOND_LEN, Layout, Point, compute_layout, detect_crossings, suggest_bond_direction,
};
pub use png::{render_png, render_png_opts};
pub use reaction_svg::{depict_reaction_svg, depict_reaction_svg_opts};
pub use svg::{
AtomLabel, HPosition, RenderOptions, atom_color, atom_color_rgb, atom_display_label,
atom_label_with_h, render_svg, render_svg_highlighted, render_svg_opts,
render_svg_with_metadata,
};
pub mod reaction_svg;
#[derive(Debug, Clone, PartialEq)]
pub enum DepictBondKind {
Single,
Double,
Triple,
Aromatic,
Up,
Down,
}
#[derive(Debug, Clone)]
pub struct DepictAtom {
pub idx: AtomIdx,
pub element: Element,
pub pos: Point,
pub charge: i8,
pub label: Option<String>,
pub color: String,
}
#[derive(Debug, Clone)]
pub struct DepictBond {
pub idx: BondIdx,
pub atom1: AtomIdx,
pub atom2: AtomIdx,
pub kind: DepictBondKind,
}
#[derive(Debug, Clone)]
pub struct DepictData {
pub atoms: Vec<DepictAtom>,
pub bonds: Vec<DepictBond>,
}
pub fn compute_depict_data(mol: &Molecule) -> DepictData {
let layout = compute_layout(mol);
depict_data_from_layout(mol, &layout)
}
pub fn depict_data_with_coords(mol: &Molecule, coords: &[(f64, f64)]) -> DepictData {
let layout = Layout {
coords: coords.iter().map(|&(x, y)| Point { x, y }).collect(),
};
depict_data_from_layout(mol, &layout)
}
fn depict_data_from_layout(mol: &Molecule, layout: &Layout) -> DepictData {
let atoms: Vec<DepictAtom> = mol
.atoms()
.map(|(idx, atom)| {
let pos = layout.get(idx);
let color = atom_color(atom.element.atomic_number()).to_string();
let label = if atom.element.atomic_number() == 6
&& atom.charge == 0
&& atom.isotope.is_none()
&& mol.degree(idx) > 0
{
None
} else {
Some(atom.element.symbol().to_string())
};
DepictAtom {
idx,
element: atom.element,
pos,
charge: atom.charge,
label,
color,
}
})
.collect();
let bonds: Vec<DepictBond> = mol
.bonds()
.map(|(bidx, bond)| {
let kind = match bond.order {
BondOrder::Single => DepictBondKind::Single,
BondOrder::Double => DepictBondKind::Double,
BondOrder::Triple => DepictBondKind::Triple,
BondOrder::Aromatic => DepictBondKind::Aromatic,
BondOrder::Up => DepictBondKind::Up,
BondOrder::Down => DepictBondKind::Down,
BondOrder::Quadruple => DepictBondKind::Triple,
BondOrder::Zero
| BondOrder::Dative
| BondOrder::QueryAny
| BondOrder::QuerySingleOrDouble
| BondOrder::QuerySingleOrAromatic
| BondOrder::QueryDoubleOrAromatic => DepictBondKind::Single,
};
DepictBond {
idx: bidx,
atom1: bond.atom1,
atom2: bond.atom2,
kind,
}
})
.collect();
DepictData { atoms, bonds }
}
pub fn depict_svg(mol: &Molecule) -> String {
let layout = compute_layout(mol);
render_svg(mol, &layout)
}
pub fn depict_svg_opts(mol: &Molecule, opts: &RenderOptions) -> String {
let layout = compute_layout(mol);
render_svg_opts(mol, &layout, opts)
}
pub fn depict_svg_highlighted(
mol: &Molecule,
highlight_atoms: &std::collections::HashSet<AtomIdx>,
highlight_bonds: &std::collections::HashSet<BondIdx>,
) -> String {
let layout = compute_layout(mol);
render_svg_highlighted(mol, &layout, highlight_atoms, highlight_bonds)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::BOND_LEN;
use chematic_smiles::parse;
fn mol(smiles: &str) -> Molecule {
parse(smiles).unwrap_or_else(|e| panic!("Failed to parse '{}': {:?}", smiles, e))
}
#[test]
fn test_layout_benzene_six_distinct_coords() {
let m = mol("c1ccccc1");
assert_eq!(m.atom_count(), 6);
let layout = compute_layout(&m);
assert_eq!(layout.coords.len(), 6);
for i in 0..6 {
for j in (i + 1)..6 {
let d = layout.coords[i].dist(&layout.coords[j]);
assert!(
d > BOND_LEN / 2.0,
"Atoms {} and {} are too close: {:.2} < {:.2}",
i,
j,
d,
BOND_LEN / 2.0
);
}
}
}
#[test]
fn test_layout_single_atom() {
let m = mol("[C]");
assert_eq!(m.atom_count(), 1);
let layout = compute_layout(&m);
assert_eq!(layout.coords.len(), 1);
let p = layout.coords[0];
assert!(
(p.x).abs() < 1.0 && (p.y).abs() < 1.0,
"Single atom not near origin: {:?}",
p
);
}
#[test]
fn test_layout_ethane_bond_length() {
let m = mol("CC");
assert_eq!(m.atom_count(), 2);
let layout = compute_layout(&m);
assert_eq!(layout.coords.len(), 2);
let d = layout.coords[0].dist(&layout.coords[1]);
let tolerance = BOND_LEN * 0.01;
assert!(
(d - BOND_LEN).abs() < tolerance,
"Ethane bond distance {:.4} != BOND_LEN {:.4} (±{:.4})",
d,
BOND_LEN,
tolerance
);
}
#[test]
fn test_layout_naphthalene_ten_coords() {
let m = mol("c1ccc2ccccc2c1");
assert_eq!(m.atom_count(), 10);
let layout = compute_layout(&m);
assert_eq!(layout.coords.len(), 10);
for i in 0..10 {
for j in (i + 1)..10 {
let d = layout.coords[i].dist(&layout.coords[j]);
assert!(
d > BOND_LEN / 2.0,
"Naphthalene atoms {} and {} too close: {:.2}",
i,
j,
d
);
}
}
let (min_x, min_y, max_x, max_y) = layout.bounding_box();
assert!(max_x - min_x < 6.0 * BOND_LEN, "Naphthalene too wide");
assert!(max_y - min_y < 6.0 * BOND_LEN, "Naphthalene too tall");
}
#[test]
fn test_layout_disconnected_fragments_no_overlap() {
let m = mol("CC.CC");
assert_eq!(m.atom_count(), 4);
let layout = compute_layout(&m);
for i in 0..2 {
for j in 2..4 {
let d = layout.coords[i].dist(&layout.coords[j]);
assert!(
d >= BOND_LEN,
"Atoms from different fragments too close: atoms {} and {}, dist {:.2}",
i,
j,
d
);
}
}
}
#[test]
fn test_svg_benzene_no_text() {
let m = mol("c1ccccc1");
let layout = compute_layout(&m);
let svg = render_svg(&m, &layout);
assert!(svg.contains("<svg"), "SVG must start with <svg");
assert!(svg.contains("<line"), "SVG must have bond lines");
assert!(
!svg.contains("<text"),
"Benzene SVG must have no atom text labels"
);
}
#[test]
fn test_svg_pyridine_contains_nitrogen_label() {
let m = mol("c1ccncc1");
let layout = compute_layout(&m);
let svg = render_svg(&m, &layout);
assert!(svg.contains("<text"), "Pyridine SVG must have a text label");
assert!(svg.contains('N'), "Pyridine SVG must contain 'N' label");
assert!(
svg.contains("#3050F8"),
"Pyridine N label should be blue (#3050F8)"
);
}
#[test]
fn test_svg_aspirin_non_empty() {
let m = mol("CC(=O)Oc1ccccc1C(=O)O");
let layout = compute_layout(&m);
let svg = render_svg(&m, &layout);
assert!(!svg.is_empty(), "Aspirin SVG must be non-empty");
assert!(
svg.contains("<line"),
"Aspirin SVG must contain line elements"
);
}
#[test]
fn test_svg_double_bond_two_lines() {
let m = mol("C=C");
let layout = compute_layout(&m);
let svg = render_svg(&m, &layout);
let count = svg.matches("<line").count();
assert!(
count >= 2,
"C=C SVG should have >= 2 <line elements, got {}",
count
);
}
#[test]
fn test_depict_svg_caffeine_valid() {
let m = mol("Cn1cnc2c1c(=O)n(c(=O)n2C)C");
let svg = depict_svg(&m);
assert!(svg.starts_with("<svg"), "Caffeine SVG must start with <svg");
assert!(svg.ends_with("</svg>"), "Caffeine SVG must end with </svg>");
}
#[test]
fn test_depict_svg_water_contains_o() {
let m = mol("[OH2]");
let svg = depict_svg(&m);
assert!(svg.contains('O'), "Water SVG must contain 'O'");
}
#[test]
fn test_depict_svg_single_carbon_shows_ch4() {
let m = mol("C");
let svg = depict_svg(&m);
assert!(svg.starts_with("<svg"), "Single C SVG must start with <svg");
assert!(svg.ends_with("</svg>"), "Single C SVG must end with </svg>");
assert!(svg.contains("CH4"), "Single C SVG must show CH4 label");
}
#[test]
fn test_depict_svg_single_oxygen_shows_h2o() {
let m = mol("O");
let svg = depict_svg(&m);
assert!(svg.contains("H2O"), "Single O SVG must show H2O label");
}
#[test]
fn test_svg_highlighted_pyridine() {
use std::collections::HashSet;
let m = mol("c1ccncc1");
let n_idx = m
.atoms()
.find(|(_, a)| a.element.atomic_number() == 7)
.map(|(idx, _)| idx)
.expect("pyridine has no N");
let mut hl_atoms = HashSet::new();
hl_atoms.insert(n_idx);
let svg = depict_svg_highlighted(&m, &hl_atoms, &HashSet::new());
assert!(
svg.contains("circle"),
"highlighted SVG must contain a circle"
);
assert!(svg.contains("FFFF00"), "highlight circle should be yellow");
}
}