Skip to main content

molrs/core/spatial/
simbox.rs

1//! Triclinic simulation box and periodic operations based on ndarray.
2//!
3//! Conventions (fractional/cartesian):
4//! - cart = origin + H * frac
5//! - frac = H^{-1} * (cart - origin)
6//! - Lattice vectors are the columns of H.
7
8use crate::math;
9use crate::types::{F, F3, F3View, F3x3, FNx3, FNx3View, Pbc3};
10use ndarray::{Array1, Array2, Array3, ArrayView1, ArrayView2, array};
11
12/// Box geometry kind, detected once at construction.
13#[derive(Debug, Clone, PartialEq)]
14pub enum BoxKind {
15    /// Orthorhombic (diagonal H): lengths, inverse lengths cached.
16    Ortho { len: F3, inv_len: F3 },
17    /// General triclinic.
18    Triclinic,
19}
20
21/// Simulation box: triclinic cell with origin and per-axis PBC mask
22#[derive(Debug, Clone)]
23pub struct SimBox {
24    /// Triclinic cell matrix H (columns are lattice vectors)
25    h: F3x3,
26    /// Precomputed inverse of H
27    inv: F3x3,
28    /// Origin of the cell in Cartesian coordinates
29    origin: F3,
30    /// Per-axis periodic boundary condition flags (x, y, z)
31    pbc: Pbc3,
32    /// Cached geometry kind
33    kind: BoxKind,
34    /// Whether the cell is geometrically defined. `false` marks a "no-cell"
35    /// box (an undefined / zero-volume cell) — distinct from `pbc`, which only
36    /// describes periodicity. A defined non-periodic box (e.g. a free-boundary
37    /// bounding box) keeps `cell_defined = true`; only a box with no meaningful
38    /// cell at all (carrying the identity matrix purely so geometry ops are
39    /// no-ops) sets it `false`.
40    cell_defined: bool,
41}
42
43/// Error type for simulation box construction.
44#[derive(Debug)]
45pub enum BoxError {
46    /// The cell matrix H is singular (determinant ≈ 0).
47    SingularCell,
48    /// The matrix does not have shape 3x3.
49    InvalidMatrixShape { rows: usize, cols: usize },
50    /// A vector does not have the expected length.
51    InvalidVectorLength { len: usize },
52    /// A required array is not contiguous in memory.
53    NonContiguous(&'static str),
54    /// Cell lengths/angles do not describe a physical triclinic cell.
55    InvalidAngles,
56}
57
58impl SimBox {
59    /// Construct from triclinic cell matrix `H`, origin `O`, and per-axis PBC flags
60    pub fn new(h: F3x3, origin: F3, pbc: Pbc3) -> Result<Self, BoxError> {
61        Self::new_cell(h, origin, pbc, true)
62    }
63
64    /// Construct a box, explicitly marking whether the cell is geometrically
65    /// defined. Pass `cell_defined = false` for a "no-cell" box (undefined /
66    /// zero-volume): supply the identity matrix so geometry ops degrade to
67    /// no-ops, and `volume` / `is_cell_defined` reflect the undefined cell.
68    pub fn new_cell(h: F3x3, origin: F3, pbc: Pbc3, cell_defined: bool) -> Result<Self, BoxError> {
69        if let Some(inv) = math::inv3(&h) {
70            let kind = detect_box_kind(&h);
71            Ok(Self {
72                h,
73                inv,
74                origin,
75                pbc,
76                kind,
77                cell_defined,
78            })
79        } else {
80            Err(BoxError::SingularCell)
81        }
82    }
83
84    /// Whether the cell is geometrically defined (`false` ⇒ a no-cell box of
85    /// undefined / zero volume). Distinct from periodicity ([`is_free`]).
86    ///
87    /// [`is_free`]: SimBox::is_free
88    pub fn is_cell_defined(&self) -> bool {
89        self.cell_defined
90    }
91
92    pub fn try_new(h: F3x3, origin: F3, pbc: Pbc3) -> Result<Self, BoxError> {
93        Self::new(h, origin, pbc)
94    }
95
96    /// Factory: cubic box with edge length `a` and origin `O`
97    pub fn cube(a: F, origin: F3, pbc: Pbc3) -> Result<Self, BoxError> {
98        if a <= 0.0 {
99            return Err(BoxError::InvalidVectorLength { len: 0 });
100        }
101        let h = array![[a, 0.0, 0.0], [0.0, a, 0.0], [0.0, 0.0, a]];
102        Self::new(h, origin, pbc)
103    }
104
105    /// Factory: ortho box with lengths (ax, ay, az) and origin `O`
106    pub fn ortho(lengths: F3, origin: F3, pbc: Pbc3) -> Result<Self, BoxError> {
107        if lengths.len() != 3 {
108            return Err(BoxError::InvalidVectorLength { len: lengths.len() });
109        }
110        if lengths.iter().any(|v| *v <= 0.0) {
111            return Err(BoxError::InvalidVectorLength { len: 0 });
112        }
113        let h = array![
114            [lengths[0], 0.0, 0.0],
115            [0.0, lengths[1], 0.0],
116            [0.0, 0.0, lengths[2]],
117        ];
118        Self::new(h, origin, pbc)
119    }
120
121    /// Restricted-triclinic matrix from edge lengths and angles in degrees.
122    pub fn matrix_from_lengths_angles(lengths: [F; 3], angles: [F; 3]) -> Result<F3x3, BoxError> {
123        let [a, b, c] = lengths;
124        let [alpha, beta, gamma] = angles.map(F::to_radians);
125        if [a, b, c].iter().any(|value| *value <= 0.0)
126            || [alpha, beta, gamma]
127                .iter()
128                .any(|angle| !(*angle > 0.0 && *angle < std::f64::consts::PI))
129        {
130            return Err(BoxError::InvalidAngles);
131        }
132        let (cos_a, cos_b, cos_c) = (alpha.cos(), beta.cos(), gamma.cos());
133        let xy = b * cos_c;
134        let xz = c * cos_b;
135        let ly = (b * b - xy * xy).sqrt();
136        if !ly.is_finite() || ly <= 0.0 {
137            return Err(BoxError::InvalidAngles);
138        }
139        let yz = (b * c * cos_a - xy * xz) / ly;
140        let lz2 = c * c - xz * xz - yz * yz;
141        if !lz2.is_finite() || lz2 <= 0.0 {
142            return Err(BoxError::InvalidAngles);
143        }
144        Ok(array![[a, xy, xz], [0.0, ly, yz], [0.0, 0.0, lz2.sqrt()]])
145    }
146
147    /// Restricted-triclinic matrix from diagonal sizes and `(xy, xz, yz)` tilts.
148    pub fn matrix_from_lengths_tilts(lengths: [F; 3], tilts: [F; 3]) -> F3x3 {
149        array![
150            [lengths[0], tilts[0], tilts[1]],
151            [0.0, lengths[1], tilts[2]],
152            [0.0, 0.0, lengths[2]],
153        ]
154    }
155
156    /// Convert a general cell matrix to LAMMPS restricted-triclinic form.
157    pub fn restricted_matrix(matrix: FNx3View<'_>) -> Result<F3x3, BoxError> {
158        if matrix.dim() != (3, 3) {
159            return Err(BoxError::InvalidMatrixShape {
160                rows: matrix.nrows(),
161                cols: matrix.ncols(),
162            });
163        }
164        let a = matrix.column(0).to_owned();
165        let b = matrix.column(1).to_owned();
166        let c = matrix.column(2).to_owned();
167        let ax = math::norm3(&a);
168        if ax <= 0.0 {
169            return Err(BoxError::SingularCell);
170        }
171        let ua = &a / ax;
172        let bx = b.dot(&ua);
173        let cross_ab = math::cross3(&a, &b);
174        let cross_norm = math::norm3(&cross_ab);
175        if cross_norm <= 0.0 {
176            return Err(BoxError::SingularCell);
177        }
178        let by = math::norm3(&math::cross3(&ua, &b));
179        let uab = &cross_ab / cross_norm;
180        let cx = c.dot(&ua);
181        let cy = c.dot(&math::cross3(&uab, &ua));
182        let cz = c.dot(&uab);
183        Ok(array![[ax, bx, cx], [0.0, by, cy], [0.0, 0.0, cz]])
184    }
185
186    /// Create a non-periodic (free-boundary) box enclosing all points.
187    ///
188    /// Computes the axis-aligned bounding box of `points` and adds `padding`
189    /// on each side. The resulting box has `pbc = [false, false, false]`.
190    ///
191    /// `padding` should be >= the neighbor cutoff distance so that all
192    /// particles sit well inside the box for correct cell assignment.
193    ///
194    /// # Errors
195    /// Returns `BoxError` if padding is non-positive or the resulting box is degenerate.
196    ///
197    /// # Panics
198    /// Panics if `padding <= 0`.
199    pub fn free(points: FNx3View<'_>, padding: F) -> Result<Self, BoxError> {
200        assert!(padding > 0.0, "padding must be positive");
201        let n = points.nrows();
202        if n == 0 {
203            // Empty point set -- return a unit cube at origin
204            return Self::cube(padding, array![0.0 as F, 0.0, 0.0], [false, false, false]);
205        }
206        let mut min = array![points[[0, 0]], points[[0, 1]], points[[0, 2]]];
207        let mut max = min.clone();
208        for i in 1..n {
209            for d in 0..3 {
210                if points[[i, d]] < min[d] {
211                    min[d] = points[[i, d]];
212                }
213                if points[[i, d]] > max[d] {
214                    max[d] = points[[i, d]];
215                }
216            }
217        }
218        let origin = array![min[0] - padding, min[1] - padding, min[2] - padding,];
219        let lengths = array![
220            (max[0] - min[0] + 2.0 * padding).max(padding),
221            (max[1] - min[1] + 2.0 * padding).max(padding),
222            (max[2] - min[2] + 2.0 * padding).max(padding),
223        ];
224        Self::ortho(lengths, origin, [false, false, false])
225    }
226
227    /// Create a tight orthorhombic box around a point cloud.
228    ///
229    /// Unlike [`free`](Self::free), padding is specified per axis and may be
230    /// zero. Periodicity is supplied by the caller instead of being forced to
231    /// free-boundary semantics.
232    pub fn from_bounds(points: FNx3View<'_>, padding: [F; 3], pbc: Pbc3) -> Result<Self, BoxError> {
233        if points.nrows() == 0 {
234            return Err(BoxError::InvalidVectorLength { len: 0 });
235        }
236        if padding.iter().any(|value| *value < 0.0) {
237            return Err(BoxError::InvalidVectorLength { len: 0 });
238        }
239        let mut min = [points[[0, 0]], points[[0, 1]], points[[0, 2]]];
240        let mut max = min;
241        for point in points.rows().into_iter().skip(1) {
242            for d in 0..3 {
243                min[d] = min[d].min(point[d]);
244                max[d] = max[d].max(point[d]);
245            }
246        }
247        let origin = array![
248            min[0] - padding[0],
249            min[1] - padding[1],
250            min[2] - padding[2]
251        ];
252        let lengths = array![
253            max[0] - min[0] + 2.0 * padding[0],
254            max[1] - min[1] + 2.0 * padding[1],
255            max[2] - min[2] + 2.0 * padding[2]
256        ];
257        Self::ortho(lengths, origin, pbc)
258    }
259
260    /// Create a non-periodic (free-boundary) box enclosing all points, reading
261    /// positions from three separate `x`/`y`/`z` slices (SoA layout).
262    ///
263    /// Arithmetically identical to [`free`](Self::free): computes the same
264    /// axis-aligned bounding box (min/max over all points) plus `padding` on
265    /// each side, and returns a box byte-identical to `free` on the same
266    /// points. Provided so callers holding column-major (SoA) coordinates need
267    /// not interleave them into an owned `Array2` first.
268    ///
269    /// # Errors
270    /// Returns `BoxError` if the resulting box is degenerate.
271    ///
272    /// # Panics
273    /// Panics if `padding <= 0` or the three slices do not have equal length.
274    pub fn free_columns(xs: &[F], ys: &[F], zs: &[F], padding: F) -> Result<Self, BoxError> {
275        assert!(padding > 0.0, "padding must be positive");
276        assert!(
277            xs.len() == ys.len() && ys.len() == zs.len(),
278            "x/y/z slices must have equal length"
279        );
280        let n = xs.len();
281        if n == 0 {
282            // Empty point set -- return a unit cube at origin
283            return Self::cube(padding, array![0.0 as F, 0.0, 0.0], [false, false, false]);
284        }
285        let mut min = array![xs[0], ys[0], zs[0]];
286        let mut max = min.clone();
287        for i in 1..n {
288            let p = [xs[i], ys[i], zs[i]];
289            for d in 0..3 {
290                if p[d] < min[d] {
291                    min[d] = p[d];
292                }
293                if p[d] > max[d] {
294                    max[d] = p[d];
295                }
296            }
297        }
298        let origin = array![min[0] - padding, min[1] - padding, min[2] - padding,];
299        let lengths = array![
300            (max[0] - min[0] + 2.0 * padding).max(padding),
301            (max[1] - min[1] + 2.0 * padding).max(padding),
302            (max[2] - min[2] + 2.0 * padding).max(padding),
303        ];
304        Self::ortho(lengths, origin, [false, false, false])
305    }
306
307    /// View of the cell matrix
308    pub fn h_view(&self) -> FNx3View<'_> {
309        self.h.view()
310    }
311
312    /// View of the inverse cell matrix
313    pub fn inv_view(&self) -> FNx3View<'_> {
314        self.inv.view()
315    }
316
317    /// View of the origin
318    pub fn origin_view(&self) -> F3View<'_> {
319        self.origin.view()
320    }
321
322    /// View of the PBC flags
323    pub fn pbc_view(&self) -> ArrayView1<'_, bool> {
324        ArrayView1::from_shape(3, &self.pbc).expect("pbc_view shape")
325    }
326
327    /// Per-axis PBC flags
328    pub fn pbc(&self) -> Pbc3 {
329        self.pbc
330    }
331
332    /// Cell volume (|det(H)|)
333    pub fn volume(&self) -> F {
334        math::det3(&self.h).abs()
335    }
336
337    /// `true` when the box is free (non-periodic on every axis).
338    pub fn is_free(&self) -> bool {
339        self.pbc.iter().all(|&p| !p)
340    }
341
342    /// Geometry style label: `"free"` (no periodic axis), `"orthogonal"`
343    /// (diagonal H), or `"triclinic"`.
344    pub fn style(&self) -> &'static str {
345        if self.is_free() {
346            "free"
347        } else {
348            match self.kind {
349                BoxKind::Ortho { .. } => "orthogonal",
350                BoxKind::Triclinic => "triclinic",
351            }
352        }
353    }
354
355    /// Off-diagonal tilts [xy, xz, yz] of the cell matrix
356    pub fn tilts(&self) -> F3 {
357        array![self.h[[0, 1]], self.h[[0, 2]], self.h[[1, 2]]]
358    }
359
360    /// Lattice vector lengths
361    pub fn lengths(&self) -> F3 {
362        let a = self.lattice(0);
363        let b = self.lattice(1);
364        let c = self.lattice(2);
365        array![math::norm3(&a), math::norm3(&b), math::norm3(&c)]
366    }
367
368    /// Lattice angles `[alpha, beta, gamma]` in degrees.
369    pub fn angles(&self) -> F3 {
370        let a = self.lattice(0);
371        let b = self.lattice(1);
372        let c = self.lattice(2);
373        let angle = |u: &F3, v: &F3| {
374            (u.dot(v) / (math::norm3(u) * math::norm3(v)))
375                .clamp(-1.0, 1.0)
376                .acos()
377                .to_degrees()
378        };
379        array![angle(&b, &c), angle(&a, &c), angle(&a, &b)]
380    }
381
382    /// Nearest plane distance (half the box size along each axis)
383    /// For triclinic boxes, this is the perpendicular distance to each face
384    pub fn nearest_plane_distance(&self) -> F3 {
385        let v = self.volume();
386        let a1 = self.lattice(0);
387        let a2 = self.lattice(1);
388        let a3 = self.lattice(2);
389
390        let c23 = math::cross3(&a2, &a3);
391        let c31 = math::cross3(&a3, &a1);
392        let c12 = math::cross3(&a1, &a2);
393
394        array![
395            v / math::norm3(&c23),
396            v / math::norm3(&c31),
397            v / math::norm3(&c12)
398        ]
399    }
400
401    pub fn kind(&self) -> &BoxKind {
402        &self.kind
403    }
404
405    /// Lattice vector by index (0,1,2) — columns of H
406    pub fn lattice(&self, index: usize) -> F3 {
407        assert!(index < 3, "lattice index must be 0..2");
408        self.h.column(index).to_owned()
409    }
410
411    /// Convert Cartesian coordinates to fractional coordinates [0, 1)
412    pub fn make_fractional(&self, r: F3View<'_>) -> F3 {
413        let dr = &r - &self.origin.view();
414        let mut frac = self.inv.dot(&dr);
415        for f in frac.iter_mut() {
416            *f -= f.floor();
417        }
418        frac
419    }
420
421    /// Fractional coordinates with ortho fast-path
422    #[inline(always)]
423    pub fn make_fractional_fast(&self, r: F3View<'_>) -> F3 {
424        match &self.kind {
425            BoxKind::Ortho { inv_len, .. } => {
426                let mut frac = array![
427                    (r[0] - self.origin[0]) * inv_len[0],
428                    (r[1] - self.origin[1]) * inv_len[1],
429                    (r[2] - self.origin[2]) * inv_len[2],
430                ];
431                for f in frac.iter_mut() {
432                    *f -= f.floor();
433                }
434                frac
435            }
436            BoxKind::Triclinic => self.make_fractional(r),
437        }
438    }
439
440    /// Fractional coordinates **without** the wrap into `[0, 1)`.
441    ///
442    /// [`make_fractional_fast_arr3`](Self::make_fractional_fast_arr3) folds
443    /// every axis back into the primitive cell unconditionally, which is right
444    /// for a fully periodic box but destroys the information a caller needs on
445    /// a **non-periodic** axis: a point above the box must stay above it, so
446    /// that a cell-list assignment can clamp it to the edge cell instead of
447    /// wrapping it to the opposite face. Callers that dispatch on
448    /// [`pbc`](Self::pbc) per axis — see `CellGrid` — start from this raw value
449    /// and apply wrap or clamp themselves.
450    ///
451    /// `make_fractional_fast_arr3(r)` is exactly this followed by
452    /// `f - f.floor()` per component, so the two agree bit-for-bit on any point
453    /// inside the cell.
454    #[inline(always)]
455    pub fn make_fractional_raw_arr3(&self, r: [F; 3]) -> [F; 3] {
456        match &self.kind {
457            BoxKind::Ortho { inv_len, .. } => [
458                (r[0] - self.origin[0]) * inv_len[0],
459                (r[1] - self.origin[1]) * inv_len[1],
460                (r[2] - self.origin[2]) * inv_len[2],
461            ],
462            BoxKind::Triclinic => {
463                let rv = ArrayView1::from_shape(3, &r).expect("make_fractional_raw_arr3 shape");
464                let dr = &rv - &self.origin.view();
465                let f = self.inv.dot(&dr);
466                [f[0], f[1], f[2]]
467            }
468        }
469    }
470
471    /// Convert fractional coordinates to Cartesian coordinates
472    pub fn make_cartesian(&self, frac: F3View<'_>) -> F3 {
473        &self.origin + &self.h.dot(&frac)
474    }
475
476    /// A detached, `Copy` minimum-image kernel.
477    ///
478    /// [`shortest_vector_impl`](Self::shortest_vector_impl) needs a `&SimBox`,
479    /// which is awkward for a caller whose hot loop already holds the owning
480    /// structure mutably: it either clones the box — two `Array2` allocations,
481    /// per evaluation — or restructures its borrows. `Mic` lifts the convention
482    /// out as a plain value with everything it needs on the stack, so it can be
483    /// captured once and carried into the loop.
484    ///
485    /// Bit-identical to `shortest_vector_impl`: both dispatch to the same
486    /// arithmetic.
487    pub fn mic(&self) -> Mic {
488        match &self.kind {
489            BoxKind::Ortho { len, inv_len } => Mic::Ortho {
490                len: [len[0], len[1], len[2]],
491                inv_len: [inv_len[0], inv_len[1], inv_len[2]],
492                pbc: self.pbc(),
493            },
494            BoxKind::Triclinic => {
495                let mut h = [0.0; 9];
496                let mut inv = [0.0; 9];
497                for i in 0..3 {
498                    for j in 0..3 {
499                        h[3 * i + j] = self.h[[i, j]];
500                        inv[3 * i + j] = self.inv[[i, j]];
501                    }
502                }
503                Mic::Triclinic {
504                    h,
505                    inv,
506                    pbc: self.pbc(),
507                }
508            }
509        }
510    }
511
512    /// Hot-loop MIC kernel: takes and returns `[F; 3]`, zero allocation.
513    ///
514    /// Ortho boxes use the `dr − round(dr / L) · L` fast path; triclinic
515    /// boxes fall back to the general `H · round(H⁻¹ · dr)` form. This
516    /// is the single source of truth for the minimum-image convention —
517    /// both [`shortest_vector`](Self::shortest_vector) (ergonomic
518    /// `F3View` / `Array1` API) and
519    /// [`shortest_vector_impl`](Self::shortest_vector_impl) (zero-alloc
520    /// `[F; 3]` API) route through here.
521    #[inline(always)]
522    fn mic_kernel(&self, a: [F; 3], b: [F; 3]) -> [F; 3] {
523        match &self.kind {
524            BoxKind::Ortho { len, inv_len } => {
525                let mut dr = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
526                if self.pbc[0] {
527                    dr[0] -= (dr[0] * inv_len[0]).round() * len[0];
528                }
529                if self.pbc[1] {
530                    dr[1] -= (dr[1] * inv_len[1]).round() * len[1];
531                }
532                if self.pbc[2] {
533                    dr[2] -= (dr[2] * inv_len[2]).round() * len[2];
534                }
535                dr
536            }
537            BoxKind::Triclinic => {
538                // General triclinic path: fold the displacement through
539                // fractional coords and wrap each periodic axis to
540                // `[-0.5, 0.5)`.
541                //
542                // Written out on the stack rather than as `inv.dot(dr)` /
543                // `h.dot(frac)`. Those allocate an `Array1` each, and this
544                // kernel sits in the innermost pair loop of every caller — a
545                // packer evaluates it millions of times per objective
546                // evaluation, where two heap allocations per pair dominate the
547                // arithmetic outright. Summation order matches ndarray's
548                // matrix-vector product (k ascending), so the result is
549                // bit-identical to the allocating form.
550                let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
551                let mut f = [
552                    self.inv[[0, 0]] * d[0] + self.inv[[0, 1]] * d[1] + self.inv[[0, 2]] * d[2],
553                    self.inv[[1, 0]] * d[0] + self.inv[[1, 1]] * d[1] + self.inv[[1, 2]] * d[2],
554                    self.inv[[2, 0]] * d[0] + self.inv[[2, 1]] * d[1] + self.inv[[2, 2]] * d[2],
555                ];
556                for (fk, &periodic) in f.iter_mut().zip(self.pbc.iter()) {
557                    if periodic {
558                        *fk -= fk.round();
559                    }
560                }
561                [
562                    self.h[[0, 0]] * f[0] + self.h[[0, 1]] * f[1] + self.h[[0, 2]] * f[2],
563                    self.h[[1, 0]] * f[0] + self.h[[1, 1]] * f[1] + self.h[[1, 2]] * f[2],
564                    self.h[[2, 0]] * f[0] + self.h[[2, 1]] * f[1] + self.h[[2, 2]] * f[2],
565                ]
566            }
567        }
568    }
569
570    /// Minimum image displacement vector from `r1` to `r2` (returns `r2 − r1`).
571    ///
572    /// Ergonomic ndarray-flavoured API: takes views and returns an owned
573    /// `Array1<F>`. Inside hot loops prefer
574    /// [`shortest_vector_impl`](Self::shortest_vector_impl) — it avoids the
575    /// heap allocation for the output (~70% faster per call).
576    #[inline]
577    pub fn shortest_vector(&self, r1: F3View<'_>, r2: F3View<'_>) -> F3 {
578        let dr = self.mic_kernel([r1[0], r1[1], r1[2]], [r2[0], r2[1], r2[2]]);
579        array![dr[0], dr[1], dr[2]]
580    }
581
582    /// Zero-allocation MIC displacement from `a` to `b` (returns `b − a`).
583    ///
584    /// Stack-array in / out; the canonical hot-loop entry point. Used by
585    /// [`LinkCell`](crate::spatial::neighbors::LinkCell),
586    /// [`BruteForce`](crate::spatial::neighbors::BruteForce), and
587    /// [`AabbQuery`](crate::spatial::neighbors::AabbQuery) inner loops.
588    #[inline(always)]
589    pub fn shortest_vector_impl(&self, a: [F; 3], b: [F; 3]) -> [F; 3] {
590        self.mic_kernel(a, b)
591    }
592
593    /// Calculate squared distance using MIC.
594    #[inline]
595    pub fn calc_distance2(&self, a: F3View<'_>, b: F3View<'_>) -> F {
596        let dr = self.shortest_vector(a, b);
597        dr.dot(&dr)
598    }
599
600    /// Convert Cartesian points to fractional coordinates (N×3)
601    pub fn to_frac(&self, xyz: FNx3View<'_>) -> FNx3 {
602        let n = xyz.nrows();
603        let mut result = FNx3::zeros((n, 3));
604        for i in 0..n {
605            let dr = &xyz.row(i) - &self.origin.view();
606            result.row_mut(i).assign(&self.inv.dot(&dr));
607        }
608        result
609    }
610
611    /// Convert fractional coordinates to Cartesian points (N×3)
612    pub fn to_cart(&self, frac: FNx3View<'_>) -> FNx3 {
613        let n = frac.nrows();
614        let mut result = FNx3::zeros((n, 3));
615        for i in 0..n {
616            let cart = &self.origin + &self.h.dot(&frac.row(i));
617            result.row_mut(i).assign(&cart);
618        }
619        result
620    }
621
622    /// Check if points lie within [0,1) in fractional space.
623    pub fn isin(&self, xyz: FNx3View<'_>) -> Array1<bool> {
624        let n = xyz.nrows();
625        let mut mask = Vec::with_capacity(n);
626        for i in 0..n {
627            let dr = &xyz.row(i) - &self.origin.view();
628            let frac = self.inv.dot(&dr);
629            let inside = (0..3).all(|d| frac[d] >= 0.0 && frac[d] < 1.0);
630            mask.push(inside);
631        }
632        Array1::from_vec(mask)
633    }
634
635    /// Batched displacement vectors row-wise (N×3).
636    /// Writes result into `out` to avoid allocation.
637    pub fn delta_out(
638        &self,
639        xyzu1: FNx3View<'_>,
640        xyzu2: FNx3View<'_>,
641        out: &mut FNx3,
642        minimum_image: bool,
643    ) {
644        assert_eq!(xyzu1.nrows(), xyzu2.nrows());
645        let n = xyzu1.nrows();
646        if minimum_image {
647            for i in 0..n {
648                let dr = self.shortest_vector(xyzu1.row(i), xyzu2.row(i));
649                out.row_mut(i).assign(&dr);
650            }
651        } else {
652            for i in 0..n {
653                let dr = &xyzu2.row(i) - &xyzu1.row(i);
654                out.row_mut(i).assign(&dr);
655            }
656        }
657    }
658
659    /// Batched displacement vectors row-wise (N×3)
660    pub fn delta(&self, xyzu1: FNx3View<'_>, xyzu2: FNx3View<'_>, minimum_image: bool) -> FNx3 {
661        assert_eq!(xyzu1.nrows(), xyzu2.nrows());
662        let n = xyzu1.nrows();
663        let mut out = FNx3::zeros((n, 3));
664        self.delta_out(xyzu1, xyzu2, &mut out, minimum_image);
665        out
666    }
667
668    /// Row-wise minimum-image distances between equally sized point arrays.
669    pub fn distances(&self, points1: FNx3View<'_>, points2: FNx3View<'_>) -> Array1<F> {
670        assert_eq!(points1.raw_dim(), points2.raw_dim());
671        let values = points1
672            .rows()
673            .into_iter()
674            .zip(points2.rows())
675            .map(|(a, b)| {
676                let dr = self.shortest_vector_impl([a[0], a[1], a[2]], [b[0], b[1], b[2]]);
677                (dr[0] * dr[0] + dr[1] * dr[1] + dr[2] * dr[2]).sqrt()
678            })
679            .collect();
680        Array1::from_vec(values)
681    }
682
683    /// All pairwise minimum-image displacement vectors (`points2 - points1`).
684    pub fn pairwise_delta(&self, points1: FNx3View<'_>, points2: FNx3View<'_>) -> Array3<F> {
685        let mut out = Array3::zeros((points1.nrows(), points2.nrows(), 3));
686        for (i, a) in points1.rows().into_iter().enumerate() {
687            for (j, b) in points2.rows().into_iter().enumerate() {
688                let dr = self.shortest_vector_impl([a[0], a[1], a[2]], [b[0], b[1], b[2]]);
689                for d in 0..3 {
690                    out[[i, j, d]] = dr[d];
691                }
692            }
693        }
694        out
695    }
696
697    /// All pairwise minimum-image distances.
698    pub fn pairwise_distances(&self, points1: FNx3View<'_>, points2: FNx3View<'_>) -> Array2<F> {
699        let mut out = Array2::zeros((points1.nrows(), points2.nrows()));
700        for (i, a) in points1.rows().into_iter().enumerate() {
701            for (j, b) in points2.rows().into_iter().enumerate() {
702                let dr = self.shortest_vector_impl([a[0], a[1], a[2]], [b[0], b[1], b[2]]);
703                out[[i, j]] = (dr[0] * dr[0] + dr[1] * dr[1] + dr[2] * dr[2]).sqrt();
704            }
705        }
706        out
707    }
708
709    /// Return a box with its cell matrix right-multiplied by a transform.
710    pub fn transformed(&self, transformation: &F3x3) -> Result<Self, BoxError> {
711        Self::new_cell(
712            self.h.dot(transformation),
713            self.origin.clone(),
714            self.pbc,
715            self.cell_defined,
716        )
717    }
718
719    /// Wrap Cartesian points into the unit cell according to PBC
720    pub fn wrap(&self, xyz: FNx3View<'_>) -> FNx3 {
721        let mut frac = self.to_frac(xyz);
722        let n = frac.nrows();
723        for i in 0..n {
724            for d in 0..3 {
725                if self.pbc[d] {
726                    frac[[i, d]] -= frac[[i, d]].floor();
727                }
728            }
729        }
730        self.to_cart(frac.view())
731    }
732
733    /// Integer periodic images for Cartesian points.
734    pub fn images(&self, xyz: FNx3View<'_>) -> Array2<i64> {
735        let frac = self.to_frac(xyz);
736        let mut images = Array2::zeros((frac.nrows(), 3));
737        for i in 0..frac.nrows() {
738            for d in 0..3 {
739                if self.pbc[d] {
740                    images[[i, d]] = (frac[[i, d]] + 1e-8).floor() as i64;
741                }
742            }
743        }
744        images
745    }
746
747    /// Reconstruct unwrapped coordinates from wrapped points and image flags.
748    pub fn unwrap(&self, xyz: FNx3View<'_>, images: ArrayView2<'_, i64>) -> FNx3 {
749        assert_eq!(xyz.raw_dim(), images.raw_dim());
750        assert_eq!(xyz.ncols(), 3);
751        let mut result = xyz.to_owned();
752        for i in 0..xyz.nrows() {
753            let image = array![
754                images[[i, 0]] as F,
755                images[[i, 1]] as F,
756                images[[i, 2]] as F,
757            ];
758            let shift = self.h.dot(&image);
759            for d in 0..3 {
760                result[[i, d]] += shift[d];
761            }
762        }
763        result
764    }
765
766    pub fn get_corners(&self) -> FNx3 {
767        self.to_cart(
768            array![
769                [0.0, 0.0, 0.0],
770                [1.0, 0.0, 0.0],
771                [1.0, 1.0, 0.0],
772                [0.0, 1.0, 0.0],
773                [0.0, 0.0, 1.0],
774                [1.0, 0.0, 1.0],
775                [1.0, 1.0, 1.0],
776                [0.0, 1.0, 1.0],
777            ]
778            .view(),
779        )
780    }
781
782    /// Axis-aligned bounding box of the cell geometry.
783    ///
784    /// Layout: rows = x/y/z, col 0 = min, col 1 = max — the AABB of the eight
785    /// corners. For triclinic cells this is larger than the true cell volume;
786    /// use [`isin`](Self::isin) for membership. Geometric region types that
787    /// describe the same volume live in `spatial::region`
788    /// (`Cuboid` / `Parallelepiped`) — not on this type.
789    pub fn bounds(&self) -> FNx3 {
790        let corners = self.get_corners();
791        let mut b = Array2::zeros((3, 2));
792        for d in 0..3 {
793            let mut lo = corners[[0, d]];
794            let mut hi = lo;
795            for i in 1..corners.nrows() {
796                lo = lo.min(corners[[i, d]]);
797                hi = hi.max(corners[[i, d]]);
798            }
799            b[[d, 0]] = lo;
800            b[[d, 1]] = hi;
801        }
802        b
803    }
804}
805
806/// Minimum-image convention as a standalone `Copy` value.
807///
808/// Produced by [`SimBox::mic`]. Carries no periodicity of its own beyond the
809/// flags captured at construction, so a box that changes shape needs a fresh
810/// one — which is the point: it is meant to be captured once per evaluation and
811/// read many times.
812#[derive(Debug, Clone, Copy, PartialEq)]
813pub enum Mic {
814    /// No axis wraps: displacements pass through untouched.
815    Free,
816    Ortho {
817        len: [F; 3],
818        inv_len: [F; 3],
819        pbc: [bool; 3],
820    },
821    Triclinic {
822        /// Row-major 3x3 lattice, columns are the lattice vectors.
823        h: [F; 9],
824        /// Row-major 3x3 inverse lattice.
825        inv: [F; 9],
826        pbc: [bool; 3],
827    },
828}
829
830impl Mic {
831    /// Minimum image of a displacement.
832    ///
833    /// Takes the displacement rather than two points: the convention depends
834    /// only on the separation, and callers in pair loops already have it.
835    #[inline(always)]
836    pub fn apply(&self, d: [F; 3]) -> [F; 3] {
837        match self {
838            Mic::Free => d,
839            Mic::Ortho { len, inv_len, pbc } => {
840                let mut dr = d;
841                for k in 0..3 {
842                    if pbc[k] {
843                        dr[k] -= (dr[k] * inv_len[k]).round() * len[k];
844                    }
845                }
846                dr
847            }
848            Mic::Triclinic { h, inv, pbc } => {
849                let mut f = [
850                    inv[0] * d[0] + inv[1] * d[1] + inv[2] * d[2],
851                    inv[3] * d[0] + inv[4] * d[1] + inv[5] * d[2],
852                    inv[6] * d[0] + inv[7] * d[1] + inv[8] * d[2],
853                ];
854                for (fk, &periodic) in f.iter_mut().zip(pbc.iter()) {
855                    if periodic {
856                        *fk -= fk.round();
857                    }
858                }
859                [
860                    h[0] * f[0] + h[1] * f[1] + h[2] * f[2],
861                    h[3] * f[0] + h[4] * f[1] + h[5] * f[2],
862                    h[6] * f[0] + h[7] * f[1] + h[8] * f[2],
863                ]
864            }
865        }
866    }
867
868    /// Collapse to [`Mic::Free`] when no axis wraps, so the hot path is a
869    /// single match arm instead of three predictable-but-present branches.
870    #[inline]
871    pub fn simplified(self) -> Self {
872        let pbc = match self {
873            Mic::Free => return self,
874            Mic::Ortho { pbc, .. } | Mic::Triclinic { pbc, .. } => pbc,
875        };
876        if pbc.iter().any(|&p| p) {
877            self
878        } else {
879            Mic::Free
880        }
881    }
882}
883
884fn detect_box_kind(h: &F3x3) -> BoxKind {
885    let eps: F = 1e-12;
886    let is_ortho = h[[0, 1]].abs() < eps
887        && h[[0, 2]].abs() < eps
888        && h[[1, 0]].abs() < eps
889        && h[[1, 2]].abs() < eps
890        && h[[2, 0]].abs() < eps
891        && h[[2, 1]].abs() < eps;
892    if is_ortho {
893        let len = array![h[[0, 0]], h[[1, 1]], h[[2, 2]]];
894        let inv_len = array![1.0 / len[0], 1.0 / len[1], 1.0 / len[2]];
895        BoxKind::Ortho { len, inv_len }
896    } else {
897        BoxKind::Triclinic
898    }
899}
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904
905    fn assert_close(a: F, b: F) {
906        assert!((a - b).abs() < 1e-6 as F, "{} != {}", a, b);
907    }
908
909    #[test]
910    fn cell_defined_distinct_from_pbc() {
911        // A defined box (any pbc) reports cell_defined = true (the default).
912        let defined = SimBox::ortho(
913            array![2.0, 2.0, 2.0],
914            array![0.0, 0.0, 0.0],
915            [false, false, false],
916        )
917        .unwrap();
918        assert!(defined.is_cell_defined());
919        assert!(defined.is_free()); // non-periodic => free, but cell IS defined
920        assert_close(defined.volume(), 8.0); // real cell volume preserved (RDF)
921
922        // A no-cell box carries the identity cell (so geometry is a no-op) but
923        // is marked cell_defined = false.
924        let nocell = SimBox::new_cell(
925            ndarray::Array2::eye(3),
926            array![0.0, 0.0, 0.0],
927            [false, false, false],
928            false,
929        )
930        .unwrap();
931        assert!(!nocell.is_cell_defined());
932        // geometry no-ops on the identity cell:
933        let pts = array![[1.0, 2.0, 3.0]];
934        assert_eq!(nocell.wrap(pts.view()), pts);
935    }
936
937    #[test]
938    fn roundtrip_frac_cart() {
939        let bx = SimBox::ortho(
940            array![2.0, 3.0, 4.0],
941            array![0.5, -1.0, 2.0],
942            [true, true, true],
943        )
944        .expect("invalid box lengths");
945        let pts = array![[0.5, -1.0, 2.0], [2.5, 2.0, 6.0]];
946        let frac = bx.to_frac(pts.view());
947        let cart = bx.to_cart(frac.view());
948        assert!((&pts - &cart).iter().all(|v| v.abs() < 1e-5));
949    }
950
951    #[test]
952    fn wrap_into_cell() {
953        let bx = SimBox::cube(2.0, array![0.0, 0.0, 0.0], [true, true, true])
954            .expect("invalid box length");
955        let pts = array![[2.1, -0.1, 3.9], [-1.9, 4.2, 0.0]];
956        let wrapped = bx.wrap(pts.view());
957        let frac = bx.to_frac(wrapped.view());
958        for i in 0..wrapped.nrows() {
959            let fx = frac[[i, 0]];
960            let fy = frac[[i, 1]];
961            let fz = frac[[i, 2]];
962            assert!((0.0..1.0).contains(&fx));
963            assert!((0.0..1.0).contains(&fy));
964            assert!((0.0..1.0).contains(&fz));
965        }
966    }
967
968    #[test]
969    fn calc_distance_matches_components() {
970        let bx = SimBox::cube(3.0, array![0.0, 0.0, 0.0], [true, true, true])
971            .expect("invalid box length");
972        let a = array![0.1, 0.2, 0.3];
973        let b = array![2.9, 0.2, 0.3];
974        let d2 = bx.calc_distance2(a.view(), b.view());
975        let dr = bx.shortest_vector(a.view(), b.view());
976        let expected = dr.dot(&dr);
977        assert!((d2 - expected).abs() < 1e-6);
978    }
979
980    #[test]
981    fn test_lengths_ortho() {
982        let bx = SimBox::ortho(
983            array![2.0, 4.0, 5.0],
984            array![0.0, 0.0, 0.0],
985            [true, true, true],
986        )
987        .expect("invalid box lengths");
988        let lengths = bx.lengths();
989        assert_close(lengths[0], 2.0);
990        assert_close(lengths[1], 4.0);
991        assert_close(lengths[2], 5.0);
992    }
993
994    #[test]
995    fn test_tilts_values() {
996        let h = array![[2.0, 1.0, 2.0], [0.0, 4.0, 3.0], [0.0, 0.0, 5.0]];
997        let bx = SimBox::new(h, array![0.0, 0.0, 0.0], [true, true, true]).expect("invalid box");
998        let tilts = bx.tilts();
999        assert_close(tilts[0], 1.0);
1000        assert_close(tilts[1], 2.0);
1001        assert_close(tilts[2], 3.0);
1002    }
1003
1004    #[test]
1005    fn test_volume() {
1006        let bx = SimBox::ortho(
1007            array![2.0, 3.0, 4.0],
1008            array![0.0, 0.0, 0.0],
1009            [true, true, true],
1010        )
1011        .expect("invalid box lengths");
1012        assert_close(bx.volume(), 24.0);
1013    }
1014
1015    #[test]
1016    fn test_wrap_single_and_multi() {
1017        let bx = SimBox::cube(2.0, array![0.0, 0.0, 0.0], [true, true, true])
1018            .expect("invalid box length");
1019        let pts = array![[10.0, -5.0, -5.0], [0.0, 0.5, 0.0]];
1020        let wrapped = bx.wrap(pts.view());
1021        assert_close(wrapped[[0, 0]], 0.0);
1022        assert_close(wrapped[[0, 1]], 1.0);
1023        assert_close(wrapped[[0, 2]], 1.0);
1024        assert_close(wrapped[[1, 0]], 0.0);
1025        assert_close(wrapped[[1, 1]], 0.5);
1026        assert_close(wrapped[[1, 2]], 0.0);
1027    }
1028
1029    #[test]
1030    fn test_fractional_and_cartesian() {
1031        let bx = SimBox::cube(2.0, array![0.0, 0.0, 0.0], [true, true, true])
1032            .expect("invalid box length");
1033        let p = array![-1.0, -1.0, -1.0];
1034        let frac = bx.make_fractional(p.view());
1035        assert_close(frac[0], 0.5);
1036        assert_close(frac[1], 0.5);
1037        assert_close(frac[2], 0.5);
1038        let cart = bx.make_cartesian(frac.view());
1039        assert_close(cart[0], 1.0);
1040        assert_close(cart[1], 1.0);
1041        assert_close(cart[2], 1.0);
1042    }
1043
1044    #[test]
1045    fn test_to_frac_to_cart_roundtrip() {
1046        let bx = SimBox::ortho(
1047            array![2.0, 3.0, 4.0],
1048            array![1.0, 2.0, 3.0],
1049            [true, true, true],
1050        )
1051        .expect("invalid box lengths");
1052        let pts = array![[1.0, 2.0, 3.0], [2.0, 3.0, 4.0]];
1053        let frac = bx.to_frac(pts.view());
1054        let cart = bx.to_cart(frac.view());
1055        for i in 0..pts.nrows() {
1056            for j in 0..3 {
1057                assert_close(pts[[i, j]], cart[[i, j]]);
1058            }
1059        }
1060    }
1061
1062    #[test]
1063    fn test_shortest_vector_and_distance() {
1064        let bx = SimBox::cube(2.0, array![0.0, 0.0, 0.0], [true, true, true])
1065            .expect("invalid box length");
1066        let a = array![0.1, 0.0, 0.0];
1067        let b = array![1.9, 0.0, 0.0];
1068        let dr = bx.shortest_vector(a.view(), b.view());
1069        assert_close(dr[0], -0.2);
1070        assert_close(dr[1], 0.0);
1071        assert_close(dr[2], 0.0);
1072        let d2 = bx.calc_distance2(a.view(), b.view());
1073        assert_close(d2, 0.04);
1074    }
1075
1076    #[test]
1077    fn test_isin_point_non_pbc() {
1078        let bx = SimBox::cube(2.0, array![0.0, 0.0, 0.0], [false, false, false])
1079            .expect("invalid box length");
1080        let pts = array![[0.5, 0.5, 0.5], [-0.1, 0.5, 0.5], [2.1, 0.5, 0.5]];
1081        let mask = bx.isin(pts.view());
1082        assert!(mask[0]);
1083        assert!(!mask[1]);
1084        assert!(!mask[2]);
1085    }
1086
1087    #[test]
1088    fn test_isin_mask() {
1089        let bx = SimBox::cube(2.0, array![0.0, 0.0, 0.0], [true, true, true])
1090            .expect("invalid box length");
1091        let pts = array![[0.1, 0.1, 0.1], [2.1, 0.0, 0.0], [-0.1, 0.0, 0.0]];
1092        let mask = bx.isin(pts.view());
1093        assert!(mask[0]);
1094        assert!(!mask[1]);
1095        assert!(!mask[2]);
1096    }
1097
1098    #[test]
1099    fn test_simbox_free_basic() {
1100        let pts = array![[1.0 as F, 2.0, 3.0], [4.0, 5.0, 6.0]];
1101        let bx = SimBox::free(pts.view(), 1.0).unwrap();
1102        assert_eq!(bx.pbc(), [false, false, false]);
1103        // origin should be min - padding = [0.0, 1.0, 2.0]
1104        let o = bx.origin_view();
1105        assert!((o[0] - 0.0).abs() < 1e-5);
1106        assert!((o[1] - 1.0).abs() < 1e-5);
1107        assert!((o[2] - 2.0).abs() < 1e-5);
1108        // lengths should be (max-min) + 2*padding = [5.0, 5.0, 5.0]
1109        let l = bx.lengths();
1110        assert!((l[0] - 5.0).abs() < 1e-5);
1111        assert!((l[1] - 5.0).abs() < 1e-5);
1112        assert!((l[2] - 5.0).abs() < 1e-5);
1113    }
1114
1115    #[test]
1116    fn test_simbox_free_single_point() {
1117        let pts = array![[1.0 as F, 2.0, 3.0]];
1118        let bx = SimBox::free(pts.view(), 2.0).unwrap();
1119        assert_eq!(bx.pbc(), [false, false, false]);
1120        // lengths = max(0 + 4, 2) = 4 on each axis
1121        let l = bx.lengths();
1122        assert!(l[0] >= 2.0);
1123        assert!(l[1] >= 2.0);
1124        assert!(l[2] >= 2.0);
1125    }
1126
1127    #[test]
1128    fn test_simbox_free_empty() {
1129        use ndarray::Array2;
1130        let pts = Array2::<F>::zeros((0, 3));
1131        let bx = SimBox::free(pts.view(), 1.0).unwrap();
1132        assert_eq!(bx.pbc(), [false, false, false]);
1133    }
1134
1135    #[test]
1136    fn test_simbox_pbc_accessor() {
1137        let bx = SimBox::cube(1.0, array![0.0 as F, 0.0, 0.0], [true, false, true]).unwrap();
1138        assert_eq!(bx.pbc(), [true, false, true]);
1139    }
1140
1141    #[test]
1142    fn free_columns_matches_free_bitwise() {
1143        let pts = array![[1.0 as F, 2.0, 3.0], [4.0, -5.0, 6.0], [-2.5, 5.5, 0.25]];
1144        let xs = vec![1.0 as F, 4.0, -2.5];
1145        let ys = vec![2.0 as F, -5.0, 5.5];
1146        let zs = vec![3.0 as F, 6.0, 0.25];
1147        let a = SimBox::free(pts.view(), 1.5).unwrap();
1148        let b = SimBox::free_columns(&xs, &ys, &zs, 1.5).unwrap();
1149
1150        let (oa, ob) = (a.origin_view(), b.origin_view());
1151        let (ha, hb) = (a.h_view(), b.h_view());
1152        for d in 0..3 {
1153            assert_eq!(oa[d], ob[d], "origin bitwise");
1154        }
1155        for i in 0..3 {
1156            for j in 0..3 {
1157                assert_eq!(ha[[i, j]], hb[[i, j]], "H bitwise");
1158            }
1159        }
1160        assert_eq!(a.pbc(), b.pbc());
1161    }
1162
1163    #[test]
1164    fn mic_matches_the_borrowed_kernel_on_both_box_kinds() {
1165        // `Mic` exists so a caller can avoid holding a `&SimBox`; it must not
1166        // become a second, drifting definition of the convention.
1167        let ortho = SimBox::ortho(
1168            array![10.0, 11.0, 12.0],
1169            array![0.5, -1.0, 2.0],
1170            [true, false, true],
1171        )
1172        .unwrap();
1173        let tri = SimBox::new(
1174            SimBox::matrix_from_lengths_angles([10.0, 11.0, 12.0], [70.0, 80.0, 65.0]).unwrap(),
1175            array![0.3, -0.2, 1.1],
1176            [true, true, false],
1177        )
1178        .unwrap();
1179
1180        for bx in [&ortho, &tri] {
1181            let mic = bx.mic();
1182            for (a, b) in [
1183                ([0.0 as F, 0.0, 0.0], [9.0 as F, 1.0, -3.0]),
1184                ([1.5, -2.5, 3.5], [-7.25, 8.125, 0.0625]),
1185                ([4.0, 4.0, 4.0], [4.0, 4.0, 4.0]),
1186            ] {
1187                let want = bx.shortest_vector_impl(a, b);
1188                let got = mic.apply([b[0] - a[0], b[1] - a[1], b[2] - a[2]]);
1189                for d in 0..3 {
1190                    assert_eq!(got[d], want[d], "component {d} for {a:?} -> {b:?}");
1191                }
1192            }
1193        }
1194    }
1195
1196    #[test]
1197    fn a_box_with_no_periodic_axis_simplifies_to_free() {
1198        let free = SimBox::cube(10.0, array![0.0, 0.0, 0.0], [false; 3]).unwrap();
1199        assert_eq!(free.mic().simplified(), Mic::Free);
1200        let d = [123.0 as F, -456.0, 789.0];
1201        assert_eq!(Mic::Free.apply(d), d);
1202
1203        let periodic = SimBox::cube(10.0, array![0.0, 0.0, 0.0], [false, true, false]).unwrap();
1204        assert_ne!(periodic.mic().simplified(), Mic::Free);
1205    }
1206
1207    #[test]
1208    fn triclinic_mic_matches_the_allocating_matrix_form() {
1209        // The stack-arithmetic kernel must agree bit-for-bit with the
1210        // `inv.dot(dr)` / `h.dot(frac)` formulation it replaced; ndarray sums
1211        // a matrix-vector product with k ascending, and so does the kernel.
1212        let bx = SimBox::new(
1213            SimBox::matrix_from_lengths_angles([10.0, 11.0, 12.0], [70.0, 80.0, 65.0]).unwrap(),
1214            array![0.3, -0.2, 1.1],
1215            [true, true, false],
1216        )
1217        .unwrap();
1218
1219        let pts = [
1220            ([0.0 as F, 0.0, 0.0], [9.0 as F, 1.0, -3.0]),
1221            ([1.5, -2.5, 3.5], [-7.25, 8.125, 0.0625]),
1222            ([4.0, 4.0, 4.0], [4.0, 4.0, 4.0]),
1223        ];
1224        for (a, b) in pts {
1225            let dr_cart = array![b[0] - a[0], b[1] - a[1], b[2] - a[2]];
1226            let mut dr_frac = bx.inv.dot(&dr_cart);
1227            for d in 0..3 {
1228                if bx.pbc[d] {
1229                    dr_frac[d] -= dr_frac[d].round();
1230                }
1231            }
1232            let want = bx.h.dot(&dr_frac);
1233            let got = bx.shortest_vector_impl(a, b);
1234            for d in 0..3 {
1235                assert_eq!(got[d], want[d], "component {d} for {a:?} -> {b:?}");
1236            }
1237        }
1238    }
1239}