Skip to main content

minimage/
cell.rs

1//! Periodic cell: three lattice vectors and a minimum-image convention.
2//!
3//! Orthorhombic boxes are the diagonal case ([`Cell::ortho`]). Triclinic
4//! (and any parallelepiped) use the same fractional wrap. Lattice
5//! vectors are stored as the columns of H, so `r = H s`. The C ABI,
6//! ASE, vesin, and CON pass rows `(a, b, c)`; constructors accept either.
7//!
8//! [`Cell::dist2`] is the per-pair minimum image. After a fold into the
9//! primary cell, pair distances in a linked-cell walk are
10//! [`Cell::dist2_shifted`] plus [`Cell::lattice_shift`], not a per-pair
11//! wrap. [`Cell::is_ortho`] is the cheap path: three independent wraps
12//! and a scaled-diagonal shift, skipping the two 3x3 matvecs.
13
14use crate::kernel;
15use crate::selling;
16use crate::Error;
17
18pub(crate) use crate::kernel::mul;
19
20/// Periodic parallelepiped: columns of H, origin, and an ortho flag.
21///
22/// ```
23/// # use minimage::Cell;
24/// # fn main() -> Result<(), minimage::Error> {
25/// let cell = Cell::ortho(10.0, 11.0, 12.0)?;
26/// assert!(cell.is_ortho());
27/// let sheared = Cell::from_vectors(
28///     [10.0, 0.0, 0.0],
29///     [5.0, 8.66, 0.0],
30///     [0.0, 0.0, 10.0],
31///     [0.0, 0.0, 0.0],
32/// )?;
33/// assert!(!sheared.is_ortho());
34/// # Ok(())
35/// # }
36/// ```
37#[derive(Clone, Copy, Debug, PartialEq)]
38pub struct Cell {
39    /// Columns of H: `h[0]` is lattice vector a.
40    h: [[f64; 3]; 3],
41    /// Inverse of H. Fractional coordinates are `s = Hinv (r - origin)`.
42    hinv: [[f64; 3]; 3],
43    origin: [f64; 3],
44    /// Perpendicular widths |a · n_a| etc.
45    widths: [f64; 3],
46    /// Axis-aligned diagonal box: MIC is three independent wraps.
47    ortho: bool,
48    /// `a` along x and `b` in the xy plane (LAMMPS / HOOMD / GROMACS).
49    restricted: bool,
50    /// Lamda coefficients. Read only when [`Self::is_restricted`] is set.
51    tri: kernel::Tri,
52}
53
54/// Load an 8-byte element 0 and the 16-byte tail at `p + 8`.
55///
56/// A zero tail is stored that way. A 16-byte load at element 0
57/// straddles the two stores and cannot forward.
58///
59/// # Safety
60///
61/// `p` points at a live `[f64; 3]` and is aligned for `f64`.
62#[cfg(target_arch = "x86_64")]
63#[inline(always)]
64unsafe fn load_lo8_hi16(p: *const f64) -> (f64, f64, f64) {
65    let x: f64;
66    let y: f64;
67    let z: f64;
68    // Register names arrive without a percent prefix, so the
69    // instructions are Intel syntax. `readonly` keeps the asm from
70    // copying the argument onto the stack.
71    std::arch::asm!(
72        "movsd {x}, qword ptr [{p}]",
73        "movupd {y}, xmmword ptr [{p} + 8]",
74        "movapd {z}, {y}",
75        "unpckhpd {z}, {y}",
76        p = in(reg) p,
77        x = out(xmm_reg) x,
78        y = out(xmm_reg) y,
79        z = out(xmm_reg) z,
80        options(nostack, preserves_flags, readonly),
81    );
82    (x, y, z)
83}
84
85/// Load a 16-byte pair at `p` and an 8-byte element 2.
86///
87/// Two leading lanes are stored that way. A 16-byte load at element
88/// 1 straddles the pair and element 2 and cannot forward.
89///
90/// # Safety
91///
92/// `p` points at a live `[f64; 3]` and is aligned for `f64`.
93#[cfg(target_arch = "x86_64")]
94#[inline(always)]
95unsafe fn load_lo16_hi8(p: *const f64) -> (f64, f64, f64) {
96    let x: f64;
97    let y: f64;
98    let z: f64;
99    std::arch::asm!(
100        "movupd {x}, xmmword ptr [{p}]",
101        "movapd {y}, {x}",
102        "unpckhpd {y}, {x}",
103        "movsd {z}, qword ptr [{p} + 16]",
104        p = in(reg) p,
105        x = out(xmm_reg) x,
106        y = out(xmm_reg) y,
107        z = out(xmm_reg) z,
108        options(nostack, preserves_flags, readonly),
109    );
110    (x, y, z)
111}
112
113/// Load three elements. The caller has no split store to match.
114///
115/// # Safety
116///
117/// `p` points at a live `[f64; 3]` and is aligned for `f64`.
118#[cfg(not(target_arch = "x86_64"))]
119#[inline(always)]
120unsafe fn load_lo8_hi16(p: *const f64) -> (f64, f64, f64) {
121    (
122        std::ptr::read_volatile(p),
123        std::ptr::read_volatile(p.add(1)),
124        std::ptr::read_volatile(p.add(2)),
125    )
126}
127
128/// Load three elements. The caller has no split store to match.
129///
130/// # Safety
131///
132/// `p` points at a live `[f64; 3]` and is aligned for `f64`.
133#[cfg(not(target_arch = "x86_64"))]
134#[inline(always)]
135unsafe fn load_lo16_hi8(p: *const f64) -> (f64, f64, f64) {
136    load_lo8_hi16(p)
137}
138
139impl Cell {
140    /// Diagonal box with origin at zero.
141    ///
142    /// Sets [`Self::is_ortho`] so [`Self::dist2`] and
143    /// [`Self::lattice_shift`] skip the two 3x3 matvecs.
144    pub fn ortho(lx: f64, ly: f64, lz: f64) -> Result<Self, Error> {
145        Self::from_vectors(
146            [lx, 0.0, 0.0],
147            [0.0, ly, 0.0],
148            [0.0, 0.0, lz],
149            [0.0, 0.0, 0.0],
150        )
151    }
152
153    /// Diagonal box with an explicit dump-cell origin.
154    pub fn ortho_origin(lx: f64, ly: f64, lz: f64, origin: [f64; 3]) -> Result<Self, Error> {
155        Self::from_vectors([lx, 0.0, 0.0], [0.0, ly, 0.0], [0.0, 0.0, lz], origin)
156    }
157
158    /// Parallelepiped from lattice vectors `a`, `b`, `c` and an origin.
159    ///
160    /// Vectors are the columns of H. A non-diagonal H clears
161    /// [`Self::is_ortho`].
162    ///
163    /// Inlined so the caller writes the cell once. An outlined return
164    /// stores it and then copies it.
165    #[inline(always)]
166    pub fn from_vectors(
167        a: [f64; 3],
168        b: [f64; 3],
169        c: [f64; 3],
170        origin: [f64; 3],
171    ) -> Result<Self, Error> {
172        // SAFETY: each argument is a live `[f64; 3]`. The pointer is the
173        // argument itself, not a stack copy. `a` is the 8-byte lane
174        // plus a 16-byte tail. `b`, `c`, and `origin` are a 16-byte
175        // pair plus an 8-byte element 2.
176        let (ax, ay, az) = unsafe { load_lo8_hi16(std::ptr::addr_of!(a).cast()) };
177        let (bx, by, bz) = unsafe { load_lo16_hi8(std::ptr::addr_of!(b).cast()) };
178        let (cx, cy, cz) = unsafe { load_lo16_hi8(std::ptr::addr_of!(c).cast()) };
179        let (ox, oy, oz) = unsafe { load_lo16_hi8(std::ptr::addr_of!(origin).cast()) };
180        // b × c, c × a, a × b. The determinant is a · (b × c).
181        let bcx = by * cz - bz * cy;
182        let bcy = bz * cx - bx * cz;
183        let bcz = bx * cy - by * cx;
184        let cax = cy * az - cz * ay;
185        let cay = cz * ax - cx * az;
186        let caz = cx * ay - cy * ax;
187        let abx = ay * bz - az * by;
188        let aby = az * bx - ax * bz;
189        let abz = ax * by - ay * bx;
190        let det = ax * bcx + ay * bcy + az * bcz;
191        if !det.is_finite() || det.abs() < 1e-18 {
192            return Err(Error::BadBox);
193        }
194        let invdet = 1.0 / det;
195        let ad = det.abs();
196        let wa = ad / (bcx * bcx + bcy * bcy + bcz * bcz).sqrt();
197        let wb = ad / (cax * cax + cay * cay + caz * caz).sqrt();
198        let wc = ad / (abx * abx + aby * aby + abz * abz).sqrt();
199        if !(wa > 0.0 && wb > 0.0 && wc > 0.0) {
200            return Err(Error::BadBox);
201        }
202        // Each face width is at most that edge length, so this scale is
203        // at most the length sum. The frame tests stay the stricter ones
204        // and do not take another three square roots.
205        let scale = (wa + wb + wc).max(1.0);
206        let tol = 1e-12 * scale;
207        let ortho = ay.abs() <= tol
208            && az.abs() <= tol
209            && bx.abs() <= tol
210            && bz.abs() <= tol
211            && cx.abs() <= tol
212            && cy.abs() <= tol;
213        let rtol = 1e-10 * scale;
214        let restricted = !(ay.abs() > rtol || az.abs() > rtol || bz.abs() > rtol)
215            && ax.abs() >= 1e-18
216            && by.abs() >= 1e-18
217            && cz.abs() >= 1e-18;
218        let tri = if restricted {
219            kernel::Tri {
220                lx: ax,
221                ly: by,
222                lz: cz,
223                xy: bx,
224                xz: cx,
225                yz: cy,
226                inv_lx: 1.0 / ax,
227                inv_ly: 1.0 / by,
228                inv_lz: 1.0 / cz,
229            }
230        } else {
231            kernel::Tri {
232                lx: 0.0,
233                ly: 0.0,
234                lz: 0.0,
235                xy: 0.0,
236                xz: 0.0,
237                yz: 0.0,
238                inv_lx: 0.0,
239                inv_ly: 0.0,
240                inv_lz: 0.0,
241            }
242        };
243        Ok(Self {
244            h: [[ax, ay, az], [bx, by, bz], [cx, cy, cz]],
245            hinv: [
246                [bcx * invdet, cax * invdet, abx * invdet],
247                [bcy * invdet, cay * invdet, aby * invdet],
248                [bcz * invdet, caz * invdet, abz * invdet],
249            ],
250            origin: [ox, oy, oz],
251            widths: [wa, wb, wc],
252            ortho,
253            restricted,
254            tri,
255        })
256    }
257
258    /// ASE-style 3x3 cell: rows are lattice vectors a, b, c.
259    ///
260    /// Origin is zero. Matches `atoms.cell` in ASE.
261    pub fn from_ase(rows: [[f64; 3]; 3]) -> Result<Self, Error> {
262        Self::from_vectors(rows[0], rows[1], rows[2], [0.0, 0.0, 0.0])
263    }
264
265    /// CON lattice: rows are lattice vectors a, b, c, origin zero.
266    ///
267    /// Same storage as readcon `lattice_vectors` and an eOn CON
268    /// header that already holds the 3x3 basis.
269    pub fn from_con(rows: [[f64; 3]; 3]) -> Result<Self, Error> {
270        Self::from_ase(rows)
271    }
272
273    /// CON / crystallographic lengths and angles (degrees).
274    ///
275    /// `alpha` is the angle at the origin between b and c, `beta`
276    /// between a and c, `gamma` between a and b. The basis is the
277    /// standard reduced triclinic frame used by ASE and eOn.
278    pub fn from_con_box(boxl: [f64; 3], angles_deg: [f64; 3]) -> Result<Self, Error> {
279        let [lx, ly, lz] = boxl;
280        if !(lx > 0.0 && ly > 0.0 && lz > 0.0) {
281            return Err(Error::BadBox);
282        }
283        let alpha = angles_deg[0].to_radians();
284        let beta = angles_deg[1].to_radians();
285        let gamma = angles_deg[2].to_radians();
286        let cos_a = alpha.cos();
287        let cos_b = beta.cos();
288        let cos_g = gamma.cos();
289        let sin_g = gamma.sin();
290        if sin_g.abs() < 1e-18 {
291            return Err(Error::BadBox);
292        }
293        let a = [lx, 0.0, 0.0];
294        let b = [ly * cos_g, ly * sin_g, 0.0];
295        let cx = lz * cos_b;
296        let cy = lz * (cos_a - cos_b * cos_g) / sin_g;
297        let cz2 = lz * lz - cx * cx - cy * cy;
298        if cz2 <= 0.0 {
299            return Err(Error::BadBox);
300        }
301        Self::from_vectors(a, b, [cx, cy, cz2.sqrt()], [0.0, 0.0, 0.0])
302    }
303
304    /// vesin box: rows are lattice vectors a, b, c, origin zero.
305    pub fn from_vesin(rows: [[f64; 3]; 3]) -> Result<Self, Error> {
306        Self::from_ase(rows)
307    }
308
309    /// LAMMPS restricted-triclinic box from `xlo xhi ylo yhi zlo zhi`
310    /// and tilt factors `xy, xz, yz`.
311    ///
312    /// Recovers the same H and origin as a dump ITEM BOX BOUNDS line
313    /// whose bound lo/hi already include the tilt padding.
314    #[allow(clippy::too_many_arguments)]
315    pub fn from_lammps(
316        xlo: f64,
317        xhi: f64,
318        ylo: f64,
319        yhi: f64,
320        zlo: f64,
321        zhi: f64,
322        xy: f64,
323        xz: f64,
324        yz: f64,
325    ) -> Result<Self, Error> {
326        Self::from_lammps_bounds(xhi - xlo, yhi - ylo, zhi - zlo, xy, xz, yz, xlo, ylo, zlo)
327    }
328
329    /// LAMMPS dump bound spans plus tilts and bound lo.
330    ///
331    /// `xspan, yspan, zspan` are `xhi_bound - xlo_bound` etc.
332    /// `xy, xz, yz` are the tilt factors. `xlo_b, ylo_b, zlo_b` are
333    /// the bound lo. Inverse of
334    /// `xlo_bound = xlo + min(0, xy, xz, xy+xz)`.
335    #[allow(clippy::too_many_arguments)]
336    pub fn from_lammps_bounds(
337        xspan: f64,
338        yspan: f64,
339        zspan: f64,
340        xy: f64,
341        xz: f64,
342        yz: f64,
343        xlo_b: f64,
344        ylo_b: f64,
345        zlo_b: f64,
346    ) -> Result<Self, Error> {
347        let (h, origin) = dump_bounds_to_h(xspan, yspan, zspan, xy, xz, yz, xlo_b, ylo_b, zlo_b);
348        Self::from_vectors(h[0], h[1], h[2], origin)
349    }
350
351    /// True when H is diagonal. Distances and lattice shifts then skip
352    /// the two 3x3 matvecs and use three independent wraps.
353    pub fn is_ortho(&self) -> bool {
354        self.ortho
355    }
356
357    /// Lattice vector a (first column of H).
358    pub fn a(&self) -> [f64; 3] {
359        self.h[0]
360    }
361
362    /// Lattice vector b.
363    pub fn b(&self) -> [f64; 3] {
364        self.h[1]
365    }
366
367    /// Lattice vector c.
368    pub fn c(&self) -> [f64; 3] {
369        self.h[2]
370    }
371
372    /// Dump-cell origin.
373    pub fn origin(&self) -> [f64; 3] {
374        self.origin
375    }
376
377    /// Perpendicular widths of the three faces.
378    pub fn widths(&self) -> [f64; 3] {
379        self.widths
380    }
381
382    /// Columns of H.
383    pub fn h(&self) -> [[f64; 3]; 3] {
384        self.h
385    }
386
387    /// Inverse of H, stored by columns.
388    pub fn hinv(&self) -> [[f64; 3]; 3] {
389        self.hinv
390    }
391
392    /// Fractional coordinates in `[0, 1)`.
393    ///
394    /// Orthorhombic boxes divide by the three widths. The general path
395    /// is `s = Hinv (r - origin)`, then wrap.
396    #[inline]
397    pub fn fractional(&self, r: [f64; 3]) -> [f64; 3] {
398        if self.ortho {
399            return [
400                wrap01((r[0] - self.origin[0]) / self.widths[0]),
401                wrap01((r[1] - self.origin[1]) / self.widths[1]),
402                wrap01((r[2] - self.origin[2]) / self.widths[2]),
403            ];
404        }
405        let d = [
406            r[0] - self.origin[0],
407            r[1] - self.origin[1],
408            r[2] - self.origin[2],
409        ];
410        let mut s = mul(self.hinv, d);
411        for e in &mut s {
412            *e = wrap01(*e);
413        }
414        s
415    }
416
417    /// Cartesian from fractional: `r = H s + origin`.
418    #[inline]
419    pub fn cartesian(&self, s: [f64; 3]) -> [f64; 3] {
420        let r = mul(self.h, s);
421        [
422            r[0] + self.origin[0],
423            r[1] + self.origin[1],
424            r[2] + self.origin[2],
425        ]
426    }
427
428    /// Cartesian translation by integer lattice counts `(na, nb, nc)`.
429    #[inline]
430    pub fn lattice_shift(&self, na: i32, nb: i32, nc: i32) -> [f64; 3] {
431        if self.ortho {
432            [
433                f64::from(na) * self.widths[0],
434                f64::from(nb) * self.widths[1],
435                f64::from(nc) * self.widths[2],
436            ]
437        } else {
438            let a = self.h[0];
439            let b = self.h[1];
440            let c = self.h[2];
441            let fa = f64::from(na);
442            let fb = f64::from(nb);
443            let fc = f64::from(nc);
444            [
445                fa * a[0] + fb * b[0] + fc * c[0],
446                fa * a[1] + fb * b[1] + fc * c[1],
447                fa * a[2] + fb * b[2] + fc * c[2],
448            ]
449        }
450    }
451
452    /// Squared Cartesian distance after applying a lattice shift to `q`.
453    ///
454    /// This is not a minimum-image wrap. The caller supplies the image
455    /// via [`Self::lattice_shift`].
456    #[inline]
457    pub fn dist2_shifted(&self, p: [f64; 3], q: [f64; 3], shift: [f64; 3]) -> f64 {
458        let dx = q[0] + shift[0] - p[0];
459        let dy = q[1] + shift[1] - p[1];
460        let dz = q[2] + shift[2] - p[2];
461        dx * dx + dy * dy + dz * dz
462    }
463
464    /// Minimum-image displacement from `p` to `q`: wrap(`q - p`).
465    ///
466    /// Orthorhombic boxes wrap each axis independently. The general path
467    /// is `ds = wrap(Hinv (q - p))`, then `dr = H ds`.
468    #[inline]
469    pub fn displacement(&self, p: [f64; 3], q: [f64; 3]) -> [f64; 3] {
470        let dp = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
471        self.wrap_diff(dp)
472    }
473
474    /// Engine wrap of a Cartesian difference. The origin does not enter.
475    #[inline]
476    pub(crate) fn wrap_diff(&self, dp: [f64; 3]) -> [f64; 3] {
477        if self.ortho {
478            return [
479                kernel::wrap_half(dp[0], self.widths[0]),
480                kernel::wrap_half(dp[1], self.widths[1]),
481                kernel::wrap_half(dp[2], self.widths[2]),
482            ];
483        }
484        if self.restricted {
485            return self.tri.wrap(dp);
486        }
487        kernel::general_wrap(self.h, self.hinv, dp)
488    }
489
490    /// Squared minimum-image distance.
491    #[inline]
492    pub fn dist2(&self, p: [f64; 3], q: [f64; 3]) -> f64 {
493        let dr = self.displacement(p, q);
494        dr[0] * dr[0] + dr[1] * dr[1] + dr[2] * dr[2]
495    }
496
497    /// Euclidean MIC by checking the 27 nearest lattice images.
498    ///
499    /// [`Self::displacement`] wraps in fractional coordinates. That is
500    /// the LAMMPS / eOn / ASE convention and is the Cartesian nearest
501    /// image on a reduced (restricted-triclinic) cell. A highly skewed
502    /// H can make a neighbouring image shorter in Cartesian space.
503    /// This walk is the check those codes omit.
504    pub fn displacement_cartesian(&self, p: [f64; 3], q: [f64; 3]) -> [f64; 3] {
505        let dp = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
506        let mut best = dp;
507        let mut best2 = dp[0] * dp[0] + dp[1] * dp[1] + dp[2] * dp[2];
508        for na in -1_i32..=1 {
509            for nb in -1_i32..=1 {
510                for nc in -1_i32..=1 {
511                    if na == 0 && nb == 0 && nc == 0 {
512                        continue;
513                    }
514                    let s = self.lattice_shift(na, nb, nc);
515                    let t = [dp[0] + s[0], dp[1] + s[1], dp[2] + s[2]];
516                    let t2 = t[0] * t[0] + t[1] * t[1] + t[2] * t[2];
517                    if t2 < best2 {
518                        best2 = t2;
519                        best = t;
520                    }
521                }
522            }
523        }
524        best
525    }
526
527    /// True when a is along x and b lies in the xy plane.
528    ///
529    /// This is the GROMACS / LAMMPS / HOOMD restricted-triclinic frame.
530    /// Those engines refuse or rotate any other orientation before wrap.
531    pub fn is_restricted(&self) -> bool {
532        self.restricted
533    }
534
535    /// True when the cell is restricted and the tilts sit inside the
536    /// GROMACS `correct_box` / LAMMPS half-box limits
537    /// `|xy| <= lx/2`, `|xz| <= lx/2`, `|yz| <= ly/2`.
538    ///
539    /// On that domain the fractional wrap is the Euclidean MIC, which
540    /// is why LAMMPS `Domain::minimum_image` and HOOMD `BoxDim::minImage`
541    /// never search extra images.
542    pub fn tilts_reduced(&self) -> bool {
543        if !self.is_restricted() {
544            return false;
545        }
546        let lx = self.h[0][0].abs();
547        let ly = self.h[1][1].abs();
548        let xy = self.h[1][0];
549        let xz = self.h[2][0];
550        let yz = self.h[2][1];
551        let tol = 1e-12 * (lx + ly).max(1.0);
552        xy.abs() <= 0.5 * lx + tol && xz.abs() <= 0.5 * lx + tol && yz.abs() <= 0.5 * ly + tol
553    }
554
555    /// Unimodular tilt reduction: GROMACS `correct_box`.
556    ///
557    /// Subtracts integer lattice vectors so `|xy|`, `|xz|`, `|yz|` sit
558    /// inside half the corresponding edge. Same Cartesian lattice, new
559    /// basis. No-op when [`Self::tilts_reduced`] is already true.
560    /// A general orientation is rotated into the restricted frame first
561    /// (LAMMPS `define_general_triclinic`).
562    pub fn reduce_tilts(&self) -> Result<Self, Error> {
563        let (cell, _rot) = self.restricted_frame()?;
564        if cell.tilts_reduced() {
565            return Ok(cell);
566        }
567        let a = cell.h[0];
568        let mut b = cell.h[1];
569        let mut c = cell.h[2];
570        correct_box_elem(&mut c, b, 1);
571        correct_box_elem(&mut c, a, 0);
572        correct_box_elem(&mut b, a, 0);
573        Self::from_vectors(a, b, c, cell.origin)
574    }
575
576    /// Rotate a general parallelepiped onto the restricted-triclinic
577    /// frame (LAMMPS general-to-restricted). Identity when already
578    /// restricted.
579    pub fn to_restricted(&self) -> Result<Self, Error> {
580        Ok(self.restricted_frame()?.0)
581    }
582
583    /// Euclidean nearest image, including displacements longer than
584    /// the cell.
585    ///
586    /// An orthorhombic box is the per-axis wrap. Otherwise Smith,
587    /// *CCP5 Newsletter* 1989: the engine wrap is nearest when its
588    /// length is strictly below half the smallest face altitude.
589    /// That is the cutoff regime of GROMACS, LAMMPS, and HOOMD.
590    /// Linkcell k-NN has no cutoff. A hex-prism body diagonal is an
591    /// engine wrap that is longer than another image.
592    ///
593    /// Past that test the Selling superbasis of `H` is handed to
594    /// McKilliam, Grant, and Clarkson, *SIAM J. Discrete Math.* **28**,
595    /// 1405 (2014). The superbasis is cached on the calling thread,
596    /// keyed by `H`, and is not stored in the cell. A tie keeps the
597    /// engine vector. [`Self::displacement`] stays that engine vector
598    /// on the caller's `H`.
599    #[inline]
600    pub fn displacement_euclidean(&self, p: [f64; 3], q: [f64; 3]) -> [f64; 3] {
601        if self.ortho {
602            return self.displacement(p, q);
603        }
604        let dp = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
605        let frac = self.wrap_diff(dp);
606        let f2 = kernel::n2(frac);
607        let w = self.widths;
608        let half_min = 0.5 * w[0].min(w[1]).min(w[2]);
609        // sqrt(f2) + 1e-12 < half_min, without the square root.
610        let room = half_min - 1e-12;
611        if room > 0.0 && f2 < room * room {
612            return frac;
613        }
614        let euc = selling::closest_for(self.h, dp);
615        let e2 = kernel::n2(euc);
616        if e2 + 1e-12 * (1.0 + f2) < f2 {
617            euc
618        } else {
619            frac
620        }
621    }
622
623    /// Squared distances from `p` to each `q`, with one lattice shift
624    /// already applied to every `q`.
625    ///
626    /// Rapaport's linked-cell pair: `|q + shift - p|^2`. No wrap and
627    /// no `Hinv`. `out` has one entry per `q`.
628    pub fn dist2_shifted_many(
629        &self,
630        p: [f64; 3],
631        qs: &[[f64; 3]],
632        shift: [f64; 3],
633        out: &mut [f64],
634    ) -> Result<(), Error> {
635        if out.len() != qs.len() {
636            return Err(Error::BufferSize);
637        }
638        let _ = self.ortho;
639        crate::simd::dist2_shifted_many(p, qs, shift, out);
640        Ok(())
641    }
642
643    /// Squared distances from `p` to positions named by `indices`, with
644    /// one lattice shift already applied.
645    ///
646    /// A linked-cell bin is an index list into the neighbour array, not
647    /// a contiguous slice. This gathers a chunk of those positions and
648    /// runs the same kernel as [`Self::dist2_shifted_many`]. The
649    /// gather copies every point before the kernel runs, and on a long
650    /// index list that copy was slower than the inlined subtract, so
651    /// the production walk keeps the subtract.
652    ///
653    /// `out` has one entry per index. An index past `positions` panics.
654    pub fn dist2_shifted_indexed(
655        &self,
656        p: [f64; 3],
657        positions: &[[f64; 3]],
658        indices: &[usize],
659        shift: [f64; 3],
660        out: &mut [f64],
661    ) -> Result<(), Error> {
662        if out.len() != indices.len() {
663            return Err(Error::BufferSize);
664        }
665        const CHUNK: usize = 64;
666        let mut gathered = [[0.0; 3]; CHUNK];
667        let mut start = 0;
668        while start < indices.len() {
669            let n = (indices.len() - start).min(CHUNK);
670            for k in 0..n {
671                gathered[k] = positions[indices[start + k]];
672            }
673            crate::simd::dist2_shifted_many(p, &gathered[..n], shift, &mut out[start..start + n]);
674            start += n;
675        }
676        let _ = self.ortho;
677        Ok(())
678    }
679
680    /// Squared Euclidean MIC distance.
681    #[inline]
682    pub fn dist2_euclidean(&self, p: [f64; 3], q: [f64; 3]) -> f64 {
683        let dr = self.displacement_euclidean(p, q);
684        dr[0] * dr[0] + dr[1] * dr[1] + dr[2] * dr[2]
685    }
686
687    fn restricted_frame(&self) -> Result<(Self, [[f64; 3]; 3]), Error> {
688        if self.is_restricted() {
689            return Ok((*self, IDENTITY));
690        }
691        let a = self.h[0];
692        let b = self.h[1];
693        let c = self.h[2];
694        let al = kernel::norm(a);
695        if al < 1e-18 {
696            return Err(Error::BadBox);
697        }
698        let a_hat = [a[0] / al, a[1] / al, a[2] / al];
699        let b_par = kernel::dot(b, a_hat);
700        let b_perp = [
701            b[0] - b_par * a_hat[0],
702            b[1] - b_par * a_hat[1],
703            b[2] - b_par * a_hat[2],
704        ];
705        let bl = kernel::norm(b_perp);
706        if bl < 1e-18 {
707            return Err(Error::BadBox);
708        }
709        let n = [b_perp[0] / bl, b_perp[1] / bl, b_perp[2] / bl];
710        let k = kernel::cross(a_hat, n);
711        let rot = [a_hat, n, k];
712        let a_new = [al, 0.0, 0.0];
713        let b_new = [b_par, bl, 0.0];
714        let c_new = [kernel::dot(c, a_hat), kernel::dot(c, n), kernel::dot(c, k)];
715        let cell = Self::from_vectors(a_new, b_new, c_new, mul_rows(rot, self.origin))?;
716        Ok((cell, rot))
717    }
718
719    /// True when fractional wrap matches the 27-image check on a grid
720    /// of short Cartesian probes (the cutoff-scale displacements MD
721    /// engines actually wrap).
722    ///
723    /// A hex prism at the tilt limit can still make a body-diagonal
724    /// fractional wrap longer than a neighbouring image. Production
725    /// codes ignore that because the cutoff is well below half the
726    /// long diagonal.
727    pub fn fractional_matches_cartesian(&self) -> bool {
728        let samples = [
729            [0.2, 0.0, 0.0],
730            [0.49, 0.49, 0.49],
731            [0.9, 0.1, 0.1],
732            [0.1, 0.9, 0.1],
733            [0.1, 0.1, 0.9],
734            [0.8, 0.8, 0.2],
735        ];
736        for p in samples {
737            let q = [0.0, 0.0, 0.0];
738            let a = self.displacement(p, q);
739            let b = self.displacement_cartesian(p, q);
740            let d2 = (a[0] - b[0]) * (a[0] - b[0])
741                + (a[1] - b[1]) * (a[1] - b[1])
742                + (a[2] - b[2]) * (a[2] - b[2]);
743            if d2 > 1e-20 {
744                return false;
745            }
746        }
747        true
748    }
749}
750
751#[cfg(test)]
752mod cartesian_tests {
753    use super::Cell;
754
755    #[test]
756    fn ortho_fractional_is_cartesian() {
757        let c = Cell::ortho(10.0, 11.0, 12.0).unwrap();
758        assert!(c.fractional_matches_cartesian());
759    }
760
761    #[test]
762    fn restricted_shear_fractional_is_cartesian() {
763        let c = Cell::from_vectors(
764            [10.0, 0.0, 0.0],
765            [5.0, 8.660254037844386, 0.0],
766            [0.0, 0.0, 10.0],
767            [0.0, 0.0, 0.0],
768        )
769        .unwrap();
770        assert!(c.fractional_matches_cartesian());
771    }
772
773    #[test]
774    fn extreme_skew_cartesian_can_be_shorter() {
775        let c = Cell::from_vectors(
776            [1.0, 0.0, 0.0],
777            [0.9, 0.1, 0.0],
778            [0.0, 0.0, 1.0],
779            [0.0, 0.0, 0.0],
780        )
781        .unwrap();
782        let p = [0.0, 0.0, 0.0];
783        let q = [0.95, 0.05, 0.0];
784        let frac = c.displacement(p, q);
785        let cart = c.displacement_cartesian(p, q);
786        let f2 = frac[0] * frac[0] + frac[1] * frac[1] + frac[2] * frac[2];
787        let c2 = cart[0] * cart[0] + cart[1] * cart[1] + cart[2] * cart[2];
788        assert!(c2 <= f2 + 1e-15);
789        assert!(c2 < 0.01);
790    }
791
792    #[test]
793    fn extreme_skew_lattice_point_needs_tilt_reduce() {
794        let c = Cell::from_vectors(
795            [1.0, 0.0, 0.0],
796            [0.99, 0.01, 0.0],
797            [0.0, 0.0, 1.0],
798            [0.0, 0.0, 0.0],
799        )
800        .unwrap();
801        assert!(c.is_restricted());
802        assert!(!c.tilts_reduced());
803        let p = [0.0, 0.0, 0.0];
804        let q = [0.02, -0.02, 0.0];
805        let cart = c.displacement_cartesian(p, q);
806        let euc = c.displacement_euclidean(p, q);
807        let c2 = cart[0] * cart[0] + cart[1] * cart[1] + cart[2] * cart[2];
808        let e2 = euc[0] * euc[0] + euc[1] * euc[1] + euc[2] * euc[2];
809        assert!(c2 > 1e-8, "27-image from the unreduced basis is not 0");
810        assert!(e2 < 1e-24, "tilt-reduced wrap sends the lattice point to 0");
811        let red = c.reduce_tilts().unwrap();
812        assert!(red.tilts_reduced());
813        assert!(red.h()[1][0].abs() <= 0.5 * red.h()[0][0].abs() + 1e-12);
814    }
815
816    #[test]
817    fn hex_body_diagonal_fractional_is_not_nearest() {
818        let c = Cell::from_vectors(
819            [10.0, 0.0, 0.0],
820            [5.0, 8.660254037844386, 0.0],
821            [0.0, 0.0, 10.0],
822            [0.0, 0.0, 0.0],
823        )
824        .unwrap();
825        assert!(c.tilts_reduced());
826        let p = c.cartesian([0.49, 0.49, 0.49]);
827        let q = c.cartesian([0.0, 0.0, 0.0]);
828        let frac = c.displacement(p, q);
829        let euc = c.displacement_euclidean(p, q);
830        let f2 = frac[0] * frac[0] + frac[1] * frac[1] + frac[2] * frac[2];
831        let e2 = euc[0] * euc[0] + euc[1] * euc[1] + euc[2] * euc[2];
832        assert!(
833            e2 + 1e-8 < f2,
834            "Nguyen-Stehle 27-image in the Minkowski basis is shorter than the fractional wrap"
835        );
836    }
837
838    #[test]
839    fn restricted_hex_is_already_reduced() {
840        let c = Cell::from_vectors(
841            [10.0, 0.0, 0.0],
842            [5.0, 8.660254037844386, 0.0],
843            [0.0, 0.0, 10.0],
844            [0.0, 0.0, 0.0],
845        )
846        .unwrap();
847        assert!(c.is_restricted());
848        assert!(c.tilts_reduced());
849        let p = [0.2, 0.1, 1.0];
850        let q = [9.7, 0.1, 1.0];
851        let a = c.displacement(p, q);
852        let b = c.displacement_euclidean(p, q);
853        assert!((a[0] - b[0]).abs() < 1e-15);
854        assert!((a[1] - b[1]).abs() < 1e-15);
855        assert!((a[2] - b[2]).abs() < 1e-15);
856    }
857
858    #[test]
859    fn euclidean_cache_alternates_two_lattices() {
860        let hex = Cell::from_vectors(
861            [10.0, 0.0, 0.0],
862            [5.0, 8.660254037844386, 0.0],
863            [0.0, 0.0, 10.0],
864            [0.0, 0.0, 0.0],
865        )
866        .unwrap();
867        let skew = Cell::from_vectors(
868            [1.0, 0.0, 0.0],
869            [0.99, 0.01, 0.0],
870            [0.0, 0.0, 1.0],
871            [0.0, 0.0, 0.0],
872        )
873        .unwrap();
874        let p = [0.0, 0.0, 0.0];
875        let q_hex = hex.cartesian([0.49, 0.49, 0.49]);
876        let q_skew = [0.02, -0.02, 0.0];
877        for _ in 0..6 {
878            let frac = hex.displacement(q_hex, p);
879            let euc = hex.displacement_euclidean(q_hex, p);
880            let f2 = frac[0] * frac[0] + frac[1] * frac[1] + frac[2] * frac[2];
881            let e2 = euc[0] * euc[0] + euc[1] * euc[1] + euc[2] * euc[2];
882            assert!(e2 + 1e-8 < f2);
883            let skew_e = skew.displacement_euclidean(p, q_skew);
884            let se2 = skew_e[0] * skew_e[0] + skew_e[1] * skew_e[1] + skew_e[2] * skew_e[2];
885            assert!(se2 < 1e-24);
886        }
887    }
888}
889
890/// Recover restricted-triclinic H (columns a, b, c) and origin from a
891/// LAMMPS dump bound box.
892#[allow(clippy::too_many_arguments)]
893pub fn dump_bounds_to_h(
894    xspan: f64,
895    yspan: f64,
896    zspan: f64,
897    xy: f64,
898    xz: f64,
899    yz: f64,
900    xlo_b: f64,
901    ylo_b: f64,
902    zlo_b: f64,
903) -> ([[f64; 3]; 3], [f64; 3]) {
904    let xmin = 0.0_f64.min(xy).min(xz).min(xy + xz);
905    let xmax = 0.0_f64.max(xy).max(xz).max(xy + xz);
906    let ymin = 0.0_f64.min(yz);
907    let ymax = 0.0_f64.max(yz);
908    let lx = xspan - xmax + xmin;
909    let ly = yspan - ymax + ymin;
910    let lz = zspan;
911    let a = [lx, 0.0, 0.0];
912    let b = [xy, ly, 0.0];
913    let c = [xz, yz, lz];
914    let origin = [xlo_b - xmin, ylo_b - ymin, zlo_b];
915    ([a, b, c], origin)
916}
917
918#[inline]
919fn wrap01(mut s: f64) -> f64 {
920    s -= s.floor();
921    if s >= 1.0 {
922        0.0
923    } else {
924        s
925    }
926}
927
928const IDENTITY: [[f64; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
929
930/// GROMACS `correct_box_elem`: subtract integer copies of `edge` from
931/// `vec` until component `d` sits inside half of `edge[d]`.
932fn correct_box_elem(vec: &mut [f64; 3], edge: [f64; 3], d: usize) {
933    const MARGIN: f64 = 1.001;
934    const MAX_SHIFT: i32 = 20;
935    let half = 0.5 * edge[d];
936    if half.abs() < 1e-18 {
937        return;
938    }
939    let mut n = 0;
940    while vec[d] > MARGIN * half && n < MAX_SHIFT {
941        vec[0] -= edge[0];
942        vec[1] -= edge[1];
943        vec[2] -= edge[2];
944        n += 1;
945    }
946    while vec[d] < -MARGIN * half && n < MAX_SHIFT {
947        vec[0] += edge[0];
948        vec[1] += edge[1];
949        vec[2] += edge[2];
950        n += 1;
951    }
952}
953
954#[inline]
955fn mul_rows(r: [[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
956    [
957        kernel::dot(r[0], v),
958        kernel::dot(r[1], v),
959        kernel::dot(r[2], v),
960    ]
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966
967    #[test]
968    fn ortho_flag_and_dist2_match_general() {
969        let b = Cell::ortho(10.0, 11.0, 12.0).unwrap();
970        assert!(b.is_ortho());
971        let p = [0.2, 1.0, 11.5];
972        let q = [9.7, 10.8, 0.4];
973        let fast = b.dist2(p, q);
974        let slow = {
975            let dr = kernel::general_wrap(b.h, b.hinv, [q[0] - p[0], q[1] - p[1], q[2] - p[2]]);
976            dr[0] * dr[0] + dr[1] * dr[1] + dr[2] * dr[2]
977        };
978        assert!((fast - slow).abs() <= 1e-12 * (1.0 + fast.abs()));
979        let left = [0.2, 0.0, 0.0];
980        let right = [9.4, 0.0, 0.0];
981        let mic = b.dist2(left, right);
982        let via = b.dist2_shifted(left, right, b.lattice_shift(-1, 0, 0));
983        assert!((via - mic).abs() <= 1e-12 * (1.0 + mic.abs()));
984        assert!((mic - 0.64).abs() <= 1e-12);
985    }
986
987    #[test]
988    fn ortho_wrap_covers_later_images_and_keeps_negative_half() {
989        let c = Cell::ortho(10.0, 11.0, 12.0).unwrap();
990        let d = c.displacement([0.0, 0.0, 0.0], [25.0, -18.0, 12.0]);
991        assert!((d[0] + 5.0).abs() < 1e-12);
992        assert!((d[1] - 4.0).abs() < 1e-12);
993        assert!(d[2].abs() < 1e-12);
994        let half = c.displacement([0.0, 0.0, 0.0], [5.0, -5.5, 6.0]);
995        assert!((half[0] + 5.0).abs() < 1e-12);
996        assert!((half[1] + 5.5).abs() < 1e-12);
997        assert!((half[2] + 6.0).abs() < 1e-12);
998        let euc = c.displacement_euclidean([0.0, 0.0, 0.0], [25.0, -18.0, 12.0]);
999        assert!((euc[0] - d[0]).abs() < 1e-12);
1000        assert!((euc[1] - d[1]).abs() < 1e-12);
1001        assert!((euc[2] - d[2]).abs() < 1e-12);
1002    }
1003
1004    #[test]
1005    fn indexed_shift_matches_the_contiguous_bin() {
1006        let cell = Cell::ortho(10.0, 11.0, 12.0).unwrap();
1007        let positions = [
1008            [0.2, 0.0, 0.0],
1009            [9.4, 1.0, 2.0],
1010            [3.0, 4.0, 5.0],
1011            [8.0, 0.5, 0.5],
1012        ];
1013        let indices = [2usize, 0, 3, 1, 2];
1014        let p = [1.0, 1.0, 1.0];
1015        let shift = cell.lattice_shift(1, 0, -1);
1016        let mut indexed = [0.0; 5];
1017        cell.dist2_shifted_indexed(p, &positions, &indices, shift, &mut indexed)
1018            .unwrap();
1019        for (slot, idx) in indexed.iter().zip(indices) {
1020            let one = cell.dist2_shifted(p, positions[idx], shift);
1021            assert!((slot - one).abs() < 1e-12);
1022        }
1023        let mut wrong = [0.0; 4];
1024        assert_eq!(
1025            cell.dist2_shifted_indexed(p, &positions, &indices, shift, &mut wrong),
1026            Err(Error::BufferSize)
1027        );
1028    }
1029
1030    #[test]
1031    fn lammps_sheared_a_image_is_quarter() {
1032        let cell =
1033            Cell::from_lammps_bounds(15.0, 8.660254037844386, 10.0, 5.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1034                .unwrap();
1035        assert!(!cell.is_ortho());
1036        assert!((cell.a()[0] - 10.0).abs() < 1e-12);
1037        assert!((cell.b()[0] - 5.0).abs() < 1e-12);
1038        let p = [0.2, 0.1, 1.0];
1039        let q = [9.7, 0.1, 1.0];
1040        assert!((cell.dist2(p, q) - 0.25).abs() <= 1e-12);
1041    }
1042
1043    #[test]
1044    fn ase_rows_match_from_vectors() {
1045        let rows = [[10.0, 0.0, 0.0], [5.0, 8.66, 0.0], [0.0, 0.0, 10.0]];
1046        let a = Cell::from_ase(rows).unwrap();
1047        let b = Cell::from_vectors(rows[0], rows[1], rows[2], [0.0, 0.0, 0.0]).unwrap();
1048        assert_eq!(a, b);
1049        assert_eq!(Cell::from_vesin(rows).unwrap(), a);
1050        assert_eq!(Cell::from_con(rows).unwrap(), a);
1051    }
1052
1053    #[test]
1054    fn con_box_ortho_is_diagonal() {
1055        let cell = Cell::from_con_box([10.0, 11.0, 12.0], [90.0, 90.0, 90.0]).unwrap();
1056        assert!(cell.is_ortho());
1057        assert!((cell.widths()[0] - 10.0).abs() < 1e-12);
1058        assert!((cell.widths()[1] - 11.0).abs() < 1e-12);
1059        assert!((cell.widths()[2] - 12.0).abs() < 1e-12);
1060    }
1061
1062    #[test]
1063    fn rel_from_j_matches_seams_mixed_image() {
1064        let cell =
1065            Cell::from_lammps_bounds(15.0, 8.660254037844386, 10.0, 5.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1066                .unwrap();
1067        let i = [0.5, 0.5, 1.0];
1068        let j = [1.0, 8.0, 1.0];
1069        let dr = cell.displacement(j, i);
1070        assert!((dr[0] - 4.5).abs() < 1e-10);
1071        assert!((dr[1] - 1.160254037844386).abs() < 1e-10);
1072        assert!(dr[2].abs() < 1e-12);
1073    }
1074}