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}