1use std::cmp::Ordering;
11
12use chematic_core::{AtomIdx, BondIdx, BondOrder, CipCode, Molecule};
13use chematic_perception::cip_priority;
14
15use crate::coords::{Coords3D, Point3};
16
17#[derive(Debug, Default)]
23pub struct StereoAssignment3D {
24 pub assignments: Vec<(AtomIdx, CipCode)>,
25}
26
27impl StereoAssignment3D {
28 pub fn get(&self, idx: AtomIdx) -> Option<CipCode> {
30 self.assignments
31 .iter()
32 .find(|(i, _)| *i == idx)
33 .map(|(_, c)| *c)
34 }
35}
36
37fn rank4(mol: &Molecule, center: AtomIdx, subs: &[AtomIdx; 4]) -> Option<[u8; 4]> {
43 let mut order: [usize; 4] = [0, 1, 2, 3];
44 order.sort_by(|&i, &j| cip_priority::compare_branches(mol, center, subs[i], subs[j]).reverse());
45
46 for k in 0..3 {
48 if cip_priority::compare_branches(mol, center, subs[order[k]], subs[order[k + 1]])
49 == Ordering::Equal
50 {
51 return None;
52 }
53 }
54
55 let mut ranks = [0u8; 4];
57 for (rank_from_top, &idx) in order.iter().enumerate() {
58 ranks[idx] = (4 - rank_from_top) as u8;
59 }
60 Some(ranks)
61}
62
63fn signed_volume(p1: Point3, p2: Point3, p3: Point3, p4: Point3) -> f64 {
73 let ax = p1.x - p4.x;
74 let ay = p1.y - p4.y;
75 let az = p1.z - p4.z;
76 let bx = p2.x - p4.x;
77 let by = p2.y - p4.y;
78 let bz = p2.z - p4.z;
79 let cx = p3.x - p4.x;
80 let cy = p3.y - p4.y;
81 let cz = p3.z - p4.z;
82 ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx)
84}
85
86fn assign_rs(mol: &Molecule, coords: &Coords3D, idx: AtomIdx) -> Option<CipCode> {
91 let nbs: Vec<AtomIdx> = mol.neighbors(idx).map(|(nb, _)| nb).collect();
93 if nbs.len() != 4 {
94 return None;
95 }
96 let subs: [AtomIdx; 4] = [nbs[0], nbs[1], nbs[2], nbs[3]];
97
98 let ranks = rank4(mol, idx, &subs)?;
99
100 let mut order: [usize; 4] = [0, 1, 2, 3];
102 order.sort_by(|&i, &j| ranks[j].cmp(&ranks[i])); let s: [Point3; 4] = [
104 coords.get(subs[order[0]]),
105 coords.get(subs[order[1]]),
106 coords.get(subs[order[2]]),
107 coords.get(subs[order[3]]),
108 ];
109
110 let v = signed_volume(s[0], s[1], s[2], s[3]);
112
113 if v.abs() < 1e-6 {
114 return None; }
116
117 Some(if v > 0.0 { CipCode::S } else { CipCode::R })
120}
121
122pub(crate) fn dihedral(pa1: Point3, pa2: Point3, ph1: Point3, ph2: Point3) -> Option<f64> {
129 let b1 = ph1.sub(&pa1);
131 let b2 = pa2.sub(&pa1);
132 let b3 = ph2.sub(&pa2);
133
134 let n1 = b1.cross(&b2);
135 let n2 = b3.cross(&b2);
136
137 let d1 = n1.norm();
138 let d2 = n2.norm();
139 if d1 < 1e-10 || d2 < 1e-10 {
140 return None;
141 }
142
143 let cos_a = n1.dot(&n2) / (d1 * d2);
144 let angle = cos_a.clamp(-1.0, 1.0).acos();
145
146 let sign = n1.cross(&n2).dot(&b2);
148 Some(if sign < 0.0 { -angle } else { angle })
149}
150
151fn assign_ez(mol: &Molecule, coords: &Coords3D, bond_idx: BondIdx) -> Option<(AtomIdx, CipCode)> {
152 let bond = mol.bond(bond_idx);
153 if bond.order != BondOrder::Double {
154 return None;
155 }
156
157 let a1 = bond.atom1;
158 let a2 = bond.atom2;
159
160 let subs_a1: Vec<AtomIdx> = mol
162 .neighbors(a1)
163 .filter(|(nb, _)| *nb != a2)
164 .map(|(nb, _)| nb)
165 .collect();
166 let subs_a2: Vec<AtomIdx> = mol
167 .neighbors(a2)
168 .filter(|(nb, _)| *nb != a1)
169 .map(|(nb, _)| nb)
170 .collect();
171
172 if subs_a1.is_empty() || subs_a2.is_empty() {
173 return None; }
175
176 let h1 = *subs_a1
178 .iter()
179 .max_by(|&&a, &&b| cip_priority::compare_branches(mol, a1, a, b))?;
180 let h2 = *subs_a2
181 .iter()
182 .max_by(|&&a, &&b| cip_priority::compare_branches(mol, a2, a, b))?;
183
184 if subs_a1.len() == 2
186 && cip_priority::compare_branches(mol, a1, subs_a1[0], subs_a1[1]) == Ordering::Equal
187 {
188 return None;
189 }
190 if subs_a2.len() == 2
191 && cip_priority::compare_branches(mol, a2, subs_a2[0], subs_a2[1]) == Ordering::Equal
192 {
193 return None;
194 }
195
196 let pa1 = coords.get(a1);
197 let pa2 = coords.get(a2);
198 let ph1 = coords.get(h1);
199 let ph2 = coords.get(h2);
200
201 let angle = dihedral(pa1, pa2, ph1, ph2)?;
202
203 let code = if angle.abs() < std::f64::consts::FRAC_PI_2 {
205 CipCode::Z
206 } else {
207 CipCode::E
208 };
209
210 Some((a1, code))
211}
212
213pub fn assign_stereo_from_3d(mol: &Molecule, coords: &Coords3D) -> StereoAssignment3D {
229 let mut assignments = Vec::new();
230
231 for i in 0..mol.atom_count() {
233 let idx = AtomIdx(i as u32);
234 if mol.atom(idx).element.atomic_number() == 1 {
236 continue;
237 }
238 let nb_count = mol.neighbors(idx).count();
239 if nb_count != 4 {
240 continue;
241 }
242 let all_single = mol.neighbors(idx).all(|(_, bidx)| {
244 matches!(
245 mol.bond(bidx).order,
246 BondOrder::Single | BondOrder::Up | BondOrder::Down
247 )
248 });
249 if !all_single {
250 continue;
251 }
252 if let Some(code) = assign_rs(mol, coords, idx) {
253 assignments.push((idx, code));
254 }
255 }
256
257 for j in 0..mol.bond_count() {
259 if let Some((atom_idx, code)) = assign_ez(mol, coords, BondIdx(j as u32)) {
260 assignments.push((atom_idx, code));
261 }
262 }
263
264 StereoAssignment3D { assignments }
265}
266
267#[cfg(test)]
272mod tests {
273 use super::*;
274 use chematic_smiles::parse;
275
276 use crate::coords::Coords3D;
277
278 fn mol(s: &str) -> chematic_core::Molecule {
279 parse(s).unwrap_or_else(|e| panic!("parse '{s}': {e}"))
280 }
281
282 #[allow(dead_code)]
285 fn make_coords4(
286 center: Point3,
287 s1: Point3,
288 s2: Point3,
289 s3: Point3,
290 s4: Point3,
291 n: usize,
292 ) -> Coords3D {
293 let mut c = Coords3D::new_zeroed(n);
294 c.set(AtomIdx(0), center);
295 if n > 1 {
296 c.set(AtomIdx(1), s1);
297 }
298 if n > 2 {
299 c.set(AtomIdx(2), s2);
300 }
301 if n > 3 {
302 c.set(AtomIdx(3), s3);
303 }
304 if n > 4 {
305 c.set(AtomIdx(4), s4);
306 }
307 c
308 }
309
310 #[test]
315 fn signed_volume_positive_ccw() {
316 let v = signed_volume(
319 Point3::new(1.0, 0.0, 0.0),
320 Point3::new(0.0, 1.0, 0.0),
321 Point3::new(0.0, 0.0, 1.0),
322 Point3::new(-1.0, -1.0, -1.0),
323 );
324 assert!(v > 0.0, "expected positive signed volume, got {v}");
325 }
326
327 #[test]
328 fn signed_volume_negative_cw() {
329 let v = signed_volume(
331 Point3::new(0.0, 1.0, 0.0),
332 Point3::new(1.0, 0.0, 0.0),
333 Point3::new(0.0, 0.0, 1.0),
334 Point3::new(-1.0, -1.0, -1.0),
335 );
336 assert!(v < 0.0, "expected negative signed volume, got {v}");
337 }
338
339 #[test]
350 fn rs_manual_s_center() {
351 let m = mol("[C]([Br])([F])([Cl])[I]");
352 assert_eq!(m.atom_count(), 5, "C + 4 halogens");
353 let mut coords = Coords3D::new_zeroed(5);
360 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);
367 let code = result.get(AtomIdx(0));
368 assert_eq!(code, Some(CipCode::S), "expected S, got {code:?}");
369 }
370
371 #[test]
372 fn rs_manual_r_center() {
373 let m = mol("[C]([Br])([F])([Cl])[I]");
374 let mut coords = Coords3D::new_zeroed(5);
376 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);
384 let code = result.get(AtomIdx(0));
385 assert_eq!(code, Some(CipCode::R), "expected R, got {code:?}");
386 }
387
388 #[test]
393 fn ez_trans_but2ene() {
394 let m = mol("ClC=CBr");
397 let mut coords = Coords3D::new_zeroed(4);
402 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);
420 let found_e = result
422 .assignments
423 .iter()
424 .any(|(_, code)| *code == CipCode::E);
425 assert!(
426 found_e,
427 "expected E assignment for trans ClCH=CHBr, got {:?}",
428 result.assignments
429 );
430 }
431
432 #[test]
433 fn ez_cis_arrangement() {
434 let m = mol("ClC=CBr");
436 let mut coords = Coords3D::new_zeroed(4);
437 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);
443 let found_z = result
444 .assignments
445 .iter()
446 .any(|(_, code)| *code == CipCode::Z);
447 assert!(
448 found_z,
449 "expected Z assignment for cis ClCH=CHBr, got {:?}",
450 result.assignments
451 );
452 }
453
454 #[test]
459 fn no_stereo_for_benzene() {
460 let m = mol("c1ccccc1");
461 let mut c = Coords3D::new_zeroed(m.atom_count());
462 for i in 0..m.atom_count() {
463 c.set(AtomIdx(i as u32), Point3::zero());
464 }
465 let result = assign_stereo_from_3d(&m, &c);
466 assert!(
467 result.assignments.is_empty(),
468 "benzene should have no stereo assignments"
469 );
470 }
471}