Skip to main content

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}