Skip to main content

chematic_core/
coords3d.rs

1//! Minimal, algorithm-free 3D coordinate storage shared across crates.
2//!
3//! [`Coords3D`] holds one [`Point3`] per atom, indexed by atom insertion
4//! order (`AtomIdx.0` as `usize`). It carries no embedding, force-field, or
5//! alignment logic -- see the 3D Breakthrough Program's master plan
6//! (`docs/rfcs/3d_breakthrough_master_plan.md`, decision 1a) for why this type
7//! lives in `chematic-core` rather than `chematic-mol` importing
8//! `chematic_3d::coords::Coords3D` directly: `chematic-3d` pulls in
9//! `chematic-ff`/`chematic-chem`/`chematic-fp`/`chematic-smarts` transitively,
10//! an unwanted dependency footprint for `chematic-mol` and every other
11//! `chematic-core` consumer that never touches 3D generation. Every crate
12//! already depends on `chematic-core`, so this requires zero new dependency
13//! edges.
14//!
15//! **Field layout and method names deliberately mirror
16//! `chematic_3d::coords::{Point3, Coords3D}` as closely as possible** (same
17//! `x`/`y`/`z` fields, same `points: Vec<Point3>` layout, same
18//! `new_zeroed`/`get`/`set`/`atom_count` names and panics-on-out-of-range
19//! indexing convention) so that a later Coordinator-authored bridge PR can
20//! make `chematic_3d::coords` re-export this type
21//! (`pub use chematic_core::{Coords3D, Point3};`) with minimal changes to
22//! existing `chematic-3d` call sites. `chematic-3d`'s own `Coords3D` is not
23//! touched by this PR (read-only reference); the two types are reconciled at
24//! Wave 1->2 integration time, not here.
25//!
26//! This module adds two things `chematic_3d::coords` doesn't have:
27//! - `is_finite()` on both types. **Not currently called by this PR's own
28//!   reader code** -- `chematic-mol`'s V2000/V3000 z-coordinate parsers
29//!   validate NaN/Inf with a direct `f64::is_finite()` check on the raw
30//!   parsed value, *before* a `Point3`/`Coords3D` is ever constructed (see
31//!   `mol2000.rs`/`mol3000.rs`), so these methods have zero non-test callers
32//!   today. Provided for future consumers (e.g. Wave 2 work that may
33//!   construct a `Coords3D` from elsewhere and want to validate it after the
34//!   fact) rather than because this PR's own code needs them.
35//! - `Default`/`PartialEq` derives on `Coords3D` (`chematic_3d::coords::Coords3D`
36//!   derives only `Debug, Clone`) -- needed for this crate's own tests and
37//!   generally harmless additions for a plain data holder.
38
39use crate::molecule::AtomIdx;
40
41/// A 3D point in Cartesian space (Angstrom).
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct Point3 {
44    pub x: f64,
45    pub y: f64,
46    pub z: f64,
47}
48
49impl Point3 {
50    /// Create a new point.
51    #[inline]
52    pub fn new(x: f64, y: f64, z: f64) -> Self {
53        Self { x, y, z }
54    }
55
56    /// The origin (0, 0, 0).
57    #[inline]
58    pub fn zero() -> Self {
59        Self::new(0.0, 0.0, 0.0)
60    }
61
62    /// `true` when all three components are finite (not NaN, not Infinite).
63    #[inline]
64    pub fn is_finite(&self) -> bool {
65        self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
66    }
67
68    /// Euclidean distance to another point.
69    #[inline]
70    pub fn distance(&self, other: &Self) -> f64 {
71        let dx = self.x - other.x;
72        let dy = self.y - other.y;
73        let dz = self.z - other.z;
74        (dx * dx + dy * dy + dz * dz).sqrt()
75    }
76
77    /// Component-wise addition.
78    #[inline]
79    pub fn add(&self, other: &Self) -> Self {
80        Self::new(self.x + other.x, self.y + other.y, self.z + other.z)
81    }
82
83    /// Component-wise subtraction.
84    #[inline]
85    pub fn sub(&self, other: &Self) -> Self {
86        Self::new(self.x - other.x, self.y - other.y, self.z - other.z)
87    }
88
89    /// Scalar multiplication.
90    #[inline]
91    pub fn scale(&self, s: f64) -> Self {
92        Self::new(self.x * s, self.y * s, self.z * s)
93    }
94
95    /// Dot product.
96    #[inline]
97    pub fn dot(&self, other: &Self) -> f64 {
98        self.x * other.x + self.y * other.y + self.z * other.z
99    }
100
101    /// Cross product.
102    #[inline]
103    pub fn cross(&self, other: &Self) -> Self {
104        Self::new(
105            self.y * other.z - self.z * other.y,
106            self.z * other.x - self.x * other.z,
107            self.x * other.y - self.y * other.x,
108        )
109    }
110
111    /// Euclidean norm (length).
112    #[inline]
113    pub fn norm(&self) -> f64 {
114        (self.x * self.x + self.y * self.y + self.z * self.z).sqrt()
115    }
116
117    /// Normalize to a unit vector.
118    ///
119    /// # Panics
120    /// Panics if the vector has zero length.
121    pub fn normalize(&self) -> Self {
122        let n = self.norm();
123        assert!(n > 0.0, "cannot normalize a zero-length vector");
124        self.scale(1.0 / n)
125    }
126
127    /// Try to normalize to a unit vector, returning `None` if the vector has
128    /// zero length.
129    pub fn try_normalize(&self) -> Option<Self> {
130        let n = self.norm();
131        if n > 0.0 {
132            Some(self.scale(1.0 / n))
133        } else {
134            None
135        }
136    }
137}
138
139/// 3D coordinates for all heavy atoms in a molecule.
140///
141/// Indexed by atom insertion order (`AtomIdx.0` as `usize`). A dumb data
142/// holder only -- no embedding, minimization, or alignment logic (those stay
143/// in `chematic-3d`).
144#[derive(Debug, Clone, Default, PartialEq)]
145pub struct Coords3D {
146    pub points: Vec<Point3>,
147}
148
149impl Coords3D {
150    /// Create zeroed coordinates for `n` atoms.
151    pub fn new_zeroed(n: usize) -> Self {
152        Self {
153            points: vec![Point3::zero(); n],
154        }
155    }
156
157    /// Get the coordinate of atom `idx`.
158    ///
159    /// # Panics
160    /// Panics if `idx` is out of range (matching `chematic_3d::coords::Coords3D`'s
161    /// existing indexing convention).
162    pub fn get(&self, idx: AtomIdx) -> Point3 {
163        self.points[idx.0 as usize]
164    }
165
166    /// Set the coordinate of atom `idx`.
167    ///
168    /// # Panics
169    /// Panics if `idx` is out of range.
170    pub fn set(&mut self, idx: AtomIdx, p: Point3) {
171        self.points[idx.0 as usize] = p;
172    }
173
174    /// Number of atom coordinate slots.
175    pub fn atom_count(&self) -> usize {
176        self.points.len()
177    }
178
179    /// `true` when every point has all-finite components.
180    pub fn is_finite(&self) -> bool {
181        self.points.iter().all(Point3::is_finite)
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn new_zeroed_has_n_points_at_origin() {
191        let c = Coords3D::new_zeroed(3);
192        assert_eq!(c.atom_count(), 3);
193        for i in 0..3 {
194            assert_eq!(c.get(AtomIdx(i)), Point3::zero());
195        }
196    }
197
198    #[test]
199    fn get_set_roundtrip() {
200        let mut c = Coords3D::new_zeroed(2);
201        c.set(AtomIdx(1), Point3::new(1.0, 2.0, 3.0));
202        assert_eq!(c.get(AtomIdx(1)), Point3::new(1.0, 2.0, 3.0));
203        assert_eq!(c.get(AtomIdx(0)), Point3::zero());
204    }
205
206    #[test]
207    fn is_finite_true_for_normal_coords() {
208        let mut c = Coords3D::new_zeroed(1);
209        c.set(AtomIdx(0), Point3::new(1.0, -2.5, 0.0));
210        assert!(c.is_finite());
211    }
212
213    #[test]
214    fn is_finite_false_for_nan() {
215        let mut c = Coords3D::new_zeroed(1);
216        c.set(AtomIdx(0), Point3::new(f64::NAN, 0.0, 0.0));
217        assert!(!c.is_finite());
218    }
219
220    #[test]
221    fn is_finite_false_for_infinite() {
222        let mut c = Coords3D::new_zeroed(1);
223        c.set(AtomIdx(0), Point3::new(0.0, f64::INFINITY, 0.0));
224        assert!(!c.is_finite());
225    }
226
227    #[test]
228    fn point3_basic_vector_ops() {
229        let a = Point3::new(1.0, 0.0, 0.0);
230        let b = Point3::new(0.0, 1.0, 0.0);
231        assert_eq!(a.cross(&b), Point3::new(0.0, 0.0, 1.0));
232        assert_eq!(a.dot(&b), 0.0);
233        assert_eq!(a.distance(&b), std::f64::consts::SQRT_2);
234        assert_eq!(a.add(&b), Point3::new(1.0, 1.0, 0.0));
235    }
236}