use omgkit_core::{BondData, BondFlags, BondOrder, BondStereo, ChiralTag, MolBuilder};
pub use omgkit_io::wedge::Wedge;
#[must_use]
pub fn read_chirality(
mol: &MolBuilder,
coords: &[Point2],
wedges: &[Wedge],
a: u32,
) -> Option<ChiralTag> {
let xyz: Vec<[f64; 3]> = coords.iter().map(|p| [p.x, p.y, 0.0]).collect();
omgkit_io::wedge::chirality_from_wedges(mol, &xyz, wedges, a)
}
use crate::geom::Point2;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Wedges {
pub bonds: Vec<Wedge>,
pub unwedged: Vec<u32>,
}
#[must_use]
pub fn assign_wedges(mol: &MolBuilder, coords: &[Point2], ranks: &[u32]) -> Wedges {
let ring_first = assign_with(mol, coords, ranks, Taboo::RingWorst);
let shared_first = assign_with(mol, coords, ranks, Taboo::SharedWorst);
let on_ring = |w: &Wedges| {
w.bonds
.iter()
.enumerate()
.filter(|(bi, x)| {
x.narrow().is_some() && mol.bonds()[*bi].flags.contains(BondFlags::IN_RING)
})
.count()
};
let key = |w: &Wedges| (w.unwedged.len(), on_ring(w));
if key(&shared_first) < key(&ring_first) {
shared_first
} else {
ring_first
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Taboo {
RingWorst,
SharedWorst,
}
fn assign_with(mol: &MolBuilder, coords: &[Point2], ranks: &[u32], taboo: Taboo) -> Wedges {
let mut out = Wedges {
bonds: vec![Wedge::None; mol.num_bonds()],
unwedged: Vec::new(),
};
let mut used: Vec<bool> = vec![false; mol.num_bonds()];
let mut picked: Vec<(u32, ChiralTag)> = Vec::new();
let genuine = omgkit_io::stereo::genuine_tetrahedral(mol);
let mut centres: Vec<u32> = (0..mol.num_atoms())
.map(|i| u32::try_from(i).expect("原子数超出 u32"))
.filter(|a| {
genuine[*a as usize]
&& matches!(
mol.atoms()[*a as usize].chiral_tag,
ChiralTag::Cw | ChiralTag::Ccw
)
})
.collect();
centres.sort_by_key(|a| (ranks[*a as usize], *a));
out.unwedged.extend(
(0..u32::try_from(mol.num_atoms()).expect("原子数超出 u32")).filter(|a| {
let tag = mol.atoms()[*a as usize].chiral_tag;
tag != ChiralTag::Unspecified && !tag.is_tetrahedral()
}),
);
for a in centres {
let want = mol.atoms()[a as usize].chiral_tag;
let mut done = false;
for bond in candidate_bonds(mol, a, &used, ranks, taboo) {
for w in [Wedge::Up { narrow: a }, Wedge::Down { narrow: a }] {
out.bonds[bond as usize] = w;
if read_chirality(mol, coords, &out.bonds, a) == Some(want) {
used[bond as usize] = true;
done = true;
break;
}
}
if done {
break;
}
out.bonds[bond as usize] = Wedge::None; }
if !done {
out.unwedged.push(a);
} else {
picked.push((a, want));
}
}
while let Some(k) = picked
.iter()
.position(|(a, want)| read_chirality(mol, coords, &out.bonds, *a) != Some(*want))
{
let (a, _) = picked.remove(k);
for (_, bi) in mol.neighbors(a) {
if out.bonds[bi as usize].narrow() == Some(a) {
out.bonds[bi as usize] = Wedge::None;
}
}
out.unwedged.push(a);
}
out.unwedged.sort_unstable();
out
}
fn candidate_bonds(
mol: &MolBuilder,
a: u32,
used: &[bool],
ranks: &[u32],
taboo: Taboo,
) -> Vec<u32> {
let mut cands: Vec<(u8, u8, u8, u32, u32, u32)> = mol
.neighbors(a)
.filter(|(_, bi)| !used[*bi as usize])
.filter(|(_, bi)| mol.bonds()[*bi as usize].order == BondOrder::Single)
.map(|(n, bi)| {
let in_ring = mol.bonds()[bi as usize].flags.contains(BondFlags::IN_RING);
let other_is_centre = matches!(
mol.atoms()[n as usize].chiral_tag,
ChiralTag::Cw | ChiralTag::Ccw
);
let (first, second) = match taboo {
Taboo::RingWorst => (in_ring, other_is_centre),
Taboo::SharedWorst => (other_is_centre, in_ring),
};
(
u8::from(first),
u8::from(second),
u8::from(mol.degree(n) > 1),
ranks[n as usize],
n,
bi,
)
})
.collect();
cands.sort_unstable();
cands.into_iter().map(|c| c.5).collect()
}
#[must_use]
pub fn read_bond_stereo(mol: &MolBuilder, coords: &[Point2], bond: u32) -> Option<BondStereo> {
let b = &mol.bonds()[bond as usize];
let [ra, rb] = b.stereo_atoms;
if ra == BondData::NO_STEREO_ATOM || rb == BondData::NO_STEREO_ATOM {
return None;
}
let p = |a: u32| {
let q = coords[a as usize];
[q.x, q.y]
};
omgkit_io::stereo::cis_trans_from_points(p(b.begin), p(b.end), p(ra), p(rb))
}
pub(crate) fn fix_cis_trans(mol: &MolBuilder, coords: &mut [Point2], ranks: &[u32]) -> Vec<u32> {
let mut fixed = Vec::new();
let mut todo: Vec<u32> = (0..u32::try_from(mol.num_bonds()).expect("键数超出 u32"))
.filter(|bi| {
matches!(
mol.bonds()[*bi as usize].stereo,
BondStereo::Cis | BondStereo::Trans
)
})
.collect();
todo.sort_by_key(|bi| {
let b = &mol.bonds()[*bi as usize];
let (x, y) = (ranks[b.begin as usize], ranks[b.end as usize]);
(x.min(y), x.max(y), *bi)
});
for bi in todo {
let want = mol.bonds()[bi as usize].stereo;
if read_bond_stereo(mol, coords, bi) == Some(want) {
continue;
}
let b = &mol.bonds()[bi as usize];
let low = |s: &Vec<u32>| {
s.iter()
.map(|a| ranks[*a as usize])
.min()
.unwrap_or(u32::MAX)
};
let side = match (subtree(mol, bi, b.begin), subtree(mol, bi, b.end)) {
(Some(x), Some(y)) => {
if low(&x) > low(&y) {
x
} else {
y
}
}
(Some(x), None) => x,
(None, Some(y)) => y,
(None, None) => continue,
};
let (pa, pb) = (coords[b.begin as usize], coords[b.end as usize]);
for a in &side {
coords[*a as usize] = coords[*a as usize].mirrored(pa, pb - pa);
}
if read_bond_stereo(mol, coords, bi) == Some(want) {
fixed.push(bi);
} else {
for a in &side {
coords[*a as usize] = coords[*a as usize].mirrored(pa, pb - pa);
}
}
}
fixed
}
pub(crate) fn stereo_mismatches(mol: &MolBuilder, coords: &[Point2]) -> Vec<u32> {
(0..u32::try_from(mol.num_bonds()).expect("键数超出 u32"))
.filter(|&bi| {
let want = mol.bonds()[bi as usize].stereo;
matches!(want, BondStereo::Cis | BondStereo::Trans)
&& read_bond_stereo(mol, coords, bi) != Some(want)
})
.collect()
}
fn subtree(mol: &MolBuilder, bond: u32, start: u32) -> Option<Vec<u32>> {
let b = &mol.bonds()[bond as usize];
let blocked = if start == b.end { b.begin } else { b.end };
let mut seen = std::collections::BTreeSet::from([start]);
let mut stack = vec![start];
while let Some(a) = stack.pop() {
for (n, bi) in mol.neighbors(a) {
if bi == bond {
continue;
}
if n == blocked {
return None;
}
if seen.insert(n) {
stack.push(n);
}
}
}
let mut out: Vec<u32> = seen.into_iter().collect();
out.sort_unstable();
Some(out)
}
#[must_use]
pub fn cis_trans_intact(mol: &MolBuilder, coords: &[Point2]) -> bool {
(0..u32::try_from(mol.num_bonds()).expect("键数超出 u32")).all(|bi| {
let want = mol.bonds()[bi as usize].stereo;
!matches!(want, BondStereo::Cis | BondStereo::Trans)
|| read_bond_stereo(mol, coords, bi) == Some(want)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{generate, style::Style};
fn prep(smi: &str) -> MolBuilder {
let mut m = omgkit_io::smiles::parse(smi).unwrap();
omgkit_chem::pipeline::sanitize(&mut m).unwrap();
m
}
#[test]
fn a_wedge_pointing_the_other_way_reads_as_the_opposite_configuration() {
let m = prep("N[C@@H](C)O");
let d = generate(&m, &Style::ACS_1996);
let a = 1u32;
assert!(
matches!(
m.atoms()[a as usize].chiral_tag,
ChiralTag::Cw | ChiralTag::Ccw
),
"原子 {a} 该是立体中心"
);
let (n, bi) = m.neighbors(a).next().expect("立体中心总有邻居");
let mut ws = vec![Wedge::None; m.num_bonds()];
ws[bi as usize] = Wedge::Up { narrow: a };
let from_centre = read_chirality(&m, &d.coords, &ws, a);
ws[bi as usize] = Wedge::Up { narrow: n };
let from_other = read_chirality(&m, &d.coords, &ws, a);
assert!(from_centre.is_some(), "窄端在中心这头该读得出构型");
assert!(from_other.is_some(), "窄端在邻居那头也该读得出构型");
assert_ne!(
from_centre, from_other,
"同一根实楔形,窄端换到另一头,读出来的构型却没变"
);
}
#[test]
fn a_centre_left_wedged_still_reads_back_after_all_the_others_are_placed() {
for smi in [
"C[C@@H]1[C@H]2[C@H]3C[C@@H](O1)O[C@@H]2OC=C3C(=O)OC",
"OC[C@H](O)[C@H]1OC(=O)C(O)=C1O",
"OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](O)[C@@H]1O",
"CC(C)CCC[C@@H](C)[C@H]1CC[C@H]2[C@@H]3CC=C4C[C@@H](O)CC[C@]4(C)[C@H]3CC[C@]12C",
] {
for style in &Style::ALL {
let m = prep(smi);
let d = generate(&m, style);
let m = d.drawn(&m);
let mut checked = 0;
for (i, a) in m.atoms().iter().enumerate() {
let at = u32::try_from(i).expect("原子数超出 u32");
if !matches!(a.chiral_tag, ChiralTag::Cw | ChiralTag::Ccw)
|| d.unwedged.contains(&at)
{
continue;
}
assert_eq!(
read_chirality(&m, &d.coords, &d.wedges, at),
Some(a.chiral_tag),
"[{}] {smi}:中心 {at} 画出来了,反读却不是它该有的构型",
style.name
);
checked += 1;
}
assert!(
checked >= 2,
"[{}] {smi}:只查到 {checked} 个中心",
style.name
);
}
}
}
#[test]
fn a_centre_with_four_drawn_neighbours_reads_even_when_the_wedges_cancel() {
let m = prep("O[C@](N)(F)Cl");
let d = generate(&m, &Style::ACS_1996);
let a = 1u32;
let bs: Vec<u32> = m.neighbors(a).map(|(_, bi)| bi).collect();
assert_eq!(bs.len(), 4, "这个中心该有四个画得出来的邻居");
let mut ws = vec![Wedge::None; m.num_bonds()];
ws[bs[0] as usize] = Wedge::Up { narrow: a };
let one = read_chirality(&m, &d.coords, &ws, a);
assert!(one.is_some(), "一个楔形就该读得出来");
ws[bs[1] as usize] = Wedge::Down { narrow: a };
assert!(
read_chirality(&m, &d.coords, &ws, a).is_some(),
"一实一虚两个楔形,z 之和为零,却被判成读不出来"
);
}
#[test]
fn every_stereocentre_is_either_drawn_or_reported() {
for smi in [
"OC[C@H](O)[C@H]1OC(=O)C(O)=C1O", "C[C@H](N)C(=O)O", "OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](O)[C@@H]1O", "CC1(C)S[C@@H]2[C@H](NC(=O)Cc3ccccc3)C(=O)N2[C@H]1C(=O)O", "CN1CCC[C@H]1c1cccnc1", ] {
for style in &Style::ALL {
let m = prep(smi);
let d = generate(&m, style);
let m = d.drawn(&m);
let centres = m
.atoms()
.iter()
.filter(|a| matches!(a.chiral_tag, ChiralTag::Cw | ChiralTag::Ccw))
.count();
let drawn = d.wedges.iter().filter(|w| **w != Wedge::None).count();
assert_eq!(
centres,
drawn + d.unwedged.len(),
"[{}] {smi}:{centres} 个立体中心,画了 {drawn} 个,报了 {} 个画不了",
style.name,
d.unwedged.len()
);
assert!(
d.unwedged.is_empty(),
"[{}] {smi}:立体中心 {:?} 没画出构型",
style.name,
d.unwedged
);
}
}
}
#[test]
fn the_reference_tetrahedron_pins_the_sign() {
let pts = [
[0.0, 0.0, 1.0],
[1.0, 0.0, -0.33],
[-0.5, 0.866, -0.33],
[-0.5, -0.866, -0.33],
];
let d = |i: usize, j: usize| pts[i][j] - pts[0][j];
let det = d(1, 0) * (d(2, 1) * d(3, 2) - d(2, 2) * d(3, 1))
- d(1, 1) * (d(2, 0) * d(3, 2) - d(2, 2) * d(3, 0))
+ d(1, 2) * (d(2, 0) * d(3, 1) - d(2, 1) * d(3, 0));
assert!(det < 0.0, "参照构型的有向体积应当为负,实得 {det}");
}
fn cramped(angles_deg: [f64; 3]) -> (MolBuilder, Vec<Point2>, Vec<Wedge>) {
let mut m = MolBuilder::new();
let c = m.add_atom(6);
let mut coords = vec![Point2::ORIGIN];
let mut wedges = Vec::new();
for (i, a) in angles_deg.iter().enumerate() {
let n = m.add_atom(if i == 0 {
8
} else if i == 1 {
7
} else {
9
});
m.add_bond(c, n, BondOrder::Single).unwrap();
let r = a.to_radians();
coords.push(Point2::new(r.cos(), r.sin()));
wedges.push(if i == 0 {
Wedge::Up { narrow: c }
} else {
Wedge::None
});
}
if let Some(at) = m.atom_mut(c) {
at.num_implicit_hs = 1;
at.chiral_tag = ChiralTag::Cw;
}
(m, coords, wedges)
}
#[test]
fn a_cramped_stereocentre_is_not_read_as_if_the_h_sat_on_the_centre() {
let angles = [0.0, 30.0, 240.0];
let (m, coords, wedges) = cramped(angles);
let got = read_chirality(&m, &coords, &wedges, 0).expect("这个几何读得出来");
let z = 1.0; let p: Vec<[f64; 3]> = (1..4)
.map(|i| {
let q = coords[i];
[q.x, q.y, if i == 1 { z } else { 0.0 }]
})
.collect();
let old = [p[0], [0.0, 0.0, -z], p[1], p[2]];
let d = |i: usize, j: usize| old[i][j] - old[0][j];
let det = d(1, 0) * (d(2, 1) * d(3, 2) - d(2, 2) * d(3, 1))
- d(1, 1) * (d(2, 0) * d(3, 2) - d(2, 2) * d(3, 0))
+ d(1, 2) * (d(2, 0) * d(3, 1) - d(2, 1) * d(3, 0));
let old_tag = if det < 0.0 {
ChiralTag::Ccw
} else {
ChiralTag::Cw
};
assert_ne!(
got, old_tag,
"把氢摆在中心与摆进空扇区读出了同一个构型 —— 这个几何没有区分力,\
判据是空过的,换一组角度"
);
}
#[test]
fn two_nearly_collinear_bonds_cannot_pin_a_configuration() {
let (m, coords, wedges) = cramped([90.0, 0.1, 180.0]); assert_eq!(
read_chirality(&m, &coords, &wedges, 0),
None,
"两根键几乎共线,这张图定不出手性,该返回 None"
);
let (m2, c2, w2) = cramped([90.0, 20.0, 180.0]);
assert!(
read_chirality(&m2, &c2, &w2, 0).is_some(),
"张开之后应当读得出来"
);
}
#[test]
fn a_lone_pair_counts_as_the_fourth_ligand() {
for smi in ["C[S@@](=O)CC", "C[S@](=O)CC"] {
let m = prep(smi);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let d = generate(&m, &Style::ACS_1996);
let w = assign_wedges(&m, &d.coords, &ranks);
assert!(
w.unwedged.is_empty(),
"{smi}:亚砜的硫没画出构型 {:?}",
w.unwedged
);
}
let wedge_of = |smi: &str| {
let m = prep(smi);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let d = generate(&m, &Style::ACS_1996);
let w = assign_wedges(&m, &d.coords, &ranks);
w.bonds
.iter()
.find_map(|x| match x {
Wedge::Up { .. } => Some(true),
Wedge::Down { .. } => Some(false),
Wedge::None => None,
})
.expect("该有一个楔形")
};
assert_ne!(
wedge_of("C[S@@](=O)CC"),
wedge_of("C[S@](=O)CC"),
"亚砜的一对对映体拿到了同一个楔形 —— 反读没有区分力"
);
}
#[test]
fn a_three_coordinate_nitrogen_is_not_given_a_wedge() {
let m = prep("C[N@](CC)CCC");
let ranks = omgkit_io::canon::canonical_ranks(&m);
let d = generate(&m, &Style::ACS_1996);
let w = assign_wedges(&m, &d.coords, &ranks);
assert!(
w.bonds.iter().all(|x| x.narrow().is_none()),
"给三配位氮画了楔形 —— 那个构型常温下不存在"
);
}
#[test]
fn when_the_two_taboos_conflict_the_better_of_both_orders_wins() {
for (smi, want_ring) in [
("CC1(OC[C@@H](O1)[C@@H]2CC23SCCCS3)C", 1usize),
("COCCCNC1=C(C(=O)N[C@@H](S1)[C@@H]2CCC=CC2)C#N", 2),
] {
let m = prep(smi);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let d = generate(&m, &Style::ACS_1996);
let w = assign_wedges(&m, &d.coords, &ranks);
assert!(
w.unwedged.is_empty(),
"{smi}:有中心没画出构型 {:?} —— 丢信息换环上楔形是亏的",
w.unwedged
);
let on_ring = w
.bonds
.iter()
.enumerate()
.filter(|(bi, x)| {
x.narrow().is_some() && m.bonds()[*bi].flags.contains(BondFlags::IN_RING)
})
.count();
assert_eq!(on_ring, want_ring, "{smi}:落在环键上的楔形数不对");
}
}
#[test]
fn a_stereocentre_gets_a_wedge() {
for smi in [
"N[C@@H](C)O",
"N[C@H](C)O",
"C[C@H](N)C(=O)O",
"CN1CCC[C@H]1c1cccnc1",
] {
let m = prep(smi);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let d = generate(&m, &Style::ACS_1996);
let w = assign_wedges(&m, &d.coords, &ranks);
assert!(
w.unwedged.is_empty(),
"{smi} 有立体中心没画出构型:{:?}",
w.unwedged
);
assert!(
w.bonds.iter().any(|x| *x != Wedge::None),
"{smi} 一根楔形键都没画"
);
}
}
#[test]
fn a_molecule_and_its_mirror_image_get_opposite_wedges() {
for (l, r) in [
("N[C@@H](C)O", "N[C@H](C)O"),
("C[C@H](N)C(=O)O", "C[C@@H](N)C(=O)O"),
("O[C@@H](Cl)Br", "O[C@H](Cl)Br"),
] {
let (ml, mr) = (prep(l), prep(r));
let rl = omgkit_io::canon::canonical_ranks(&ml);
let rr = omgkit_io::canon::canonical_ranks(&mr);
let dl = generate(&ml, &Style::ACS_1996);
let dr = generate(&mr, &Style::ACS_1996);
let wl = assign_wedges(&ml, &dl.coords, &rl);
let wr = assign_wedges(&mr, &dr.coords, &rr);
assert_eq!(dl.coords.len(), dr.coords.len());
for (a, b) in dl.coords.iter().zip(&dr.coords) {
assert!(a.dist(*b) < 1e-9, "{l} 与 {r} 的坐标不一致");
}
assert_ne!(
wl.bonds, wr.bonds,
"{l} 与 {r} 拿到了同一套楔形 —— 图上分不出对映体"
);
}
}
#[test]
fn reading_back_gives_the_recorded_configuration() {
for smi in ["N[C@@H](C)O", "C[C@H](N)C(=O)O", "CN1CCC[C@H]1c1cccnc1"] {
let m = prep(smi);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let d = generate(&m, &Style::ACS_1996);
let w = assign_wedges(&m, &d.coords, &ranks);
for a in 0..u32::try_from(m.num_atoms()).unwrap() {
let tag = m.atoms()[a as usize].chiral_tag;
if matches!(tag, ChiralTag::Cw | ChiralTag::Ccw) {
assert_eq!(
read_chirality(&m, &d.coords, &w.bonds, a),
Some(tag),
"{smi} 的原子 {a} 反读回来不是原构型"
);
}
}
}
}
#[test]
fn a_centre_with_no_wedge_reads_as_unknown_rather_than_guessing() {
let m = prep("N[C@@H](C)O");
let d = generate(&m, &Style::ACS_1996);
let none = vec![Wedge::None; m.num_bonds()];
let centre = (0..u32::try_from(m.num_atoms()).unwrap())
.find(|a| {
matches!(
m.atoms()[*a as usize].chiral_tag,
ChiralTag::Cw | ChiralTag::Ccw
)
})
.expect("有立体中心");
assert_eq!(read_chirality(&m, &d.coords, &none, centre), None);
}
#[test]
fn cis_and_trans_are_read_back_from_the_geometry() {
for smi in ["C/C=C/C", r"C/C=C\C", "C/C=C/CC", r"CC/C=C\C"] {
let mut m = prep(smi);
omgkit_io::stereo::perceive_bond_stereo(&mut m);
let m = m;
let d = generate(&m, &Style::ACS_1996);
let mut checked = 0;
for b in 0..u32::try_from(m.num_bonds()).unwrap() {
let want = m.bonds()[b as usize].stereo;
if !matches!(want, BondStereo::Cis | BondStereo::Trans) {
continue;
}
checked += 1;
let got = read_bond_stereo(&m, &d.coords, b);
assert_eq!(got, Some(want), "{smi} 的键 {b} 几何与记录不符");
}
assert!(
checked > 0,
"{smi} 一根带顺反的双键都没查到 —— 这一档在空过"
);
assert!(
d.misdrawn_stereo.is_empty(),
"{smi} 明明画对了,却被报成画错"
);
}
}
#[test]
fn a_ring_double_bond_it_cannot_draw_is_reported_not_hidden() {
let mut checked = 0;
for n in 8..=14usize {
let smi = format!("C/1{}\\C=C1", "C".repeat(n - 3));
let Ok(mut m) = omgkit_io::smiles::parse(&smi) else {
continue;
};
if omgkit_chem::pipeline::sanitize(&mut m).is_err() {
continue;
}
omgkit_io::stereo::perceive_bond_stereo(&mut m);
let want: Vec<u32> = (0..u32::try_from(m.num_bonds()).unwrap())
.filter(|&b| {
matches!(
m.bonds()[b as usize].stereo,
BondStereo::Cis | BondStereo::Trans
)
})
.collect();
if want.is_empty() {
continue;
}
let d = generate(&m, &Style::ACS_1996);
for b in want {
checked += 1;
if read_bond_stereo(&m, &d.coords, b) != Some(m.bonds()[b as usize].stereo) {
assert!(
d.misdrawn_stereo.contains(&b),
"{smi}: 键 {b} 画反了,而诊断里没有它"
);
assert!(!d.is_clean(), "{smi}: 画反了还报 is_clean");
}
}
}
assert!(checked > 0, "一根环内的顺反键都没查到 —— 这一档在空过");
}
#[test]
fn a_coordination_geometry_it_cannot_draw_goes_into_unwedged() {
let mut checked = 0;
for smi in [
"F[Pt@SP1](Cl)(Br)I",
"S[As@TB1](F)(Cl)(Br)N",
"O[Co@OH1](Cl)(C)(N)(F)P",
] {
let Ok(mut m) = omgkit_io::smiles::parse(smi) else {
continue;
};
if omgkit_chem::pipeline::sanitize(&mut m).is_err() {
continue;
}
let centres: Vec<u32> = (0..u32::try_from(m.num_atoms()).unwrap())
.filter(|a| {
let t = m.atoms()[*a as usize].chiral_tag;
t != ChiralTag::Unspecified && !t.is_tetrahedral()
})
.collect();
if centres.is_empty() {
continue;
}
let d = generate(&m, &Style::ACS_1996);
for a in centres {
checked += 1;
assert!(
d.unwedged.contains(&a),
"{smi}: 原子 {a} 的配位构型没画出来,诊断里也没有它"
);
}
assert!(!d.is_clean(), "{smi}: 构型丢了还报 is_clean");
}
assert!(checked > 0, "一个配位中心都没查到 —— 这一档在空过");
}
}