molpack/target.rs
1//! Target builder for molecular packing.
2
3use std::sync::Arc;
4
5use crate::frame::frame_to_coords_and_elements;
6use crate::relaxer::Relaxer;
7use crate::restraint::{AtomRestraint, Restraint};
8use molrs::types::F;
9
10/// Cartesian axis selector used in `Target::with_rotation_bound` and
11/// other API surfaces that need to name an axis.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Axis {
14 X,
15 Y,
16 Z,
17}
18
19/// Angular quantity stored internally as radians.
20///
21/// Constructors make the unit explicit at the call site:
22/// `Angle::from_degrees(30.0)` vs `Angle::from_radians(FRAC_PI_6)`.
23/// Implements `Copy` — pass by value, no `&`.
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct Angle(F);
26
27impl Angle {
28 /// Zero rotation.
29 pub const ZERO: Self = Self(0.0);
30
31 pub const fn from_radians(rad: F) -> Self {
32 Self(rad)
33 }
34
35 pub fn from_degrees(deg: F) -> Self {
36 Self(deg * (std::f64::consts::PI as F) / 180.0)
37 }
38
39 pub const fn radians(self) -> F {
40 self.0
41 }
42
43 pub fn degrees(self) -> F {
44 self.0 * 180.0 / (std::f64::consts::PI as F)
45 }
46}
47
48/// Centering behavior for structure coordinates.
49///
50/// Packmol semantics:
51/// - `Auto`: free molecules are centered; fixed molecules are not centered.
52/// - `Center`: force centering.
53/// - `Off`: keep input coordinates unchanged.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum CenteringMode {
56 #[default]
57 Auto,
58 Center,
59 Off,
60}
61
62/// Fixed-molecule placement: translation + Euler orientation.
63#[derive(Debug, Clone)]
64pub struct Placement {
65 /// Translation vector `[x, y, z]`.
66 pub position: [F; 3],
67 /// Euler rotations around x / y / z in the `eulerfixed` convention,
68 /// stored as [`Angle`] triples.
69 pub orientation: [Angle; 3],
70}
71
72/// Describes one type of molecule to be packed.
73#[derive(Debug, Clone)]
74pub struct Target {
75 /// Input coordinates as provided by the source structure.
76 pub input_coords: Vec<[F; 3]>,
77 /// Flat list of atom positions — the centered reference coordinates.
78 /// Shape: natoms × 3, stored as Vec<[F; 3]>.
79 pub ref_coords: Vec<[F; 3]>,
80 /// Van der Waals radii per atom.
81 pub radii: Vec<F>,
82 /// Element symbols per atom (e.g. `"C"`, `"O"`). Defaults to `"X"` if unknown.
83 pub elements: Vec<String>,
84 /// Number of copies to pack.
85 pub count: usize,
86 /// Optional name for logging.
87 pub name: Option<String>,
88 /// Restraints applied to every atom of every molecule copy.
89 pub molecule_restraints: Vec<Arc<dyn AtomRestraint>>,
90 /// Per-atom-subset restraints: `(atom_indices_0_based, restraint)`.
91 /// Each entry holds the 0-based atom indices (converted from Packmol's
92 /// 1-based convention at registration time) and the restraint applied to them.
93 pub atom_restraints: Vec<(Vec<usize>, Arc<dyn AtomRestraint>)>,
94 /// Group-level restraints evaluated over **all copies** of this type at
95 /// once (e.g. distribution matching). Unlike `molecule_restraints`, these
96 /// couple the copies through their joint coordinate, so they cannot be
97 /// expressed as a per-atom [`AtomRestraint`]. See [`Restraint`].
98 pub collective_restraints: Vec<Arc<dyn Restraint>>,
99 /// Optional structure-level limit for the perturbation heuristic
100 /// (Packmol's `maxmove`).
101 pub perturb_budget: Option<usize>,
102 /// Centering policy.
103 pub centering: CenteringMode,
104 /// Rotation bounds in Euler variable order
105 /// `[beta(y), gama(z), teta(x)]` as `(center, half_width)` [`Angle`] pairs.
106 pub rotation_bound: [Option<(Angle, Angle)>; 3],
107 /// If `Some`, this molecule is fixed (one copy, placed at the given location).
108 pub fixed_at: Option<Placement>,
109 /// Per-target in-loop relaxers (e.g. torsion MC). Called in order each iteration.
110 pub relaxers: Vec<Box<dyn Relaxer>>,
111 /// Source frame this target was built from, retained so the packer can
112 /// replay its full topology (bonds/angles/…) and per-atom metadata onto
113 /// the packed coordinates. `None` for targets built from bare coordinates
114 /// ([`Target::from_coords`]), whose result frame is coordinates-only.
115 pub template: Option<molrs::Frame>,
116}
117
118impl Target {
119 /// Create a new target from a `molrs::Frame` (read from PDB/XYZ) and a copy count.
120 ///
121 /// Positions are extracted from the `"atoms"` block (`"x"`, `"y"`, `"z"` columns)
122 /// and automatically centered at the geometric center.
123 /// VdW radii and element symbols are looked up from the `"element"` column.
124 pub fn new(frame: molrs::Frame, count: usize) -> Self {
125 let (positions, radii, elements) = frame_to_coords_and_elements(&frame);
126 let mut target = Self::from_parts(&positions, &radii, elements, count);
127 target.template = Some(frame);
128 target
129 }
130
131 /// Create a new target directly from coordinate arrays.
132 ///
133 /// Useful for testing or when coordinates are already available.
134 /// Stores both raw input coordinates and a geometrically centered reference copy.
135 /// Effective usage follows [`CenteringMode::Auto`] unless overridden.
136 pub fn from_coords(frame_positions: &[[F; 3]], radii: &[F], count: usize) -> Self {
137 let n = frame_positions.len();
138 Self::from_parts(frame_positions, radii, vec!["X".to_string(); n], count)
139 }
140
141 fn from_parts(
142 frame_positions: &[[F; 3]],
143 radii: &[F],
144 elements: Vec<String>,
145 count: usize,
146 ) -> Self {
147 assert_eq!(
148 frame_positions.len(),
149 radii.len(),
150 "positions and radii must have the same length"
151 );
152 let input_coords = frame_positions.to_vec();
153 let ref_coords = centered_coords(frame_positions);
154 Self {
155 input_coords,
156 ref_coords,
157 radii: radii.to_vec(),
158 elements,
159 count,
160 name: None,
161 molecule_restraints: Vec::new(),
162 atom_restraints: Vec::new(),
163 collective_restraints: Vec::new(),
164 perturb_budget: None,
165 centering: CenteringMode::Auto,
166 rotation_bound: [None, None, None],
167 fixed_at: None,
168 relaxers: Vec::new(),
169 template: None,
170 }
171 }
172
173 pub fn with_name(mut self, name: impl Into<String>) -> Self {
174 self.name = Some(name.into());
175 self
176 }
177
178 /// Attach a restraint applied to every atom of every molecule copy.
179 pub fn with_restraint(mut self, r: impl AtomRestraint + 'static) -> Self {
180 self.molecule_restraints.push(Arc::new(r));
181 self
182 }
183
184 /// Attach a restraint for selected atoms of every molecule copy.
185 ///
186 /// # Atom indexing
187 ///
188 /// Indices are **0-based**, matching Rust convention: atom `0` is
189 /// the first atom in the PDB/XYZ file. For example, `&[0, 1, 2]`
190 /// selects the first three atoms. If you are porting from a Packmol
191 /// `.inp` file (which uses 1-based indices), subtract 1 at the
192 /// call site.
193 pub fn with_atom_restraint(
194 mut self,
195 indices: &[usize],
196 r: impl AtomRestraint + 'static,
197 ) -> Self {
198 self.atom_restraints.push((indices.to_vec(), Arc::new(r)));
199 self
200 }
201
202 /// Attach a group-level restraint evaluated over all copies of this type at
203 /// once (e.g. distribution matching). The restraint sees every copy's
204 /// coordinate jointly and returns a coupled gradient.
205 ///
206 /// Here `Restraint` is the **group/collective** trait
207 /// ([`crate::restraint::Restraint`]) — it sees every copy's coordinate at
208 /// once, not the per-atom [`AtomRestraint`].
209 pub fn with_collective_restraint(mut self, r: impl Restraint + 'static) -> Self {
210 self.collective_restraints.push(Arc::new(r));
211 self
212 }
213
214 /// Attach an in-loop relaxer for this target.
215 ///
216 /// Multiple relaxers can be attached (called in order).
217 /// Relaxers require `count == 1` because all copies share reference coords.
218 ///
219 /// Mirrors [`with_restraint`](Self::with_restraint) — a per-target builder method.
220 pub fn with_relaxer(mut self, relaxer: impl Relaxer + 'static) -> Self {
221 assert!(
222 self.count <= 1,
223 "relaxers require count == 1 (all copies share ref coords)"
224 );
225 self.relaxers.push(Box::new(relaxer));
226 self
227 }
228
229 /// Structure-level budget for the perturbation heuristic
230 /// (Packmol's `maxmove`). Defaults to `count` when unset.
231 pub fn with_perturb_budget(mut self, n: usize) -> Self {
232 self.perturb_budget = Some(n);
233 self
234 }
235
236 /// Set the centering policy.
237 ///
238 /// - [`CenteringMode::Auto`] (default): free molecules centered,
239 /// fixed molecules kept in place.
240 /// - [`CenteringMode::Center`]: always center.
241 /// - [`CenteringMode::Off`]: keep input coordinates unchanged.
242 pub fn with_centering(mut self, mode: CenteringMode) -> Self {
243 self.centering = mode;
244 self
245 }
246
247 /// Rotation bound on a single Euler axis, analogous to Packmol's
248 /// `constrain_rotation <axis> <center> <delta>`. Arguments are
249 /// [`Angle`] values — `Angle::from_degrees(30.0)` or
250 /// `Angle::from_radians(FRAC_PI_6)`.
251 pub fn with_rotation_bound(mut self, axis: Axis, center: Angle, half_width: Angle) -> Self {
252 let idx = match axis {
253 // Internal index order follows Packmol's Euler variable order
254 // `[beta(y), gama(z), teta(x)]`.
255 Axis::Y => 0,
256 Axis::Z => 1,
257 Axis::X => 2,
258 };
259 self.rotation_bound[idx] = Some((center, half_width));
260 self
261 }
262
263 /// Fix this molecule at a specific position with zero rotation.
264 ///
265 /// Forces `count` to 1 — a fixed molecule is by definition a single
266 /// copy. Pair with [`with_orientation`][Self::with_orientation] if
267 /// a non-zero Euler orientation is needed.
268 pub fn fixed_at(mut self, position: [F; 3]) -> Self {
269 assert!(
270 self.count <= 1,
271 "fixed_at() requires count <= 1, got count = {}. \
272 A fixed target is a single placed copy.",
273 self.count
274 );
275 self.fixed_at = Some(Placement {
276 position,
277 orientation: [Angle::ZERO; 3],
278 });
279 self.count = 1;
280 self
281 }
282
283 /// Set the Euler orientation of a previously-fixed target. Must be
284 /// called after [`fixed_at`][Self::fixed_at]; panics otherwise.
285 pub fn with_orientation(mut self, orientation: [Angle; 3]) -> Self {
286 let placement = self.fixed_at.as_mut().expect(
287 "with_orientation() requires a prior .fixed_at(pos) call — \
288 orientation is only meaningful on fixed targets",
289 );
290 placement.orientation = orientation;
291 self
292 }
293
294 pub fn natoms(&self) -> usize {
295 self.ref_coords.len()
296 }
297}
298
299fn centered_coords(coords: &[[F; 3]]) -> Vec<[F; 3]> {
300 let (cx, cy, cz) = geometric_center(coords);
301 coords
302 .iter()
303 .map(|p| [p[0] - cx, p[1] - cy, p[2] - cz])
304 .collect()
305}
306
307fn geometric_center(coords: &[[F; 3]]) -> (F, F, F) {
308 if coords.is_empty() {
309 return (0.0, 0.0, 0.0);
310 }
311 let n = coords.len() as F;
312 let cx = coords.iter().map(|p| p[0]).sum::<F>() / n;
313 let cy = coords.iter().map(|p| p[1]).sum::<F>() / n;
314 let cz = coords.iter().map(|p| p[2]).sum::<F>() / n;
315 (cx, cy, cz)
316}