use omgkit_core::{AtomFlags, BondData, BondDirection, BondOrder, ChiralTag, MolBuilder};
pub fn remove_hs(mol: &mut MolBuilder) -> usize {
let n = mol.num_atoms();
let doomed: Vec<bool> = (0..n as u32).map(|a| is_removable(mol, a)).collect();
let n_removed = doomed.iter().filter(|&&d| d).count();
if n_removed == 0 {
return 0;
}
let mut new_idx = vec![u32::MAX; n];
let mut out = MolBuilder::with_capacity(n - n_removed, mol.num_bonds());
for a in 0..n as u32 {
if doomed[a as usize] {
continue;
}
let mut data = mol.atoms()[a as usize];
let positions: Vec<usize> = mol
.neighbors(a)
.enumerate()
.filter(|(_, (other, _))| doomed[*other as usize])
.map(|(pos, _)| pos)
.collect();
let degree_before = mol.neighbors(a).count();
if !positions.is_empty() {
let merged = u8::try_from(positions.len()).unwrap_or(u8::MAX);
if data.flags.contains(AtomFlags::NO_IMPLICIT) {
data.num_explicit_hs = data.num_explicit_hs.saturating_add(merged);
} else {
data.num_implicit_hs = data.num_implicit_hs.saturating_add(merged);
}
data.chiral_tag = rebased_tag(data.chiral_tag, &positions, degree_before);
}
new_idx[a as usize] = out.add_atom_data(data);
}
for b in mol.bonds() {
if doomed[b.begin as usize] || doomed[b.end as usize] {
continue;
}
let mut nb = *b;
nb.begin = new_idx[b.begin as usize];
nb.end = new_idx[b.end as usize];
nb.stereo_atoms = [
translate(b.stereo_atoms[0], &new_idx),
translate(b.stereo_atoms[1], &new_idx),
];
let _ = out.add_bond_data(nb);
}
if let Some(name) = mol.name() {
out.set_name(name.to_string());
}
*mol = out;
n_removed
}
fn translate(idx: u32, new_idx: &[u32]) -> u32 {
if idx == BondData::NO_STEREO_ATOM {
return BondData::NO_STEREO_ATOM;
}
new_idx
.get(idx as usize)
.copied()
.filter(|&v| v != u32::MAX)
.unwrap_or(BondData::NO_STEREO_ATOM)
}
fn rebased_tag(tag: ChiralTag, removed_positions: &[usize], degree_before: usize) -> ChiralTag {
if !tag.is_tetrahedral() || removed_positions.len() != 1 {
return tag;
}
let k = removed_positions[0];
if (degree_before - 1 - k) % 2 == 1 {
tag.inverted()
} else {
tag
}
}
#[must_use]
pub fn is_removable(mol: &MolBuilder, atom: u32) -> bool {
let Some(&a) = mol.atoms().get(atom as usize) else {
return false;
};
if a.atomic_num != 1 {
return false;
}
if a.isotope != 0
|| a.formal_charge != 0
|| a.atom_map != 0
|| a.num_radical_electrons != 0
|| a.flags.contains(AtomFlags::AROMATIC)
{
return false;
}
if a.num_explicit_hs != 0 {
return false;
}
let mut it = mol.neighbors(atom);
let Some((other, bond)) = it.next() else {
return false; };
if it.next().is_some() {
return false; }
let b = mol.bonds()[bond as usize];
if b.order != BondOrder::Single || b.direction != BondDirection::None {
return false;
}
let host = mol.atoms()[other as usize];
if host.atomic_num == 1 || host.atomic_num == 0 {
return false;
}
!mol.bonds()
.iter()
.any(|bb| bb.stereo_atoms[0] == atom || bb.stereo_atoms[1] == atom)
}
#[cfg(test)]
mod chirality_tests {
#[test]
fn adding_hydrogens_and_removing_them_again_leaves_the_configuration_alone() {
let canon = |m: &omgkit_core::MolBuilder| omgkit_io::canon::canonical_smiles(m).smiles;
let mut checked = 0;
for smi in [
"C[C@H](N)C(=O)O", "[H][C@](C)(N)C(=O)O", "C[P@H]CC", "C[As@H]CC", "[C@H](N)(O)C", "C[C@H]1CC1", "C[C@@H](O)[C@H](N)C", ] {
let mut base = omgkit_io::smiles::parse(smi).expect("测试用的 SMILES 该能解析");
crate::sanitize(&mut base).expect("测试用的分子该能净化");
if !base.atoms().iter().any(|a| a.chiral_tag.is_tetrahedral()) {
continue;
}
checked += 1;
let mut round = base.clone();
super::remove_hs(&mut base);
let want = canon(&base);
let order: Vec<u32> = (0..u32::try_from(round.num_atoms()).unwrap()).collect();
crate::add_explicit_hs(&mut round, &order);
super::remove_hs(&mut round);
assert_eq!(canon(&round), want, "{smi}: 补氢再删氢改掉了构型");
}
assert!(checked >= 6, "只查到 {checked} 个手性分子 —— 这一档在空过");
}
}