Skip to main content

chematic_3d/
stereo3d.rs

1//! v0.1.93: Stereochemistry assignment from 3D coordinates with full CIP prioritization.
2//!
3//! [`assign_stereo_from_3d`] determines R/S (tetrahedral) and E/Z (alkene)
4//! stereo descriptors from 3D atom positions, without relying on SMILES
5//! wedge/dash bond annotations.
6//!
7//! **Priority rule**: v0.1.93+ uses full multi-sphere BFS CIP priority rules,
8//! superseding the simplified 1-sphere approach from prior versions.
9
10use 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// ---------------------------------------------------------------------------
18// Public result type
19// ---------------------------------------------------------------------------
20
21/// Result of a 3D-based stereochemistry assignment.
22#[derive(Debug, Default)]
23pub struct StereoAssignment3D {
24    pub assignments: Vec<(AtomIdx, CipCode)>,
25}
26
27impl StereoAssignment3D {
28    /// Look up the CIP code for a given atom index.
29    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
37// Note: priority helpers moved to chematic_perception::cip_priority (v0.1.93+)
38
39/// Rank 4 substituents by priority (rank 1 = lowest, rank 4 = highest).
40/// Returns `None` if any two substituents are tied (ambiguous).
41/// Uses full multi-sphere BFS CIP from chematic_perception.
42fn 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    // Check for ties.
47    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    // ranks[i] = rank of subs[i] (1 = lowest, 4 = highest).
56    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
63// ---------------------------------------------------------------------------
64// Signed volume
65// ---------------------------------------------------------------------------
66
67/// Signed volume of the tetrahedron with vertices p1, p2, p3, p4.
68///
69/// V = det([p1-p4, p2-p4, p3-p4])
70/// V > 0 → p1→p2→p3 appears CCW when viewed from p4 → S
71/// V < 0 → p1→p2→p3 appears CW when viewed from p4 → R
72fn 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    // det([a, b, c])
83    ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx)
84}
85
86// ---------------------------------------------------------------------------
87// R/S assignment
88// ---------------------------------------------------------------------------
89
90fn assign_rs(mol: &Molecule, coords: &Coords3D, idx: AtomIdx) -> Option<CipCode> {
91    // Collect heavy-atom neighbors.
92    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    // Sort subs so subs_sorted[0]=highest priority, subs_sorted[3]=lowest.
101    let mut order: [usize; 4] = [0, 1, 2, 3];
102    order.sort_by(|&i, &j| ranks[j].cmp(&ranks[i])); // descending rank
103    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    // V = signed_volume(highest, 2nd, 3rd; viewed from lowest)
111    let v = signed_volume(s[0], s[1], s[2], s[3]);
112
113    if v.abs() < 1e-6 {
114        return None; // degenerate / coplanar
115    }
116
117    // V > 0 → CCW sequence of 1→2→3 when viewed from 4 → S
118    // V < 0 → CW → R
119    Some(if v > 0.0 { CipCode::S } else { CipCode::R })
120}
121
122// ---------------------------------------------------------------------------
123// E/Z assignment
124// ---------------------------------------------------------------------------
125
126/// Dihedral angle (radians) for the sequence h1–a1=a2–h2.
127/// Returns `None` if vectors are degenerate.
128pub(crate) fn dihedral(pa1: Point3, pa2: Point3, ph1: Point3, ph2: Point3) -> Option<f64> {
129    // b1 = a1→h1, b2 = a1→a2 (bond axis), b3 = a2→h2
130    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    // Sign: (n1 × n2) · b2 > 0 → positive (same sense as b2)
147    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    // Substituents at each end (exclude the other alkene carbon).
161    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; // terminal alkene
174    }
175
176    // Highest-priority substituent at each end (by full multi-sphere CIP).
177    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 either end has two equal-priority substituents, skip.
185    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    // |angle| < π/2 → same side → Z (cis); |angle| ≥ π/2 → E (trans).
204    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
213// ---------------------------------------------------------------------------
214// Public entry point
215// ---------------------------------------------------------------------------
216
217/// Assign R/S and E/Z stereochemistry from 3D coordinates.
218///
219/// Uses full multi-sphere BFS CIP priority rules (v0.1.93+).
220/// Centers where priorities cannot be resolved are omitted from the result.
221///
222/// # Example
223/// ```rust,ignore
224/// let mol = parse("N[C@@H](C)C(=O)O").unwrap();
225/// let coords = assign_stereo_from_3d(&mol, &coords_3d);
226/// // returns S for the chiral center if the 3D coords encode L-alanine
227/// ```
228pub fn assign_stereo_from_3d(mol: &Molecule, coords: &Coords3D) -> StereoAssignment3D {
229    let mut assignments = Vec::new();
230
231    // R/S for sp3 centers (degree 4, all bonds single/up/down).
232    for i in 0..mol.atom_count() {
233        let idx = AtomIdx(i as u32);
234        // Only carbon and heteroatoms with exactly 4 heavy-atom neighbors.
235        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        // Verify all bonds from this atom are single (sp3-like).
243        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    // E/Z for double bonds.
258    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// ---------------------------------------------------------------------------
268// Tests
269// ---------------------------------------------------------------------------
270
271#[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    /// Build a known tetrahedral center manually for testing.
283    /// Center at origin; substituents placed at given positions.
284    #[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    // -------------------------------------------------------------------------
311    // signed_volume
312    // -------------------------------------------------------------------------
313
314    #[test]
315    fn signed_volume_positive_ccw() {
316        // p1=(1,0,0), p2=(0,1,0), p3=(0,0,1), p4=(-1,-1,-1)
317        // V = det([p1-p4, p2-p4, p3-p4]) = det([(2,1,1),(1,2,1),(1,1,2)]) = 4 > 0
318        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        // Swap p1 and p2 → negative.
330        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    // -------------------------------------------------------------------------
340    // R/S assignment with manual Coords3D
341    // -------------------------------------------------------------------------
342    //
343    // Molecule: BrC(F)(Cl)I — 4 different halogens + C center.
344    // C=atom0, Br=atom1(an=35), F=atom2(an=9), Cl=atom3(an=17), I=atom4(an=53).
345    // CIP priority: I(53) > Br(35) > Cl(17) > F(9).
346    //
347    // Place them so that I→Br→Cl appears CCW viewed from F → should be S.
348
349    #[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        // center=atom0 at origin.
354        // I(atom4)  = (0, 0,  1)   → highest priority (53)
355        // Br(atom1) = (1, 0, -0.3) → 2nd (35)
356        // Cl(atom3) = (-0.5,  0.87, -0.3) → 3rd (17)
357        // F(atom2)  = (-0.5, -0.87, -0.3) → lowest (9)
358        // Using signed_volume(I, Br, Cl; viewed from F) > 0 → S
359        let mut coords = Coords3D::new_zeroed(5);
360        coords.set(AtomIdx(0), Point3::new(0.0, 0.0, 0.0)); // C
361        coords.set(AtomIdx(1), Point3::new(1.0, 0.0, -0.3)); // Br
362        coords.set(AtomIdx(2), Point3::new(-0.5, -0.87, -0.3)); // F
363        coords.set(AtomIdx(3), Point3::new(-0.5, 0.87, -0.3)); // Cl
364        coords.set(AtomIdx(4), Point3::new(0.0, 0.0, 1.0)); // I
365
366        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        // Swap I and Br positions → inverts stereo → R
375        let mut coords = Coords3D::new_zeroed(5);
376        coords.set(AtomIdx(0), Point3::new(0.0, 0.0, 0.0)); // C
377        coords.set(AtomIdx(1), Point3::new(0.0, 0.0, 1.0)); // Br (now at top)
378        coords.set(AtomIdx(2), Point3::new(-0.5, -0.87, -0.3)); // F
379        coords.set(AtomIdx(3), Point3::new(-0.5, 0.87, -0.3)); // Cl
380        coords.set(AtomIdx(4), Point3::new(1.0, 0.0, -0.3)); // I (now at side)
381        // Now: I(53)=side, Br(35)=top, Cl(17)=side2, F(9)=bottom
382        // signed_volume(I, Br, Cl; viewed from F) should be < 0 → R
383        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    // -------------------------------------------------------------------------
389    // E/Z assignment
390    // -------------------------------------------------------------------------
391
392    #[test]
393    fn ez_trans_but2ene() {
394        // (E)-but-2-ene: Cl-CH=CH-Br type for easy priority.
395        // Use ClCH=CHBr (trans = E).
396        let m = mol("ClC=CBr");
397        // Place trans: Cl and Br on opposite sides.
398        // C0=Cl(an=17), C1=C(alkene,atom1), C2=C(alkene,atom2), C3=Br(an=35)
399        // C1=C2 double bond; subs: Cl at C1, Br at C2.
400        // Trans arrangement: Cl-C1=C2-Br dihedral ≈ 180°
401        let mut coords = Coords3D::new_zeroed(4);
402        coords.set(AtomIdx(0), Point3::new(-1.5, 0.0, 0.0)); // Cl
403        coords.set(AtomIdx(1), Point3::new(-0.67, 0.0, 0.0)); // C1 (alkene)
404        coords.set(AtomIdx(2), Point3::new(0.67, 0.0, 0.0)); // C2 (alkene)
405        coords.set(AtomIdx(3), Point3::new(2.0, 0.5, 0.0)); // Br (same side as Cl → trans? no...)
406
407        // For E (trans), Cl and Br should be on opposite sides of the C=C axis.
408        // Let's define: Cl at (-1.5, 0.5, 0), C1 at (-0.67, 0, 0), C2 at (0.67, 0, 0), Br at (1.5, -0.5, 0)
409        // Dihedral Cl-C1=C2-Br:
410        //   b1 = C1→Cl = (-0.83, 0.5, 0)
411        //   b2 = C1→C2 = (1.34, 0, 0)
412        //   b3 = C2→Br = (0.83, -0.5, 0)
413        //   n1 = b1 × b2 = (0, 0, 0.5*0 - 0*0) ... let's just trust the code.
414        coords.set(AtomIdx(0), Point3::new(-1.5, 0.5, 0.0)); // Cl (above)
415        coords.set(AtomIdx(1), Point3::new(-0.67, 0.0, 0.0)); // C1
416        coords.set(AtomIdx(2), Point3::new(0.67, 0.0, 0.0)); // C2
417        coords.set(AtomIdx(3), Point3::new(1.5, -0.5, 0.0)); // Br (below) → opposite = E
418
419        let result = assign_stereo_from_3d(&m, &coords);
420        // The double bond C1=C2 should be labeled E.
421        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        // Z: Cl and Br on the same side.
435        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)); // Cl (above)
438        coords.set(AtomIdx(1), Point3::new(-0.67, 0.0, 0.0)); // C1
439        coords.set(AtomIdx(2), Point3::new(0.67, 0.0, 0.0)); // C2
440        coords.set(AtomIdx(3), Point3::new(1.5, 0.5, 0.0)); // Br (also above) → same side = Z
441
442        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    // -------------------------------------------------------------------------
455    // Empty / degenerate inputs
456    // -------------------------------------------------------------------------
457
458    #[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}