Skip to main content

xtalforge_core/
symop.rs

1//! Symmetry operations with exact integer rotations and rational translations.
2
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6
7use crate::Error;
8
9/// One symmetry operation: `x' = R x + t` in fractional coordinates.
10#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
11pub struct SymOp {
12    rotation: [[i32; 3]; 3],
13    translation: [f64; 3],
14}
15
16impl SymOp {
17    /// Creates an operation and wraps the translation into `[0, 1)`.
18    ///
19    /// # Errors
20    ///
21    /// Returns [`Error::InvalidInput`] for a singular rotation or non-finite
22    /// translation.
23    pub fn new(rotation: [[i32; 3]; 3], translation: [f64; 3]) -> Result<Self, Error> {
24        if !translation.iter().all(|value| value.is_finite()) {
25            return Err(Error::InvalidInput(
26                "symmetry translation must be finite".to_owned(),
27            ));
28        }
29        if determinant(rotation) == 0 {
30            return Err(Error::InvalidInput(
31                "symmetry rotation is singular".to_owned(),
32            ));
33        }
34        Ok(Self {
35            rotation,
36            translation: translation.map(|value| value.rem_euclid(1.0)),
37        })
38    }
39
40    /// Identity operation.
41    #[must_use]
42    pub const fn identity() -> Self {
43        Self {
44            rotation: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
45            translation: [0.0, 0.0, 0.0],
46        }
47    }
48
49    /// Integer rotation matrix.
50    #[must_use]
51    pub const fn rotation(&self) -> [[i32; 3]; 3] {
52        self.rotation
53    }
54
55    /// Fractional translation in `[0, 1)`.
56    #[must_use]
57    pub const fn translation(&self) -> [f64; 3] {
58        self.translation
59    }
60
61    /// Applies the operation to one fractional point.
62    #[must_use]
63    pub fn apply(&self, frac: [f64; 3]) -> [f64; 3] {
64        let [x, y, z] = frac;
65        let [[a, b, c], [d, e, f], [g, h, i]] = self.rotation;
66        let [tx, ty, tz] = self.translation;
67        [
68            a as f64 * x + b as f64 * y + c as f64 * z + tx,
69            d as f64 * x + e as f64 * y + f as f64 * z + ty,
70            g as f64 * x + h as f64 * y + i as f64 * z + tz,
71        ]
72    }
73
74    /// Applies the operation to a block of points.
75    #[must_use]
76    pub fn apply_many(&self, points: &[[f64; 3]]) -> Vec<[f64; 3]> {
77        points.iter().map(|point| self.apply(*point)).collect()
78    }
79
80    /// Composition `self ∘ other`: apply `other`, then `self`.
81    ///
82    /// # Errors
83    ///
84    /// Returns the errors of [`SymOp::new`].
85    pub fn compose(&self, other: &Self) -> Result<Self, Error> {
86        let rotation = multiply_int(self.rotation, other.rotation);
87        let shifted = apply_int(self.rotation, other.translation);
88        let translation = [
89            shifted[0] + self.translation[0],
90            shifted[1] + self.translation[1],
91            shifted[2] + self.translation[2],
92        ];
93        Self::new(rotation, translation)
94    }
95
96    /// Inverse operation.
97    ///
98    /// # Errors
99    ///
100    /// Returns [`Error::InvalidInput`] when the rotation determinant is not
101    /// `±1`.
102    pub fn inverse(&self) -> Result<Self, Error> {
103        let det = determinant(self.rotation);
104        if det.abs() != 1 {
105            return Err(Error::InvalidInput(
106                "symmetry rotation determinant must be ±1".to_owned(),
107            ));
108        }
109        let inverse = inverse_int(self.rotation, det)?;
110        let shifted = apply_int(inverse, self.translation);
111        Self::new(inverse, [-shifted[0], -shifted[1], -shifted[2]])
112    }
113
114    /// Whether this is the identity operation.
115    #[must_use]
116    pub fn is_identity(&self) -> bool {
117        self.rotation == [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
118            && self.translation.iter().all(|value| *value == 0.0)
119    }
120
121    /// Parses a triplet such as `-x+1/2,y,z`.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`Error::InvalidInput`] for the wrong number of components or a
126    /// malformed term.
127    pub fn parse(text: &str) -> Result<Self, Error> {
128        let components: Vec<&str> = text.split(',').map(str::trim).collect();
129        if components.len() != 3 {
130            return Err(Error::InvalidInput(format!(
131                "a symmetry triplet needs three comma-separated components: {text:?}"
132            )));
133        }
134        let mut rotation = [[0i32; 3]; 3];
135        let mut translation = [0.0; 3];
136        for (row, component) in components.iter().enumerate() {
137            let (line, constant) = parse_component(component)?;
138            if let (Some(line_slot), Some(constant_slot)) =
139                (rotation.get_mut(row), translation.get_mut(row))
140            {
141                *line_slot = line;
142                *constant_slot = constant;
143            }
144        }
145        Self::new(rotation, translation)
146    }
147
148    /// Formats the operation as a triplet.
149    #[must_use]
150    pub fn triplet(&self) -> String {
151        (0..3)
152            .map(|row| format_component(&self.rotation, self.translation, row))
153            .collect::<Vec<_>>()
154            .join(",")
155    }
156}
157
158impl fmt::Display for SymOp {
159    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
160        formatter.write_str(&self.triplet())
161    }
162}
163
164fn parse_component(text: &str) -> Result<([i32; 3], f64), Error> {
165    let compact: String = text
166        .chars()
167        .filter(|character| !character.is_whitespace())
168        .collect();
169    if compact.is_empty() {
170        return Err(Error::InvalidInput("empty symmetry component".to_owned()));
171    }
172    let mut rotation = [0i32; 3];
173    let mut translation = 0.0;
174    let mut sign = 1.0;
175    let mut term = String::new();
176    for character in compact.chars() {
177        if character == '+' || character == '-' {
178            if !term.is_empty() {
179                add_term(&term, sign, &mut rotation, &mut translation)?;
180                term.clear();
181            }
182            sign = if character == '-' { -1.0 } else { 1.0 };
183        } else {
184            term.push(character);
185        }
186    }
187    if term.is_empty() {
188        return Err(Error::InvalidInput(format!(
189            "malformed symmetry component: {text:?}"
190        )));
191    }
192    add_term(&term, sign, &mut rotation, &mut translation)?;
193    Ok((rotation, translation))
194}
195
196fn add_term(
197    term: &str,
198    sign: f64,
199    rotation: &mut [i32; 3],
200    translation: &mut f64,
201) -> Result<(), Error> {
202    if let Some(index) = term.find(['x', 'y', 'z']) {
203        let axis = match term.get(index..index + 1) {
204            Some("x") => 0,
205            Some("y") => 1,
206            Some("z") => 2,
207            _ => 0,
208        };
209        let coefficient = term.get(..index).unwrap_or("");
210        let after = term.get(index + 1..).unwrap_or("");
211        let mut value = if coefficient.is_empty() || coefficient == "+" {
212            1.0
213        } else if coefficient == "-" {
214            -1.0
215        } else {
216            coefficient.parse::<f64>().map_err(|_| {
217                Error::InvalidInput(format!("malformed symmetry coefficient: {term:?}"))
218            })?
219        };
220        if let Some(denominator) = after.strip_prefix('/') {
221            let denominator: f64 = denominator.parse().map_err(|_| {
222                Error::InvalidInput(format!("malformed symmetry denominator: {term:?}"))
223            })?;
224            if denominator == 0.0 {
225                return Err(Error::InvalidInput(
226                    "symmetry denominator must not be zero".to_owned(),
227                ));
228            }
229            value /= denominator;
230        }
231        let value = (sign * value).round() as i32;
232        if let Some(slot) = rotation.get_mut(axis) {
233            *slot += value;
234        }
235        return Ok(());
236    }
237    let value = parse_rational(term)?;
238    *translation += sign * value;
239    Ok(())
240}
241
242fn parse_rational(text: &str) -> Result<f64, Error> {
243    if let Some((numerator, denominator)) = text.split_once('/') {
244        let numerator = numerator
245            .parse::<f64>()
246            .map_err(|_| Error::InvalidInput(format!("malformed rational constant: {text:?}")))?;
247        let denominator = denominator.parse::<f64>().map_err(|_| {
248            Error::InvalidInput(format!("malformed rational denominator: {text:?}"))
249        })?;
250        if denominator == 0.0 {
251            return Err(Error::InvalidInput(
252                "symmetry denominator must not be zero".to_owned(),
253            ));
254        }
255        Ok(numerator / denominator)
256    } else {
257        text.parse::<f64>()
258            .map_err(|_| Error::InvalidInput(format!("malformed constant: {text:?}")))
259    }
260}
261
262fn format_component(rotation: &[[i32; 3]; 3], translation: [f64; 3], row: usize) -> String {
263    let mut out = String::new();
264    let line = rotation.get(row).copied().unwrap_or([0; 3]);
265    for (axis, coefficient) in line.iter().enumerate() {
266        let coefficient = *coefficient;
267        if coefficient == 0 {
268            continue;
269        }
270        let negative = coefficient < 0;
271        if out.is_empty() {
272            if negative {
273                out.push('-');
274            }
275        } else {
276            out.push(if negative { '-' } else { '+' });
277        }
278        let magnitude = coefficient.unsigned_abs();
279        if magnitude != 1 {
280            out.push_str(&magnitude.to_string());
281        }
282        out.push(match axis {
283            0 => 'x',
284            1 => 'y',
285            _ => 'z',
286        });
287    }
288    let constant = translation.get(row).copied().unwrap_or(0.0);
289    if constant != 0.0 {
290        let text = fraction(constant);
291        if out.is_empty() {
292            out.push_str(&text);
293        } else {
294            out.push('+');
295            out.push_str(&text);
296        }
297    }
298    if out.is_empty() {
299        out.push('0');
300    }
301    out
302}
303
304fn fraction(value: f64) -> String {
305    let value = value.rem_euclid(1.0);
306    for denominator in 1..=12u32 {
307        let numerator = (value * f64::from(denominator)).round();
308        let candidate = numerator / f64::from(denominator);
309        if (candidate - value).abs() < 1e-9 {
310            if denominator == 1 {
311                return format!("{}", numerator as i64);
312            }
313            let divisor = gcd(numerator as i64, i64::from(denominator));
314            return format!(
315                "{}/{}",
316                numerator as i64 / divisor,
317                i64::from(denominator) / divisor
318            );
319        }
320    }
321    format!("{value:.6}")
322}
323
324fn gcd(mut first: i64, mut second: i64) -> i64 {
325    while second != 0 {
326        let remainder = first % second;
327        first = second;
328        second = remainder;
329    }
330    first.abs().max(1)
331}
332
333fn determinant(matrix: [[i32; 3]; 3]) -> i32 {
334    let [[a, b, c], [d, e, f], [g, h, i]] = matrix;
335    a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
336}
337
338fn multiply_int(first: [[i32; 3]; 3], second: [[i32; 3]; 3]) -> [[i32; 3]; 3] {
339    let [[a, b, c], [d, e, f], [g, h, i]] = first;
340    let [[j, k, l], [m, n, o], [p, q, r]] = second;
341    [
342        [
343            a * j + b * m + c * p,
344            a * k + b * n + c * q,
345            a * l + b * o + c * r,
346        ],
347        [
348            d * j + e * m + f * p,
349            d * k + e * n + f * q,
350            d * l + e * o + f * r,
351        ],
352        [
353            g * j + h * m + i * p,
354            g * k + h * n + i * q,
355            g * l + h * o + i * r,
356        ],
357    ]
358}
359
360fn apply_int(matrix: [[i32; 3]; 3], vector: [f64; 3]) -> [f64; 3] {
361    let [[a, b, c], [d, e, f], [g, h, i]] = matrix;
362    let [x, y, z] = vector;
363    [
364        a as f64 * x + b as f64 * y + c as f64 * z,
365        d as f64 * x + e as f64 * y + f as f64 * z,
366        g as f64 * x + h as f64 * y + i as f64 * z,
367    ]
368}
369
370fn inverse_int(matrix: [[i32; 3]; 3], det: i32) -> Result<[[i32; 3]; 3], Error> {
371    let [[a, b, c], [d, e, f], [g, h, i]] = matrix;
372    let adjugate = [
373        [e * i - f * h, c * h - b * i, b * f - c * e],
374        [f * g - d * i, a * i - c * g, c * d - a * f],
375        [d * h - e * g, b * g - a * h, a * e - b * d],
376    ];
377    let mut inverse = [[0i32; 3]; 3];
378    for (output_row, adjudicate_row) in inverse.iter_mut().zip(adjugate) {
379        for (output, value) in output_row.iter_mut().zip(adjudicate_row) {
380            if value % det != 0 {
381                return Err(Error::InvalidInput(
382                    "symmetry rotation is not unimodular".to_owned(),
383                ));
384            }
385            *output = value / det;
386        }
387    }
388    Ok(inverse)
389}