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}