use std::collections::{HashMap, HashSet};
use crate::aromaticity::{AromaticityModel, assign_aromaticity};
use crate::cip_priority::compare_branches;
use chematic_core::{AtomIdx, BondIdx, BondOrder, Molecule};
const AXIS_EPS: f64 = 1e-6;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EzDirectionRejectionReason {
MissingCoordinate,
NonFiniteCoordinate,
DegenerateGeometry,
ExplicitlyUnspecified,
UnsupportedTopology,
CarrierConflict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EzDirectionDiagnostic {
pub bond: BondIdx,
pub reason: EzDirectionRejectionReason,
}
pub fn apply_ez_directions_from_2d(mol: &mut Molecule, coords: &[(f64, f64)]) {
apply_ez_directions_from_2d_with_diagnostics(mol, coords);
}
pub fn apply_ez_directions_from_2d_with_diagnostics(
mol: &mut Molecule,
coords: &[(f64, f64)],
) -> Vec<EzDirectionDiagnostic> {
apply_ez_directions_from_2d_ex(mol, coords, &HashSet::new())
}
pub fn apply_ez_directions_from_2d_ex(
mol: &mut Molecule,
coords: &[(f64, f64)],
explicitly_unspecified: &HashSet<BondIdx>,
) -> Vec<EzDirectionDiagnostic> {
let double_bonds: Vec<BondIdx> = mol
.bonds()
.filter(|(_, b)| b.order == BondOrder::Double)
.map(|(bidx, _)| bidx)
.collect();
let aromaticity = assign_aromaticity(mol);
let poisoned = poisoned_by_branch_ambiguity(mol, &aromaticity);
let mut outcomes: HashMap<BondIdx, EzOutcome> = HashMap::with_capacity(double_bonds.len());
for &bidx in &double_bonds {
let outcome = if poisoned.contains(&bidx) {
EzOutcome::Rejected(EzDirectionRejectionReason::CarrierConflict)
} else {
classify_double_bond(mol, coords, bidx, explicitly_unspecified, &aromaticity)
};
outcomes.insert(bidx, outcome);
}
let mut claims: HashMap<BondIdx, Vec<(BondIdx, BondOrder)>> = HashMap::new();
for (&db, outcome) in &outcomes {
if let EzOutcome::Assigned {
carrier_a1,
carrier_a2,
} = outcome
{
claims
.entry(carrier_a1.0)
.or_default()
.push((db, carrier_a1.1));
claims
.entry(carrier_a2.0)
.or_default()
.push((db, carrier_a2.1));
}
}
let mut conflicted: HashSet<BondIdx> = HashSet::new();
for claimants in claims.values() {
if claimants.len() > 1 {
let first = claimants[0].1;
if claimants.iter().any(|&(_, v)| v != first) {
for &(db, _) in claimants {
conflicted.insert(db);
}
}
}
}
let mut diagnostics = Vec::with_capacity(double_bonds.len());
for &bidx in &double_bonds {
match outcomes.remove(&bidx).expect("classified in phase 1") {
EzOutcome::NotRequested => {}
EzOutcome::Rejected(reason) => {
diagnostics.push(EzDirectionDiagnostic { bond: bidx, reason });
}
EzOutcome::Assigned {
carrier_a1,
carrier_a2,
} => {
if conflicted.contains(&bidx) {
diagnostics.push(EzDirectionDiagnostic {
bond: bidx,
reason: EzDirectionRejectionReason::CarrierConflict,
});
} else {
mol.set_bond_direction(carrier_a1.0, carrier_a1.1);
mol.set_bond_direction(carrier_a2.0, carrier_a2.1);
}
}
}
}
diagnostics
}
enum EzOutcome {
NotRequested,
Assigned {
carrier_a1: (BondIdx, BondOrder),
carrier_a2: (BondIdx, BondOrder),
},
Rejected(EzDirectionRejectionReason),
}
enum EndOutcome {
NonStereogenic,
Resolved(BondIdx, bool),
Failed(EzDirectionRejectionReason),
}
fn classify_double_bond(
mol: &Molecule,
coords: &[(f64, f64)],
bond_idx: BondIdx,
explicitly_unspecified: &HashSet<BondIdx>,
aromaticity: &AromaticityModel,
) -> EzOutcome {
let bond = mol.bond(bond_idx);
debug_assert_eq!(bond.order, BondOrder::Double);
let a1 = bond.atom1;
let a2 = bond.atom2;
if aromaticity.is_bond_aromatic(bond_idx) {
return EzOutcome::NotRequested;
}
if has_other_double_bond(mol, a1, bond_idx) || has_other_double_bond(mol, a2, bond_idx) {
return EzOutcome::Rejected(EzDirectionRejectionReason::UnsupportedTopology);
}
if explicitly_unspecified.contains(&bond_idx) {
return EzOutcome::Rejected(EzDirectionRejectionReason::ExplicitlyUnspecified);
}
let subs_a1 = substituents(mol, a1, a2);
let subs_a2 = substituents(mol, a2, a1);
if subs_a1.is_empty() || subs_a2.is_empty() {
return EzOutcome::NotRequested; }
if subs_a1.len() > 2 || subs_a2.len() > 2 {
return EzOutcome::Rejected(EzDirectionRejectionReason::UnsupportedTopology);
}
let p1 = match coord(coords, a1) {
Ok(p) => p,
Err(e) => return EzOutcome::Rejected(e),
};
let p2 = match coord(coords, a2) {
Ok(p) => p,
Err(e) => return EzOutcome::Rejected(e),
};
let axis = (p2.0 - p1.0, p2.1 - p1.1);
if axis.0.hypot(axis.1) < AXIS_EPS {
return EzOutcome::Rejected(EzDirectionRejectionReason::DegenerateGeometry); }
let end1 = resolve_end(mol, coords, a1, &subs_a1, p1, axis, aromaticity);
let end2 = resolve_end(mol, coords, a2, &subs_a2, p1, axis, aromaticity);
match (end1, end2) {
(EndOutcome::NonStereogenic, _) | (_, EndOutcome::NonStereogenic) => {
EzOutcome::NotRequested
}
(EndOutcome::Failed(reason), _) => EzOutcome::Rejected(reason),
(_, EndOutcome::Failed(reason)) => EzOutcome::Rejected(reason),
(EndOutcome::Resolved(b1, up1), EndOutcome::Resolved(b2, up2)) => {
let dir1 = direction_for_up(mol.bond(b1).atom1, a1, up1);
let dir2 = direction_for_up(mol.bond(b2).atom1, a2, up2);
EzOutcome::Assigned {
carrier_a1: (b1, dir1),
carrier_a2: (b2, dir2),
}
}
}
}
fn resolve_end(
mol: &Molecule,
coords: &[(f64, f64)],
end: AtomIdx,
subs: &[(AtomIdx, BondIdx)],
axis_origin: (f64, f64),
axis: (f64, f64),
aromaticity: &AromaticityModel,
) -> EndOutcome {
if subs.len() == 2
&& compare_branches(mol, end, subs[0].0, subs[1].0) == std::cmp::Ordering::Equal
{
return EndOutcome::NonStereogenic;
}
if subs.len() == 2
&& subs
.iter()
.any(|&(a, b)| is_conjugated_to_another_double_bond(mol, a, b, aromaticity))
{
return EndOutcome::Failed(EzDirectionRejectionReason::CarrierConflict);
}
let mut first_geometry_failure: Option<EzDirectionRejectionReason> = None;
for &(sub_atom, sub_bond) in subs {
if matches!(mol.bond(sub_bond).order, BondOrder::Up | BondOrder::Down) {
continue; }
if mol.bond_direction(sub_bond).is_some() {
continue; }
match coord(coords, sub_atom) {
Err(reason) => {
first_geometry_failure.get_or_insert(reason);
continue;
}
Ok((sx, sy)) => {
let side = cross2d(axis.0, axis.1, sx - axis_origin.0, sy - axis_origin.1);
if side.abs() < AXIS_EPS {
first_geometry_failure
.get_or_insert(EzDirectionRejectionReason::DegenerateGeometry);
continue; }
return EndOutcome::Resolved(sub_bond, side > 0.0);
}
}
}
match first_geometry_failure {
Some(reason) => EndOutcome::Failed(reason),
None => EndOutcome::Failed(EzDirectionRejectionReason::CarrierConflict),
}
}
fn poisoned_by_branch_ambiguity(
mol: &Molecule,
aromaticity: &AromaticityModel,
) -> HashSet<BondIdx> {
let mut poisoned = HashSet::new();
for (bidx, bond) in mol.bonds() {
if bond.order != BondOrder::Double || aromaticity.is_bond_aromatic(bidx) {
continue;
}
for (end, other_end) in [(bond.atom1, bond.atom2), (bond.atom2, bond.atom1)] {
let subs = substituents(mol, end, other_end);
if subs.len() != 2 {
continue;
}
for &(sub_atom, sub_bond) in &subs {
let other_db = mol.neighbors(sub_atom).find(|&(_, nb_bidx)| {
nb_bidx != sub_bond
&& mol.bond(nb_bidx).order == BondOrder::Double
&& !aromaticity.is_bond_aromatic(nb_bidx)
});
if let Some((_, other_db)) = other_db {
poisoned.insert(bidx);
poisoned.insert(other_db);
}
}
}
}
poisoned
}
fn has_other_double_bond(mol: &Molecule, atom: AtomIdx, exclude: BondIdx) -> bool {
mol.neighbors(atom)
.any(|(_, bidx)| bidx != exclude && mol.bond(bidx).order == BondOrder::Double)
}
fn is_conjugated_to_another_double_bond(
mol: &Molecule,
sub_atom: AtomIdx,
sub_bond: BondIdx,
aromaticity: &AromaticityModel,
) -> bool {
mol.neighbors(sub_atom).any(|(_, bidx)| {
bidx != sub_bond
&& mol.bond(bidx).order == BondOrder::Double
&& !aromaticity.is_bond_aromatic(bidx)
})
}
fn substituents(mol: &Molecule, end: AtomIdx, other_end: AtomIdx) -> Vec<(AtomIdx, BondIdx)> {
mol.neighbors(end)
.filter(|&(nb, bidx)| nb != other_end && mol.bond(bidx).order != BondOrder::Double)
.collect()
}
fn coord(coords: &[(f64, f64)], idx: AtomIdx) -> Result<(f64, f64), EzDirectionRejectionReason> {
let (x, y) = coords
.get(idx.0 as usize)
.copied()
.ok_or(EzDirectionRejectionReason::MissingCoordinate)?;
if !x.is_finite() || !y.is_finite() {
return Err(EzDirectionRejectionReason::NonFiniteCoordinate);
}
Ok((x, y))
}
fn cross2d(vx: f64, vy: f64, ux: f64, uy: f64) -> f64 {
vx * uy - vy * ux
}
fn direction_for_up(bond_atom1: AtomIdx, alkene_end: AtomIdx, want_up: bool) -> BondOrder {
if (bond_atom1 == alkene_end) == want_up {
BondOrder::Up
} else {
BondOrder::Down
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stereo2d::cip_ez_descriptor;
use chematic_core::{Atom, CipCode, Element, MoleculeBuilder};
fn stored_is_up(mol: &Molecule, alkene_end: AtomIdx, sub_bond: BondIdx) -> Option<bool> {
let bond = mol.bond(sub_bond);
let effective = mol.bond_direction(sub_bond).unwrap_or(bond.order);
match effective {
BondOrder::Up => Some(bond.atom1 == alkene_end),
BondOrder::Down => Some(bond.atom1 != alkene_end),
_ => None,
}
}
fn but2ene(
c0: (f64, f64),
c1: (f64, f64),
c2: (f64, f64),
c3: (f64, f64),
) -> (Molecule, Vec<(f64, f64)>, BondIdx) {
let mut b = MoleculeBuilder::new();
let m0 = b.add_atom(Atom::new(Element::C));
let m1 = b.add_atom(Atom::new(Element::C));
let m2 = b.add_atom(Atom::new(Element::C));
let m3 = b.add_atom(Atom::new(Element::C));
b.add_bond(m0, m1, BondOrder::Single).unwrap();
let db = b.add_bond(m1, m2, BondOrder::Double).unwrap();
b.add_bond(m2, m3, BondOrder::Single).unwrap();
(b.build(), vec![c0, c1, c2, c3], db)
}
#[test]
fn z_but2ene_assigns_and_matches_legacy_cip_engine() {
let (mut mol, coords, db) = but2ene((-0.866, 0.5), (0.0, 0.0), (1.5, 0.0), (2.366, 0.5));
let legacy = cip_ez_descriptor(&mol, db, &coords);
assert_eq!(legacy, Some(CipCode::Z));
let diagnostics = apply_ez_directions_from_2d_with_diagnostics(&mut mol, &coords);
assert!(diagnostics.is_empty(), "{diagnostics:?}");
let bond = mol.bond(db);
let (c1, c2) = (bond.atom1, bond.atom2);
let sub1 = mol.bond_between(c1, AtomIdx(0)).unwrap().0;
let sub2 = mol.bond_between(c2, AtomIdx(3)).unwrap().0;
assert!(mol.bond_direction(sub1).is_some());
assert!(mol.bond_direction(sub2).is_some());
let up1 = stored_is_up(&mol, c1, sub1).unwrap();
let up2 = stored_is_up(&mol, c2, sub2).unwrap();
let reconstructed = if up1 == up2 { CipCode::Z } else { CipCode::E };
assert_eq!(
reconstructed,
legacy.unwrap(),
"direction convention must reproduce the legacy CIP engine's own Z/E verdict"
);
assert_eq!(mol.bond(db).order, BondOrder::Double);
}
#[test]
fn e_but2ene_assigns_and_matches_legacy_cip_engine() {
let (mut mol, coords, db) = but2ene((-0.866, 0.5), (0.0, 0.0), (1.5, 0.0), (2.366, -0.5));
let legacy = cip_ez_descriptor(&mol, db, &coords);
assert_eq!(legacy, Some(CipCode::E));
apply_ez_directions_from_2d(&mut mol, &coords);
let bond = mol.bond(db);
let (c1, c2) = (bond.atom1, bond.atom2);
let sub1 = mol.bond_between(c1, AtomIdx(0)).unwrap().0;
let sub2 = mol.bond_between(c2, AtomIdx(3)).unwrap().0;
let up1 = stored_is_up(&mol, c1, sub1).unwrap();
let up2 = stored_is_up(&mol, c2, sub2).unwrap();
let reconstructed = if up1 == up2 { CipCode::Z } else { CipCode::E };
assert_eq!(reconstructed, legacy.unwrap());
}
#[test]
fn terminal_alkene_not_requested() {
let mut b = MoleculeBuilder::new();
let c0 = b.add_atom(Atom::new(Element::C)); let c1 = b.add_atom(Atom::new(Element::C));
let c2 = b.add_atom(Atom::new(Element::C)); b.add_bond(c0, c1, BondOrder::Double).unwrap();
b.add_bond(c1, c2, BondOrder::Single).unwrap();
let mut mol = b.build();
let coords = vec![(0.0, 0.0), (1.5, 0.0), (2.366, 0.5)];
let diagnostics = apply_ez_directions_from_2d_with_diagnostics(&mut mol, &coords);
assert!(diagnostics.is_empty());
assert!(mol.bond_direction(BondIdx(1)).is_none());
}
#[test]
fn carbonyl_not_requested() {
let mut b = MoleculeBuilder::new();
let c0 = b.add_atom(Atom::new(Element::C));
let c1 = b.add_atom(Atom::new(Element::C));
let o = b.add_atom(Atom::new(Element::O));
b.add_bond(c0, c1, BondOrder::Single).unwrap();
b.add_bond(c1, o, BondOrder::Double).unwrap();
let mut mol = b.build();
let coords = vec![(-1.0, 0.0), (0.0, 0.0), (0.5, 1.0)];
let diagnostics = apply_ez_directions_from_2d_with_diagnostics(&mut mol, &coords);
assert!(diagnostics.is_empty());
}
#[test]
fn equivalent_substituents_not_requested() {
let mut b = MoleculeBuilder::new();
let center = b.add_atom(Atom::new(Element::C));
let me_a = b.add_atom(Atom::new(Element::C));
let me_b = b.add_atom(Atom::new(Element::C));
let ch = b.add_atom(Atom::new(Element::C));
let me_c = b.add_atom(Atom::new(Element::C));
b.add_bond(center, me_a, BondOrder::Single).unwrap();
b.add_bond(center, me_b, BondOrder::Single).unwrap();
let db = b.add_bond(center, ch, BondOrder::Double).unwrap();
b.add_bond(ch, me_c, BondOrder::Single).unwrap();
let mut mol = b.build();
let coords = vec![
(0.0, 0.0),
(-0.866, 0.5),
(-0.866, -0.5),
(1.5, 0.0),
(2.366, 0.5),
];
let diagnostics = apply_ez_directions_from_2d_with_diagnostics(&mut mol, &coords);
assert!(diagnostics.is_empty(), "{diagnostics:?}");
for bidx in 0..mol.bond_count() {
assert!(mol.bond_direction(BondIdx(bidx as u32)).is_none());
}
let _ = db;
}
#[test]
fn trisubstituted_alkene_assigns() {
let mut b = MoleculeBuilder::new();
let center = b.add_atom(Atom::new(Element::C));
let cl = b.add_atom(Atom::new(Element::CL));
let br = b.add_atom(Atom::new(Element::BR));
let ch = b.add_atom(Atom::new(Element::C));
let me = b.add_atom(Atom::new(Element::C));
b.add_bond(center, cl, BondOrder::Single).unwrap();
b.add_bond(center, br, BondOrder::Single).unwrap();
b.add_bond(center, ch, BondOrder::Double).unwrap();
b.add_bond(ch, me, BondOrder::Single).unwrap();
let mut mol = b.build();
let coords = vec![
(0.0, 0.0),
(-0.866, 0.5),
(-0.866, -0.5),
(1.5, 0.0),
(2.366, 0.5),
];
let diagnostics = apply_ez_directions_from_2d_with_diagnostics(&mut mol, &coords);
assert!(diagnostics.is_empty(), "{diagnostics:?}");
let has_direction =
(0..mol.bond_count()).any(|i| mol.bond_direction(BondIdx(i as u32)).is_some());
assert!(has_direction);
}
#[test]
fn missing_coordinate_rejected() {
let (mut mol, mut coords, _db) =
but2ene((-0.866, 0.5), (0.0, 0.0), (1.5, 0.0), (2.366, 0.5));
coords.truncate(3); let diagnostics = apply_ez_directions_from_2d_with_diagnostics(&mut mol, &coords);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].reason,
EzDirectionRejectionReason::MissingCoordinate
);
for bidx in 0..mol.bond_count() {
assert!(mol.bond_direction(BondIdx(bidx as u32)).is_none());
}
}
#[test]
fn non_finite_coordinate_rejected() {
let (mut mol, coords, _db) =
but2ene((-0.866, 0.5), (0.0, 0.0), (1.5, 0.0), (f64::NAN, 0.5));
let diagnostics = apply_ez_directions_from_2d_with_diagnostics(&mut mol, &coords);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].reason,
EzDirectionRejectionReason::NonFiniteCoordinate
);
}
#[test]
fn zero_length_double_bond_rejected() {
let (mut mol, coords, _db) = but2ene((-0.866, 0.5), (0.0, 0.0), (0.0, 0.0), (2.366, 0.5));
let diagnostics = apply_ez_directions_from_2d_with_diagnostics(&mut mol, &coords);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].reason,
EzDirectionRejectionReason::DegenerateGeometry
);
}
#[test]
fn collinear_substituent_and_all_or_nothing() {
let (mut mol, coords, db) = but2ene((-1.0, 0.0), (0.0, 0.0), (1.5, 0.0), (2.366, 0.5));
let diagnostics = apply_ez_directions_from_2d_with_diagnostics(&mut mol, &coords);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].reason,
EzDirectionRejectionReason::DegenerateGeometry
);
let bond = mol.bond(db);
let sub2 = mol.bond_between(bond.atom2, AtomIdx(3)).unwrap().0;
assert!(
mol.bond_direction(sub2).is_none(),
"the good end must not get a direction when the other end fails"
);
}
#[test]
fn explicitly_unspecified_rejected() {
let (mut mol, coords, db) = but2ene((-0.866, 0.5), (0.0, 0.0), (1.5, 0.0), (2.366, 0.5));
let mut unspecified = HashSet::new();
unspecified.insert(db);
let diagnostics = apply_ez_directions_from_2d_ex(&mut mol, &coords, &unspecified);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].reason,
EzDirectionRejectionReason::ExplicitlyUnspecified
);
for bidx in 0..mol.bond_count() {
assert!(mol.bond_direction(BondIdx(bidx as u32)).is_none());
}
}
#[test]
fn cumulene_allene_rejected() {
let mut b = MoleculeBuilder::new();
let t1 = b.add_atom(Atom::new(Element::C));
let central = b.add_atom(Atom::new(Element::C));
let t2 = b.add_atom(Atom::new(Element::C));
let s1 = b.add_atom(Atom::new(Element::C));
let s2 = b.add_atom(Atom::new(Element::C));
b.add_bond(t1, central, BondOrder::Double).unwrap();
b.add_bond(central, t2, BondOrder::Double).unwrap();
b.add_bond(t1, s1, BondOrder::Single).unwrap();
b.add_bond(t2, s2, BondOrder::Single).unwrap();
let mut mol = b.build();
let coords = vec![(-1.0, 0.0), (0.0, 0.0), (1.0, 0.0), (-1.5, 1.0), (1.5, 1.0)];
let diagnostics = apply_ez_directions_from_2d_with_diagnostics(&mut mol, &coords);
assert_eq!(diagnostics.len(), 2);
assert!(
diagnostics
.iter()
.all(|d| d.reason == EzDirectionRejectionReason::UnsupportedTopology)
);
}
#[test]
fn existing_wedge_on_only_candidate_is_carrier_conflict() {
let (mol, coords, db) = but2ene((-0.866, 0.5), (0.0, 0.0), (1.5, 0.0), (2.366, 0.5));
let bond = mol.bond(db);
let sub1 = mol.bond_between(bond.atom1, AtomIdx(0)).unwrap().0;
let mut b = MoleculeBuilder::new();
for (_, atom) in mol.atoms() {
b.add_atom(atom.clone());
}
for (bidx, bond) in mol.bonds() {
let order = if bidx == sub1 {
BondOrder::Up
} else {
bond.order
};
b.add_bond(bond.atom1, bond.atom2, order).unwrap();
}
let mut mol = b.build();
let diagnostics = apply_ez_directions_from_2d_with_diagnostics(&mut mol, &coords);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].reason,
EzDirectionRejectionReason::CarrierConflict
);
assert_eq!(mol.bond(sub1).order, BondOrder::Up);
assert!(mol.bond_direction(sub1).is_none());
}
#[test]
fn existing_wedge_on_sibling_falls_back_to_other_substituent() {
let mut b = MoleculeBuilder::new();
let center = b.add_atom(Atom::new(Element::C));
let cl = b.add_atom(Atom::new(Element::CL));
let br = b.add_atom(Atom::new(Element::BR));
let ch = b.add_atom(Atom::new(Element::C));
let me = b.add_atom(Atom::new(Element::C));
let cl_bond = b.add_bond(center, cl, BondOrder::Up).unwrap(); let br_bond = b.add_bond(center, br, BondOrder::Single).unwrap();
b.add_bond(center, ch, BondOrder::Double).unwrap();
b.add_bond(ch, me, BondOrder::Single).unwrap();
let mut mol = b.build();
let coords = vec![
(0.0, 0.0),
(-0.866, 0.5),
(-0.866, -0.5),
(1.5, 0.0),
(2.366, 0.5),
];
let diagnostics = apply_ez_directions_from_2d_with_diagnostics(&mut mol, &coords);
assert!(diagnostics.is_empty(), "{diagnostics:?}");
assert_eq!(mol.bond(cl_bond).order, BondOrder::Up, "wedge untouched");
assert!(
mol.bond_direction(cl_bond).is_none(),
"must not stash a direction on the wedge bond"
);
assert!(
mol.bond_direction(br_bond).is_some(),
"must fall back to the sibling substituent"
);
}
#[test]
fn conjugated_diene_shared_bond_agrees() {
let mut b = MoleculeBuilder::new();
let me1 = b.add_atom(Atom::new(Element::C));
let ca = b.add_atom(Atom::new(Element::C));
let cb = b.add_atom(Atom::new(Element::C));
let cc = b.add_atom(Atom::new(Element::C));
let cd = b.add_atom(Atom::new(Element::C));
let me2 = b.add_atom(Atom::new(Element::C));
b.add_bond(me1, ca, BondOrder::Single).unwrap();
let db1 = b.add_bond(ca, cb, BondOrder::Double).unwrap();
let shared = b.add_bond(cb, cc, BondOrder::Single).unwrap();
let db2 = b.add_bond(cc, cd, BondOrder::Double).unwrap();
b.add_bond(cd, me2, BondOrder::Single).unwrap();
let mut mol = b.build();
let coords = vec![
(-2.0, 0.5), (-1.0, 0.0), (0.0, 0.5), (1.0, 0.0), (2.0, 0.5), (3.0, 0.0), ];
let diagnostics = apply_ez_directions_from_2d_with_diagnostics(&mut mol, &coords);
assert!(diagnostics.is_empty(), "{diagnostics:?}");
assert_eq!(mol.bond_direction(shared), Some(BondOrder::Down));
let _ = (db1, db2);
}
#[test]
fn conjugated_diene_shared_bond_conflict() {
let mut b = MoleculeBuilder::new();
let me1 = b.add_atom(Atom::new(Element::C));
let ca = b.add_atom(Atom::new(Element::C));
let cb = b.add_atom(Atom::new(Element::C));
let cc = b.add_atom(Atom::new(Element::C));
let cd = b.add_atom(Atom::new(Element::C));
let me2 = b.add_atom(Atom::new(Element::C));
b.add_bond(me1, ca, BondOrder::Single).unwrap();
b.add_bond(ca, cb, BondOrder::Double).unwrap();
let shared = b.add_bond(cb, cc, BondOrder::Single).unwrap();
b.add_bond(cc, cd, BondOrder::Double).unwrap();
b.add_bond(cd, me2, BondOrder::Single).unwrap();
let mut mol = b.build();
let coords = vec![
(-1.0, 1.0), (0.0, 0.0), (1.0, 0.0), (2.0, 1.0), (2.0, 2.0), (3.0, 3.0), ];
let diagnostics = apply_ez_directions_from_2d_with_diagnostics(&mut mol, &coords);
assert_eq!(diagnostics.len(), 2, "{diagnostics:?}");
assert!(
diagnostics
.iter()
.all(|d| d.reason == EzDirectionRejectionReason::CarrierConflict)
);
assert!(
mol.bond_direction(shared).is_none(),
"a conflicting shared carrier must end up with NO direction written"
);
}
}