Skip to main content

oxiphysics_materials/anisotropic/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#[allow(unused_imports)]
6use super::functions::*;
7/// Classical laminate plate theory (CLT) single ply in a laminate stack.
8///
9/// Represents one ply with its orientation angle (in degrees from the
10/// laminate reference axis) and thickness, backed by a `WovenLamina` or
11/// equivalent orthotropic properties.
12#[derive(Debug, Clone, Copy)]
13pub struct Ply {
14    /// In-plane modulus along ply axis 1 \[Pa\]
15    pub e1: f64,
16    /// In-plane modulus along ply axis 2 \[Pa\]
17    pub e2: f64,
18    /// In-plane Poisson ratio ν12
19    pub nu12: f64,
20    /// In-plane shear modulus G12 \[Pa\]
21    pub g12: f64,
22    /// Thickness \[m\]
23    pub thickness: f64,
24    /// Orientation angle θ \[degrees\] from laminate reference axis
25    pub angle_deg: f64,
26}
27impl Ply {
28    /// Create a new `Ply`.
29    #[allow(clippy::too_many_arguments)]
30    pub fn new(e1: f64, e2: f64, nu12: f64, g12: f64, thickness: f64, angle_deg: f64) -> Self {
31        Self {
32            e1,
33            e2,
34            nu12,
35            g12,
36            thickness,
37            angle_deg,
38        }
39    }
40    /// Minor Poisson ratio ν₂₁ = ν₁₂ · E₂ / E₁.
41    pub fn nu21(&self) -> f64 {
42        self.nu12 * self.e2 / self.e1
43    }
44    /// Reduced stiffness matrix Q \[Pa\] in ply principal coordinates.
45    ///
46    /// Returns `[Q11, Q22, Q12, Q66]`.
47    pub fn reduced_stiffness(&self) -> [f64; 4] {
48        let nu21 = self.nu12 * self.e2 / self.e1;
49        let denom = 1.0 - self.nu12 * nu21;
50        let q11 = self.e1 / denom;
51        let q22 = self.e2 / denom;
52        let q12 = self.nu12 * self.e2 / denom;
53        let q66 = self.g12;
54        [q11, q22, q12, q66]
55    }
56    /// Transformed (rotated) reduced stiffness Q̄ in laminate coordinates.
57    ///
58    /// Returns the 3×3 matrix `[Qbar_11, Qbar_12, Qbar_16; Qbar_12, Qbar_22, Qbar_26;
59    /// Qbar_16, Qbar_26, Qbar_66]` as a flat array `[q11,q12,q16,q12,q22,q26,q16,q26,q66]`.
60    pub fn transformed_stiffness(&self) -> [[f64; 3]; 3] {
61        let [q11, q22, q12, q66] = self.reduced_stiffness();
62        let theta = self.angle_deg.to_radians();
63        let m = theta.cos();
64        let n = theta.sin();
65        let m2 = m * m;
66        let n2 = n * n;
67        let m4 = m2 * m2;
68        let n4 = n2 * n2;
69        let m2n2 = m2 * n2;
70        let m3n = m2 * m * n;
71        let mn3 = m * n2 * n;
72        let qb11 = q11 * m4 + 2.0 * (q12 + 2.0 * q66) * m2n2 + q22 * n4;
73        let qb22 = q11 * n4 + 2.0 * (q12 + 2.0 * q66) * m2n2 + q22 * m4;
74        let qb12 = (q11 + q22 - 4.0 * q66) * m2n2 + q12 * (m4 + n4);
75        let qb66 = (q11 + q22 - 2.0 * q12 - 2.0 * q66) * m2n2 + q66 * (m4 + n4);
76        let qb16 = (q11 - q12 - 2.0 * q66) * m3n - (q22 - q12 - 2.0 * q66) * mn3;
77        let qb26 = (q11 - q12 - 2.0 * q66) * mn3 - (q22 - q12 - 2.0 * q66) * m3n;
78        [[qb11, qb12, qb16], [qb12, qb22, qb26], [qb16, qb26, qb66]]
79    }
80}
81/// Transversely isotropic material: isotropic in one plane, anisotropic along the axis.
82///
83/// This is a special case of orthotropic with `E2 = E3`, `ν12 = ν13`, `G12 = G13`.
84/// Typical for unidirectional fiber-reinforced composites.
85#[derive(Debug, Clone, Copy)]
86pub struct TransverselyIsotropic {
87    /// Axial modulus along the fiber direction (Pa)
88    pub ea: f64,
89    /// Transverse modulus (Pa)
90    pub et: f64,
91    /// Axial Poisson ratio ν12 (strain in transverse due to axial stress)
92    pub nua: f64,
93    /// Transverse Poisson ratio ν23
94    pub nut: f64,
95    /// Axial shear modulus G12 (Pa)
96    pub ga: f64,
97}
98impl TransverselyIsotropic {
99    /// Create a new transversely isotropic material.
100    pub fn new(ea: f64, et: f64, nua: f64, nut: f64, ga: f64) -> Self {
101        Self {
102            ea,
103            et,
104            nua,
105            nut,
106            ga,
107        }
108    }
109    /// Transverse shear modulus `Gt = Et / (2 * (1 + nut))`.
110    pub fn gt(&self) -> f64 {
111        self.et / (2.0 * (1.0 + self.nut))
112    }
113    /// Convert to equivalent orthotropic material.
114    ///
115    /// `E1 = ea`, `E2 = E3 = et`, `ν12 = ν13 = nua`, `ν23 = nut`, `G12 = G13 = ga`, `G23 = gt()`.
116    pub fn to_orthotropic(&self) -> OrthotropicMaterial {
117        OrthotropicMaterial::new(
118            self.ea,
119            self.et,
120            self.et,
121            self.nua,
122            self.nut,
123            self.nua,
124            self.ga,
125            self.gt(),
126            self.ga,
127        )
128    }
129    /// 6×6 stiffness (constitutive) matrix D.
130    pub fn constitutive_matrix(&self) -> [[f64; 6]; 6] {
131        self.to_orthotropic().constitutive_matrix()
132    }
133}
134/// Anisotropic thermal conductivity tensor (3×3 symmetric positive-definite matrix).
135///
136/// Represents materials where heat flow differs in each crystallographic direction,
137/// such as hexagonal boron nitride or graphite.
138#[derive(Debug, Clone, Copy)]
139pub struct ThermalConductivityTensor {
140    /// The 3×3 conductivity matrix κ \[W/(m·K)\], stored in row-major order.
141    pub kappa: [[f64; 3]; 3],
142}
143impl ThermalConductivityTensor {
144    /// Construct from a 3×3 matrix.
145    pub fn new(kappa: [[f64; 3]; 3]) -> Self {
146        Self { kappa }
147    }
148    /// Isotropic thermal conductivity (same in all directions).
149    pub fn isotropic(k: f64) -> Self {
150        Self::orthotropic(k, k, k)
151    }
152    /// Orthotropic (diagonal) thermal conductivity.
153    ///
154    /// `kx`, `ky`, `kz` are the principal conductivities along the x, y, z axes.
155    pub fn orthotropic(kx: f64, ky: f64, kz: f64) -> Self {
156        let mut k = [[0.0_f64; 3]; 3];
157        k[0][0] = kx;
158        k[1][1] = ky;
159        k[2][2] = kz;
160        Self::new(k)
161    }
162    /// Compute the heat flux vector `q = -κ · ∇T`.
163    ///
164    /// `grad_t` is the temperature gradient vector \[K/m\].
165    #[allow(clippy::needless_range_loop)]
166    pub fn heat_flux(&self, grad_t: [f64; 3]) -> [f64; 3] {
167        let mut q = [0.0f64; 3];
168        for i in 0..3 {
169            for j in 0..3 {
170                q[i] -= self.kappa[i][j] * grad_t[j];
171            }
172        }
173        q
174    }
175    /// Effective thermal conductivity along a direction `n` (unit vector).
176    ///
177    /// κ_eff = nᵀ · κ · n
178    #[allow(clippy::needless_range_loop)]
179    pub fn effective_conductivity(&self, n: [f64; 3]) -> f64 {
180        let mut kn = [0.0f64; 3];
181        for i in 0..3 {
182            for j in 0..3 {
183                kn[i] += self.kappa[i][j] * n[j];
184            }
185        }
186        n[0] * kn[0] + n[1] * kn[1] + n[2] * kn[2]
187    }
188    /// Average isotropic thermal conductivity: (κ_xx + κ_yy + κ_zz) / 3.
189    pub fn mean_conductivity(&self) -> f64 {
190        (self.kappa[0][0] + self.kappa[1][1] + self.kappa[2][2]) / 3.0
191    }
192}
193/// LaRC03/04 failure criteria for advanced composite materials.
194///
195/// NASA LaRC (Langley Research Center) failure criteria account for
196/// fibre kinking and in-situ strength effects.
197///
198/// Reference: Davila, C.G., Camanho, P.P. & Rose, C.A. (2005). "Failure
199/// criteria for FRP laminates". J. Compos. Mater. 39(4), 323–345.
200#[derive(Debug, Clone, Copy)]
201pub struct LaRCFailureCriteria {
202    /// Fibre tensile strength \[Pa\].
203    pub xt: f64,
204    /// Fibre compressive strength \[Pa\] (positive).
205    pub xc: f64,
206    /// In-situ matrix tensile strength \[Pa\].
207    pub yis: f64,
208    /// In-situ shear strength \[Pa\].
209    pub sis: f64,
210    /// Fibre misalignment angle φ₀ \[rad\].
211    pub phi0: f64,
212    /// Fracture toughness ratio parameter η.
213    pub eta: f64,
214}
215impl LaRCFailureCriteria {
216    /// Create with explicit parameters.
217    #[allow(clippy::too_many_arguments)]
218    pub fn new(xt: f64, xc: f64, yis: f64, sis: f64, phi0: f64, eta: f64) -> Self {
219        Self {
220            xt,
221            xc,
222            yis,
223            sis,
224            phi0,
225            eta,
226        }
227    }
228    /// Default parameters for carbon/epoxy (approximation of IM7/8552).
229    pub fn default_carbon_epoxy() -> Self {
230        Self::new(2560.0e6, 1590.0e6, 160.0e6, 130.0e6, 0.015, 0.43)
231    }
232    /// Fibre kinking failure index for compressive loading.
233    ///
234    /// `FI_fk = |τ_m| / (sis - η * σ_mn)` where stresses are in the kink band frame.
235    pub fn fibre_kinking_index(&self, sigma11: f64, sigma22: f64, tau12: f64) -> f64 {
236        let _ = sigma22;
237        let phi = self.phi0;
238        let sigma_m = sigma11 * phi.sin().powi(2) + tau12 * 2.0 * phi.sin() * phi.cos();
239        let tau_m = (sigma11 - 0.0) * phi.sin() * phi.cos()
240            + tau12 * (phi.cos().powi(2) - phi.sin().powi(2));
241        let denominator = (self.sis - self.eta * sigma_m).max(1.0);
242        tau_m.abs() / denominator
243    }
244    /// Matrix cracking failure index.
245    ///
246    /// `FI_mc = (τ_n/sis)² + (σ_n/yis)²` (simplified Mohr-Coulomb form).
247    pub fn matrix_cracking_index(&self, sigma22: f64, tau12: f64, tau23: f64) -> f64 {
248        let _ = tau23;
249        let a = (tau12 / self.sis).powi(2);
250        let b = if sigma22 > 0.0 {
251            (sigma22 / self.yis).powi(2)
252        } else {
253            0.0
254        };
255        (a + b).sqrt()
256    }
257}
258/// Woven/braided reinforcement model for triaxially braided composites.
259///
260/// Uses a simplified mixture-theory approach with off-axis transformations
261/// for the bias (braided) tows plus the axial tows.
262#[derive(Debug, Clone, Copy)]
263pub struct BraidedComposite {
264    /// Volume fraction of fibre (all tow directions combined).
265    pub vf: f64,
266    /// Fibre Young's modulus \[Pa\].
267    pub e_fibre: f64,
268    /// Matrix Young's modulus \[Pa\].
269    pub e_matrix: f64,
270    /// Fibre Poisson ratio.
271    pub nu_fibre: f64,
272    /// Matrix Poisson ratio.
273    pub nu_matrix: f64,
274    /// Braid angle θ \[degrees\] from axial direction.
275    pub braid_angle_deg: f64,
276}
277impl BraidedComposite {
278    /// Construct a triaxial braid from constituent properties.
279    ///
280    /// `braid_angle_deg` is the angle of the bias tows from the axial direction.
281    pub fn triaxial_braid(
282        vf: f64,
283        e_fibre: f64,
284        e_matrix: f64,
285        nu_fibre: f64,
286        nu_matrix: f64,
287        braid_angle_deg: f64,
288    ) -> Self {
289        Self {
290            vf,
291            e_fibre,
292            e_matrix,
293            nu_fibre,
294            nu_matrix,
295            braid_angle_deg,
296        }
297    }
298    /// Matrix volume fraction.
299    fn vm(&self) -> f64 {
300        1.0 - self.vf
301    }
302    /// Rule-of-mixtures axial modulus \[Pa\].
303    fn e_axial(&self) -> f64 {
304        self.vf * self.e_fibre + self.vm() * self.e_matrix
305    }
306    /// Inverse rule-of-mixtures transverse modulus \[Pa\].
307    fn e_transverse(&self) -> f64 {
308        1.0 / (self.vf / self.e_fibre + self.vm() / self.e_matrix)
309    }
310    /// Rule-of-mixtures Poisson ratio ν12.
311    fn nu12(&self) -> f64 {
312        self.vf * self.nu_fibre + self.vm() * self.nu_matrix
313    }
314    /// In-plane shear modulus G12 \[Pa\] (Reuss/series lower bound).
315    fn g12(&self) -> f64 {
316        let gf = self.e_fibre / (2.0 * (1.0 + self.nu_fibre));
317        let gm = self.e_matrix / (2.0 * (1.0 + self.nu_matrix));
318        1.0 / (self.vf / gf + self.vm() / gm)
319    }
320    /// Effective in-plane modulus Ex of the braided laminate.
321    ///
322    /// Computed by averaging the Q̄ matrices of the axial (0°) and
323    /// bias (±θ) tow contributions.
324    pub fn in_plane_modulus(&self) -> f64 {
325        let theta = self.braid_angle_deg.to_radians();
326        let m = theta.cos();
327        let n = theta.sin();
328        let m2 = m * m;
329        let n2 = n * n;
330        let m4 = m2 * m2;
331        let n4 = n2 * n2;
332        let m2n2 = m2 * n2;
333        let e1 = self.e_axial();
334        let e2 = self.e_transverse();
335        let nu12 = self.nu12();
336        let nu21 = nu12 * e2 / e1;
337        let g12 = self.g12();
338        let denom = 1.0 - nu12 * nu21;
339        let q11 = e1 / denom;
340        let q22 = e2 / denom;
341        let q12 = nu12 * e2 / denom;
342        let q66 = g12;
343        let qb11_bias = q11 * m4 + 2.0 * (q12 + 2.0 * q66) * m2n2 + q22 * n4;
344        let weight_axial = 1.0 / 3.0;
345        let weight_bias = 2.0 / 3.0;
346        weight_axial * q11 + weight_bias * qb11_bias
347    }
348    /// Effective shear modulus G of the braided laminate \[Pa\].
349    pub fn effective_shear_modulus(&self) -> f64 {
350        let theta = self.braid_angle_deg.to_radians();
351        let m = theta.cos();
352        let n = theta.sin();
353        let m2 = m * m;
354        let n2 = n * n;
355        let m4 = m2 * m2;
356        let n4 = n2 * n2;
357        let m2n2 = m2 * n2;
358        let e1 = self.e_axial();
359        let e2 = self.e_transverse();
360        let nu12 = self.nu12();
361        let nu21 = nu12 * e2 / e1;
362        let g12 = self.g12();
363        let denom = 1.0 - nu12 * nu21;
364        let q11 = e1 / denom;
365        let q22 = e2 / denom;
366        let q12 = nu12 * e2 / denom;
367        let q66 = g12;
368        let qb66_bias = (q11 + q22 - 2.0 * q12 - 2.0 * q66) * m2n2 + q66 * (m4 + n4);
369        (1.0 / 3.0) * q66 + (2.0 / 3.0) * qb66_bias
370    }
371}
372/// Monoclinic linear elastic material (single symmetry plane = plane 1-2).
373///
374/// Has 13 independent elastic constants (compared to 21 for triclinic).
375/// The compliance matrix has the following form (Voigt notation, plane 3 is
376/// the symmetry axis):
377/// ```text
378/// S = [S11 S12 S13  0   0  S16 ]
379///     [S12 S22 S23  0   0  S26 ]
380///     [S13 S23 S33  0   0  S36 ]
381///     [ 0   0   0  S44 S45  0  ]
382///     [ 0   0   0  S45 S55  0  ]
383///     [S16 S26 S36  0   0  S66 ]
384/// ```
385#[derive(Debug, Clone, Copy)]
386pub struct MonoclinicMaterial {
387    /// S11 compliance component \[1/Pa\]
388    pub s11: f64,
389    /// S12 compliance component \[1/Pa\]
390    pub s12: f64,
391    /// S13 compliance component \[1/Pa\]
392    pub s13: f64,
393    /// S16 coupling compliance component \[1/Pa\]
394    pub s16: f64,
395    /// S22 compliance component \[1/Pa\]
396    pub s22: f64,
397    /// S23 compliance component \[1/Pa\]
398    pub s23: f64,
399    /// S26 coupling compliance component \[1/Pa\]
400    pub s26: f64,
401    /// S33 compliance component \[1/Pa\]
402    pub s33: f64,
403    /// S36 coupling compliance component \[1/Pa\]
404    pub s36: f64,
405    /// S44 compliance component \[1/Pa\]
406    pub s44: f64,
407    /// S45 coupling compliance component \[1/Pa\]
408    pub s45: f64,
409    /// S55 compliance component \[1/Pa\]
410    pub s55: f64,
411    /// S66 compliance component \[1/Pa\]
412    pub s66: f64,
413}
414impl MonoclinicMaterial {
415    /// Build from the 13 independent compliance values.
416    #[allow(clippy::too_many_arguments)]
417    pub fn new(
418        s11: f64,
419        s12: f64,
420        s13: f64,
421        s16: f64,
422        s22: f64,
423        s23: f64,
424        s26: f64,
425        s33: f64,
426        s36: f64,
427        s44: f64,
428        s45: f64,
429        s55: f64,
430        s66: f64,
431    ) -> Self {
432        Self {
433            s11,
434            s12,
435            s13,
436            s16,
437            s22,
438            s23,
439            s26,
440            s33,
441            s36,
442            s44,
443            s45,
444            s55,
445            s66,
446        }
447    }
448    /// Effective in-plane Young's modulus E₁ = 1 / S₁₁.
449    pub fn e1(&self) -> f64 {
450        1.0 / self.s11
451    }
452    /// Effective in-plane Young's modulus E₂ = 1 / S₂₂.
453    pub fn e2(&self) -> f64 {
454        1.0 / self.s22
455    }
456    /// Check that the compliance matrix has the correct symmetry (S16, S26, S36 coupling).
457    ///
458    /// Returns `true` if S11 and S22 are positive (minimal physical check).
459    pub fn is_physically_valid(&self) -> bool {
460        self.s11 > 0.0 && self.s22 > 0.0 && self.s33 > 0.0
461    }
462}
463/// Woven composite lamina (in-plane weave) with distinct in-plane and through-thickness properties.
464#[derive(Debug, Clone, Copy)]
465pub struct WovenLamina {
466    /// In-plane modulus along axis 1 (Pa)
467    pub e11: f64,
468    /// In-plane modulus along axis 2 (Pa)
469    pub e22: f64,
470    /// In-plane Poisson ratio ν12
471    pub nu12: f64,
472    /// In-plane shear modulus G12 (Pa)
473    pub g12: f64,
474    /// Through-thickness modulus E33 (Pa)
475    pub e33: f64,
476    /// Out-of-plane shear modulus G13 (Pa)
477    pub g13: f64,
478    /// Out-of-plane shear modulus G23 (Pa)
479    pub g23: f64,
480}
481impl WovenLamina {
482    /// Construct a balanced plain weave lamina from constituent properties using rule of mixtures.
483    ///
484    /// For a balanced weave: `E11 = E22 = vf*Ef + vm*Em`.
485    /// - `fiber_vol`: fiber volume fraction (0..1)
486    /// - `e_fiber`, `e_matrix`: constituent Young's moduli (Pa)
487    /// - `nu_fiber`, `nu_matrix`: constituent Poisson ratios
488    pub fn balanced_plain_weave(
489        fiber_vol: f64,
490        e_fiber: f64,
491        e_matrix: f64,
492        nu_fiber: f64,
493        nu_matrix: f64,
494    ) -> Self {
495        let vm = 1.0 - fiber_vol;
496        let vf = fiber_vol;
497        let e11 = vf * e_fiber + vm * e_matrix;
498        let e22 = e11;
499        let nu12 = vf * nu_fiber + vm * nu_matrix;
500        let g_fiber = e_fiber / (2.0 * (1.0 + nu_fiber));
501        let g_matrix = e_matrix / (2.0 * (1.0 + nu_matrix));
502        let g12 = 1.0 / (vf / g_fiber + vm / g_matrix);
503        let e33 = 1.0 / (vf / e_fiber + vm / e_matrix);
504        let g13 = 1.0 / (vf / g_fiber + vm / g_matrix);
505        let g23 = g13;
506        Self {
507            e11,
508            e22,
509            nu12,
510            g12,
511            e33,
512            g13,
513            g23,
514        }
515    }
516    /// 3×3 plane-stress constitutive matrix Q for `[σ_11, σ_22, τ_12] = Q * [ε_11, ε_22, γ_12]`.
517    pub fn constitutive_matrix_2d(&self) -> [[f64; 3]; 3] {
518        let nu21 = self.nu12 * self.e22 / self.e11;
519        let denom = 1.0 - self.nu12 * nu21;
520        let mut q = [[0.0_f64; 3]; 3];
521        q[0][0] = self.e11 / denom;
522        q[1][1] = self.e22 / denom;
523        q[0][1] = self.nu12 * self.e22 / denom;
524        q[1][0] = q[0][1];
525        q[2][2] = self.g12;
526        q
527    }
528}
529/// Orthotropic linear elastic material with 3 distinct Young's moduli, 3 shear moduli,
530/// and 3 Poisson ratios.
531///
532/// Used for wood, fiber-reinforced composites, etc.
533///
534/// The compliance matrix S = D⁻¹:
535/// ```text
536/// [ε_11]   [1/E1    -ν21/E2  -ν31/E3  0      0      0    ] [σ_11]
537/// [ε_22] = [-ν12/E1  1/E2    -ν32/E3  0      0      0    ] [σ_22]
538/// [ε_33]   [-ν13/E1 -ν23/E2   1/E3    0      0      0    ] [σ_33]
539/// [γ_12]   [0        0        0       1/G12  0      0    ] [τ_12]
540/// [γ_23]   [0        0        0       0      1/G23  0    ] [τ_23]
541/// [γ_13]   [0        0        0       0      0      1/G13] [τ_13]
542/// ```
543#[derive(Debug, Clone, Copy)]
544pub struct OrthotropicMaterial {
545    /// Young's modulus along axis 1 (Pa)
546    pub e1: f64,
547    /// Young's modulus along axis 2 (Pa)
548    pub e2: f64,
549    /// Young's modulus along axis 3 (Pa)
550    pub e3: f64,
551    /// Major Poisson ratio ν12 (strain in 2 due to stress in 1)
552    pub nu12: f64,
553    /// Major Poisson ratio ν23 (strain in 3 due to stress in 2)
554    pub nu23: f64,
555    /// Major Poisson ratio ν13 (strain in 3 due to stress in 1)
556    pub nu13: f64,
557    /// Shear modulus in the 1-2 plane (Pa)
558    pub g12: f64,
559    /// Shear modulus in the 2-3 plane (Pa)
560    pub g23: f64,
561    /// Shear modulus in the 1-3 plane (Pa)
562    pub g13: f64,
563}
564impl OrthotropicMaterial {
565    /// Create with explicit parameters. Symmetry requires `nu_ij/Ei = nu_ji/Ej`.
566    #[allow(clippy::too_many_arguments)]
567    pub fn new(
568        e1: f64,
569        e2: f64,
570        e3: f64,
571        nu12: f64,
572        nu23: f64,
573        nu13: f64,
574        g12: f64,
575        g23: f64,
576        g13: f64,
577    ) -> Self {
578        Self {
579            e1,
580            e2,
581            e3,
582            nu12,
583            nu23,
584            nu13,
585            g12,
586            g23,
587            g13,
588        }
589    }
590    /// Carbon fiber composite (AS4 fiber/epoxy matrix).
591    ///
592    /// - E1 = 140 GPa (along fiber), E2 = E3 = 10 GPa (transverse)
593    /// - ν12 = ν13 = 0.3, ν23 = 0.4
594    /// - G12 = G13 = 5 GPa, G23 = 3.5 GPa
595    pub fn carbon_fiber_epoxy() -> Self {
596        Self::new(140.0e9, 10.0e9, 10.0e9, 0.3, 0.4, 0.3, 5.0e9, 3.5e9, 5.0e9)
597    }
598    /// Douglas fir wood (along grain = 1, radial = 2, tangential = 3).
599    ///
600    /// - E1 = 13.5 GPa, E2 = 0.9 GPa, E3 = 0.75 GPa
601    /// - ν12 = 0.292, ν23 = 0.37, ν13 = 0.374
602    /// - G12 = 0.88 GPa, G23 = 0.06 GPa, G13 = 1.07 GPa
603    pub fn douglas_fir() -> Self {
604        Self::new(
605            13.5e9, 0.9e9, 0.75e9, 0.292, 0.37, 0.374, 0.88e9, 0.06e9, 1.07e9,
606        )
607    }
608    /// 6×6 compliance matrix S (inverse of stiffness D).
609    ///
610    /// Uses the minor Poisson ratios: ν21 = ν12·E2/E1, ν31 = ν13·E3/E1, ν32 = ν23·E3/E2.
611    pub fn compliance_matrix(&self) -> [[f64; 6]; 6] {
612        let nu21 = self.nu12 * self.e2 / self.e1;
613        let nu31 = self.nu13 * self.e3 / self.e1;
614        let nu32 = self.nu23 * self.e3 / self.e2;
615        let mut s = [[0.0_f64; 6]; 6];
616        s[0][0] = 1.0 / self.e1;
617        s[1][1] = 1.0 / self.e2;
618        s[2][2] = 1.0 / self.e3;
619        s[0][1] = -nu21 / self.e2;
620        s[1][0] = -nu21 / self.e2;
621        s[0][2] = -nu31 / self.e3;
622        s[2][0] = -nu31 / self.e3;
623        s[1][2] = -nu32 / self.e3;
624        s[2][1] = -nu32 / self.e3;
625        s[3][3] = 1.0 / self.g12;
626        s[4][4] = 1.0 / self.g23;
627        s[5][5] = 1.0 / self.g13;
628        s
629    }
630    /// 6×6 stiffness (constitutive) matrix D = S⁻¹.
631    ///
632    /// Computes by analytically inverting the 3×3 normal block and placing shear terms directly.
633    pub fn constitutive_matrix(&self) -> [[f64; 6]; 6] {
634        let nu21 = self.nu12 * self.e2 / self.e1;
635        let nu31 = self.nu13 * self.e3 / self.e1;
636        let nu32 = self.nu23 * self.e3 / self.e2;
637        let nu12 = self.nu12;
638        let nu13 = self.nu13;
639        let nu23 = self.nu23;
640        let delta = 1.0 - nu12 * nu21 - nu23 * nu32 - nu13 * nu31 - 2.0 * nu21 * nu32 * nu13;
641        let e1 = self.e1;
642        let e2 = self.e2;
643        let e3 = self.e3;
644        let mut d = [[0.0_f64; 6]; 6];
645        d[0][0] = (1.0 - nu23 * nu32) * e1 / delta;
646        d[1][1] = (1.0 - nu13 * nu31) * e2 / delta;
647        d[2][2] = (1.0 - nu12 * nu21) * e3 / delta;
648        d[0][1] = (nu21 + nu31 * nu23) * e1 / delta;
649        d[1][0] = d[0][1];
650        d[0][2] = (nu31 + nu21 * nu32) * e1 / delta;
651        d[2][0] = d[0][2];
652        d[1][2] = (nu32 + nu12 * nu31) * e2 / delta;
653        d[2][1] = d[1][2];
654        d[3][3] = self.g12;
655        d[4][4] = self.g23;
656        d[5][5] = self.g13;
657        d
658    }
659    /// Check Voigt symmetry: `nu_ij/Ei = nu_ji/Ej` within a tolerance of 1e-10.
660    pub fn check_symmetry(&self) -> bool {
661        let tol = 1.0e-10;
662        let nu21 = self.nu12 * self.e2 / self.e1;
663        let nu31 = self.nu13 * self.e3 / self.e1;
664        let nu32 = self.nu23 * self.e3 / self.e2;
665        (self.nu12 / self.e1 - nu21 / self.e2).abs() < tol
666            && (self.nu13 / self.e1 - nu31 / self.e3).abs() < tol
667            && (self.nu23 / self.e2 - nu32 / self.e3).abs() < tol
668    }
669    /// Longitudinal bulk modulus: average over the three axes, E_avg / (3*(1 - 2*nu_avg)).
670    ///
671    /// This is an engineering approximation for the effective bulk modulus.
672    pub fn bulk_modulus_longitudinal(&self) -> f64 {
673        let e_avg = (self.e1 + self.e2 + self.e3) / 3.0;
674        let nu_avg = (self.nu12 + self.nu23 + self.nu13) / 3.0;
675        e_avg / (3.0 * (1.0 - 2.0 * nu_avg))
676    }
677}
678/// Biaxial failure envelope for a composite material.
679///
680/// Represents the outer boundary of admissible stress states in the
681/// (σ11, σ22) plane using a maximum-stress interaction criterion.
682#[derive(Debug, Clone, Copy)]
683pub struct FailureEnvelope {
684    /// Tensile strength along axis 1 \[Pa\].
685    pub xt: f64,
686    /// Compressive strength along axis 1 \[Pa\] (negative value).
687    pub xc: f64,
688    /// Tensile strength along axis 2 \[Pa\].
689    pub yt: f64,
690    /// Compressive strength along axis 2 \[Pa\] (negative value).
691    pub yc: f64,
692}
693impl FailureEnvelope {
694    /// Create with explicit strengths.
695    pub fn max_stress(xt: f64, xc: f64, yt: f64, yc: f64) -> Self {
696        Self { xt, xc, yt, yc }
697    }
698    /// Biaxial failure index for `(σ11, σ22)`.
699    ///
700    /// Returns the maximum of the two normalised stress ratios.
701    /// A value ≥ 1 indicates failure.
702    pub fn failure_index_biaxial(&self, sigma11: f64, sigma22: f64) -> f64 {
703        let fi1 = if sigma11 >= 0.0 {
704            sigma11 / self.xt
705        } else {
706            sigma11.abs() / self.xc.abs()
707        };
708        let fi2 = if sigma22 >= 0.0 {
709            sigma22 / self.yt
710        } else {
711            sigma22.abs() / self.yc.abs()
712        };
713        fi1.max(fi2)
714    }
715    /// Tsai-Wu tensor failure index for biaxial `(σ11, σ22)`.
716    ///
717    /// `FI = F1*σ11 + F2*σ22 + F11*σ11² + F22*σ22² + 2*F12*σ11*σ22`
718    ///
719    /// Uses `F12 = -0.5*sqrt(F11*F22)` (Tsai-Wu interaction term).
720    pub fn tsai_wu_index(&self, sigma11: f64, sigma22: f64) -> f64 {
721        let f1 = 1.0 / self.xt + 1.0 / self.xc;
722        let f2 = 1.0 / self.yt + 1.0 / self.yc;
723        let f11 = -1.0 / (self.xt * self.xc);
724        let f22 = -1.0 / (self.yt * self.yc);
725        let f12 = -0.5 * (f11 * f22).abs().sqrt();
726        f1 * sigma11
727            + f2 * sigma22
728            + f11 * sigma11 * sigma11
729            + f22 * sigma22 * sigma22
730            + 2.0 * f12 * sigma11 * sigma22
731    }
732    /// Check if the stress state `(σ11, σ22)` is inside the failure envelope.
733    pub fn is_inside(&self, sigma11: f64, sigma22: f64) -> bool {
734        self.failure_index_biaxial(sigma11, sigma22) < 1.0
735    }
736}
737/// Anisotropic diffusion tensor \[m²/s\] for a material.
738#[derive(Debug, Clone, Copy)]
739pub struct DiffusionTensor {
740    /// 3×3 diffusion matrix D.
741    pub d: [[f64; 3]; 3],
742}
743impl DiffusionTensor {
744    /// Create from a full 3×3 matrix.
745    pub fn new(d: [[f64; 3]; 3]) -> Self {
746        Self { d }
747    }
748    /// Create an orthotropic (diagonal) diffusion tensor.
749    pub fn orthotropic(dx: f64, dy: f64, dz: f64) -> Self {
750        let mut d = [[0.0f64; 3]; 3];
751        d[0][0] = dx;
752        d[1][1] = dy;
753        d[2][2] = dz;
754        Self::new(d)
755    }
756    /// Compute the diffusive flux vector `J = -D · ∇c`.
757    #[allow(clippy::needless_range_loop)]
758    pub fn diffusive_flux(&self, grad_c: [f64; 3]) -> [f64; 3] {
759        let mut j = [0.0f64; 3];
760        for i in 0..3 {
761            for j_idx in 0..3 {
762                j[i] -= self.d[i][j_idx] * grad_c[j_idx];
763            }
764        }
765        j
766    }
767    /// Mean-square displacement in direction `n` after time `t`.
768    ///
769    /// MSD = 2 * D_eff * t, where D_eff = nᵀ D n.
770    pub fn msd_along(&self, n: [f64; 3], t: f64) -> f64 {
771        let d_eff: f64 = (0..3)
772            .flat_map(|i| (0..3).map(move |j| n[i] * self.d[i][j] * n[j]))
773            .sum();
774        2.0 * d_eff * t
775    }
776    /// Average isotropic diffusivity: (Dxx + Dyy + Dzz) / 3.
777    pub fn mean_diffusivity(&self) -> f64 {
778        (self.d[0][0] + self.d[1][1] + self.d[2][2]) / 3.0
779    }
780}
781/// Hill's anisotropic yield criterion (1948) for orthotropic plasticity.
782///
783/// The yield function is:
784/// `f(σ) = F(σ22-σ33)² + G(σ33-σ11)² + H(σ11-σ22)² + 2Lτ23² + 2Mτ31² + 2Nτ12² - σ_y²`
785///
786/// Reference: Hill, R. (1948). "A Theory of the Yielding and Plastic Flow of
787/// Anisotropic Metals". Proc. R. Soc. Lond. A 193, 281–297.
788#[derive(Debug, Clone, Copy)]
789pub struct HillYieldCriterion {
790    /// F parameter (22-33 anisotropy).
791    pub f: f64,
792    /// G parameter (33-11 anisotropy).
793    pub g: f64,
794    /// H parameter (11-22 anisotropy).
795    pub h: f64,
796    /// L parameter (23 shear anisotropy).
797    pub l: f64,
798    /// M parameter (31 shear anisotropy).
799    pub m: f64,
800    /// N parameter (12 shear anisotropy).
801    pub n: f64,
802}
803impl HillYieldCriterion {
804    /// Create from the six Hill parameters.
805    pub fn new(f: f64, g: f64, h: f64, l: f64, m: f64, n: f64) -> Self {
806        Self { f, g, h, l, m, n }
807    }
808    /// Isotropic case: reduces to von Mises criterion.
809    ///
810    /// For isotropic Hill: F = G = H = 1/(2 σ_y²), L = M = N = 3/(2 σ_y²).
811    /// This constructor uses a unit yield stress convention (F=G=H=0.5, L=M=N=1.5).
812    pub fn isotropic(sigma_y: f64) -> Self {
813        let sy2 = sigma_y * sigma_y;
814        Self::new(
815            0.5 / sy2,
816            0.5 / sy2,
817            0.5 / sy2,
818            1.5 / sy2,
819            1.5 / sy2,
820            1.5 / sy2,
821        )
822    }
823    /// Construct from Lankford R-values `(R11, R22, R33)`.
824    ///
825    /// R-values describe plastic anisotropy in each principal direction.
826    /// Uses the standard conversion:
827    /// - `H = R11 / (1 + R11)`, `G = 1 / (1 + R11)`, `F = G * R22 / R11`
828    /// - `N = (R11 + R22)*(1 + 2*R33) / (2*R33*(1+R11))`, `L = M = 1.5`
829    pub fn from_r_values(r11: f64, r22: f64, r33: f64) -> Self {
830        let h = r11 / (1.0 + r11);
831        let g = 1.0 / (1.0 + r11);
832        let f = g * r22 / r11;
833        let n = (r11 + r22) * (1.0 + 2.0 * r33) / (2.0 * r33 * (1.0 + r11));
834        let l = 1.5;
835        let m = 1.5;
836        Self::new(f, g, h, l, m, n)
837    }
838    /// Evaluate the Hill yield function `f(σ) = q² - σ_y²`.
839    ///
840    /// `sigma` = `[σ11, σ22, σ33, τ12, τ23, τ13]` (Voigt notation).
841    /// Returns `> 0` for yielded state, `= 0` at yield, `< 0` elastic.
842    pub fn yield_function(&self, sigma: [f64; 6], sigma_y: f64) -> f64 {
843        let [s11, s22, s33, t12, t23, t13] = sigma;
844        let q2 = self.f * (s22 - s33).powi(2)
845            + self.g * (s33 - s11).powi(2)
846            + self.h * (s11 - s22).powi(2)
847            + 2.0 * self.l * t23 * t23
848            + 2.0 * self.m * t13 * t13
849            + 2.0 * self.n * t12 * t12;
850        q2 - sigma_y * sigma_y
851    }
852    /// Hill effective stress: `σ_eff = sqrt(Q²)`.
853    ///
854    /// `sigma` = `[σ11, σ22, σ33, τ12, τ23, τ13]`.
855    pub fn effective_stress(&self, sigma: [f64; 6]) -> f64 {
856        let [s11, s22, s33, t12, t23, t13] = sigma;
857        let q2 = self.f * (s22 - s33).powi(2)
858            + self.g * (s33 - s11).powi(2)
859            + self.h * (s11 - s22).powi(2)
860            + 2.0 * self.l * t23 * t23
861            + 2.0 * self.m * t13 * t13
862            + 2.0 * self.n * t12 * t12;
863        q2.max(0.0).sqrt()
864    }
865    /// Yield surface normal (direction of plastic strain rate) at `sigma`.
866    ///
867    /// Returns `dφ/dσ = [∂f/∂σ11, ∂f/∂σ22, ∂f/∂σ33, ∂f/∂τ12, ∂f/∂τ23, ∂f/∂τ13]`.
868    pub fn normal(&self, sigma: [f64; 6]) -> [f64; 6] {
869        let [s11, s22, s33, t12, t23, t13] = sigma;
870        [
871            -2.0 * self.g * (s33 - s11) + 2.0 * self.h * (s11 - s22),
872            2.0 * self.f * (s22 - s33) - 2.0 * self.h * (s11 - s22),
873            -2.0 * self.f * (s22 - s33) + 2.0 * self.g * (s33 - s11),
874            4.0 * self.n * t12,
875            4.0 * self.l * t23,
876            4.0 * self.m * t13,
877        ]
878    }
879}
880/// Hashin (1980) failure criteria for unidirectional composites.
881///
882/// Distinguishes fibre and matrix failure modes in both tension and compression.
883///
884/// Reference: Hashin, Z. (1980). "Failure criteria for unidirectional fiber
885/// composites". J. Appl. Mech. 47(2), 329–334.
886#[derive(Debug, Clone, Copy)]
887pub struct HashinFailureCriteria {
888    /// Fibre tensile strength Xt \[Pa\].
889    pub xt: f64,
890    /// Fibre compressive strength Xc \[Pa\] (positive value).
891    pub xc: f64,
892    /// Matrix tensile strength Yt \[Pa\].
893    pub yt: f64,
894    /// Matrix compressive strength Yc \[Pa\] (positive value).
895    pub yc: f64,
896    /// Longitudinal (in-plane) shear strength S12 \[Pa\].
897    pub s12: f64,
898    /// Transverse shear strength S23 \[Pa\].
899    pub s23: f64,
900}
901impl HashinFailureCriteria {
902    /// Create a new Hashin failure criteria set.
903    #[allow(clippy::too_many_arguments)]
904    pub fn new(xt: f64, xc: f64, yt: f64, yc: f64, s12: f64, s23: f64) -> Self {
905        Self {
906            xt,
907            xc,
908            yt,
909            yc,
910            s12,
911            s23,
912        }
913    }
914    /// Typical carbon/epoxy system (AS4/3501-6).
915    pub fn carbon_epoxy_typical() -> Self {
916        Self::new(1480.0e6, 1200.0e6, 50.0e6, 170.0e6, 70.0e6, 40.0e6)
917    }
918    /// Fibre tension failure index `FI_ft = (σ11/Xt)² + (τ12/S12)²`.
919    ///
920    /// Returns FI ≥ 1 if failure predicted.
921    pub fn fibre_tension(&self, sigma11: f64, tau12: f64) -> f64 {
922        (sigma11 / self.xt).powi(2) + (tau12 / self.s12).powi(2)
923    }
924    /// Fibre compression failure index `FI_fc = (σ11/Xc)²`.
925    pub fn fibre_compression(&self, sigma11: f64) -> f64 {
926        (sigma11.abs() / self.xc).powi(2)
927    }
928    /// Fiber tension failure index (American English alias).
929    pub fn fiber_tension(&self, sigma11: f64, tau12: f64, _tau13: f64) -> f64 {
930        self.fibre_tension(sigma11, tau12)
931    }
932    /// Fiber compression failure index (American English alias).
933    pub fn fiber_compression(&self, sigma11: f64) -> f64 {
934        self.fibre_compression(sigma11)
935    }
936    /// Matrix tension failure index `FI_mt = (σ22/Yt)² + (τ12/S12)²`.
937    pub fn matrix_tension(&self, sigma22: f64, tau12: f64) -> f64 {
938        (sigma22 / self.yt).powi(2) + (tau12 / self.s12).powi(2)
939    }
940    /// Matrix compression failure index (Hashin 1980 form).
941    ///
942    /// `FI_mc = (σ22/(2S23))² + (Yc/(2S23) - 1)*(σ22/Yc) + (τ12/S12)²`
943    pub fn matrix_compression(&self, sigma22: f64, tau12: f64) -> f64 {
944        let a = (sigma22.abs() / (2.0 * self.s23)).powi(2);
945        let b = (self.yc / (2.0 * self.s23) - 1.0) * sigma22.abs() / self.yc;
946        let c = (tau12 / self.s12).powi(2);
947        a + b + c
948    }
949    /// Compute all four failure indices and return `(max_fi, mode_name)`.
950    ///
951    /// - Mode 0: fibre tension, 1: fibre compression, 2: matrix tension, 3: matrix compression.
952    pub fn max_failure_index(
953        &self,
954        sigma11: f64,
955        sigma22: f64,
956        tau12: f64,
957        tau23: f64,
958    ) -> (f64, usize) {
959        let _ = tau23;
960        let fi = [
961            if sigma11 >= 0.0 {
962                self.fibre_tension(sigma11, tau12)
963            } else {
964                0.0
965            },
966            if sigma11 < 0.0 {
967                self.fibre_compression(sigma11)
968            } else {
969                0.0
970            },
971            if sigma22 >= 0.0 {
972                self.matrix_tension(sigma22, tau12)
973            } else {
974                0.0
975            },
976            if sigma22 < 0.0 {
977                self.matrix_compression(sigma22, tau12)
978            } else {
979                0.0
980            },
981        ];
982        let max_idx = fi
983            .iter()
984            .enumerate()
985            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
986            .map(|(i, _)| i)
987            .unwrap_or(0);
988        (fi[max_idx], max_idx)
989    }
990}
991/// Puck (1996) physically based failure criteria for unidirectional composites.
992///
993/// Distinguishes fibre failure (FF) from inter-fibre failure (IFF) with
994/// fracture-plane-based formulation.
995///
996/// Reference: Puck, A. & Schürmann, H. (1998). "Failure analysis of FRP
997/// laminates by means of physically based phenomenological models".
998/// Compos. Sci. Technol. 58(7), 1045–1067.
999#[derive(Debug, Clone, Copy)]
1000pub struct PuckFailureCriteria {
1001    /// Fibre tensile strength Xt \[Pa\].
1002    pub xt: f64,
1003    /// Matrix tensile strength Yt \[Pa\].
1004    pub yt: f64,
1005    /// Longitudinal shear strength S21 \[Pa\].
1006    pub s21: f64,
1007    /// Inclination parameter p_t for transverse tensile loading.
1008    pub p_t: f64,
1009    /// Inclination parameter p_c for transverse compressive loading.
1010    pub p_c: f64,
1011}
1012impl PuckFailureCriteria {
1013    /// Create with explicit parameters.
1014    pub fn new(xt: f64, yt: f64, s21: f64, p_t: f64, p_c: f64) -> Self {
1015        Self {
1016            xt,
1017            yt,
1018            s21,
1019            p_t,
1020            p_c,
1021        }
1022    }
1023    /// Typical parameters for carbon/epoxy (AS4/3501-6).
1024    pub fn carbon_epoxy_typical() -> Self {
1025        Self::new(1480.0e6, 50.0e6, 70.0e6, 0.27, 0.32)
1026    }
1027    /// Fibre failure index (tension mode): `FI_ff = |σ11| / Xt`.
1028    pub fn fibre_failure_index(&self, sigma11: f64, xt: f64) -> f64 {
1029        sigma11.abs() / xt
1030    }
1031    /// Inter-fibre failure index (simplified IFF mode A).
1032    ///
1033    /// For transverse tension: `FI_iff = sqrt((τ21/S21)² + (1 - p_t*Yt/S21)²*(σ22/Yt)²) + p_t*σ22/S21`
1034    pub fn inter_fibre_failure_index(&self, sigma22: f64, tau21: f64, _tau23: f64) -> f64 {
1035        if sigma22 >= 0.0 {
1036            let a = (tau21 / self.s21).powi(2);
1037            let b = (1.0 - self.p_t * self.yt / self.s21).powi(2) * (sigma22 / self.yt).powi(2);
1038            let fi = (a + b).sqrt() + self.p_t * sigma22 / self.s21;
1039            fi.max(0.0)
1040        } else {
1041            let a = (tau21 / self.s21).powi(2);
1042            let b = (self.p_c * sigma22.abs() / self.s21).powi(2);
1043            (a + b).sqrt() + self.p_c * sigma22.abs() / self.s21
1044        }
1045    }
1046    /// Fiber failure tension (Puck criterion).
1047    ///
1048    /// Simplified: f_FF = σ_1 / X_T with minor σ_2 correction.
1049    pub fn fiber_failure_tension(&self, sigma1: f64, _e_f1: f64, sigma2: f64, _e_f2: f64) -> f64 {
1050        if sigma1 <= 0.0 {
1051            return 0.0;
1052        }
1053        let correction = 1.0 + sigma2 / self.yt * 0.01;
1054        (sigma1 / self.xt) * correction
1055    }
1056    /// Fiber failure compression (Puck criterion).
1057    pub fn fiber_failure_compression(&self, sigma1: f64) -> f64 {
1058        if sigma1 >= 0.0 {
1059            return 0.0;
1060        }
1061        // Puck uses Xt as the reference for compressive FF as well for this 5-field struct;
1062        // callers supply negative sigma1.
1063        (-sigma1) / self.xt
1064    }
1065    /// Inter-fiber failure Mode A (transverse tension + shear).
1066    pub fn inter_fiber_failure_mode_a(
1067        &self,
1068        sigma2: f64,
1069        tau12: f64,
1070        p_tt: f64,
1071        _p_ct: f64,
1072    ) -> f64 {
1073        if sigma2 < 0.0 {
1074            return 0.0;
1075        }
1076        let term_tau = (tau12 / self.s21).powi(2);
1077        let factor = 1.0 - p_tt * self.yt / self.s21;
1078        let term_sig = (factor * sigma2 / self.yt).powi(2);
1079        (term_tau + term_sig).sqrt() + p_tt * sigma2 / self.s21
1080    }
1081}
1082/// A 3×3 rotation matrix representing a crystal symmetry operation.
1083#[derive(Debug, Clone, Copy)]
1084pub struct SymmetryOperation {
1085    /// The 3×3 rotation/reflection matrix.
1086    pub matrix: [[f64; 3]; 3],
1087}
1088impl SymmetryOperation {
1089    /// Create from a 3×3 matrix.
1090    pub fn new(matrix: [[f64; 3]; 3]) -> Self {
1091        Self { matrix }
1092    }
1093    /// Identity operation (no transformation).
1094    pub fn identity() -> Self {
1095        Self::new([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])
1096    }
1097    /// 180° rotation about the z-axis.
1098    pub fn c2_z() -> Self {
1099        Self::new([[-1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, 1.0]])
1100    }
1101    /// Inversion (centrosymmetric operation).
1102    pub fn inversion() -> Self {
1103        Self::new([[-1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, -1.0]])
1104    }
1105    /// Mirror plane perpendicular to z (σh).
1106    pub fn mirror_z() -> Self {
1107        Self::new([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]])
1108    }
1109    /// Apply this operation to a 3-vector.
1110    #[allow(clippy::needless_range_loop)]
1111    pub fn apply(&self, v: [f64; 3]) -> [f64; 3] {
1112        let mut out = [0.0f64; 3];
1113        for i in 0..3 {
1114            for j in 0..3 {
1115                out[i] += self.matrix[i][j] * v[j];
1116            }
1117        }
1118        out
1119    }
1120    /// Compose this operation with another: `self * other`.
1121    #[allow(clippy::needless_range_loop)]
1122    pub fn compose(&self, other: &SymmetryOperation) -> SymmetryOperation {
1123        let mut m = [[0.0f64; 3]; 3];
1124        for i in 0..3 {
1125            for j in 0..3 {
1126                for k in 0..3 {
1127                    m[i][j] += self.matrix[i][k] * other.matrix[k][j];
1128                }
1129            }
1130        }
1131        SymmetryOperation::new(m)
1132    }
1133    /// Determinant of the matrix (+1 = proper rotation, -1 = improper).
1134    pub fn determinant(&self) -> f64 {
1135        let m = &self.matrix;
1136        m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
1137            - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
1138            + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
1139    }
1140    /// Returns `true` if the operation is a proper rotation (det = +1).
1141    pub fn is_proper_rotation(&self) -> bool {
1142        (self.determinant() - 1.0).abs() < 1e-10
1143    }
1144}