use chematic_core::{BondIdx, BondOrder, Molecule};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AtropisomerType {
Biaryl,
Allene,
Constrained,
}
pub fn detect_atropisomers(mol: &Molecule) -> Vec<(BondIdx, AtropisomerType)> {
let mut result = Vec::new();
for (bidx, bond) in mol.bonds() {
let a1 = mol.atom(bond.atom1);
let a2 = mol.atom(bond.atom2);
if a1.aromatic
&& a2.aromatic
&& a1.element.atomic_number() == 6
&& a2.element.atomic_number() == 6
&& bond.order == BondOrder::Single
{
let a1_degree = mol.neighbors(bond.atom1).count();
let a2_degree = mol.neighbors(bond.atom2).count();
if a1_degree >= 3 && a2_degree >= 3 {
result.push((bidx, AtropisomerType::Biaryl));
}
}
if a1.element.atomic_number() == 6
&& a2.element.atomic_number() == 6
&& bond.order == BondOrder::Double
{
let a1_has_double = mol
.neighbors(bond.atom1)
.filter(|(n, _)| n != &bond.atom2)
.any(|(_, nb)| mol.bond(nb).order == BondOrder::Double);
let a2_has_double = mol
.neighbors(bond.atom2)
.filter(|(n, _)| n != &bond.atom1)
.any(|(_, nb)| mol.bond(nb).order == BondOrder::Double);
if a1_has_double && a2_has_double {
result.push((bidx, AtropisomerType::Allene));
}
}
}
result
}
pub fn assign_atropisomer_chirality(mol: &Molecule) -> Molecule {
use chematic_core::MoleculeBuilder;
let atropisomers = detect_atropisomers(mol);
let mut builder = MoleculeBuilder::new();
let mut remap = std::collections::HashMap::new();
for (idx, atom) in mol.atoms() {
let new_idx = builder.add_atom(atom.clone());
remap.insert(idx, new_idx);
}
for (bidx, bond) in mol.bonds() {
let mut new_bond_order = bond.order;
if let Some((_, _)) = atropisomers.iter().find(|(b, _)| b == &bidx)
&& bond.order == BondOrder::Single {
let a1_neighbors: Vec<_> = mol.neighbors(bond.atom1).collect();
let a2_neighbors: Vec<_> = mol.neighbors(bond.atom2).collect();
let a1_max_an = a1_neighbors
.iter()
.filter(|(n, _)| n != &bond.atom2)
.map(|(n, _)| mol.atom(*n).element.atomic_number())
.max()
.unwrap_or(0);
let a2_max_an = a2_neighbors
.iter()
.filter(|(n, _)| n != &bond.atom1)
.map(|(n, _)| mol.atom(*n).element.atomic_number())
.max()
.unwrap_or(0);
if a1_max_an > a2_max_an {
new_bond_order = BondOrder::Up;
} else if a2_max_an > a1_max_an {
new_bond_order = BondOrder::Down;
}
}
if let (Some(&a), Some(&b)) = (remap.get(&bond.atom1), remap.get(&bond.atom2)) {
let _ = builder.add_bond(a, b, new_bond_order);
}
}
builder.build()
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
fn mol(s: &str) -> Molecule {
parse(s).unwrap_or_else(|e| panic!("parse '{s}': {e}"))
}
#[test]
fn detect_atropisomers_biaryl() {
let m = mol("c1ccccc1c2ccccc2");
let atrops = detect_atropisomers(&m);
assert!(atrops.is_empty(), "biphenyl without ortho subs should have no atropisomers");
}
#[test]
fn detect_atropisomers_none() {
let m = mol("CC");
let atrops = detect_atropisomers(&m);
assert_eq!(atrops.len(), 0, "ethane should have no atropisomers");
}
#[test]
fn assign_atropisomer_chirality_preserves_atoms() {
let m = mol("c1ccccc1c2ccccc2");
let result = assign_atropisomer_chirality(&m);
assert_eq!(result.atom_count(), m.atom_count(), "atom count should match");
}
#[test]
fn assign_atropisomer_chirality_preserves_bonds() {
let m = mol("c1ccccc1c2ccccc2");
let result = assign_atropisomer_chirality(&m);
assert_eq!(result.bond_count(), m.bond_count(), "bond count should match");
}
}