use std::cmp::Ordering;
use chematic_core::{AtomIdx, BondIdx, BondOrder, CipCode, Molecule};
use crate::coords::{Coords3D, Point3};
#[derive(Debug, Default)]
pub struct StereoAssignment3D {
pub assignments: Vec<(AtomIdx, CipCode)>,
}
impl StereoAssignment3D {
pub fn get(&self, idx: AtomIdx) -> Option<CipCode> {
self.assignments
.iter()
.find(|(i, _)| *i == idx)
.map(|(_, c)| *c)
}
}
fn priority_key(mol: &Molecule, center: AtomIdx, sub: AtomIdx) -> (u8, Vec<u8>) {
let an = mol.atom(sub).element.atomic_number();
let mut nbs: Vec<u8> = mol
.neighbors(sub)
.filter(|(nb, _)| *nb != center)
.map(|(nb, _)| mol.atom(nb).element.atomic_number())
.collect();
nbs.sort_unstable_by(|a, b| b.cmp(a)); (an, nbs)
}
fn cmp_priority(mol: &Molecule, center: AtomIdx, a: AtomIdx, b: AtomIdx) -> Ordering {
let ka = priority_key(mol, center, a);
let kb = priority_key(mol, center, b);
match ka.0.cmp(&kb.0) {
Ordering::Equal => {
let max_len = ka.1.len().max(kb.1.len());
for i in 0..max_len {
let va = ka.1.get(i).copied().unwrap_or(0);
let vb = kb.1.get(i).copied().unwrap_or(0);
match va.cmp(&vb) {
Ordering::Equal => {}
other => return other,
}
}
Ordering::Equal
}
other => other,
}
}
fn rank4(mol: &Molecule, center: AtomIdx, subs: &[AtomIdx; 4]) -> Option<[u8; 4]> {
let mut order: [usize; 4] = [0, 1, 2, 3];
order.sort_by(|&i, &j| cmp_priority(mol, center, subs[i], subs[j]).reverse());
for k in 0..3 {
if cmp_priority(mol, center, subs[order[k]], subs[order[k + 1]]) == Ordering::Equal {
return None;
}
}
let mut ranks = [0u8; 4];
for (rank_from_top, &idx) in order.iter().enumerate() {
ranks[idx] = (4 - rank_from_top) as u8;
}
Some(ranks)
}
fn signed_volume(p1: Point3, p2: Point3, p3: Point3, p4: Point3) -> f64 {
let ax = p1.x - p4.x;
let ay = p1.y - p4.y;
let az = p1.z - p4.z;
let bx = p2.x - p4.x;
let by = p2.y - p4.y;
let bz = p2.z - p4.z;
let cx = p3.x - p4.x;
let cy = p3.y - p4.y;
let cz = p3.z - p4.z;
ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx)
}
fn assign_rs(mol: &Molecule, coords: &Coords3D, idx: AtomIdx) -> Option<CipCode> {
let nbs: Vec<AtomIdx> = mol.neighbors(idx).map(|(nb, _)| nb).collect();
if nbs.len() != 4 {
return None;
}
let subs: [AtomIdx; 4] = [nbs[0], nbs[1], nbs[2], nbs[3]];
let ranks = rank4(mol, idx, &subs)?;
let mut order: [usize; 4] = [0, 1, 2, 3];
order.sort_by(|&i, &j| ranks[j].cmp(&ranks[i])); let s: [Point3; 4] = [
coords.get(subs[order[0]]),
coords.get(subs[order[1]]),
coords.get(subs[order[2]]),
coords.get(subs[order[3]]),
];
let v = signed_volume(s[0], s[1], s[2], s[3]);
if v.abs() < 1e-6 {
return None; }
Some(if v > 0.0 { CipCode::S } else { CipCode::R })
}
fn dihedral(pa1: Point3, pa2: Point3, ph1: Point3, ph2: Point3) -> Option<f64> {
let b1 = ph1.sub(&pa1);
let b2 = pa2.sub(&pa1);
let b3 = ph2.sub(&pa2);
let n1 = b1.cross(&b2);
let n2 = b3.cross(&b2);
let d1 = n1.norm();
let d2 = n2.norm();
if d1 < 1e-10 || d2 < 1e-10 {
return None;
}
let cos_a = n1.dot(&n2) / (d1 * d2);
let angle = cos_a.clamp(-1.0, 1.0).acos();
let sign = n1.cross(&n2).dot(&b2);
Some(if sign < 0.0 { -angle } else { angle })
}
fn assign_ez(mol: &Molecule, coords: &Coords3D, bond_idx: BondIdx) -> Option<(AtomIdx, CipCode)> {
let bond = mol.bond(bond_idx);
if bond.order != BondOrder::Double {
return None;
}
let a1 = bond.atom1;
let a2 = bond.atom2;
let subs_a1: Vec<AtomIdx> = mol
.neighbors(a1)
.filter(|(nb, _)| *nb != a2)
.map(|(nb, _)| nb)
.collect();
let subs_a2: Vec<AtomIdx> = mol
.neighbors(a2)
.filter(|(nb, _)| *nb != a1)
.map(|(nb, _)| nb)
.collect();
if subs_a1.is_empty() || subs_a2.is_empty() {
return None; }
let h1 = *subs_a1
.iter()
.max_by(|&&a, &&b| cmp_priority(mol, a1, a, b))?;
let h2 = *subs_a2
.iter()
.max_by(|&&a, &&b| cmp_priority(mol, a2, a, b))?;
if subs_a1.len() == 2 && cmp_priority(mol, a1, subs_a1[0], subs_a1[1]) == Ordering::Equal {
return None;
}
if subs_a2.len() == 2 && cmp_priority(mol, a2, subs_a2[0], subs_a2[1]) == Ordering::Equal {
return None;
}
let pa1 = coords.get(a1);
let pa2 = coords.get(a2);
let ph1 = coords.get(h1);
let ph2 = coords.get(h2);
let angle = dihedral(pa1, pa2, ph1, ph2)?;
let code = if angle.abs() < std::f64::consts::FRAC_PI_2 {
CipCode::Z
} else {
CipCode::E
};
Some((a1, code))
}
pub fn assign_stereo_from_3d(mol: &Molecule, coords: &Coords3D) -> StereoAssignment3D {
let mut assignments = Vec::new();
for i in 0..mol.atom_count() {
let idx = AtomIdx(i as u32);
if mol.atom(idx).element.atomic_number() == 1 {
continue;
}
let nb_count = mol.neighbors(idx).count();
if nb_count != 4 {
continue;
}
let all_single = mol.neighbors(idx).all(|(_, bidx)| {
matches!(
mol.bond(bidx).order,
BondOrder::Single | BondOrder::Up | BondOrder::Down
)
});
if !all_single {
continue;
}
if let Some(code) = assign_rs(mol, coords, idx) {
assignments.push((idx, code));
}
}
for j in 0..mol.bond_count() {
if let Some((atom_idx, code)) = assign_ez(mol, coords, BondIdx(j as u32)) {
assignments.push((atom_idx, code));
}
}
StereoAssignment3D { assignments }
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
use crate::coords::Coords3D;
fn mol(s: &str) -> chematic_core::Molecule {
parse(s).unwrap_or_else(|e| panic!("parse '{s}': {e}"))
}
#[allow(dead_code)]
fn make_coords4(
center: Point3,
s1: Point3,
s2: Point3,
s3: Point3,
s4: Point3,
n: usize,
) -> Coords3D {
let mut c = Coords3D::new_zeroed(n);
c.set(AtomIdx(0), center);
if n > 1 {
c.set(AtomIdx(1), s1);
}
if n > 2 {
c.set(AtomIdx(2), s2);
}
if n > 3 {
c.set(AtomIdx(3), s3);
}
if n > 4 {
c.set(AtomIdx(4), s4);
}
c
}
#[test]
fn signed_volume_positive_ccw() {
let v = signed_volume(
Point3::new(1.0, 0.0, 0.0),
Point3::new(0.0, 1.0, 0.0),
Point3::new(0.0, 0.0, 1.0),
Point3::new(-1.0, -1.0, -1.0),
);
assert!(v > 0.0, "expected positive signed volume, got {v}");
}
#[test]
fn signed_volume_negative_cw() {
let v = signed_volume(
Point3::new(0.0, 1.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
Point3::new(0.0, 0.0, 1.0),
Point3::new(-1.0, -1.0, -1.0),
);
assert!(v < 0.0, "expected negative signed volume, got {v}");
}
#[test]
fn rs_manual_s_center() {
let m = mol("[C]([Br])([F])([Cl])[I]");
assert_eq!(m.atom_count(), 5, "C + 4 halogens");
let mut coords = Coords3D::new_zeroed(5);
coords.set(AtomIdx(0), Point3::new(0.0, 0.0, 0.0)); coords.set(AtomIdx(1), Point3::new(1.0, 0.0, -0.3)); coords.set(AtomIdx(2), Point3::new(-0.5, -0.87, -0.3)); coords.set(AtomIdx(3), Point3::new(-0.5, 0.87, -0.3)); coords.set(AtomIdx(4), Point3::new(0.0, 0.0, 1.0));
let result = assign_stereo_from_3d(&m, &coords);
let code = result.get(AtomIdx(0));
assert_eq!(code, Some(CipCode::S), "expected S, got {code:?}");
}
#[test]
fn rs_manual_r_center() {
let m = mol("[C]([Br])([F])([Cl])[I]");
let mut coords = Coords3D::new_zeroed(5);
coords.set(AtomIdx(0), Point3::new(0.0, 0.0, 0.0)); coords.set(AtomIdx(1), Point3::new(0.0, 0.0, 1.0)); coords.set(AtomIdx(2), Point3::new(-0.5, -0.87, -0.3)); coords.set(AtomIdx(3), Point3::new(-0.5, 0.87, -0.3)); coords.set(AtomIdx(4), Point3::new(1.0, 0.0, -0.3)); let result = assign_stereo_from_3d(&m, &coords);
let code = result.get(AtomIdx(0));
assert_eq!(code, Some(CipCode::R), "expected R, got {code:?}");
}
#[test]
fn ez_trans_but2ene() {
let m = mol("ClC=CBr");
let mut coords = Coords3D::new_zeroed(4);
coords.set(AtomIdx(0), Point3::new(-1.5, 0.0, 0.0)); coords.set(AtomIdx(1), Point3::new(-0.67, 0.0, 0.0)); coords.set(AtomIdx(2), Point3::new(0.67, 0.0, 0.0)); coords.set(AtomIdx(3), Point3::new(2.0, 0.5, 0.0));
coords.set(AtomIdx(0), Point3::new(-1.5, 0.5, 0.0)); coords.set(AtomIdx(1), Point3::new(-0.67, 0.0, 0.0)); coords.set(AtomIdx(2), Point3::new(0.67, 0.0, 0.0)); coords.set(AtomIdx(3), Point3::new(1.5, -0.5, 0.0));
let result = assign_stereo_from_3d(&m, &coords);
let found_e = result
.assignments
.iter()
.any(|(_, code)| *code == CipCode::E);
assert!(
found_e,
"expected E assignment for trans ClCH=CHBr, got {:?}",
result.assignments
);
}
#[test]
fn ez_cis_arrangement() {
let m = mol("ClC=CBr");
let mut coords = Coords3D::new_zeroed(4);
coords.set(AtomIdx(0), Point3::new(-1.5, 0.5, 0.0)); coords.set(AtomIdx(1), Point3::new(-0.67, 0.0, 0.0)); coords.set(AtomIdx(2), Point3::new(0.67, 0.0, 0.0)); coords.set(AtomIdx(3), Point3::new(1.5, 0.5, 0.0));
let result = assign_stereo_from_3d(&m, &coords);
let found_z = result
.assignments
.iter()
.any(|(_, code)| *code == CipCode::Z);
assert!(
found_z,
"expected Z assignment for cis ClCH=CHBr, got {:?}",
result.assignments
);
}
#[test]
fn no_stereo_for_benzene() {
let m = mol("c1ccccc1");
let mut c = Coords3D::new_zeroed(m.atom_count());
for i in 0..m.atom_count() {
c.set(AtomIdx(i as u32), Point3::zero());
}
let result = assign_stereo_from_3d(&m, &c);
assert!(
result.assignments.is_empty(),
"benzene should have no stereo assignments"
);
}
}