Skip to main content

molrs_core/spatial/region/
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 super::region::Region;
9use crate::math;
10use crate::types::{F, F3, F3View, F3x3, FNx3, FNx3View, Pbc3};
11use ndarray::{Array1, Array2, ArrayView1, array};
12
13/// Box geometry kind, detected once at construction.
14#[derive(Debug, Clone, PartialEq)]
15pub enum BoxKind {
16    /// Orthorhombic (diagonal H): lengths, inverse lengths cached.
17    Ortho { len: F3, inv_len: F3 },
18    /// General triclinic.
19    Triclinic,
20}
21
22/// Simulation box: triclinic cell with origin and per-axis PBC mask
23#[derive(Debug, Clone)]
24pub struct SimBox {
25    /// Triclinic cell matrix H (columns are lattice vectors)
26    h: F3x3,
27    /// Precomputed inverse of H
28    inv: F3x3,
29    /// Origin of the cell in Cartesian coordinates
30    origin: F3,
31    /// Per-axis periodic boundary condition flags (x, y, z)
32    pbc: Pbc3,
33    /// Cached geometry kind
34    kind: BoxKind,
35    /// Whether the cell is geometrically defined. `false` marks a "no-cell"
36    /// box (an undefined / zero-volume cell) — distinct from `pbc`, which only
37    /// describes periodicity. A defined non-periodic box (e.g. a free-boundary
38    /// bounding box) keeps `cell_defined = true`; only a box with no meaningful
39    /// cell at all (carrying the identity matrix purely so geometry ops are
40    /// no-ops) sets it `false`.
41    cell_defined: bool,
42}
43
44/// Error type for simulation box construction.
45#[derive(Debug)]
46pub enum BoxError {
47    /// The cell matrix H is singular (determinant ≈ 0).
48    SingularCell,
49    /// The matrix does not have shape 3x3.
50    InvalidMatrixShape { rows: usize, cols: usize },
51    /// A vector does not have the expected length.
52    InvalidVectorLength { len: usize },
53    /// A required array is not contiguous in memory.
54    NonContiguous(&'static str),
55}
56
57impl SimBox {
58    /// Construct from triclinic cell matrix `H`, origin `O`, and per-axis PBC flags
59    pub fn new(h: F3x3, origin: F3, pbc: Pbc3) -> Result<Self, BoxError> {
60        Self::new_cell(h, origin, pbc, true)
61    }
62
63    /// Construct a box, explicitly marking whether the cell is geometrically
64    /// defined. Pass `cell_defined = false` for a "no-cell" box (undefined /
65    /// zero-volume): supply the identity matrix so geometry ops degrade to
66    /// no-ops, and `volume` / `is_cell_defined` reflect the undefined cell.
67    pub fn new_cell(h: F3x3, origin: F3, pbc: Pbc3, cell_defined: bool) -> Result<Self, BoxError> {
68        if let Some(inv) = math::inv3(&h) {
69            let kind = detect_box_kind(&h);
70            Ok(Self {
71                h,
72                inv,
73                origin,
74                pbc,
75                kind,
76                cell_defined,
77            })
78        } else {
79            Err(BoxError::SingularCell)
80        }
81    }
82
83    /// Whether the cell is geometrically defined (`false` ⇒ a no-cell box of
84    /// undefined / zero volume). Distinct from periodicity ([`is_free`]).
85    ///
86    /// [`is_free`]: SimBox::is_free
87    pub fn is_cell_defined(&self) -> bool {
88        self.cell_defined
89    }
90
91    pub fn try_new(h: F3x3, origin: F3, pbc: Pbc3) -> Result<Self, BoxError> {
92        Self::new(h, origin, pbc)
93    }
94
95    /// Factory: cubic box with edge length `a` and origin `O`
96    pub fn cube(a: F, origin: F3, pbc: Pbc3) -> Result<Self, BoxError> {
97        if a <= 0.0 {
98            return Err(BoxError::InvalidVectorLength { len: 0 });
99        }
100        let h = array![[a, 0.0, 0.0], [0.0, a, 0.0], [0.0, 0.0, a]];
101        Self::new(h, origin, pbc)
102    }
103
104    /// Factory: ortho box with lengths (ax, ay, az) and origin `O`
105    pub fn ortho(lengths: F3, origin: F3, pbc: Pbc3) -> Result<Self, BoxError> {
106        if lengths.len() != 3 {
107            return Err(BoxError::InvalidVectorLength { len: lengths.len() });
108        }
109        if lengths.iter().any(|v| *v <= 0.0) {
110            return Err(BoxError::InvalidVectorLength { len: 0 });
111        }
112        let h = array![
113            [lengths[0], 0.0, 0.0],
114            [0.0, lengths[1], 0.0],
115            [0.0, 0.0, lengths[2]],
116        ];
117        Self::new(h, origin, pbc)
118    }
119
120    /// Create a non-periodic (free-boundary) box enclosing all points.
121    ///
122    /// Computes the axis-aligned bounding box of `points` and adds `padding`
123    /// on each side. The resulting box has `pbc = [false, false, false]`.
124    ///
125    /// `padding` should be >= the neighbor cutoff distance so that all
126    /// particles sit well inside the box for correct cell assignment.
127    ///
128    /// # Errors
129    /// Returns `BoxError` if padding is non-positive or the resulting box is degenerate.
130    ///
131    /// # Panics
132    /// Panics if `padding <= 0`.
133    pub fn free(points: FNx3View<'_>, padding: F) -> Result<Self, BoxError> {
134        assert!(padding > 0.0, "padding must be positive");
135        let n = points.nrows();
136        if n == 0 {
137            // Empty point set -- return a unit cube at origin
138            return Self::cube(padding, array![0.0 as F, 0.0, 0.0], [false, false, false]);
139        }
140        let mut min = array![points[[0, 0]], points[[0, 1]], points[[0, 2]]];
141        let mut max = min.clone();
142        for i in 1..n {
143            for d in 0..3 {
144                if points[[i, d]] < min[d] {
145                    min[d] = points[[i, d]];
146                }
147                if points[[i, d]] > max[d] {
148                    max[d] = points[[i, d]];
149                }
150            }
151        }
152        let origin = array![min[0] - padding, min[1] - padding, min[2] - padding,];
153        let lengths = array![
154            (max[0] - min[0] + 2.0 * padding).max(padding),
155            (max[1] - min[1] + 2.0 * padding).max(padding),
156            (max[2] - min[2] + 2.0 * padding).max(padding),
157        ];
158        Self::ortho(lengths, origin, [false, false, false])
159    }
160
161    /// View of the cell matrix
162    pub fn h_view(&self) -> FNx3View<'_> {
163        self.h.view()
164    }
165
166    /// View of the inverse cell matrix
167    pub fn inv_view(&self) -> FNx3View<'_> {
168        self.inv.view()
169    }
170
171    /// View of the origin
172    pub fn origin_view(&self) -> F3View<'_> {
173        self.origin.view()
174    }
175
176    /// View of the PBC flags
177    pub fn pbc_view(&self) -> ArrayView1<'_, bool> {
178        ArrayView1::from_shape(3, &self.pbc).expect("pbc_view shape")
179    }
180
181    /// Per-axis PBC flags
182    pub fn pbc(&self) -> Pbc3 {
183        self.pbc
184    }
185
186    /// Cell volume (|det(H)|)
187    pub fn volume(&self) -> F {
188        math::det3(&self.h).abs()
189    }
190
191    /// `true` when the box is free (non-periodic on every axis).
192    pub fn is_free(&self) -> bool {
193        self.pbc.iter().all(|&p| !p)
194    }
195
196    /// Geometry style label: `"free"` (no periodic axis), `"orthogonal"`
197    /// (diagonal H), or `"triclinic"`.
198    pub fn style(&self) -> &'static str {
199        if self.is_free() {
200            "free"
201        } else {
202            match self.kind {
203                BoxKind::Ortho { .. } => "orthogonal",
204                BoxKind::Triclinic => "triclinic",
205            }
206        }
207    }
208
209    /// Off-diagonal tilts [xy, xz, yz] of the cell matrix
210    pub fn tilts(&self) -> F3 {
211        array![self.h[[0, 1]], self.h[[0, 2]], self.h[[1, 2]]]
212    }
213
214    /// Lattice vector lengths
215    pub fn lengths(&self) -> F3 {
216        let a = self.lattice(0);
217        let b = self.lattice(1);
218        let c = self.lattice(2);
219        array![math::norm3(&a), math::norm3(&b), math::norm3(&c)]
220    }
221
222    /// Nearest plane distance (half the box size along each axis)
223    /// For triclinic boxes, this is the perpendicular distance to each face
224    pub fn nearest_plane_distance(&self) -> F3 {
225        let v = self.volume();
226        let a1 = self.lattice(0);
227        let a2 = self.lattice(1);
228        let a3 = self.lattice(2);
229
230        let c23 = math::cross3(&a2, &a3);
231        let c31 = math::cross3(&a3, &a1);
232        let c12 = math::cross3(&a1, &a2);
233
234        array![
235            v / math::norm3(&c23),
236            v / math::norm3(&c31),
237            v / math::norm3(&c12)
238        ]
239    }
240
241    pub fn kind(&self) -> &BoxKind {
242        &self.kind
243    }
244
245    /// Lattice vector by index (0,1,2) — columns of H
246    pub fn lattice(&self, index: usize) -> F3 {
247        assert!(index < 3, "lattice index must be 0..2");
248        self.h.column(index).to_owned()
249    }
250
251    /// Convert Cartesian coordinates to fractional coordinates [0, 1)
252    pub fn make_fractional(&self, r: F3View<'_>) -> F3 {
253        let dr = &r - &self.origin.view();
254        let mut frac = self.inv.dot(&dr);
255        for f in frac.iter_mut() {
256            *f -= f.floor();
257        }
258        frac
259    }
260
261    /// Fractional coordinates with ortho fast-path
262    #[inline(always)]
263    pub fn make_fractional_fast(&self, r: F3View<'_>) -> F3 {
264        match &self.kind {
265            BoxKind::Ortho { inv_len, .. } => {
266                let mut frac = array![
267                    (r[0] - self.origin[0]) * inv_len[0],
268                    (r[1] - self.origin[1]) * inv_len[1],
269                    (r[2] - self.origin[2]) * inv_len[2],
270                ];
271                for f in frac.iter_mut() {
272                    *f -= f.floor();
273                }
274                frac
275            }
276            BoxKind::Triclinic => self.make_fractional(r),
277        }
278    }
279
280    /// Fractional coordinates returned as `[F; 3]` (zero-alloc hot path).
281    ///
282    /// Equivalent to [`make_fractional_fast`](Self::make_fractional_fast) but
283    /// avoids the `Array1<F>` heap allocation by returning a stack array.
284    /// Use in tight inner loops (neighbor-list cell assignment, etc.).
285    #[inline(always)]
286    pub fn make_fractional_fast_arr(&self, r: F3View<'_>) -> [F; 3] {
287        match &self.kind {
288            BoxKind::Ortho { inv_len, .. } => {
289                let fx = (r[0] - self.origin[0]) * inv_len[0];
290                let fy = (r[1] - self.origin[1]) * inv_len[1];
291                let fz = (r[2] - self.origin[2]) * inv_len[2];
292                [fx - fx.floor(), fy - fy.floor(), fz - fz.floor()]
293            }
294            BoxKind::Triclinic => {
295                let f = self.make_fractional(r);
296                [f[0], f[1], f[2]]
297            }
298        }
299    }
300
301    /// Convert fractional coordinates to Cartesian coordinates
302    pub fn make_cartesian(&self, frac: F3View<'_>) -> F3 {
303        &self.origin + &self.h.dot(&frac)
304    }
305
306    /// Hot-loop MIC kernel: takes and returns `[F; 3]`, zero allocation.
307    ///
308    /// Ortho boxes use the `dr − round(dr / L) · L` fast path; triclinic
309    /// boxes fall back to the general `H · round(H⁻¹ · dr)` form. This
310    /// is the single source of truth for the minimum-image convention —
311    /// both [`shortest_vector`](Self::shortest_vector) (ergonomic
312    /// `F3View` / `Array1` API) and
313    /// [`shortest_vector_impl`](Self::shortest_vector_impl) (zero-alloc
314    /// `[F; 3]` API) route through here.
315    #[inline(always)]
316    fn mic_kernel(&self, a: [F; 3], b: [F; 3]) -> [F; 3] {
317        match &self.kind {
318            BoxKind::Ortho { len, inv_len } => {
319                let mut dr = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
320                if self.pbc[0] {
321                    dr[0] -= (dr[0] * inv_len[0]).round() * len[0];
322                }
323                if self.pbc[1] {
324                    dr[1] -= (dr[1] * inv_len[1]).round() * len[1];
325                }
326                if self.pbc[2] {
327                    dr[2] -= (dr[2] * inv_len[2]).round() * len[2];
328                }
329                dr
330            }
331            BoxKind::Triclinic => {
332                // General triclinic path: fold the displacement through
333                // fractional coords and wrap each periodic axis to
334                // `[-0.5, 0.5)`.
335                let dr_cart = array![b[0] - a[0], b[1] - a[1], b[2] - a[2]];
336                let mut dr_frac = self.inv.dot(&dr_cart);
337                for d in 0..3 {
338                    if self.pbc[d] {
339                        dr_frac[d] -= dr_frac[d].round();
340                    }
341                }
342                let v = self.h.dot(&dr_frac);
343                [v[0], v[1], v[2]]
344            }
345        }
346    }
347
348    /// Minimum image displacement vector from `r1` to `r2` (returns `r2 − r1`).
349    ///
350    /// Ergonomic ndarray-flavoured API: takes views and returns an owned
351    /// `Array1<F>`. Inside hot loops prefer
352    /// [`shortest_vector_impl`](Self::shortest_vector_impl) — it avoids the
353    /// heap allocation for the output (~70% faster per call).
354    #[inline]
355    pub fn shortest_vector(&self, r1: F3View<'_>, r2: F3View<'_>) -> F3 {
356        let dr = self.mic_kernel([r1[0], r1[1], r1[2]], [r2[0], r2[1], r2[2]]);
357        array![dr[0], dr[1], dr[2]]
358    }
359
360    /// Zero-allocation MIC displacement from `a` to `b` (returns `b − a`).
361    ///
362    /// Stack-array in / out; the canonical hot-loop entry point. Used by
363    /// [`LinkCell`](crate::spatial::neighbors::LinkCell),
364    /// [`BruteForce`](crate::spatial::neighbors::BruteForce), and
365    /// [`AabbQuery`](crate::spatial::neighbors::AabbQuery) inner loops.
366    #[inline(always)]
367    pub fn shortest_vector_impl(&self, a: [F; 3], b: [F; 3]) -> [F; 3] {
368        self.mic_kernel(a, b)
369    }
370
371    /// Calculate squared distance using MIC.
372    #[inline]
373    pub fn calc_distance2(&self, a: F3View<'_>, b: F3View<'_>) -> F {
374        let dr = self.shortest_vector(a, b);
375        dr.dot(&dr)
376    }
377
378    /// Convert Cartesian points to fractional coordinates (N×3)
379    pub fn to_frac(&self, xyz: FNx3View<'_>) -> FNx3 {
380        let n = xyz.nrows();
381        let mut result = FNx3::zeros((n, 3));
382        for i in 0..n {
383            let dr = &xyz.row(i) - &self.origin.view();
384            result.row_mut(i).assign(&self.inv.dot(&dr));
385        }
386        result
387    }
388
389    /// Convert fractional coordinates to Cartesian points (N×3)
390    pub fn to_cart(&self, frac: FNx3View<'_>) -> FNx3 {
391        let n = frac.nrows();
392        let mut result = FNx3::zeros((n, 3));
393        for i in 0..n {
394            let cart = &self.origin + &self.h.dot(&frac.row(i));
395            result.row_mut(i).assign(&cart);
396        }
397        result
398    }
399
400    /// Check if points lie within [0,1) in fractional space.
401    pub fn isin(&self, xyz: FNx3View<'_>) -> Array1<bool> {
402        let n = xyz.nrows();
403        let mut mask = Vec::with_capacity(n);
404        for i in 0..n {
405            let dr = &xyz.row(i) - &self.origin.view();
406            let frac = self.inv.dot(&dr);
407            let inside = (0..3).all(|d| frac[d] >= 0.0 && frac[d] < 1.0);
408            mask.push(inside);
409        }
410        Array1::from_vec(mask)
411    }
412
413    /// Batched displacement vectors row-wise (N×3).
414    /// Writes result into `out` to avoid allocation.
415    pub fn delta_out(
416        &self,
417        xyzu1: FNx3View<'_>,
418        xyzu2: FNx3View<'_>,
419        out: &mut FNx3,
420        minimum_image: bool,
421    ) {
422        assert_eq!(xyzu1.nrows(), xyzu2.nrows());
423        let n = xyzu1.nrows();
424        if minimum_image {
425            for i in 0..n {
426                let dr = self.shortest_vector(xyzu1.row(i), xyzu2.row(i));
427                out.row_mut(i).assign(&dr);
428            }
429        } else {
430            for i in 0..n {
431                let dr = &xyzu2.row(i) - &xyzu1.row(i);
432                out.row_mut(i).assign(&dr);
433            }
434        }
435    }
436
437    /// Batched displacement vectors row-wise (N×3)
438    pub fn delta(&self, xyzu1: FNx3View<'_>, xyzu2: FNx3View<'_>, minimum_image: bool) -> FNx3 {
439        assert_eq!(xyzu1.nrows(), xyzu2.nrows());
440        let n = xyzu1.nrows();
441        let mut out = FNx3::zeros((n, 3));
442        self.delta_out(xyzu1, xyzu2, &mut out, minimum_image);
443        out
444    }
445
446    /// Wrap Cartesian points into the unit cell according to PBC
447    pub fn wrap(&self, xyz: FNx3View<'_>) -> FNx3 {
448        let mut frac = self.to_frac(xyz);
449        let n = frac.nrows();
450        for i in 0..n {
451            for d in 0..3 {
452                if self.pbc[d] {
453                    frac[[i, d]] -= frac[[i, d]].floor();
454                }
455            }
456        }
457        self.to_cart(frac.view())
458    }
459
460    pub fn get_corners(&self) -> FNx3 {
461        let l = self.lengths();
462        let (ox, oy, oz) = (self.origin[0], self.origin[1], self.origin[2]);
463        let (lx, ly, lz) = (l[0], l[1], l[2]);
464        array![
465            [ox, oy, oz],
466            [ox + lx, oy, oz],
467            [ox + lx, oy + ly, oz],
468            [ox, oy + ly, oz],
469            [ox, oy, oz + lz],
470            [ox + lx, oy, oz + lz],
471            [ox + lx, oy + ly, oz + lz],
472            [ox, oy + ly, oz + lz],
473        ]
474    }
475}
476
477impl Region for SimBox {
478    fn bounds(&self) -> FNx3 {
479        let lengths = self.lengths();
480        let mut b = Array2::zeros((3, 2));
481        for d in 0..3 {
482            b[[d, 0]] = self.origin[d];
483            b[[d, 1]] = self.origin[d] + lengths[d];
484        }
485        b
486    }
487
488    fn contains(&self, points: &FNx3) -> Array1<bool> {
489        self.isin(points.view())
490    }
491
492    fn contains_point(&self, point: &[F; 3]) -> bool {
493        let r = ArrayView1::from_shape(3, point).expect("contains_point shape");
494        let dr = &r - &self.origin.view();
495        let frac = self.inv.dot(&dr);
496        (0..3).all(|d| frac[d] >= 0.0 && frac[d] < 1.0)
497    }
498}
499
500fn detect_box_kind(h: &F3x3) -> BoxKind {
501    let eps: F = 1e-12;
502    let is_ortho = h[[0, 1]].abs() < eps
503        && h[[0, 2]].abs() < eps
504        && h[[1, 0]].abs() < eps
505        && h[[1, 2]].abs() < eps
506        && h[[2, 0]].abs() < eps
507        && h[[2, 1]].abs() < eps;
508    if is_ortho {
509        let len = array![h[[0, 0]], h[[1, 1]], h[[2, 2]]];
510        let inv_len = array![1.0 / len[0], 1.0 / len[1], 1.0 / len[2]];
511        BoxKind::Ortho { len, inv_len }
512    } else {
513        BoxKind::Triclinic
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    fn assert_close(a: F, b: F) {
522        assert!((a - b).abs() < 1e-6 as F, "{} != {}", a, b);
523    }
524
525    #[test]
526    fn cell_defined_distinct_from_pbc() {
527        // A defined box (any pbc) reports cell_defined = true (the default).
528        let defined = SimBox::ortho(
529            array![2.0, 2.0, 2.0],
530            array![0.0, 0.0, 0.0],
531            [false, false, false],
532        )
533        .unwrap();
534        assert!(defined.is_cell_defined());
535        assert!(defined.is_free()); // non-periodic => free, but cell IS defined
536        assert_close(defined.volume(), 8.0); // real cell volume preserved (RDF)
537
538        // A no-cell box carries the identity cell (so geometry is a no-op) but
539        // is marked cell_defined = false.
540        let nocell = SimBox::new_cell(
541            ndarray::Array2::eye(3),
542            array![0.0, 0.0, 0.0],
543            [false, false, false],
544            false,
545        )
546        .unwrap();
547        assert!(!nocell.is_cell_defined());
548        // geometry no-ops on the identity cell:
549        let pts = array![[1.0, 2.0, 3.0]];
550        assert_eq!(nocell.wrap(pts.view()), pts);
551    }
552
553    #[test]
554    fn roundtrip_frac_cart() {
555        let bx = SimBox::ortho(
556            array![2.0, 3.0, 4.0],
557            array![0.5, -1.0, 2.0],
558            [true, true, true],
559        )
560        .expect("invalid box lengths");
561        let pts = array![[0.5, -1.0, 2.0], [2.5, 2.0, 6.0]];
562        let frac = bx.to_frac(pts.view());
563        let cart = bx.to_cart(frac.view());
564        assert!((&pts - &cart).iter().all(|v| v.abs() < 1e-5));
565    }
566
567    #[test]
568    fn wrap_into_cell() {
569        let bx = SimBox::cube(2.0, array![0.0, 0.0, 0.0], [true, true, true])
570            .expect("invalid box length");
571        let pts = array![[2.1, -0.1, 3.9], [-1.9, 4.2, 0.0]];
572        let wrapped = bx.wrap(pts.view());
573        let frac = bx.to_frac(wrapped.view());
574        for i in 0..wrapped.nrows() {
575            let fx = frac[[i, 0]];
576            let fy = frac[[i, 1]];
577            let fz = frac[[i, 2]];
578            assert!((0.0..1.0).contains(&fx));
579            assert!((0.0..1.0).contains(&fy));
580            assert!((0.0..1.0).contains(&fz));
581        }
582    }
583
584    #[test]
585    fn calc_distance_matches_components() {
586        let bx = SimBox::cube(3.0, array![0.0, 0.0, 0.0], [true, true, true])
587            .expect("invalid box length");
588        let a = array![0.1, 0.2, 0.3];
589        let b = array![2.9, 0.2, 0.3];
590        let d2 = bx.calc_distance2(a.view(), b.view());
591        let dr = bx.shortest_vector(a.view(), b.view());
592        let expected = dr.dot(&dr);
593        assert!((d2 - expected).abs() < 1e-6);
594    }
595
596    #[test]
597    fn test_lengths_ortho() {
598        let bx = SimBox::ortho(
599            array![2.0, 4.0, 5.0],
600            array![0.0, 0.0, 0.0],
601            [true, true, true],
602        )
603        .expect("invalid box lengths");
604        let lengths = bx.lengths();
605        assert_close(lengths[0], 2.0);
606        assert_close(lengths[1], 4.0);
607        assert_close(lengths[2], 5.0);
608    }
609
610    #[test]
611    fn test_tilts_values() {
612        let h = array![[2.0, 1.0, 2.0], [0.0, 4.0, 3.0], [0.0, 0.0, 5.0]];
613        let bx = SimBox::new(h, array![0.0, 0.0, 0.0], [true, true, true]).expect("invalid box");
614        let tilts = bx.tilts();
615        assert_close(tilts[0], 1.0);
616        assert_close(tilts[1], 2.0);
617        assert_close(tilts[2], 3.0);
618    }
619
620    #[test]
621    fn test_volume() {
622        let bx = SimBox::ortho(
623            array![2.0, 3.0, 4.0],
624            array![0.0, 0.0, 0.0],
625            [true, true, true],
626        )
627        .expect("invalid box lengths");
628        assert_close(bx.volume(), 24.0);
629    }
630
631    #[test]
632    fn test_wrap_single_and_multi() {
633        let bx = SimBox::cube(2.0, array![0.0, 0.0, 0.0], [true, true, true])
634            .expect("invalid box length");
635        let pts = array![[10.0, -5.0, -5.0], [0.0, 0.5, 0.0]];
636        let wrapped = bx.wrap(pts.view());
637        assert_close(wrapped[[0, 0]], 0.0);
638        assert_close(wrapped[[0, 1]], 1.0);
639        assert_close(wrapped[[0, 2]], 1.0);
640        assert_close(wrapped[[1, 0]], 0.0);
641        assert_close(wrapped[[1, 1]], 0.5);
642        assert_close(wrapped[[1, 2]], 0.0);
643    }
644
645    #[test]
646    fn test_fractional_and_cartesian() {
647        let bx = SimBox::cube(2.0, array![0.0, 0.0, 0.0], [true, true, true])
648            .expect("invalid box length");
649        let p = array![-1.0, -1.0, -1.0];
650        let frac = bx.make_fractional(p.view());
651        assert_close(frac[0], 0.5);
652        assert_close(frac[1], 0.5);
653        assert_close(frac[2], 0.5);
654        let cart = bx.make_cartesian(frac.view());
655        assert_close(cart[0], 1.0);
656        assert_close(cart[1], 1.0);
657        assert_close(cart[2], 1.0);
658    }
659
660    #[test]
661    fn test_to_frac_to_cart_roundtrip() {
662        let bx = SimBox::ortho(
663            array![2.0, 3.0, 4.0],
664            array![1.0, 2.0, 3.0],
665            [true, true, true],
666        )
667        .expect("invalid box lengths");
668        let pts = array![[1.0, 2.0, 3.0], [2.0, 3.0, 4.0]];
669        let frac = bx.to_frac(pts.view());
670        let cart = bx.to_cart(frac.view());
671        for i in 0..pts.nrows() {
672            for j in 0..3 {
673                assert_close(pts[[i, j]], cart[[i, j]]);
674            }
675        }
676    }
677
678    #[test]
679    fn test_shortest_vector_and_distance() {
680        let bx = SimBox::cube(2.0, array![0.0, 0.0, 0.0], [true, true, true])
681            .expect("invalid box length");
682        let a = array![0.1, 0.0, 0.0];
683        let b = array![1.9, 0.0, 0.0];
684        let dr = bx.shortest_vector(a.view(), b.view());
685        assert_close(dr[0], -0.2);
686        assert_close(dr[1], 0.0);
687        assert_close(dr[2], 0.0);
688        let d2 = bx.calc_distance2(a.view(), b.view());
689        assert_close(d2, 0.04);
690    }
691
692    #[test]
693    fn test_contains_point_non_pbc() {
694        let bx = SimBox::cube(2.0, array![0.0, 0.0, 0.0], [false, false, false])
695            .expect("invalid box length");
696        assert!(bx.contains_point(&[0.5, 0.5, 0.5]));
697        assert!(!bx.contains_point(&[-0.1, 0.5, 0.5]));
698        assert!(!bx.contains_point(&[2.1, 0.5, 0.5]));
699    }
700
701    #[test]
702    fn test_contains_mask() {
703        let bx = SimBox::cube(2.0, array![0.0, 0.0, 0.0], [true, true, true])
704            .expect("invalid box length");
705        let pts = array![[0.1, 0.1, 0.1], [2.1, 0.0, 0.0], [-0.1, 0.0, 0.0]];
706        let mask = bx.contains(&pts);
707        assert!(mask[0]);
708        assert!(!mask[1]);
709        assert!(!mask[2]);
710    }
711
712    #[test]
713    fn test_simbox_free_basic() {
714        let pts = array![[1.0 as F, 2.0, 3.0], [4.0, 5.0, 6.0]];
715        let bx = SimBox::free(pts.view(), 1.0).unwrap();
716        assert_eq!(bx.pbc(), [false, false, false]);
717        // origin should be min - padding = [0.0, 1.0, 2.0]
718        let o = bx.origin_view();
719        assert!((o[0] - 0.0).abs() < 1e-5);
720        assert!((o[1] - 1.0).abs() < 1e-5);
721        assert!((o[2] - 2.0).abs() < 1e-5);
722        // lengths should be (max-min) + 2*padding = [5.0, 5.0, 5.0]
723        let l = bx.lengths();
724        assert!((l[0] - 5.0).abs() < 1e-5);
725        assert!((l[1] - 5.0).abs() < 1e-5);
726        assert!((l[2] - 5.0).abs() < 1e-5);
727    }
728
729    #[test]
730    fn test_simbox_free_single_point() {
731        let pts = array![[1.0 as F, 2.0, 3.0]];
732        let bx = SimBox::free(pts.view(), 2.0).unwrap();
733        assert_eq!(bx.pbc(), [false, false, false]);
734        // lengths = max(0 + 4, 2) = 4 on each axis
735        let l = bx.lengths();
736        assert!(l[0] >= 2.0);
737        assert!(l[1] >= 2.0);
738        assert!(l[2] >= 2.0);
739    }
740
741    #[test]
742    fn test_simbox_free_empty() {
743        use ndarray::Array2;
744        let pts = Array2::<F>::zeros((0, 3));
745        let bx = SimBox::free(pts.view(), 1.0).unwrap();
746        assert_eq!(bx.pbc(), [false, false, false]);
747    }
748
749    #[test]
750    fn test_simbox_pbc_accessor() {
751        let bx = SimBox::cube(1.0, array![0.0 as F, 0.0, 0.0], [true, false, true]).unwrap();
752        assert_eq!(bx.pbc(), [true, false, true]);
753    }
754}