Skip to main content

chematic_core/
atom.rs

1//! Atom type: a single atom in a molecule.
2
3use crate::element::Element;
4
5/// A `@SP1`/`@SP2`/`@SP3` square-planar stereo tag (OpenSMILES's extended
6/// chirality-class syntax, e.g. Pt(II)/Pd(II) complexes like cisplatin).
7///
8/// Each variant names which pair of the 4 explicit neighbor positions
9/// (0-indexed, in the order recorded by [`crate::Molecule::stereo_neighbor_order`])
10/// sit *trans* (~180°) to each other:
11///
12/// - `SP1`: positions (0,2) trans, (1,3) trans
13/// - `SP2`: positions (0,1) trans, (2,3) trans
14/// - `SP3`: positions (0,3) trans, (1,2) trans
15///
16/// Oracle-verified against RDKit 2026.03.3 (3D embedding bond angles, cross-checked
17/// against RDKit's own documented cisplatin/transplatin example) — see
18/// `docs/rfcs/square_planar_stereo_rfc.md`.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum SquarePlanarPermutation {
21    SP1,
22    SP2,
23    SP3,
24}
25
26impl SquarePlanarPermutation {
27    /// The two trans-pairs this permutation implies, as 0-indexed neighbor positions.
28    pub fn trans_pairs(self) -> [(u8, u8); 2] {
29        match self {
30            Self::SP1 => [(0, 2), (1, 3)],
31            Self::SP2 => [(0, 1), (2, 3)],
32            Self::SP3 => [(0, 3), (1, 2)],
33        }
34    }
35}
36
37/// Chirality as specified in OpenSMILES: tetrahedral (`@`/`@@`) or, since this
38/// crate also models coordination complexes, square-planar (`@SP1`/`@SP2`/`@SP3`).
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
40pub enum Chirality {
41    /// No chirality specified.
42    #[default]
43    None,
44    /// `@` — counterclockwise (looking from the first neighbor).
45    CounterClockwise,
46    /// `@@` — clockwise.
47    Clockwise,
48    /// `@SP1`/`@SP2`/`@SP3` — square-planar (4-coordinate) stereo.
49    SquarePlanar(SquarePlanarPermutation),
50}
51
52impl Chirality {
53    /// `true` only for [`Self::CounterClockwise`]/[`Self::Clockwise`] — the classic
54    /// tetrahedral-parity forms every CIP/ECFP/dedup consumer written before
55    /// square-planar existed assumes. Consumers that mean "is this a real
56    /// tetrahedral stereocenter" must check this, not `!= Self::None`, now that a
57    /// second non-tetrahedral kind of "not None" chirality exists.
58    pub fn is_tetrahedral(&self) -> bool {
59        matches!(self, Self::CounterClockwise | Self::Clockwise)
60    }
61}
62
63/// Assigned CIP (Cahn–Ingold–Prelog) stereodescriptor.
64///
65/// Stored on [`Atom`] after running [`chematic_chem::assign_cip`] or
66/// [`chematic_chem::cip::assign_cip`].
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum CipCode {
69    /// Tetrahedral center with *rectus* (right-handed) configuration.
70    R,
71    /// Tetrahedral center with *sinister* (left-handed) configuration.
72    S,
73    /// Double-bond *entgegen* (opposite, trans) geometry.
74    E,
75    /// Double-bond *zusammen* (together, cis) geometry.
76    Z,
77    /// Pseudoasymmetric center, *rectus*-like (Rule 5, lowercase `r`). Emitted only by
78    /// `chematic_cip::assign_cip_accurate_experimental`'s Rule 5 pass; the default
79    /// `chematic_chem::assign_cip` never produces this variant.
80    LowerR,
81    /// Pseudoasymmetric center, *sinister*-like (Rule 5, lowercase `s`). See [`Self::LowerR`].
82    LowerS,
83}
84
85/// A single atom in a molecular graph.
86///
87/// - `isotope`: mass number (e.g. 13 for ¹³C). `None` = natural isotope abundance.
88/// - `charge`: formal charge.
89/// - `hydrogen_count`: explicit H count from a bracket atom `[...]`.
90///   `None` for organic-subset atoms whose H count is inferred from valence.
91/// - `aromatic`: set when the atom is written as a lowercase letter (c, n, …)
92///   or connected via `:` bonds.
93/// - `wildcard`: `true` for the SMILES `*` atom (any element, query context).
94/// - `atom_map`: atom-mapping number used in reaction SMILES.
95/// - `cip_code`: CIP stereodescriptor (R/S/E/Z). Populated by
96///   `chematic_chem::assign_cip`; `None` until then.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct Atom {
99    pub element: Element,
100    pub isotope: Option<u16>,
101    pub charge: i8,
102    /// Explicit H count (bracket atoms only). `None` for organic-subset atoms.
103    pub hydrogen_count: Option<u8>,
104    pub aromatic: bool,
105    pub chirality: Chirality,
106    /// True for the wildcard atom `*` or `[*]`.
107    pub wildcard: bool,
108    pub atom_map: Option<u16>,
109    /// CIP stereodescriptor assigned by `chematic_chem::assign_cip`.
110    /// `None` until explicitly computed.
111    pub cip_code: Option<CipCode>,
112}
113
114impl Atom {
115    /// Create a plain, neutral, non-aromatic atom.
116    pub fn new(element: Element) -> Self {
117        Self {
118            element,
119            isotope: None,
120            charge: 0,
121            hydrogen_count: None,
122            aromatic: false,
123            chirality: Chirality::None,
124            wildcard: false,
125            atom_map: None,
126            cip_code: None,
127        }
128    }
129
130    /// Organic-subset atom (charge=0, non-aromatic, implicit H from valence).
131    pub fn organic(element: Element) -> Self {
132        Self::new(element)
133    }
134
135    /// Aromatic organic atom (lowercase SMILES notation).
136    pub fn aromatic(element: Element) -> Self {
137        Self {
138            aromatic: true,
139            ..Self::new(element)
140        }
141    }
142
143    /// Bracket atom with explicit properties.
144    pub fn bracket(
145        element: Element,
146        isotope: Option<u16>,
147        chirality: Chirality,
148        hydrogen_count: u8,
149        charge: i8,
150        atom_map: Option<u16>,
151    ) -> Self {
152        Self {
153            element,
154            isotope,
155            charge,
156            hydrogen_count: Some(hydrogen_count),
157            aromatic: false,
158            chirality,
159            wildcard: false,
160            atom_map,
161            cip_code: None,
162        }
163    }
164
165    /// Wildcard atom `*` / `[*]` (matches any element in query contexts).
166    pub fn wildcard() -> Self {
167        Self {
168            // Element is a placeholder; callers should check `wildcard` first.
169            element: Element::C,
170            wildcard: true,
171            hydrogen_count: Some(0),
172            ..Self::new(Element::C)
173        }
174    }
175
176    /// Return the explicit H count for bracket atoms; `None` for organic-subset atoms.
177    pub fn explicit_hcount(&self) -> Option<u8> {
178        self.hydrogen_count
179    }
180}
181
182impl core::fmt::Display for Atom {
183    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
184        if self.wildcard {
185            return write!(f, "*");
186        }
187        let symbol = if self.aromatic {
188            self.element.symbol().to_lowercase()
189        } else {
190            self.element.symbol().to_string()
191        };
192        match self.isotope {
193            Some(iso) => write!(f, "[{iso}{symbol}]"),
194            None => write!(f, "{symbol}"),
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_atom_new() {
205        let a = Atom::new(Element::C);
206        assert_eq!(a.element, Element::C);
207        assert_eq!(a.charge, 0);
208        assert!(!a.aromatic);
209        assert!(!a.wildcard);
210        assert_eq!(a.hydrogen_count, None);
211    }
212
213    #[test]
214    fn test_aromatic_atom() {
215        let a = Atom::aromatic(Element::C);
216        assert!(a.aromatic);
217    }
218
219    #[test]
220    fn test_wildcard_atom() {
221        let a = Atom::wildcard();
222        assert!(a.wildcard);
223        assert_eq!(format!("{a}"), "*");
224    }
225}