Skip to main content

molpack/
initial.rs

1//! Initialization procedures.
2//! Port of `initial.f90`, `restmol.f90`, `cenmass.f90`.
3//!
4//! Call order matches Packmol's `initial.f90` exactly:
5//!   1. compute_dmax
6//!   2. restmol(type 0, solve=true) → sizemin/sizemax
7//!   3. first random guess (within sizemin/sizemax)
8//!   4. Phase 1: constraint-only GENCAN per type (reduced x!)
9//!   5. Rescale sizemin/sizemax + compute cm_min/cm_max per type
10//!   6. Setup cell grid + place fixed atoms
11//!   7. Random initial point using cm_min/cm_max (restmol(false) only, up to 20 tries)
12//!   8. Random angles
13//!   9. Phase 2: constraint-only GENCAN per type (reduced x!)
14
15use molrs::types::F;
16use std::time::Instant;
17
18use crate::cell::{cell_ind, index_cell, setcell};
19use crate::constraints::EvalMode;
20use crate::context::{NONE_IDX, PackContext};
21use crate::euler::{compcart, eulerrmat};
22use crate::gencan::{GencanParams, GencanWorkspace, pgencan};
23use crate::movebad::{MoveBadConfig, movebad};
24use crate::random::uniform01;
25
26use rand::Rng;
27
28const TWO_PI: F = std::f64::consts::TAU as F;
29/// Max tries for random placement per molecule (Packmol `max_guess_try = 20`).
30const MAX_GUESS_TRY: usize = 20;
31
32// ── swaptype state ─────────────────────────────────────────────────────────
33
34/// Saved state for swaptype operations (mirrors `swaptypemod.f90`).
35pub struct SwapState {
36    /// Full x vector (all molecules of all types), indexed [COM...|euler...].
37    xfull: Vec<F>,
38    /// Original `sys.ntotmol` (all free molecules).
39    ntotmol_full: usize,
40}
41
42impl SwapState {
43    /// action=0: save full x.
44    pub fn init(x: &[F], sys: &PackContext) -> Self {
45        SwapState {
46            xfull: x.to_vec(),
47            ntotmol_full: sys.ntotmol,
48        }
49    }
50
51    /// action=1: set up reduced x for `itype` only.
52    ///
53    /// Returns the compact x vector (length = `nmols[itype]` * 6).
54    /// Also updates `sys.ntotmol` and `sys.comptype`.
55    pub fn set_type(&self, itype: usize, sys: &mut PackContext) -> Vec<F> {
56        // Byte-offsets in xfull for this type's COM/euler variables
57        // (Packmol swaptype.f90 action 1, with 0-based indexing)
58        let ilubar_start: usize = sys.nmols[0..itype].iter().sum::<usize>() * 3;
59        let ilugan_start: usize = self.ntotmol_full * 3 + ilubar_start;
60        let nm = sys.nmols[itype];
61
62        let mut xtype = vec![0.0 as F; nm * 6];
63        xtype[..nm * 3].copy_from_slice(&self.xfull[ilubar_start..ilubar_start + nm * 3]);
64        xtype[nm * 3..nm * 6].copy_from_slice(&self.xfull[ilugan_start..ilugan_start + nm * 3]);
65
66        // Reduce ntotmol + set comptype
67        sys.ntotmol = nm;
68        for i in 0..sys.ntype_with_fixed {
69            sys.comptype[i] = i >= sys.ntype || i == itype;
70        }
71
72        xtype
73    }
74
75    /// action=2: save per-type results back into xfull.
76    pub fn save_type(&mut self, itype: usize, xtype: &[F], sys: &PackContext) {
77        let ilubar_start: usize = sys.nmols[0..itype].iter().sum::<usize>() * 3;
78        let ilugan_start: usize = self.ntotmol_full * 3 + ilubar_start;
79        let nm = sys.nmols[itype];
80
81        self.xfull[ilubar_start..ilubar_start + nm * 3].copy_from_slice(&xtype[..nm * 3]);
82        self.xfull[ilugan_start..ilugan_start + nm * 3].copy_from_slice(&xtype[nm * 3..nm * 6]);
83    }
84
85    /// action=3: restore full x and ntotmol.
86    pub fn restore(&self, x: &mut [F], sys: &mut PackContext) {
87        debug_assert_eq!(x.len(), self.xfull.len());
88        x.copy_from_slice(&self.xfull);
89        sys.ntotmol = self.ntotmol_full;
90        for i in 0..sys.ntype_with_fixed {
91            sys.comptype[i] = true;
92        }
93    }
94}
95
96// ── dmax ───────────────────────────────────────────────────────────────────
97
98/// Compute maximum internal distance per molecule type.
99pub fn compute_dmax(sys: &mut PackContext) {
100    sys.dmax = vec![0.0 as F; sys.ntype];
101    for itype in 0..sys.ntype {
102        let idatom_base = sys.idfirst[itype];
103        let na = sys.natoms[itype];
104        for ia in 0..na {
105            for ib in (ia + 1)..na {
106                let a = sys.coor[idatom_base + ia];
107                let b = sys.coor[idatom_base + ib];
108                let d2 = (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2);
109                if d2 > sys.dmax[itype] {
110                    sys.dmax[itype] = d2;
111                }
112            }
113        }
114        sys.dmax[itype] = sys.dmax[itype].sqrt();
115        if sys.dmax[itype] == 0.0 {
116            sys.dmax[itype] = 1.0;
117        }
118        log::debug!("  dmax type {itype}: {:.4}", sys.dmax[itype]);
119    }
120}
121
122// ── restmol ────────────────────────────────────────────────────────────────
123
124/// Scoped state override for `restmol`; restores context on drop.
125struct RestmolScope<'a> {
126    sys: &'a mut PackContext,
127    itype: usize,
128    ntotmol: usize,
129    nmols_itype: usize,
130    comptype: Vec<bool>,
131    init1: bool,
132}
133
134impl<'a> RestmolScope<'a> {
135    fn enter(sys: &'a mut PackContext, itype: usize) -> Self {
136        let saved = Self {
137            ntotmol: sys.ntotmol,
138            nmols_itype: sys.nmols[itype],
139            comptype: sys.comptype.clone(),
140            init1: sys.init1,
141            itype,
142            sys,
143        };
144
145        saved.sys.ntotmol = 1;
146        // Only reduce the active type to 1 molecule.
147        // Other types keep their original nmols so compute_f's icart counter advances
148        // correctly past them — preserving the constraint array index alignment.
149        // (Packmol restmol.f90 line 34: only nmols(itype) = 1, others unchanged.)
150        saved.sys.nmols[itype] = 1;
151        for i in 0..saved.sys.ntype_with_fixed {
152            saved.sys.comptype[i] = i == itype;
153        }
154        saved.sys.init1 = true; // constraint-only, no cell list
155
156        saved
157    }
158
159    fn ctx_mut(&mut self) -> &mut PackContext {
160        self.sys
161    }
162}
163
164impl Drop for RestmolScope<'_> {
165    fn drop(&mut self) {
166        self.sys.ntotmol = self.ntotmol;
167        self.sys.nmols[self.itype] = self.nmols_itype;
168        self.sys.comptype.clone_from(&self.comptype);
169        self.sys.init1 = self.init1;
170    }
171}
172
173/// Run a single-molecule GENCAN solve (restmol).
174/// Port of `restmol.f90`.
175///
176/// `ilubar` is the offset in `x` for the COM of this molecule.
177/// Euler angles are at `x[ilubar + ntotmol*3 ..]`.
178///
179/// - `solve = false`: evaluate constraint function only (no optimization).
180/// - `solve = true`: run GENCAN to minimize constraint violations.
181///
182/// On return, `sys.frest` holds the constraint violation for this molecule.
183#[allow(clippy::too_many_arguments)]
184pub fn restmol(
185    itype: usize,
186    ilubar: usize,
187    x: &mut [F],
188    sys: &mut PackContext,
189    precision: F,
190    gencan_maxit: usize,
191    solve: bool,
192    workspace: &mut GencanWorkspace,
193) {
194    let ilugan_offset = sys.ntotmol * 3;
195    let mut xmol = vec![0.0 as F; 6];
196    xmol[0] = x[ilubar];
197    xmol[1] = x[ilubar + 1];
198    xmol[2] = x[ilubar + 2];
199    xmol[3] = x[ilubar + ilugan_offset];
200    xmol[4] = x[ilubar + ilugan_offset + 1];
201    xmol[5] = x[ilubar + ilugan_offset + 2];
202
203    {
204        let mut scope = RestmolScope::enter(sys, itype);
205        let sys = scope.ctx_mut();
206        if !solve {
207            sys.evaluate(&xmol, EvalMode::FOnly, None);
208        } else {
209            let params = GencanParams {
210                maxit: gencan_maxit,
211                maxfc: gencan_maxit * 10,
212                iprint: 0,
213                ..Default::default()
214            };
215            pgencan(&mut xmol, sys, &params, precision, workspace);
216        }
217    }
218
219    x[ilubar] = xmol[0];
220    x[ilubar + 1] = xmol[1];
221    x[ilubar + 2] = xmol[2];
222    x[ilubar + ilugan_offset] = xmol[3];
223    x[ilubar + ilugan_offset + 1] = xmol[4];
224    x[ilubar + ilugan_offset + 2] = xmol[5];
225    // sys.frest retains the value from the restmol compute_f
226}
227
228// ── gencan loop for one type ───────────────────────────────────────────────
229
230/// Run the GENCAN init loop for one type on a **compact** xtype vector.
231///
232/// Equivalent to Packmol's per-type init loop:
233/// ```text
234/// do while (frest > precision .and. i < nloop0_type)
235///   pgencan; computef; movebad if needed
236/// done
237/// ```
238#[allow(clippy::too_many_arguments)]
239fn init_loop_one_type(
240    itype: usize,
241    nloop0: usize,
242    xtype: &mut [F],
243    sys: &mut PackContext,
244    precision: F,
245    gencan_maxit: usize,
246    movebad_cfg: &MoveBadConfig<'_>,
247    rng: &mut impl Rng,
248    t0: &Instant,
249    workspace: &mut GencanWorkspace,
250) {
251    // Packmol calls pgencan with global maxit (default 20) inside each nloop0 loop.
252    // nloop0 controls the number of outer loops, not the inner maxit.
253    let params = GencanParams {
254        maxit: gencan_maxit,
255        maxfc: gencan_maxit * 10,
256        iprint: 0,
257        ..Default::default()
258    };
259
260    let mut iter = 0usize;
261    sys.evaluate(xtype, EvalMode::FOnly, None);
262    log::debug!(
263        "[{:.3}s]     initial frest={:.4e}",
264        t0.elapsed().as_secs_f64(),
265        sys.frest
266    );
267
268    while sys.frest > precision && iter < nloop0 {
269        iter += 1;
270        pgencan(xtype, sys, &params, precision, workspace);
271        sys.evaluate(xtype, EvalMode::FOnly, None);
272        log::debug!(
273            "[{:.3}s]     post-gencan frest={:.4e}",
274            t0.elapsed().as_secs_f64(),
275            sys.frest
276        );
277        if sys.frest > precision {
278            log::debug!(
279                "[{:.3}s]     movebad iter {iter}",
280                t0.elapsed().as_secs_f64()
281            );
282            movebad(xtype, sys, precision, movebad_cfg, rng, workspace);
283        }
284    }
285    log::debug!(
286        "[{:.3}s]   type {itype} done (nloop0={nloop0}): frest={:.4e}",
287        t0.elapsed().as_secs_f64(),
288        sys.frest
289    );
290}
291
292// ── full initialization ────────────────────────────────────────────────────
293
294/// Full initialization procedure.
295/// Faithful port of `initial.f90`.
296#[allow(clippy::too_many_arguments)]
297pub fn initial(
298    x: &mut [F],
299    sys: &mut PackContext,
300    precision: F,
301    discale: F,
302    sidemax: F,
303    nloop0: usize,
304    pbc: Option<([F; 3], [F; 3], [bool; 3])>,
305    avoid_overlap: bool,
306    movebad_cfg: &MoveBadConfig<'_>,
307    rng: &mut impl Rng,
308) {
309    let t0 = Instant::now();
310    let mut workspace = GencanWorkspace::new();
311
312    sys.move_flag = false;
313    sys.init1 = false;
314    sys.lcellfirst = NONE_IDX;
315
316    for i in 0..sys.ntype_with_fixed {
317        sys.comptype[i] = true;
318    }
319
320    // Packmol initial.f90 line 50-51
321    sys.scale = 1.0;
322    sys.scale2 = 0.01;
323
324    // ── 1. compute dmax ──────────────────────────────────────────────────────
325    log::debug!("[{:.3}s] computing dmax", t0.elapsed().as_secs_f64());
326    compute_dmax(sys);
327
328    // ── 2. restmol(type 0) → sizemin/sizemax ─────────────────────────────────
329    // Packmol initial.f90 lines 77-84
330    x.fill(0.0);
331    log::debug!(
332        "[{:.3}s] initial restmol → sizemin/sizemax",
333        t0.elapsed().as_secs_f64()
334    );
335    restmol(
336        0,
337        0,
338        x,
339        sys,
340        precision,
341        movebad_cfg.gencan_maxit,
342        true,
343        &mut workspace,
344    );
345    let cm0 = [x[0], x[1], x[2]];
346    sys.sizemin = [cm0[0] - sidemax, cm0[1] - sidemax, cm0[2] - sidemax];
347    sys.sizemax = [cm0[0] + sidemax, cm0[1] + sidemax, cm0[2] + sidemax];
348    log::debug!(
349        "[{:.3}s] sizemin={:?}  sizemax={:?}",
350        t0.elapsed().as_secs_f64(),
351        sys.sizemin,
352        sys.sizemax
353    );
354
355    // ── 3. first random guess ─────────────────────────────────────────────────
356    // Packmol initial.f90 lines 88-117
357    log::debug!(
358        "[{:.3}s] generating first random guess",
359        t0.elapsed().as_secs_f64()
360    );
361    {
362        let mut ilubar = 0usize;
363        let mut ilugan = sys.ntotmol * 3;
364        for itype in 0..sys.ntype {
365            for _imol in 0..sys.nmols[itype] {
366                x[ilubar] = sys.sizemin[0] + uniform01(rng) * (sys.sizemax[0] - sys.sizemin[0]);
367                x[ilubar + 1] = sys.sizemin[1] + uniform01(rng) * (sys.sizemax[1] - sys.sizemin[1]);
368                x[ilubar + 2] = sys.sizemin[2] + uniform01(rng) * (sys.sizemax[2] - sys.sizemin[2]);
369                x[ilugan] = random_angle_for_type(itype, 0, sys, rng);
370                x[ilugan + 1] = random_angle_for_type(itype, 1, sys, rng);
371                x[ilugan + 2] = random_angle_for_type(itype, 2, sys, rng);
372                ilubar += 3;
373                ilugan += 3;
374            }
375        }
376    }
377
378    // Init xcart (Packmol initial.f90 lines 121-138)
379    init_xcart_from_x(x, sys);
380
381    let free_atoms = sys.ntotat - sys.nfixedat;
382    // Packmol's initial.f90 lines 140-165 re-flip fixedatom=true on the
383    // fixed-atom tail here, but by this point `Molpack::pack` has already
384    // done that and called `sync_atom_props` — writing the `Vec<bool>`
385    // directly would desynchronize `atom_props` and trip the debug
386    // invariant in `compute_f`. The assertion below confirms the state
387    // inherited from `pack()` is already what Packmol expects.
388    debug_assert!((free_atoms..sys.ntotat).all(|icart| sys.fixedatom[icart]));
389
390    // ── 4. Phase 1: constraint-only GENCAN per type (reduced x) ──────────────
391    // Packmol initial.f90 lines 174-224 (via swaptype)
392    log::debug!(
393        "[{:.3}s] Phase 1: constraint-only GENCAN ({} types, nloop0={})",
394        t0.elapsed().as_secs_f64(),
395        sys.ntype,
396        nloop0,
397    );
398    sys.init1 = true;
399    {
400        let mut swap = SwapState::init(x, sys);
401        for itype in 0..sys.ntype {
402            let nm = sys.nmols[itype];
403            log::debug!(
404                "[{:.3}s]   type {itype}: {nm} mols × {} atoms  (n={})",
405                t0.elapsed().as_secs_f64(),
406                sys.natoms[itype],
407                nm * 6
408            );
409            let mut xtype = swap.set_type(itype, sys);
410            init_loop_one_type(
411                itype,
412                nloop0,
413                &mut xtype,
414                sys,
415                precision,
416                movebad_cfg.gencan_maxit,
417                movebad_cfg,
418                rng,
419                &t0,
420                &mut workspace,
421            );
422            swap.save_type(itype, &xtype, sys);
423        }
424        swap.restore(x, sys);
425    }
426    sys.init1 = false;
427
428    // ── 5. Rescale sizemin/sizemax + compute cm_min/cm_max ───────────────────
429    // Packmol initial.f90 lines 227-336
430    log::debug!(
431        "[{:.3}s] rescaling bounds + computing cm_min/cm_max",
432        t0.elapsed().as_secs_f64()
433    );
434
435    // Update xcart from the Phase-1 result
436    init_xcart_from_x(x, sys);
437
438    // Packmol sets radmax as the maximum *diameter* (2 * radius),
439    // not the maximum radius (packmol.f90 lines 532-534).
440    let radmax = sys
441        .radius_ini
442        .iter()
443        .copied()
444        .map(|r| 2.0 * r)
445        .fold(0.0 as F, F::max);
446
447    let mut smin = [1.0e20 as F; 3];
448    let mut smax = [-1.0e20 as F; 3];
449
450    // Fixed atoms (Packmol lines 234-246)
451    for icart in free_atoms..sys.ntotat {
452        let pos = sys.xcart[icart];
453        for k in 0..3 {
454            smin[k] = smin[k].min(pos[k]);
455            smax[k] = smax[k].max(pos[k]);
456        }
457    }
458
459    // Free atoms + compute cm_min/cm_max per type (Packmol lines 248-336)
460    let mut cm_min_per_type = vec![[1.0e20 as F; 3]; sys.ntype];
461    let mut cm_max_per_type = vec![[-1.0e20 as F; 3]; sys.ntype];
462    {
463        let mut icart = 0usize;
464        for itype in 0..sys.ntype {
465            for _imol in 0..sys.nmols[itype] {
466                let mut xcm = [0.0 as F; 3];
467                for _iatom in 0..sys.natoms[itype] {
468                    let pos = sys.xcart[icart];
469                    for k in 0..3 {
470                        smin[k] = smin[k].min(pos[k]);
471                        smax[k] = smax[k].max(pos[k]);
472                        xcm[k] += pos[k];
473                    }
474                    icart += 1;
475                }
476                let na = sys.natoms[itype] as F;
477                for k in 0..3 {
478                    xcm[k] /= na;
479                    cm_min_per_type[itype][k] = cm_min_per_type[itype][k].min(xcm[k]);
480                    cm_max_per_type[itype][k] = cm_max_per_type[itype][k].max(xcm[k]);
481                }
482            }
483        }
484    }
485
486    // Guard: if no atoms were found, fall back to sizemin/sizemax
487    for k in 0..3 {
488        if smin[k] > 1.0e19 {
489            smin[k] = sys.sizemin[k];
490            smax[k] = sys.sizemax[k];
491        }
492    }
493
494    // Apply 1.1*radmax padding (Packmol lines 266-267)
495    for k in 0..3 {
496        sys.sizemin[k] = smin[k] - 1.1 * radmax;
497        sys.sizemax[k] = smax[k] + 1.1 * radmax;
498    }
499
500    log::debug!(
501        "[{:.3}s] sizemin={:?}  sizemax={:?}  radmax={:.4}",
502        t0.elapsed().as_secs_f64(),
503        sys.sizemin,
504        sys.sizemax,
505        radmax
506    );
507    for itype in 0..sys.ntype {
508        log::debug!(
509            "[{:.3}s]   type {itype} cm range: min={:?}  max={:?}",
510            t0.elapsed().as_secs_f64(),
511            cm_min_per_type[itype],
512            cm_max_per_type[itype]
513        );
514    }
515
516    // ── 6. Setup periodic box + cell grid + fixed atoms ──────────────────────
517    // Packmol initial.f90 lines 272-317
518    if let Some((pbc_min, pbc_max, pbc_periodic)) = pbc {
519        sys.pbc_min = pbc_min;
520        sys.pbc_length = [
521            pbc_max[0] - pbc_min[0],
522            pbc_max[1] - pbc_min[1],
523            pbc_max[2] - pbc_min[2],
524        ];
525        sys.pbc_periodic = pbc_periodic;
526    } else {
527        sys.pbc_min = sys.sizemin;
528        sys.pbc_length = [
529            sys.sizemax[0] - sys.sizemin[0],
530            sys.sizemax[1] - sys.sizemin[1],
531            sys.sizemax[2] - sys.sizemin[2],
532        ];
533        sys.pbc_periodic = [false; 3];
534    }
535
536    let cell_side = if radmax > 0.0 {
537        discale * 1.01 * radmax
538    } else {
539        1.0
540    };
541    log::debug!(
542        "[{:.3}s] setting up cell grid (cell_side={:.4})",
543        t0.elapsed().as_secs_f64(),
544        cell_side
545    );
546    // Raw grid resolution: one cell per `cell_side` along each axis.
547    let raw = [
548        ((sys.pbc_length[0] / cell_side).floor() as usize).max(1),
549        ((sys.pbc_length[1] / cell_side).floor() as usize).max(1),
550        ((sys.pbc_length[2] / cell_side).floor() as usize).max(1),
551    ];
552    // Cap the total cell count. With no spatial constraint the fallback box is
553    // ±`sidemax` (default 1000 Å) wide, which drives the raw grid to ~10⁹ cells
554    // and OOMs `resize_cell_arrays` (each cell costs ~120 B across the cell
555    // arrays). There is no benefit to having far more cells than atoms, so the
556    // budget scales with `ntotat` under a hard ceiling. Coarser cells only slow
557    // the neighbor search — they never change the packing result.
558    let max_total_cells = sys.ntotat.max(1).saturating_mul(64).clamp(1 << 16, 1 << 22);
559    let raw_total = raw[0].saturating_mul(raw[1]).saturating_mul(raw[2]);
560    let shrink = if raw_total > max_total_cells {
561        (raw_total as f64 / max_total_cells as f64).cbrt()
562    } else {
563        1.0
564    };
565    for (k, &raw_k) in raw.iter().enumerate() {
566        sys.ncells[k] = ((raw_k as f64 / shrink).floor() as usize).max(1);
567        sys.cell_length[k] = sys.pbc_length[k] / sys.ncells[k] as F;
568    }
569    log::debug!(
570        "[{:.3}s] ncells={:?}  cell_length={:?}",
571        t0.elapsed().as_secs_f64(),
572        sys.ncells,
573        sys.cell_length
574    );
575
576    sys.resize_cell_arrays();
577
578    // Add fixed atoms to latomfix (Packmol lines 303-318)
579    for icart in free_atoms..sys.ntotat {
580        let pos = sys.xcart[icart];
581        let cell = setcell(
582            &pos,
583            &sys.pbc_min,
584            &sys.pbc_length,
585            &sys.cell_length,
586            &sys.ncells,
587            &sys.pbc_periodic,
588        );
589        let icell = index_cell(&cell, &sys.ncells);
590        if sys.latomfix[icell] == NONE_IDX {
591            sys.fixed_cells.push(icell);
592        }
593        sys.latomnext[icart] = sys.latomfix[icell];
594        sys.latomfix[icell] = icart as u32;
595    }
596
597    // ── 7. Random initial point using cm_min/cm_max ───────────────────────────
598    // Packmol initial.f90 lines 362-427
599    // For each molecule: try up to MAX_GUESS_TRY random positions within the
600    // per-type COM bounding box, calling restmol(false) to check constraints.
601    // Packmol does NOT call restmol(true) per molecule here.
602    log::debug!(
603        "[{:.3}s] setting random initial point ({} types, {} total mols)",
604        t0.elapsed().as_secs_f64(),
605        sys.ntype,
606        sys.ntotmol
607    );
608    // Packmol's `fix` flag, gated by the `avoid_overlap` keyword: only reject
609    // placements near fixed atoms when avoidance is enabled and such atoms exist.
610    let has_fixed = avoid_overlap && sys.nfixedat > 0;
611    {
612        let mut ilubar = 0usize;
613        for itype in 0..sys.ntype {
614            let cm_lo = cm_min_per_type[itype];
615            let cm_hi = cm_max_per_type[itype];
616            let nmols = sys.nmols[itype];
617            log::debug!(
618                "[{:.3}s]   type {itype}: {nmols} mols, \
619                 cm_x=[{:.2},{:.2}] cm_y=[{:.2},{:.2}] cm_z=[{:.2},{:.2}]",
620                t0.elapsed().as_secs_f64(),
621                cm_lo[0],
622                cm_hi[0],
623                cm_lo[1],
624                cm_hi[1],
625                cm_lo[2],
626                cm_hi[2]
627            );
628            for _imol in 0..nmols {
629                // Packmol initial.f90:396-423 (avoidoverlap, default .true.): retry the
630                // random COM until it both satisfies the region constraints AND does not
631                // land within a ±1-cell stencil of a fixed (e.g. solute) atom. Skipping
632                // the fixed-atom rejection seeds ~15-20% of a dense solvent inside a large
633                // fixed solute, inflating the initial overlap ~2× and stalling GENCAN.
634                let mut ntry = 0usize;
635                let mut fmol = 1.0 as F;
636                let mut overlap = false;
637                while (overlap || fmol > precision) && ntry < MAX_GUESS_TRY {
638                    overlap = false;
639                    ntry += 1;
640                    let rx: F = uniform01(rng);
641                    let ry: F = uniform01(rng);
642                    let rz: F = uniform01(rng);
643                    x[ilubar] = cm_lo[0] + rx * (cm_hi[0] - cm_lo[0]);
644                    x[ilubar + 1] = cm_lo[1] + ry * (cm_hi[1] - cm_lo[1]);
645                    x[ilubar + 2] = cm_lo[2] + rz * (cm_hi[2] - cm_lo[2]);
646                    if has_fixed {
647                        let pos = [x[ilubar], x[ilubar + 1], x[ilubar + 2]];
648                        let cell = setcell(
649                            &pos,
650                            &sys.pbc_min,
651                            &sys.pbc_length,
652                            &sys.cell_length,
653                            &sys.ncells,
654                            &sys.pbc_periodic,
655                        );
656                        'scan: for ic in -1isize..=1 {
657                            for jc in -1isize..=1 {
658                                for kc in -1isize..=1 {
659                                    let nc = [
660                                        cell_ind(cell[0] as isize + ic, sys.ncells[0]),
661                                        cell_ind(cell[1] as isize + jc, sys.ncells[1]),
662                                        cell_ind(cell[2] as isize + kc, sys.ncells[2]),
663                                    ];
664                                    if sys.latomfix[index_cell(&nc, &sys.ncells)] != NONE_IDX {
665                                        overlap = true;
666                                        break 'scan;
667                                    }
668                                }
669                            }
670                        }
671                    }
672                    if !overlap {
673                        restmol(
674                            itype,
675                            ilubar,
676                            x,
677                            sys,
678                            precision,
679                            movebad_cfg.gencan_maxit,
680                            false,
681                            &mut workspace,
682                        );
683                        fmol = sys.frest;
684                    }
685                }
686                ilubar += 3;
687            }
688            log::debug!(
689                "[{:.3}s]   type {itype} placement done",
690                t0.elapsed().as_secs_f64()
691            );
692        }
693    }
694
695    // ── 8. Random angles ─────────────────────────────────────────────────────
696    // Packmol initial.f90 lines 431-458
697    log::debug!("[{:.3}s] setting random angles", t0.elapsed().as_secs_f64());
698    {
699        let mut ilugan = sys.ntotmol * 3;
700        for itype in 0..sys.ntype {
701            for _imol in 0..sys.nmols[itype] {
702                x[ilugan] = random_angle_for_type(itype, 0, sys, rng);
703                x[ilugan + 1] = random_angle_for_type(itype, 1, sys, rng);
704                x[ilugan + 2] = random_angle_for_type(itype, 2, sys, rng);
705                ilugan += 3;
706            }
707        }
708    }
709
710    // ── 9. Phase 2: constraint-only GENCAN per type (reduced x) ──────────────
711    // Packmol initial.f90 lines 516-550
712    log::debug!(
713        "[{:.3}s] Phase 2: constraint-only GENCAN ({} types, nloop0={})",
714        t0.elapsed().as_secs_f64(),
715        sys.ntype,
716        nloop0,
717    );
718    sys.init1 = true;
719    {
720        let mut swap = SwapState::init(x, sys);
721        for itype in 0..sys.ntype {
722            let nm = sys.nmols[itype];
723            log::debug!(
724                "[{:.3}s]   type {itype}: {nm} mols × {} atoms  (n={})",
725                t0.elapsed().as_secs_f64(),
726                sys.natoms[itype],
727                nm * 6
728            );
729            let mut xtype = swap.set_type(itype, sys);
730            init_loop_one_type(
731                itype,
732                nloop0,
733                &mut xtype,
734                sys,
735                precision,
736                movebad_cfg.gencan_maxit,
737                movebad_cfg,
738                rng,
739                &t0,
740                &mut workspace,
741            );
742            swap.save_type(itype, &xtype, sys);
743        }
744        swap.restore(x, sys);
745    }
746    sys.init1 = false;
747
748    log::debug!("[{:.3}s] initial() complete", t0.elapsed().as_secs_f64());
749}
750
751fn random_angle_for_type(itype: usize, axis: usize, sys: &PackContext, rng: &mut impl Rng) -> F {
752    if sys.constrain_rot[itype][axis] {
753        let center = sys.rot_bound[itype][axis][0];
754        let half_width = sys.rot_bound[itype][axis][1].abs();
755        (center - half_width) + 2.0 * uniform01(rng) * half_width
756    } else {
757        TWO_PI * uniform01(rng)
758    }
759}
760
761// ── init_xcart_from_x ──────────────────────────────────────────────────────
762
763/// Initialize xcart from x (COM + Euler angles).
764pub fn init_xcart_from_x(x: &[F], sys: &mut PackContext) {
765    let mut ilubar = 0usize;
766    let mut ilugan = sys.ntotmol * 3;
767    let mut icart = 0usize;
768
769    for itype in 0..sys.ntype {
770        for _imol in 0..sys.nmols[itype] {
771            let xcm = [x[ilubar], x[ilubar + 1], x[ilubar + 2]];
772            let beta = x[ilugan];
773            let gama = x[ilugan + 1];
774            let teta = x[ilugan + 2];
775            let (v1, v2, v3) = eulerrmat(beta, gama, teta);
776
777            let idatom_base = sys.idfirst[itype];
778            for iatom in 0..sys.natoms[itype] {
779                let pos = compcart(&xcm, &sys.coor[idatom_base + iatom], &v1, &v2, &v3);
780                sys.xcart[icart] = pos;
781                // Packmol's initial.f90 sets fixedatom=false on every free
782                // atom here, but in Rust that bit is already false from
783                // construction and `sync_atom_props` has been called —
784                // writing it again would desync `atom_props` flags.
785                debug_assert!(!sys.fixedatom[icart]);
786                icart += 1;
787            }
788
789            ilugan += 3;
790            ilubar += 3;
791        }
792    }
793}