#[must_use]
pub fn ranks_of(mol: &omgkit_core::MolBuilder) -> Vec<u32> {
omgkit_io::canon::classed_ranks(mol)
}
pub mod chains;
pub mod geom;
pub mod hydrogens;
pub mod label;
pub mod layout;
pub mod orient;
pub mod palette;
mod palette_data;
mod arcs;
#[cfg(feature = "raster")]
pub mod raster;
pub mod refine;
pub mod render;
pub mod rings;
pub mod stereo;
pub mod style;
pub mod svg;
pub mod templates;
pub mod three;
use std::collections::BTreeMap;
use omgkit_core::MolBuilder;
use geom::Point2;
use rings::Degradation;
use style::Style;
#[derive(Debug, Clone, PartialEq)]
pub struct Depiction {
pub coords: Vec<Point2>,
pub degraded: Vec<Degradation>,
pub unresolved: Vec<(u32, u32)>,
pub crossings: Vec<(u32, u32)>,
pub wedges: Vec<stereo::Wedge>,
pub unwedged: Vec<u32>,
pub misdrawn_stereo: Vec<u32>,
pub style_name: &'static str,
pub style_fingerprint: u64,
pub added: hydrogens::Augmented,
}
impl Depiction {
#[must_use]
pub fn matches(&self, style: &Style) -> bool {
self.style_fingerprint == style.layout_fingerprint()
}
#[must_use]
pub fn drawn<'a>(&self, mol: &'a MolBuilder) -> std::borrow::Cow<'a, MolBuilder> {
if self.added.is_empty() {
std::borrow::Cow::Borrowed(mol)
} else {
std::borrow::Cow::Owned(self.added.apply(mol))
}
}
#[must_use]
pub fn is_clean(&self) -> bool {
self.degraded.is_empty()
&& self.unresolved.is_empty()
&& self.crossings.is_empty()
&& self.unwedged.is_empty()
&& self.misdrawn_stereo.is_empty()
}
}
#[cfg(test)]
pub(crate) fn tests_prep(smi: &str) -> MolBuilder {
let mut m = omgkit_io::smiles::parse(smi).expect("测试用的 SMILES 该能解析");
omgkit_chem::pipeline::sanitize(&mut m).expect("测试用的分子该能净化");
m
}
#[must_use]
pub fn generate(mol: &MolBuilder, style: &Style) -> Depiction {
debug_assert!(
!omgkit_io::stereo::directions_not_perceived(mol),
"这个分子的双键几何**方向键已经写明**、却没有感知过顺反 —— \
漏了 omgkit_io::stereo::perceive_bond_stereo。这样画不会报错,\
但顺反校正整个空转,E/Z 可能画反"
);
generate_with(mol, style, None)
}
pub(crate) fn generate_with(
mol: &MolBuilder,
style: &Style,
over: templates::Override<'_>,
) -> Depiction {
let added = hydrogens::with_stereo_hs(mol).unwrap_or_default();
let grown = (!added.is_empty()).then(|| added.apply(mol));
let mol = grown.as_ref().unwrap_or(mol);
let ranks = ranks_of(mol);
let laid = as_plain_bonds(mol);
let mol = laid.as_ref().unwrap_or(mol);
let hapto = hapto_extras(mol, &ranks);
let thinned = hapto.as_ref().and_then(|(extras, _)| {
let mut copy = MolBuilder::with_capacity(mol.num_atoms(), mol.num_bonds());
for a in mol.atoms() {
copy.add_atom_data(*a);
}
for (bi, b) in mol.bonds().iter().enumerate() {
if !extras.contains(&bi) {
copy.add_bond_data(*b).ok()?;
}
}
omgkit_chem::pipeline::sanitize(&mut copy).ok()?;
Some(copy)
});
let whole = mol;
let mol = thinned.as_ref().unwrap_or(mol);
let mut pieces = layout::layout_all(mol, &ranks, style, over);
pieces.sort_by_key(|p| {
p.pos
.keys()
.map(|a| ranks[*a as usize])
.min()
.unwrap_or(u32::MAX)
});
let mut degraded: Vec<Degradation> = pieces.iter().flat_map(|p| p.degraded.clone()).collect();
if let Some((_, told)) = &hapto {
degraded.extend(told.iter().cloned());
}
let radii = refine::radii(whole, style);
let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
let mut shift = 0.0f64;
for p in &pieces {
let (lo, hi) = extent(p.pos.iter().map(|(a, q)| (*q, radii[*a as usize])));
for (a, q) in &p.pos {
pos.insert(*a, Point2::new(q.x - lo + shift, q.y));
}
shift += hi - lo + PIECE_GAP;
}
let mut flat = vec![Point2::ORIGIN; mol.num_atoms()];
for (a, q) in &pos {
flat[*a as usize] = *q;
}
stereo::fix_cis_trans(whole, &mut flat, &ranks);
for (a, q) in pos.iter_mut() {
*q = flat[*a as usize];
}
let mut report = refine::relieve(mol, &mut pos, &ranks, style);
if thinned.is_some() {
report.crossings = refine::crossings(whole, &pos);
}
let mut coords = vec![Point2::ORIGIN; mol.num_atoms()];
for (a, q) in pos {
coords[a as usize] = q;
}
orient::canonicalise(&mut coords, &ranks);
let w = stereo::assign_wedges(whole, &coords, &ranks);
let misdrawn_stereo = stereo::stereo_mismatches(whole, &coords);
Depiction {
coords,
wedges: w.bonds,
unwedged: w.unwedged,
misdrawn_stereo,
degraded,
unresolved: report.unresolved,
crossings: report.crossings,
style_name: style.name,
style_fingerprint: style.layout_fingerprint(),
added,
}
}
fn contiguous_on_ring(ring: &[u32], picked: &std::collections::BTreeSet<u32>) -> bool {
let n = ring.len();
let k = ring.iter().filter(|a| picked.contains(a)).count();
if k < 2 {
return k == 1;
}
if k == n {
return true; }
let breaks = (0..n)
.filter(|i| picked.contains(&ring[*i]) && !picked.contains(&ring[(i + 1) % n]))
.count();
breaks == 1
}
fn hapto_extras(
mol: &MolBuilder,
ranks: &[u32],
) -> Option<(std::collections::BTreeSet<usize>, Vec<Degradation>)> {
let metals: Vec<u32> = (0..u32::try_from(mol.num_atoms()).ok()?)
.filter(|a| {
omgkit_chem::organometallics::is_metal(mol.atoms()[*a as usize].atomic_num)
&& mol.degree(*a) >= 3
})
.collect();
if metals.is_empty() {
return None;
}
let mut lig = MolBuilder::with_capacity(mol.num_atoms(), mol.num_bonds());
for a in mol.atoms() {
lig.add_atom_data(*a);
}
for b in mol.bonds() {
if !metals.contains(&b.begin) && !metals.contains(&b.end) {
lig.add_bond_data(*b).ok()?;
}
}
omgkit_chem::pipeline::sanitize(&mut lig).ok()?;
let rings = omgkit_chem::sssr::ring_set(&lig);
let mut extras = std::collections::BTreeSet::new();
let mut told = Vec::new();
for m in &metals {
for r in &rings {
let mut into: Vec<(u32, usize)> = mol
.neighbors(*m)
.filter(|(nb, _)| r.atoms.contains(nb))
.map(|(nb, bi)| (ranks[nb as usize], bi as usize))
.collect();
if into.len() < 3 {
continue;
}
let bonded: std::collections::BTreeSet<u32> =
mol.neighbors(*m).map(|(nb, _)| nb).collect();
if !contiguous_on_ring(&r.atoms, &bonded) {
continue;
}
into.sort_unstable();
extras.extend(into.into_iter().skip(1).map(|(_, bi)| bi));
let mut ring: Vec<u32> = r.atoms.clone();
ring.sort_by_key(|a| (ranks[*a as usize], *a));
told.push(Degradation::HaptoCoordination { metal: *m, ring });
}
}
(!extras.is_empty()).then_some((extras, told))
}
fn as_plain_bonds(mol: &MolBuilder) -> Option<MolBuilder> {
if !mol
.bonds()
.iter()
.any(|b| b.order == omgkit_core::BondOrder::Dative)
{
return None;
}
let mut copy = MolBuilder::with_capacity(mol.num_atoms(), mol.num_bonds());
for a in mol.atoms() {
copy.add_atom_data(*a);
}
for b in mol.bonds() {
let mut bd = *b;
if bd.order == omgkit_core::BondOrder::Dative {
bd.order = omgkit_core::BondOrder::Single;
}
copy.add_bond_data(bd).ok()?;
}
omgkit_chem::pipeline::sanitize(&mut copy).ok()?;
Some(copy)
}
const PIECE_GAP: f64 = 0.5;
fn extent(pts: impl Iterator<Item = (Point2, f64)>) -> (f64, f64) {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for (p, r) in pts {
lo = lo.min(p.x - r);
hi = hi.max(p.x + r);
}
if lo.is_finite() {
(lo, hi)
} else {
(0.0, 0.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn prep(smi: &str) -> MolBuilder {
let mut m = omgkit_io::smiles::parse(smi).unwrap();
omgkit_chem::pipeline::sanitize(&mut m).unwrap();
omgkit_io::stereo::perceive_bond_stereo(&mut m);
m
}
fn shape_key(smi: &str, style: &Style) -> Vec<i64> {
let d = generate(&prep(smi), style);
let mut ds: Vec<i64> = (0..d.coords.len())
.flat_map(|i| ((i + 1)..d.coords.len()).map(move |j| (i, j)))
.map(|(i, j)| (d.coords[i].dist(d.coords[j]) * 1e4).round() as i64)
.collect();
ds.sort_unstable();
ds
}
#[test]
fn the_same_molecule_written_differently_gets_the_same_picture() {
let groups = [
vec![
"CC(=O)Oc1ccccc1C(=O)O",
"O=C(C)Oc1ccccc1C(O)=O",
"OC(=O)c1ccccc1OC(C)=O",
],
vec!["CC(C)(C)c1ccccc1", "c1ccccc1C(C)(C)C", "CC(c1ccccc1)(C)C"],
vec!["C1CC2(CC1)CCCC2", "C1CCC2(C1)CCCC2"],
vec!["c1ccc2ccccc2c1", "c1ccc2c(c1)cccc2"],
vec!["CCCCO", "OCCCC"],
];
for style in &Style::ALL {
for ws in &groups {
let keys: Vec<Vec<i64>> = ws.iter().map(|s| shape_key(s, style)).collect();
for (w, k) in ws.iter().zip(&keys).skip(1) {
assert_eq!(&keys[0], k, "[{}] {w} 与 {} 形状不同", style.name, ws[0]);
}
}
}
}
fn scene_key(smi: &str, style: &Style) -> Vec<String> {
let m = prep(smi);
let d = generate(&m, style);
let q = |p: Point2| format!("{:.3},{:.3}", p.x, p.y);
let mut v: Vec<String> = render::scene(&m, &d, style)
.items
.iter()
.map(|it| match it {
render::Primitive::Line { from, to, .. } => {
let (x, y) = (q(*from), q(*to));
if x <= y {
format!("L {x} {y}")
} else {
format!("L {y} {x}")
}
}
render::Primitive::Wedge { from, to, .. } => format!("W {} {}", q(*from), q(*to)),
render::Primitive::Hash { from, to, .. } => format!("H {} {}", q(*from), q(*to)),
render::Primitive::Ball { .. } | render::Primitive::Stick { .. } => {
unreachable!("二维那条路的场景里没有球棍 —— 收到就说明拿错了场景")
}
render::Primitive::Text { at, runs, .. } => format!("T {} {runs:?}", q(*at)),
})
.collect();
v.sort();
v
}
#[test]
fn a_mirror_symmetric_molecule_gets_the_same_wedges_whichever_way_it_is_written() {
let groups = [
vec![
"C(#C)[C@@]1(CC[C@H](C2=CC=CC=C2)CC1)O",
"C1C[C@H](CC[C@@]1(O)C#C)c1ccccc1",
],
vec!["C1CN2CN1CN3CCN(C2)C3", "C1N2CN(CN3CCN(C2)C3)C1"],
vec![
"N1(C(=C(C(=O)OCC)N=N1)CSC2=NC3N(C4C=CC=CC(C(N=N2)=3)=4)C)C5C(=NON=5)N",
"n1c2c3ccccc3n(c2nc(n1)SCc1n(-c2nonc2N)nnc1C(OCC)=O)C",
],
vec![
"O=C1C[N+]23CC[N+]45CC(=O)O[Ni]24(O1)(OC(=O)C3)OC(=O)C5",
"O1C(=O)C[N+]23CC(=O)O[Ni]1143OC(C[N+]1(CC2)CC(O4)=O)=O",
],
vec![
"C1(/C(NC2=C(N1)C=CC=C2)=N\\C3=CC=C(C(=O)OCC)C=C3)=N/C4=CC=C(C(=O)OCC)C=C4",
"c1ccc2c(c1)[nH]c(=N/c1ccc(C(OCC)=O)cc1)/c([nH]2)=N\\c1ccc(cc1)C(=O)OCC",
"c1c(\\N=c2/c([nH]c3ccccc3[nH]2)=N\\c2ccc(cc2)C(=O)OCC)ccc(c1)C(=O)OCC",
],
];
for ws in &groups {
let seqs: Vec<Vec<(u8, u8)>> = ws
.iter()
.map(|s| {
let m = prep(s);
(0..m.num_atoms())
.map(|i| {
let a = m.atoms()[i];
(a.atomic_num, a.num_explicit_hs + a.num_implicit_hs)
})
.collect()
})
.collect();
assert!(
seqs[1..].iter().any(|s| *s != seqs[0]),
"{} 与 {} 的存储序一模一样,这一组验不了写法无关",
ws[0],
ws[1]
);
}
for style in &Style::ALL {
for ws in &groups {
let keys: Vec<Vec<String>> = ws.iter().map(|s| scene_key(s, style)).collect();
for (w, k) in ws.iter().zip(&keys).skip(1) {
assert_eq!(
&keys[0], k,
"[{}] {w} 与 {} 画出来的图元不同",
style.name, ws[0]
);
}
}
}
}
#[test]
fn a_sandwich_complex_gets_two_proper_rings_and_says_it_is_degraded() {
for smi in [
"C12C3=C4C5=C1[Fe]23456789C%10C6=C7C8=C9%10",
"CN(C)C[C-]12C3=C4C5=C1[Fe++]23456789[C-]%10C6=C7C8=C9%10",
] {
let m = prep(smi);
for style in &Style::ALL {
let d = generate(&m, style);
let hapto: Vec<&rings::Degradation> = d
.degraded
.iter()
.filter(|x| matches!(x, rings::Degradation::HaptoCoordination { .. }))
.collect();
assert_eq!(
hapto.len(),
2,
"[{}] {smi}:该报两处 η 配位退化,实得 {}",
style.name,
hapto.len()
);
assert!(
!d.crossings.is_empty(),
"[{}] {smi}:扇出去的 η5 键必然穿过环,交叉不能报 0",
style.name
);
let mut mid = Vec::new();
for h in &hapto {
let rings::Degradation::HaptoCoordination { metal, ring } = h else {
unreachable!("上面已经筛过")
};
assert_eq!(ring.len(), 5, "[{}] {smi}:Cp 该是五元环", style.name);
for w in ring
.windows(2)
.chain(std::iter::once(&[ring[0], ring[ring.len() - 1]][..]))
{
let (p, q) = (d.coords[w[0] as usize], d.coords[w[1] as usize]);
if m.neighbors(w[0]).any(|(x, _)| x == w[1]) {
let len = p.dist(q);
assert!(
(len - 1.0).abs() < 1e-6,
"[{}] {smi}:Cp 环上的键长 {len:.4},该是 1",
style.name
);
}
}
let c = ring
.iter()
.fold(Point2::ORIGIN, |s, a| s + d.coords[*a as usize])
* (1.0 / ring.len() as f64);
mid.push((d.coords[*metal as usize], c));
}
let (fe, c0) = mid[0];
let c1 = mid[1].1;
let (u, v) = ((c0 - fe).normalized(), (c1 - fe).normalized());
assert!(
u.dot(v) < -0.3,
"[{}] {smi}:两个环该分列金属两侧,实得夹角余弦 {:.3}",
style.name,
u.dot(v)
);
}
}
}
#[test]
fn a_macrocyclic_chelate_is_not_mistaken_for_a_sandwich() {
let smi = "[Ni]123N4CCN1CCCN2CCN3CCC4";
let m = prep(smi);
let ranks = ranks_of(&m);
assert!(
hapto_extras(&m, &ranks).is_none(),
"{smi} 是 σ 给体的大环螯合,不是 η 配位,一根键都不该摘"
);
let ni = (0..u32::try_from(m.num_atoms()).unwrap())
.find(|a| m.atoms()[*a as usize].atomic_num == 28)
.expect("该有一个镍");
assert_eq!(m.degree(ni), 4, "镍该是四配位");
for style in &Style::ALL {
let d = generate(&m, style);
assert!(
d.crossings.is_empty(),
"[{}] {smi} 该画得出 0 交叉,实得 {} 处",
style.name,
d.crossings.len()
);
}
}
#[test]
fn contiguity_is_what_separates_a_sandwich_from_a_chelate() {
let s = |v: &[u32]| {
v.iter()
.copied()
.collect::<std::collections::BTreeSet<u32>>()
};
let ring: Vec<u32> = (0..6).collect();
assert!(contiguous_on_ring(&ring, &s(&[0, 1, 2])), "连续的三个");
assert!(
contiguous_on_ring(&ring, &s(&[4, 5, 0])),
"跨过接头也算连续"
);
assert!(contiguous_on_ring(&ring, &s(&[0, 1, 2, 3, 4, 5])), "整圈");
assert!(
!contiguous_on_ring(&ring, &s(&[0, 2, 4])),
"隔一个的三个不算"
);
assert!(!contiguous_on_ring(&ring, &s(&[0, 1, 3])), "两段不算");
let cp: Vec<u32> = (0..5).collect();
assert!(contiguous_on_ring(&cp, &s(&[0, 1, 2, 3, 4])));
}
#[test]
fn a_chelate_ring_is_left_alone_because_it_only_exists_through_the_metal() {
for smi in [
"C1CN[Co]23(N1)(NCCN2)NCCN3",
"[O-]S([O-])(=O)=O.C1CN[Cr+3]23(N1)(NCCN2)NCCN3",
"CC1=[O+][Co]23([O+]=C(C)C1)([O+]=C(C)CC(=[O+]2)C)[O+]=C(C)CC(=[O+]3)C",
] {
let m = prep(smi);
let ranks = ranks_of(&m);
assert!(
hapto_extras(&m, &ranks).is_none(),
"{smi} 里没有 η 配位,不该摘任何键"
);
assert!(
(0..u32::try_from(m.num_atoms()).unwrap())
.any(|a| m.atoms()[a as usize].atomic_num > 20 && m.degree(a) >= 4),
"{smi} 里该有一个多配位的金属,不然这条判据说明不了问题"
);
}
}
#[test]
fn which_hapto_bond_survives_does_not_depend_on_how_it_was_written() {
for base in [
"C12C3=C4C5=C1[Fe]23456789C%10C6=C7C8=C9%10",
"CN(C)C[C-]12C3=C4C5=C1[Fe++]23456789[C-]%10C6=C7C8=C9%10",
] {
let m = prep(base);
let other = omgkit_io::canon::canonical_smiles(&m).smiles;
let seq = |s: &str| -> Vec<(u32, u32)> {
prep(s).bonds().iter().map(|b| (b.begin, b.end)).collect()
};
assert_ne!(
seq(base),
seq(&other),
"{base} 与它的规范式存储序一样,这条判据验不了东西"
);
for style in &Style::ALL {
assert_eq!(
scene_key(base, style),
scene_key(&other, style),
"[{}] {base}\n 写成 {other} 之后画出来的图元不同",
style.name
);
}
}
}
#[test]
fn no_two_atoms_land_on_the_same_spot() {
for smi in [
"CC(=O)Oc1ccccc1C(=O)O",
"OC(=O)c1ccccc1OC(C)=O",
"c1ccc2ccccc2c1",
"CC(C)(C)c1ccccc1",
"CCCCCCCC",
"C1CC2(CC1)CCCC2",
"[Na+].[Cl-]",
"CN1C=NC2=C1C(=O)N(C)C(=O)N2C",
] {
let d = generate(&prep(smi), &Style::ACS_1996);
for i in 0..d.coords.len() {
for j in (i + 1)..d.coords.len() {
let dist = d.coords[i].dist(d.coords[j]);
assert!(dist > 0.3, "{smi}:原子 {i} 与 {j} 距离只有 {dist:.4}");
}
}
}
}
#[test]
fn every_bond_keeps_its_unit_length() {
for smi in [
"CC(=O)Oc1ccccc1C(=O)O",
"c1ccc2ccccc2c1",
"CCCCCCCC",
"CN1C=NC2=C1C(=O)N(C)C(=O)N2C",
] {
let m = prep(smi);
let d = generate(&m, &Style::ACS_1996);
for b in m.bonds() {
let len = d.coords[b.begin as usize].dist(d.coords[b.end as usize]);
assert!(
(len - 1.0).abs() < 1e-9,
"{smi} 键 {}–{} 长 {len}",
b.begin,
b.end
);
}
}
}
#[test]
fn disconnected_components_do_not_sit_on_top_of_each_other() {
let m = prep("[Na+].[Cl-]");
let d = generate(&m, &Style::ACS_1996);
assert!(d.coords[0].dist(d.coords[1]) > 1.0, "两个离子挨得太近");
}
#[test]
fn a_depiction_knows_which_style_made_it() {
let d = generate(&prep("CCO"), &Style::ACS_1996);
assert!(d.matches(&Style::ACS_1996));
assert!(!d.matches(&Style::CHEMDRAW_DEFAULT), "换了规范却认为匹配");
assert_eq!(d.style_name, "ACS Document 1996");
let mut only_render = Style::ACS_1996;
only_render.line_width_pt = 3.0;
assert!(d.matches(&only_render), "改线宽不该让已有坐标失效");
}
#[test]
fn trouble_is_reported_rather_than_hidden() {
let d = generate(&prep("C1CC2CCC1CC2"), &Style::ACS_1996);
assert!(!d.degraded.is_empty(), "桥环应当记进 degraded");
assert!(!d.is_clean());
let ok = generate(&prep("CCO"), &Style::ACS_1996);
assert!(ok.is_clean(), "乙醇不该有任何问题:{ok:?}");
}
}