Skip to main content

molframe_spatial/geometry/
periodic.rs

1//! Orthorhombic and triclinic minimum-image displacement.
2
3use super::reduction::{self, Matrix};
4use crate::SpatialError;
5use crate::numeric::{f64_f32, i64_f64, rounded_i64};
6use molframe_core::structure::UnitCell;
7
8const ORTHOGONAL_ANGLE_TOLERANCE: f64 = 1.0e-10;
9
10/// Safety cap on the per-axis offset search; reduced bases need at most 2.
11const MAX_SEARCH_REACH: f64 = 16.0;
12
13/// An invertible unit-cell basis and its inverse.
14#[derive(Clone, Copy, PartialEq, Debug)]
15pub struct PeriodicBox {
16    basis: [[f64; 3]; 3],
17    inverse: [[f64; 3]; 3],
18    orthogonal: bool,
19    reduction: Reduction,
20}
21
22/// Integer change of basis to a lattice-reduced basis, `reduced = basis * T`.
23///
24/// Only the integer matrices are stored; the reduced basis is rebuilt per
25/// search. That keeps the box small enough to pass by value.
26#[derive(Clone, Copy, PartialEq, Debug)]
27struct Reduction {
28    transform: [[i32; 3]; 3],
29    inverse_transform: [[i32; 3]; 3],
30}
31
32/// The reduced basis, its inverse and the row norms bounding the search.
33struct ReducedFrame {
34    basis: Matrix,
35    inverse: Matrix,
36    row_norms: [f64; 3],
37}
38
39impl Reduction {
40    const IDENTITY: Self = Self {
41        transform: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
42        inverse_transform: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
43    };
44
45    fn new(basis: Matrix) -> Self {
46        let reduced = reduction::reduce(basis);
47        reduction::narrow(reduced.transform).map_or(
48            Self::IDENTITY,
49            |(transform, inverse_transform)| Self {
50                transform,
51                inverse_transform,
52            },
53        )
54    }
55
56    fn frame(&self, basis: Matrix, inverse: Matrix) -> ReducedFrame {
57        let to_f64 = |matrix: [[i32; 3]; 3]| matrix.map(|row| row.map(f64::from));
58        let reduced = multiply_matrices(basis, to_f64(self.transform));
59        let reduced_inverse = multiply_matrices(to_f64(self.inverse_transform), inverse);
60        ReducedFrame {
61            basis: reduced,
62            inverse: reduced_inverse,
63            row_norms: reduced_inverse.map(|row| squared(row).sqrt()),
64        }
65    }
66
67    /// Maps an integer shift in reduced coordinates to the original basis.
68    fn original_shift(&self, shift: [i64; 3]) -> [i64; 3] {
69        std::array::from_fn(|row| {
70            (0..3).fold(0_i64, |total, column| {
71                total.saturating_add(
72                    i64::from(self.transform[row][column]).saturating_mul(shift[column]),
73                )
74            })
75        })
76    }
77}
78
79fn small_integer(value: f64) -> i32 {
80    match i32::try_from(rounded_i64(value)) {
81        Ok(converted) => converted,
82        Err(_) => 0,
83    }
84}
85
86fn multiply_matrices(left: Matrix, right: Matrix) -> Matrix {
87    std::array::from_fn(|row| {
88        std::array::from_fn(|column| (0..3).map(|k| left[row][k] * right[k][column]).sum())
89    })
90}
91
92/// A shortest displacement on a periodic flat torus and the chosen image.
93#[derive(Clone, Copy, PartialEq, Debug)]
94pub struct PeriodicImage {
95    /// Cartesian displacement from the left point to the chosen right image.
96    pub displacement: [f32; 3],
97    /// Lattice translation subtracted from the un-wrapped right point.
98    pub lattice_shift: [i64; 3],
99}
100
101impl PeriodicBox {
102    /// Builds a periodic box from crystallographic lengths and angles.
103    ///
104    /// # Errors
105    ///
106    /// Returns [`SpatialError::InvalidCell`] when any parameter is non-finite,
107    /// an edge is not positive, or the vectors are coplanar.
108    pub fn from_cell(cell: UnitCell) -> Result<Self, SpatialError> {
109        validate_cell(&cell)?;
110
111        let orthogonal = cell
112            .angles
113            .iter()
114            .all(|angle| (*angle - 90.0).abs() < ORTHOGONAL_ANGLE_TOLERANCE);
115
116        let basis = basis_from_cell(&cell)?;
117        let Some(inverse) = inverse3(basis) else {
118            return Err(SpatialError::InvalidCell);
119        };
120
121        let reduction = Reduction::new(basis);
122
123        Ok(Self {
124            basis,
125            inverse,
126            orthogonal,
127            reduction,
128        })
129    }
130
131    /// The shortest periodic displacement from `left` to `right`.
132    ///
133    /// The nearest rounded fractional image is sufficient for an orthogonal
134    /// cell. In a skewed cell a neighbouring lattice translation can be
135    /// shorter, so a lattice-reduced basis is searched over a provably
136    /// sufficient neighbourhood and the result is exact.
137    #[must_use]
138    pub fn displacement(&self, left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
139        self.minimum_image(left, right).displacement
140    }
141
142    /// The shortest displacement and exact lattice image selected for it.
143    ///
144    /// Exact distance ties retain the deposited image. Triclinic candidates
145    /// are visited in a fixed order, so the selected image is deterministic.
146    #[must_use]
147    pub fn minimum_image(&self, left: [f32; 3], right: [f32; 3]) -> PeriodicImage {
148        let (best, lattice_shift) = self.image_f64(left.map(f64::from), right.map(f64::from));
149
150        PeriodicImage {
151            displacement: best.map(f64_f32),
152            lattice_shift,
153        }
154    }
155
156    /// Minimum-image displacement without rounding generated sample positions
157    /// to stored-coordinate precision. Image selection matches `minimum_image`.
158    #[must_use]
159    pub fn displacement_f64(&self, left: [f64; 3], right: [f64; 3]) -> [f64; 3] {
160        self.image_f64(left, right).0
161    }
162
163    fn image_f64(&self, left: [f64; 3], right: [f64; 3]) -> ([f64; 3], [i64; 3]) {
164        let cartesian = cartesian_delta(left, right);
165        if self.orthogonal {
166            let fractional = multiply(self.inverse, cartesian);
167            orthogonal_image(self.basis, cartesian, fractional)
168        } else {
169            triclinic_image(&self.reduction, self.basis, self.inverse, cartesian)
170        }
171    }
172
173    /// Squared minimum-image separation.
174    #[must_use]
175    pub fn distance_squared(&self, left: [f32; 3], right: [f32; 3]) -> f32 {
176        let displacement = self.displacement(left, right);
177
178        displacement[0] * displacement[0]
179            + displacement[1] * displacement[1]
180            + displacement[2] * displacement[2]
181    }
182
183    /// Converts a Cartesian position to fractional cell coordinates.
184    #[must_use]
185    pub fn fractional(&self, position: [f32; 3]) -> [f64; 3] {
186        multiply(self.inverse, position.map(f64::from))
187    }
188
189    /// Converts fractional cell coordinates to Cartesian ångström.
190    #[must_use]
191    pub fn cartesian(&self, fractional: [f64; 3]) -> [f32; 3] {
192        multiply(self.basis, fractional).map(f64_f32)
193    }
194
195    /// Wraps one position into the primary fractional interval `[0, 1)`.
196    #[must_use]
197    pub fn wrap(&self, position: [f32; 3]) -> [f32; 3] {
198        let fractional = self.fractional(position).map(|value| value.rem_euclid(1.0));
199
200        self.cartesian(fractional)
201    }
202
203    /// Interpolates along the shortest periodic geodesic and wraps the result.
204    ///
205    /// Returns `None` for a non-finite interpolation parameter. Values outside
206    /// `[0, 1]` extrapolate along the same selected image.
207    #[must_use]
208    pub fn interpolate(&self, left: [f32; 3], right: [f32; 3], amount: f64) -> Option<[f32; 3]> {
209        if !amount.is_finite() {
210            return None;
211        }
212        let displacement = self.minimum_image(left, right).displacement;
213        let point = std::array::from_fn(|axis| {
214            f64_f32(f64::from(left[axis]) + amount * f64::from(displacement[axis]))
215        });
216        Some(self.wrap(point))
217    }
218
219    /// Maximum fractional-coordinate changes produced by a Cartesian radius.
220    ///
221    /// The bounds are the Euclidean norms of the reciprocal-basis rows times
222    /// the radius. They conservatively bound cell and tree image searches.
223    #[must_use]
224    pub(crate) fn fractional_cutoff_bounds(&self, cutoff: f32) -> [f64; 3] {
225        self.inverse
226            .map(|row| squared(row).sqrt() * f64::from(cutoff))
227    }
228
229    /// Whether a sphere of radius `cutoff` around any point reaches no periodic
230    /// image of that same point.
231    ///
232    /// When it holds, every pair within `cutoff` has exactly one image inside the
233    /// cutoff, so the minimum-image distance and the all-image neighbourhood
234    /// coincide. It fails once the cutoff exceeds half the shortest perpendicular
235    /// width of the cell.
236    #[must_use]
237    pub fn contains_cutoff_sphere(&self, cutoff: f32) -> bool {
238        self.fractional_cutoff_bounds(cutoff)
239            .iter()
240            .all(|bound| *bound * 2.0 <= 1.0)
241    }
242
243    /// Translates a Cartesian point by an exact lattice shift.
244    pub(crate) fn translated(
245        &self,
246        point: [f32; 3],
247        shift: [i64; 3],
248    ) -> Result<[f32; 3], SpatialError> {
249        let convert = |value| i64_f64(value).ok_or(SpatialError::NumericRangeExceeded);
250        let translation = multiply(
251            self.basis,
252            [convert(shift[0])?, convert(shift[1])?, convert(shift[2])?],
253        );
254        Ok(std::array::from_fn(|axis| {
255            f64_f32(f64::from(point[axis]) + translation[axis])
256        }))
257    }
258}
259
260/// Validates finite cell lengths/angles and positive edge lengths.
261///
262/// Runtime and auxiliary space are `O(1)`.
263fn validate_cell(cell: &UnitCell) -> Result<(), SpatialError> {
264    let finite = cell
265        .lengths
266        .iter()
267        .chain(&cell.angles)
268        .all(|value| value.is_finite());
269
270    let positive = cell.lengths.iter().all(|length| *length > 0.0);
271
272    if finite && positive {
273        Ok(())
274    } else {
275        Err(SpatialError::InvalidCell)
276    }
277}
278
279/// Builds the crystallographic Cartesian basis matrix.
280///
281/// # Errors
282///
283/// Returns [`SpatialError::InvalidCell`] for singular or numerically invalid
284/// angle combinations.
285fn basis_from_cell(cell: &UnitCell) -> Result<[[f64; 3]; 3], SpatialError> {
286    let [a, b, c] = cell.lengths;
287    let [alpha, beta, gamma] = cell.angles.map(f64::to_radians);
288
289    let sin_gamma = gamma.sin();
290
291    if !sin_gamma.is_finite() || sin_gamma.abs() <= f64::EPSILON {
292        return Err(SpatialError::InvalidCell);
293    }
294
295    let cos_gamma = gamma.cos();
296    let cos_beta = beta.cos();
297    let cos_alpha = alpha.cos();
298
299    let cx = c * cos_beta;
300    let cy = c * (cos_alpha - cos_beta * cos_gamma) / sin_gamma;
301    let cz_squared = c.mul_add(c, -cx.mul_add(cx, cy * cy));
302
303    if !cz_squared.is_finite() || cz_squared <= f64::EPSILON {
304        return Err(SpatialError::InvalidCell);
305    }
306
307    Ok([
308        [a, b * cos_gamma, cx],
309        [0.0, b * sin_gamma, cy],
310        [0.0, 0.0, cz_squared.sqrt()],
311    ])
312}
313
314/// Computes Cartesian `right - left` in `f64` arithmetic.
315///
316/// Runtime and auxiliary space are `O(1)`.
317#[inline]
318fn cartesian_delta(left: [f64; 3], right: [f64; 3]) -> [f64; 3] {
319    [right[0] - left[0], right[1] - left[1], right[2] - left[2]]
320}
321
322/// Computes the minimum image for an orthogonal cell.
323///
324/// The original Cartesian displacement is retained on exact distance ties,
325/// preserving the displacement sign convention at half-cell boundaries.
326fn orthogonal_image(
327    basis: [[f64; 3]; 3],
328    cartesian: [f64; 3],
329    fractional: [f64; 3],
330) -> ([f64; 3], [i64; 3]) {
331    let rounded = fractional.map(f64::round);
332    let wrapped = [
333        fractional[0] - rounded[0],
334        fractional[1] - rounded[1],
335        fractional[2] - rounded[2],
336    ];
337
338    let candidate = multiply(basis, wrapped);
339
340    if squared(candidate) < squared(cartesian) {
341        (candidate, integer_shift(rounded))
342    } else {
343        (cartesian, [0; 3])
344    }
345}
346
347/// Computes the minimum image for a skewed triclinic cell.
348///
349/// The search runs in a lattice-reduced basis. The rounded fractional position
350/// there is a candidate at distance `r`; the true nearest image lies within
351/// `r` of the target, hence within `2r` of that candidate, which bounds each
352/// reduced-coordinate offset by `2r` times the norm of the matching row of the
353/// reduced inverse. Every offset inside those bounds is evaluated, so the
354/// result is exact for any valid cell. Runtime is bounded by the reduced
355/// basis, not by the skew of the cell parameters.
356fn triclinic_image(
357    reduction: &Reduction,
358    basis: Matrix,
359    inverse: Matrix,
360    cartesian: [f64; 3],
361) -> ([f64; 3], [i64; 3]) {
362    let frame = reduction.frame(basis, inverse);
363    let fractional = multiply(frame.inverse, cartesian);
364    let centre = fractional.map(f64::round);
365    let base = [
366        fractional[0] - centre[0],
367        fractional[1] - centre[1],
368        fractional[2] - centre[2],
369    ];
370
371    let rounded = multiply(frame.basis, base);
372    let radius = squared(rounded).sqrt();
373    if !radius.is_finite() {
374        return (cartesian, [0; 3]);
375    }
376
377    let reach = frame.row_norms.map(|norm| {
378        let bound = (2.0 * radius * norm).mul_add(1.0 + 1.0e-9, 1.0e-9).ceil();
379        small_integer(bound.clamp(0.0, MAX_SEARCH_REACH))
380    });
381
382    let mut best = cartesian;
383    let mut best_squared = squared(best);
384    let mut best_shift = [0; 3];
385
386    for first in -reach[0]..=reach[0] {
387        for second in -reach[1]..=reach[1] {
388            for third in -reach[2]..=reach[2] {
389                let offset = [first, second, third].map(f64::from);
390                let image = [
391                    base[0] - offset[0],
392                    base[1] - offset[1],
393                    base[2] - offset[2],
394                ];
395                let candidate = multiply(frame.basis, image);
396                let candidate_squared = squared(candidate);
397
398                if candidate_squared < best_squared {
399                    best = candidate;
400                    best_squared = candidate_squared;
401                    best_shift = reduction.original_shift([
402                        rounded_i64(centre[0]).saturating_add(i64::from(first)),
403                        rounded_i64(centre[1]).saturating_add(i64::from(second)),
404                        rounded_i64(centre[2]).saturating_add(i64::from(third)),
405                    ]);
406                }
407            }
408        }
409    }
410
411    (best, best_shift)
412}
413
414fn integer_shift(shift: [f64; 3]) -> [i64; 3] {
415    shift.map(rounded_i64)
416}
417
418/// Multiplies a three-by-three matrix by a three-component vector.
419///
420/// Runtime and auxiliary space are `O(1)`.
421#[inline]
422fn multiply(matrix: [[f64; 3]; 3], vector: [f64; 3]) -> [f64; 3] {
423    [
424        matrix[0][0] * vector[0] + matrix[0][1] * vector[1] + matrix[0][2] * vector[2],
425        matrix[1][0] * vector[0] + matrix[1][1] * vector[1] + matrix[1][2] * vector[2],
426        matrix[2][0] * vector[0] + matrix[2][1] * vector[1] + matrix[2][2] * vector[2],
427    ]
428}
429
430/// Returns a three-dimensional squared vector length.
431///
432/// Runtime and auxiliary space are `O(1)`.
433#[inline]
434fn squared(vector: [f64; 3]) -> f64 {
435    vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]
436}
437
438/// Computes all cofactors of a three-by-three matrix.
439///
440/// Runtime and auxiliary space are `O(1)`.
441fn cofactors3(matrix: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
442    [
443        [
444            matrix[1][1] * matrix[2][2] - matrix[1][2] * matrix[2][1],
445            matrix[1][2] * matrix[2][0] - matrix[1][0] * matrix[2][2],
446            matrix[1][0] * matrix[2][1] - matrix[1][1] * matrix[2][0],
447        ],
448        [
449            matrix[0][2] * matrix[2][1] - matrix[0][1] * matrix[2][2],
450            matrix[0][0] * matrix[2][2] - matrix[0][2] * matrix[2][0],
451            matrix[0][1] * matrix[2][0] - matrix[0][0] * matrix[2][1],
452        ],
453        [
454            matrix[0][1] * matrix[1][2] - matrix[0][2] * matrix[1][1],
455            matrix[0][2] * matrix[1][0] - matrix[0][0] * matrix[1][2],
456            matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0],
457        ],
458    ]
459}
460
461/// Computes a three-by-three inverse using the adjugate formula.
462///
463/// Returns `None` for non-finite or effectively singular matrices. Runtime and
464/// auxiliary space are `O(1)`.
465fn inverse3(matrix: [[f64; 3]; 3]) -> Option<[[f64; 3]; 3]> {
466    let cofactors = cofactors3(matrix);
467
468    let determinant = matrix[0][0] * cofactors[0][0]
469        + matrix[0][1] * cofactors[0][1]
470        + matrix[0][2] * cofactors[0][2];
471
472    if !determinant.is_finite() || determinant.abs() <= f64::EPSILON {
473        return None;
474    }
475
476    let reciprocal = determinant.recip();
477
478    Some([
479        [
480            cofactors[0][0] * reciprocal,
481            cofactors[1][0] * reciprocal,
482            cofactors[2][0] * reciprocal,
483        ],
484        [
485            cofactors[0][1] * reciprocal,
486            cofactors[1][1] * reciprocal,
487            cofactors[2][1] * reciprocal,
488        ],
489        [
490            cofactors[0][2] * reciprocal,
491            cofactors[1][2] * reciprocal,
492            cofactors[2][2] * reciprocal,
493        ],
494    ])
495}
496
497#[cfg(test)]
498#[path = "periodic_tests.rs"]
499mod tests;