Skip to main content

oxiphysics_geometry/sphere_packing/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#![allow(clippy::needless_range_loop)]
6#[allow(unused_imports)]
7use super::functions::*;
8use rand::Rng;
9use rand::RngExt;
10
11/// Random sequential adsorption (RSA) packing configuration.
12#[derive(Debug, Clone)]
13pub struct RsaConfig {
14    /// Sphere radius.
15    pub radius: f64,
16    /// Domain dimensions \[lx, ly, lz\].
17    pub domain: [f64; 3],
18    /// Maximum number of attempts before declaring jamming.
19    pub max_attempts: usize,
20    /// Minimum gap between spheres (m). Usually 0.
21    pub min_gap: f64,
22}
23/// Polydisperse sphere packing with various size distributions.
24pub struct PolyDispersePacking {
25    /// Distribution type.
26    pub distribution: SizeDistribution,
27    /// Distribution parameters.
28    pub params: SizeDistributionParams,
29    /// Domain dimensions.
30    pub domain: [f64; 3],
31    /// Placed spheres.
32    pub spheres: Vec<PackingSphere>,
33    /// Maximum placement attempts.
34    pub max_attempts: usize,
35}
36impl PolyDispersePacking {
37    /// Create a new polydisperse packing.
38    pub fn new(
39        distribution: SizeDistribution,
40        params: SizeDistributionParams,
41        domain: [f64; 3],
42        max_attempts: usize,
43    ) -> Self {
44        Self {
45            distribution,
46            params,
47            domain,
48            spheres: Vec::new(),
49            max_attempts,
50        }
51    }
52    /// Sample a radius from the configured distribution.
53    pub fn sample_radius(&self, rng: &mut impl Rng) -> f64 {
54        match self.distribution {
55            SizeDistribution::Monodisperse => self.params.mean_radius,
56            SizeDistribution::Bidisperse => {
57                if rng.random::<f64>() < self.params.large_fraction {
58                    self.params.mean_radius * self.params.size_ratio
59                } else {
60                    self.params.mean_radius
61                }
62            }
63            SizeDistribution::LogNormal => {
64                let mu = self.params.mean_radius.ln();
65                let sigma = self.params.std_radius;
66                let z = self.normal_sample(rng);
67                let r = (mu + sigma * z).exp();
68                r.clamp(self.params.min_radius, self.params.max_radius)
69            }
70            SizeDistribution::Gaussian => {
71                let r = self.params.mean_radius + self.params.std_radius * self.normal_sample(rng);
72                r.clamp(self.params.min_radius, self.params.max_radius)
73            }
74            SizeDistribution::PowerLaw => {
75                let alpha = self.params.power_law_exponent;
76                let r_min = self.params.min_radius;
77                let r_max = self.params.max_radius;
78                let u: f64 = rng.random();
79                if (alpha + 1.0).abs() < 1e-12 {
80                    r_min * (r_max / r_min).powf(u)
81                } else {
82                    let p = alpha + 1.0;
83                    (r_min.powf(p) + u * (r_max.powf(p) - r_min.powf(p))).powf(1.0 / p)
84                }
85            }
86        }
87    }
88    /// Box-Muller normal sample.
89    fn normal_sample(&self, rng: &mut impl Rng) -> f64 {
90        let u1: f64 = rng.random::<f64>().max(1e-300);
91        let u2: f64 = rng.random();
92        (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
93    }
94    /// Check if a sphere with given center and radius can be placed.
95    pub fn is_valid_placement(&self, center: [f64; 3], radius: f64) -> bool {
96        let [lx, ly, lz] = self.domain;
97        if center[0] - radius < 0.0
98            || center[0] + radius > lx
99            || center[1] - radius < 0.0
100            || center[1] + radius > ly
101            || center[2] - radius < 0.0
102            || center[2] + radius > lz
103        {
104            return false;
105        }
106        let candidate = PackingSphere::new(center, radius, 0);
107        for s in &self.spheres {
108            if s.overlaps(&candidate) {
109                return false;
110            }
111        }
112        true
113    }
114    /// Run RSA with polydisperse radii.
115    ///
116    /// Returns the number of spheres placed.
117    pub fn run(&mut self, n_spheres: usize) -> usize {
118        let mut rng = rand::rng();
119        let mut attempts = 0;
120        while self.spheres.len() < n_spheres && attempts < self.max_attempts {
121            let r = self.sample_radius(&mut rng);
122            let [lx, ly, lz] = self.domain;
123            let cx = rng.random_range(r..(lx - r).max(r + 1e-12));
124            let cy = rng.random_range(r..(ly - r).max(r + 1e-12));
125            let cz = rng.random_range(r..(lz - r).max(r + 1e-12));
126            if self.is_valid_placement([cx, cy, cz], r) {
127                let id = self.spheres.len();
128                self.spheres.push(PackingSphere::new([cx, cy, cz], r, id));
129            }
130            attempts += 1;
131        }
132        self.spheres.len()
133    }
134    /// Compute the packing fraction.
135    pub fn packing_fraction(&self) -> f64 {
136        let [lx, ly, lz] = self.domain;
137        let vol = lx * ly * lz;
138        let sv: f64 = self.spheres.iter().map(|s| s.volume()).sum();
139        sv / vol
140    }
141    /// Return radius statistics: (mean, std, min, max).
142    pub fn radius_statistics(&self) -> (f64, f64, f64, f64) {
143        if self.spheres.is_empty() {
144            return (0.0, 0.0, 0.0, 0.0);
145        }
146        let radii: Vec<f64> = self.spheres.iter().map(|s| s.radius).collect();
147        let n = radii.len() as f64;
148        let mean = radii.iter().sum::<f64>() / n;
149        let variance = radii.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / n;
150        let std = variance.sqrt();
151        let min = radii.iter().cloned().fold(f64::INFINITY, f64::min);
152        let max = radii.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
153        (mean, std, min, max)
154    }
155    /// Compute the polydispersity index (PDI): std/mean.
156    pub fn polydispersity_index(&self) -> f64 {
157        let (mean, std, _, _) = self.radius_statistics();
158        if mean > 1e-30 { std / mean } else { 0.0 }
159    }
160    /// Compute the size distribution histogram.
161    ///
162    /// Returns (bin_centers, counts).
163    pub fn size_histogram(&self, n_bins: usize) -> (Vec<f64>, Vec<usize>) {
164        if self.spheres.is_empty() || n_bins == 0 {
165            return (Vec::new(), Vec::new());
166        }
167        let (_, _, min_r, max_r) = self.radius_statistics();
168        let bin_width = (max_r - min_r) / n_bins as f64;
169        if bin_width < 1e-30 {
170            let centers = vec![min_r; n_bins];
171            let mut counts = vec![0usize; n_bins];
172            counts[0] = self.spheres.len();
173            return (centers, counts);
174        }
175        let mut counts = vec![0usize; n_bins];
176        for s in &self.spheres {
177            let bin = ((s.radius - min_r) / bin_width).floor() as usize;
178            let bin = bin.min(n_bins - 1);
179            counts[bin] += 1;
180        }
181        let centers: Vec<f64> = (0..n_bins)
182            .map(|i| min_r + (i as f64 + 0.5) * bin_width)
183            .collect();
184        (centers, counts)
185    }
186}
187/// Void space analysis for sphere packings.
188///
189/// Analyses the pore space between spheres, computing pore size distributions,
190/// percolation thresholds, and void connectivity.
191pub struct VoidSpaceAnalysis {
192    /// Spheres in the packing.
193    pub spheres: Vec<PackingSphere>,
194    /// Domain dimensions.
195    pub domain: [f64; 3],
196    /// Grid resolution for voxelization.
197    pub grid_resolution: usize,
198}
199impl VoidSpaceAnalysis {
200    /// Create a new void space analyzer.
201    pub fn new(spheres: Vec<PackingSphere>, domain: [f64; 3], grid_resolution: usize) -> Self {
202        Self {
203            spheres,
204            domain,
205            grid_resolution,
206        }
207    }
208    /// Check if a point is inside any sphere.
209    pub fn point_in_sphere(&self, pt: [f64; 3]) -> bool {
210        for s in &self.spheres {
211            let dx = pt[0] - s.center[0];
212            let dy = pt[1] - s.center[1];
213            let dz = pt[2] - s.center[2];
214            if dx * dx + dy * dy + dz * dz <= s.radius * s.radius {
215                return true;
216            }
217        }
218        false
219    }
220    /// Compute the void fraction by voxelization.
221    pub fn void_fraction_by_voxelization(&self) -> f64 {
222        let n = self.grid_resolution;
223        let [lx, ly, lz] = self.domain;
224        let dx = lx / n as f64;
225        let dy = ly / n as f64;
226        let dz = lz / n as f64;
227        let mut void_count = 0usize;
228        let total = n * n * n;
229        for ix in 0..n {
230            for iy in 0..n {
231                for iz in 0..n {
232                    let pt = [
233                        (ix as f64 + 0.5) * dx,
234                        (iy as f64 + 0.5) * dy,
235                        (iz as f64 + 0.5) * dz,
236                    ];
237                    if !self.point_in_sphere(pt) {
238                        void_count += 1;
239                    }
240                }
241            }
242        }
243        void_count as f64 / total as f64
244    }
245    /// Compute the distance from point `pt` to the nearest sphere surface.
246    ///
247    /// Returns the maximum inscribed sphere radius at this point (pore radius).
248    pub fn pore_radius_at(&self, pt: [f64; 3]) -> f64 {
249        if self.point_in_sphere(pt) {
250            return 0.0;
251        }
252        let mut min_dist_to_surface = f64::INFINITY;
253        for s in &self.spheres {
254            let dx = pt[0] - s.center[0];
255            let dy = pt[1] - s.center[1];
256            let dz = pt[2] - s.center[2];
257            let dist_to_center = (dx * dx + dy * dy + dz * dz).sqrt();
258            let dist_to_surface = (dist_to_center - s.radius).max(0.0);
259            min_dist_to_surface = min_dist_to_surface.min(dist_to_surface);
260        }
261        let [lx, ly, lz] = self.domain;
262        let wall_dist = [pt[0], lx - pt[0], pt[1], ly - pt[1], pt[2], lz - pt[2]]
263            .iter()
264            .cloned()
265            .fold(f64::INFINITY, f64::min);
266        min_dist_to_surface.min(wall_dist)
267    }
268    /// Compute the pore size distribution using random sampling.
269    ///
270    /// Samples `n_samples` random points in the void space and
271    /// returns a histogram of pore radii.
272    pub fn pore_size_distribution(&self, n_samples: usize, n_bins: usize) -> Vec<(f64, usize)> {
273        let mut rng = rand::rng();
274        let [lx, ly, lz] = self.domain;
275        let mut pore_radii = Vec::new();
276        for _ in 0..n_samples {
277            let pt = [
278                rng.random_range(0.0..lx),
279                rng.random_range(0.0..ly),
280                rng.random_range(0.0..lz),
281            ];
282            if !self.point_in_sphere(pt) {
283                pore_radii.push(self.pore_radius_at(pt));
284            }
285        }
286        if pore_radii.is_empty() || n_bins == 0 {
287            return Vec::new();
288        }
289        let r_min = pore_radii.iter().cloned().fold(f64::INFINITY, f64::min);
290        let r_max = pore_radii.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
291        let bin_width = (r_max - r_min) / n_bins as f64;
292        if bin_width < 1e-30 {
293            return vec![(r_min, pore_radii.len())];
294        }
295        let mut counts = vec![0usize; n_bins];
296        for &r in &pore_radii {
297            let bin = ((r - r_min) / bin_width).floor() as usize;
298            counts[bin.min(n_bins - 1)] += 1;
299        }
300        (0..n_bins)
301            .map(|i| (r_min + (i as f64 + 0.5) * bin_width, counts[i]))
302            .collect()
303    }
304    /// Estimate the percolation threshold.
305    ///
306    /// Uses the void fraction as a proxy: percolation typically occurs
307    /// when void fraction > 0.32 in 3D.
308    pub fn estimate_percolation_threshold(&self) -> f64 {
309        0.3116
310    }
311    /// Check if the packing is above the percolation threshold.
312    pub fn is_percolating(&self, void_fraction: f64) -> bool {
313        void_fraction > self.estimate_percolation_threshold()
314    }
315    /// Compute the specific surface area (surface area per unit volume).
316    pub fn specific_surface_area(&self) -> f64 {
317        let [lx, ly, lz] = self.domain;
318        let vol = lx * ly * lz;
319        let sa: f64 = self
320            .spheres
321            .iter()
322            .map(|s| 4.0 * std::f64::consts::PI * s.radius * s.radius)
323            .sum();
324        sa / vol
325    }
326    /// Perform a full analysis.
327    pub fn analyze(&self, n_psd_samples: usize, n_bins: usize) -> VoidSpaceResult {
328        let void_frac = self.void_fraction_by_voxelization();
329        let [lx, ly, lz] = self.domain;
330        let void_vol = void_frac * lx * ly * lz;
331        let psd = self.pore_size_distribution(n_psd_samples, n_bins);
332        VoidSpaceResult {
333            pore_size_distribution: psd,
334            percolation_threshold: self.estimate_percolation_threshold(),
335            void_volume: void_vol,
336            void_fraction: void_frac,
337            connectivity: if void_frac > 0.32 { 1.0 } else { 0.0 },
338        }
339    }
340}
341/// Sphere packing inside various confining geometries.
342///
343/// Wall effects (reduced packing near walls) and boundary layers
344/// are naturally captured by the placement algorithm.
345pub struct ConfiningGeometry {
346    /// Confinement configuration.
347    pub config: ConfinementConfig,
348    /// Placed spheres.
349    pub spheres: Vec<PackingSphere>,
350    /// Number of rejected attempts.
351    pub n_rejected: usize,
352}
353impl ConfiningGeometry {
354    /// Create a new confined packing.
355    pub fn new(config: ConfinementConfig) -> Self {
356        Self {
357            config,
358            spheres: Vec::new(),
359            n_rejected: 0,
360        }
361    }
362    /// Check if a point is inside the container (accounting for sphere radius).
363    pub fn point_inside_container(&self, center: [f64; 3], radius: f64) -> bool {
364        match self.config.shape {
365            ConfinementShape::Box => {
366                let [hx, hy, hz] = self.config.dimensions;
367                center[0] - radius >= -hx
368                    && center[0] + radius <= hx
369                    && center[1] - radius >= -hy
370                    && center[1] + radius <= hy
371                    && center[2] - radius >= -hz
372                    && center[2] + radius <= hz
373            }
374            ConfinementShape::Cylinder => {
375                let r_cyl = self.config.dimensions[0];
376                let h_cyl = self.config.dimensions[1];
377                let r2 = center[0] * center[0] + center[1] * center[1];
378                r2.sqrt() + radius <= r_cyl
379                    && center[2] - radius >= -h_cyl
380                    && center[2] + radius <= h_cyl
381            }
382            ConfinementShape::SphericalContainer => {
383                let r_cont = self.config.dimensions[0];
384                let dist =
385                    (center[0] * center[0] + center[1] * center[1] + center[2] * center[2]).sqrt();
386                dist + radius <= r_cont
387            }
388        }
389    }
390    /// Sample a random point inside the container.
391    pub fn random_point_in_container(&self, rng: &mut impl Rng) -> [f64; 3] {
392        match self.config.shape {
393            ConfinementShape::Box => {
394                let [hx, hy, hz] = self.config.dimensions;
395                [
396                    rng.random_range(-hx..hx),
397                    rng.random_range(-hy..hy),
398                    rng.random_range(-hz..hz),
399                ]
400            }
401            ConfinementShape::Cylinder => {
402                let r_cyl = self.config.dimensions[0];
403                let h_cyl = self.config.dimensions[1];
404                loop {
405                    let x = rng.random_range(-r_cyl..r_cyl);
406                    let y = rng.random_range(-r_cyl..r_cyl);
407                    let z = rng.random_range(-h_cyl..h_cyl);
408                    if (x * x + y * y).sqrt() <= r_cyl {
409                        return [x, y, z];
410                    }
411                }
412            }
413            ConfinementShape::SphericalContainer => {
414                let r_cont = self.config.dimensions[0];
415                loop {
416                    let x = rng.random_range(-r_cont..r_cont);
417                    let y = rng.random_range(-r_cont..r_cont);
418                    let z = rng.random_range(-r_cont..r_cont);
419                    if (x * x + y * y + z * z).sqrt() <= r_cont {
420                        return [x, y, z];
421                    }
422                }
423            }
424        }
425    }
426    /// Check if a candidate sphere overlaps any placed sphere.
427    fn has_overlap(&self, center: [f64; 3], radius: f64) -> bool {
428        let candidate = PackingSphere::new(center, radius, 0);
429        for s in &self.spheres {
430            if s.overlaps(&candidate) {
431                return true;
432            }
433        }
434        false
435    }
436    /// Try to place a sphere at a random location inside the container.
437    pub fn try_place(&mut self, rng: &mut impl Rng) -> bool {
438        let r = self.config.sphere_radius;
439        let center = self.random_point_in_container(rng);
440        if self.point_inside_container(center, r) && !self.has_overlap(center, r) {
441            let id = self.spheres.len();
442            self.spheres.push(PackingSphere::new(center, r, id));
443            true
444        } else {
445            self.n_rejected += 1;
446            false
447        }
448    }
449    /// Run packing until jamming.
450    pub fn run(&mut self) -> usize {
451        let mut rng = rand::rng();
452        let mut consecutive_failures = 0;
453        loop {
454            if self.try_place(&mut rng) {
455                consecutive_failures = 0;
456            } else {
457                consecutive_failures += 1;
458                if consecutive_failures >= self.config.max_attempts {
459                    break;
460                }
461            }
462        }
463        self.spheres.len()
464    }
465    /// Compute the container volume.
466    pub fn container_volume(&self) -> f64 {
467        match self.config.shape {
468            ConfinementShape::Box => {
469                let [hx, hy, hz] = self.config.dimensions;
470                8.0 * hx * hy * hz
471            }
472            ConfinementShape::Cylinder => {
473                let r = self.config.dimensions[0];
474                let h = self.config.dimensions[1];
475                std::f64::consts::PI * r * r * 2.0 * h
476            }
477            ConfinementShape::SphericalContainer => {
478                let r = self.config.dimensions[0];
479                4.0 / 3.0 * std::f64::consts::PI * r * r * r
480            }
481        }
482    }
483    /// Compute the packing fraction.
484    pub fn packing_fraction(&self) -> f64 {
485        let vol = self.container_volume();
486        let sv: f64 = self.spheres.iter().map(|s| s.volume()).sum();
487        if vol > 1e-30 { sv / vol } else { 0.0 }
488    }
489    /// Compute the boundary layer fraction.
490    ///
491    /// Returns the fraction of spheres within one sphere diameter of the wall.
492    pub fn boundary_layer_fraction(&self) -> f64 {
493        if self.spheres.is_empty() {
494            return 0.0;
495        }
496        let r = self.config.sphere_radius;
497        let layer_thickness = 2.0 * r;
498        let n_boundary = self
499            .spheres
500            .iter()
501            .filter(|s| match self.config.shape {
502                ConfinementShape::Box => {
503                    let [hx, hy, hz] = self.config.dimensions;
504                    let dist_x = hx - s.center[0].abs();
505                    let dist_y = hy - s.center[1].abs();
506                    let dist_z = hz - s.center[2].abs();
507                    [dist_x, dist_y, dist_z]
508                        .iter()
509                        .any(|&d| d <= layer_thickness)
510                }
511                ConfinementShape::Cylinder => {
512                    let r_cyl = self.config.dimensions[0];
513                    let h_cyl = self.config.dimensions[1];
514                    let dist_r =
515                        r_cyl - (s.center[0] * s.center[0] + s.center[1] * s.center[1]).sqrt();
516                    let dist_z = h_cyl - s.center[2].abs();
517                    dist_r <= layer_thickness || dist_z <= layer_thickness
518                }
519                ConfinementShape::SphericalContainer => {
520                    let r_cont = self.config.dimensions[0];
521                    let dist_r = r_cont
522                        - (s.center[0] * s.center[0]
523                            + s.center[1] * s.center[1]
524                            + s.center[2] * s.center[2])
525                            .sqrt();
526                    dist_r <= layer_thickness
527                }
528            })
529            .count();
530        n_boundary as f64 / self.spheres.len() as f64
531    }
532    /// Compute the radial distribution function g(r) for packing in a spherical container.
533    ///
534    /// Returns (r_values, g_r) histogram.
535    pub fn radial_distribution_function(&self, n_bins: usize, r_max: f64) -> (Vec<f64>, Vec<f64>) {
536        let n = self.spheres.len();
537        if n < 2 || n_bins == 0 {
538            return (Vec::new(), Vec::new());
539        }
540        let dr = r_max / n_bins as f64;
541        let mut hist = vec![0usize; n_bins];
542        let mut n_pairs = 0usize;
543        for i in 0..n {
544            for j in (i + 1)..n {
545                let dist = self.spheres[i].distance_to(&self.spheres[j]);
546                if dist < r_max {
547                    let bin = (dist / dr).floor() as usize;
548                    if bin < n_bins {
549                        hist[bin] += 2;
550                        n_pairs += 1;
551                    }
552                }
553            }
554        }
555        let vol = self.container_volume();
556        let rho = n as f64 / vol;
557        let r_values: Vec<f64> = (0..n_bins).map(|i| (i as f64 + 0.5) * dr).collect();
558        let g_r: Vec<f64> = (0..n_bins)
559            .map(|i| {
560                let r = r_values[i];
561                let shell_vol = 4.0 * std::f64::consts::PI * r * r * dr;
562                let expected = rho * shell_vol * n as f64;
563                if expected > 1e-30 {
564                    hist[i] as f64 / expected
565                } else {
566                    0.0
567                }
568            })
569            .collect();
570        let _ = n_pairs;
571        (r_values, g_r)
572    }
573}
574/// Result of a void space analysis.
575#[derive(Debug, Clone)]
576pub struct VoidSpaceResult {
577    /// Pore size distribution: (radius, count) pairs.
578    pub pore_size_distribution: Vec<(f64, usize)>,
579    /// Estimated percolation threshold (porosity fraction).
580    pub percolation_threshold: f64,
581    /// Total void volume.
582    pub void_volume: f64,
583    /// Void fraction (porosity).
584    pub void_fraction: f64,
585    /// Connectivity (fraction of void grid points connected to inlet).
586    pub connectivity: f64,
587}
588/// Parameters for a polydisperse size distribution.
589#[derive(Debug, Clone)]
590pub struct SizeDistributionParams {
591    /// Mean radius (m).
592    pub mean_radius: f64,
593    /// Standard deviation or spread parameter.
594    pub std_radius: f64,
595    /// Minimum allowed radius (m).
596    pub min_radius: f64,
597    /// Maximum allowed radius (m).
598    pub max_radius: f64,
599    /// Size ratio for bidisperse (large/small).
600    pub size_ratio: f64,
601    /// Volume fraction of large spheres in bidisperse.
602    pub large_fraction: f64,
603    /// Power-law exponent.
604    pub power_law_exponent: f64,
605}
606/// Configuration for packing in a confining geometry.
607#[derive(Debug, Clone)]
608pub struct ConfinementConfig {
609    /// Shape of the container.
610    pub shape: ConfinementShape,
611    /// Container dimensions: \[half_x, half_y, half_z\] for box,
612    /// \[radius, height, 0\] for cylinder, \[radius, 0, 0\] for sphere.
613    pub dimensions: [f64; 3],
614    /// Sphere radius for packing.
615    pub sphere_radius: f64,
616    /// Maximum placement attempts.
617    pub max_attempts: usize,
618}
619/// Random packing via Random Sequential Adsorption (RSA).
620///
621/// In RSA, spheres are placed one at a time at random positions,
622/// accepted only if they do not overlap with already-placed spheres.
623/// The process terminates at the jamming limit (~64% for equal spheres in 3D).
624pub struct RandomPacking {
625    /// RSA configuration.
626    pub config: RsaConfig,
627    /// Placed spheres.
628    pub spheres: Vec<PackingSphere>,
629    /// Number of rejected attempts.
630    pub n_rejected: usize,
631    /// Whether the packing has reached the jamming limit.
632    pub jammed: bool,
633}
634impl RandomPacking {
635    /// Create a new RSA packing system.
636    pub fn new(config: RsaConfig) -> Self {
637        Self {
638            config,
639            spheres: Vec::new(),
640            n_rejected: 0,
641            jammed: false,
642        }
643    }
644    /// Attempt to place one sphere at a random location.
645    ///
646    /// Returns `true` if placement succeeded.
647    pub fn try_place_sphere(&mut self, rng: &mut impl Rng) -> bool {
648        let r = self.config.radius + self.config.min_gap;
649        let [lx, ly, lz] = self.config.domain;
650        let cx = rng.random_range(r..(lx - r).max(r + 1e-12));
651        let cy = rng.random_range(r..(ly - r).max(r + 1e-12));
652        let cz = rng.random_range(r..(lz - r).max(r + 1e-12));
653        let candidate = PackingSphere::new([cx, cy, cz], self.config.radius, self.spheres.len());
654        if self.is_valid_placement(&candidate) {
655            self.spheres.push(candidate);
656            true
657        } else {
658            self.n_rejected += 1;
659            false
660        }
661    }
662    /// Check if a sphere can be placed without overlap.
663    pub fn is_valid_placement(&self, candidate: &PackingSphere) -> bool {
664        let gap = self.config.min_gap;
665        for s in &self.spheres {
666            let min_dist = s.radius + candidate.radius + gap;
667            let dx = s.center[0] - candidate.center[0];
668            let dy = s.center[1] - candidate.center[1];
669            let dz = s.center[2] - candidate.center[2];
670            if dx * dx + dy * dy + dz * dz < min_dist * min_dist {
671                return false;
672            }
673        }
674        true
675    }
676    /// Run the RSA algorithm until jammed.
677    ///
678    /// Returns the number of spheres placed.
679    pub fn run(&mut self) -> usize {
680        let mut rng = rand::rng();
681        let mut consecutive_failures = 0;
682        let jam_threshold = self.config.max_attempts;
683        loop {
684            if self.try_place_sphere(&mut rng) {
685                consecutive_failures = 0;
686            } else {
687                consecutive_failures += 1;
688                if consecutive_failures >= jam_threshold {
689                    self.jammed = true;
690                    break;
691                }
692            }
693        }
694        self.spheres.len()
695    }
696    /// Compute the current packing fraction (volume fraction).
697    pub fn packing_fraction(&self) -> f64 {
698        let [lx, ly, lz] = self.config.domain;
699        let domain_volume = lx * ly * lz;
700        let sphere_volume: f64 = self.spheres.iter().map(|s| s.volume()).sum();
701        sphere_volume / domain_volume
702    }
703    /// Compute the theoretical jamming limit for monodisperse RSA in 3D.
704    ///
705    /// The RSA jamming limit in 3D is approximately 0.3841.
706    pub fn theoretical_jamming_limit() -> f64 {
707        0.3841
708    }
709    /// Return the number of contacts (overlapping pairs) in the packing.
710    pub fn count_contacts(&self, tolerance: f64) -> usize {
711        let mut count = 0;
712        for i in 0..self.spheres.len() {
713            for j in (i + 1)..self.spheres.len() {
714                let gap = self.spheres[i].overlap_with(&self.spheres[j]);
715                if gap.abs() < tolerance {
716                    count += 1;
717                }
718            }
719        }
720        count
721    }
722    /// Place spheres at specific locations (for testing).
723    pub fn place_at(&mut self, center: [f64; 3]) -> bool {
724        let candidate = PackingSphere::new(center, self.config.radius, self.spheres.len());
725        if self.is_valid_placement(&candidate) {
726            self.spheres.push(candidate);
727            true
728        } else {
729            false
730        }
731    }
732}
733/// Ordered sphere packing on crystal lattices.
734pub struct OrderedPacking {
735    /// Lattice type.
736    pub lattice: LatticeType,
737    /// Sphere radius.
738    pub radius: f64,
739    /// Lattice parameter (edge length of unit cell).
740    pub lattice_parameter: f64,
741    /// Number of unit cells in each direction.
742    pub n_cells: [usize; 3],
743    /// Generated sphere positions.
744    pub spheres: Vec<PackingSphere>,
745}
746impl OrderedPacking {
747    /// Create a new ordered packing.
748    ///
749    /// The lattice parameter is set so spheres are touching:
750    /// - FCC: `a = 2*r*sqrt(2)`
751    /// - BCC: `a = 4*r/sqrt(3)`
752    /// - SC:  `a = 2*r`
753    pub fn new(lattice: LatticeType, radius: f64, n_cells: [usize; 3]) -> Self {
754        let a = match lattice {
755            LatticeType::Fcc | LatticeType::Hcp => 2.0 * radius * 2.0_f64.sqrt(),
756            LatticeType::Bcc => 4.0 * radius / 3.0_f64.sqrt(),
757            LatticeType::SimpleCubic => 2.0 * radius,
758        };
759        Self {
760            lattice,
761            radius,
762            lattice_parameter: a,
763            n_cells,
764            spheres: Vec::new(),
765        }
766    }
767    /// Generate all sphere positions for the lattice.
768    pub fn generate(&mut self) {
769        self.spheres.clear();
770        let a = self.lattice_parameter;
771        let [nx, ny, nz] = self.n_cells;
772        let basis: &[[f64; 3]] = match self.lattice {
773            LatticeType::Fcc => &[
774                [0.0, 0.0, 0.0],
775                [0.5, 0.5, 0.0],
776                [0.5, 0.0, 0.5],
777                [0.0, 0.5, 0.5],
778            ],
779            LatticeType::Hcp => &[
780                [0.0, 0.0, 0.0],
781                [0.5, 0.5, 0.0],
782                [0.0, 1.0 / 3.0, 0.5],
783                [0.5, 5.0 / 6.0, 0.5],
784            ],
785            LatticeType::Bcc => &[[0.0, 0.0, 0.0], [0.5, 0.5, 0.5]],
786            LatticeType::SimpleCubic => &[[0.0, 0.0, 0.0]],
787        };
788        for ix in 0..nx {
789            for iy in 0..ny {
790                for iz in 0..nz {
791                    for b in basis {
792                        let cx = (ix as f64 + b[0]) * a;
793                        let cy = (iy as f64 + b[1]) * a;
794                        let cz = (iz as f64 + b[2]) * a;
795                        let id = self.spheres.len();
796                        self.spheres
797                            .push(PackingSphere::new([cx, cy, cz], self.radius, id));
798                    }
799                }
800            }
801        }
802    }
803    /// Theoretical packing efficiency for the lattice type.
804    pub fn theoretical_packing_fraction(&self) -> f64 {
805        match self.lattice {
806            LatticeType::Fcc | LatticeType::Hcp => std::f64::consts::PI / (3.0 * 2.0_f64.sqrt()),
807            LatticeType::Bcc => std::f64::consts::PI * 3.0_f64.sqrt() / 8.0,
808            LatticeType::SimpleCubic => std::f64::consts::PI / 6.0,
809        }
810    }
811    /// Coordination number (number of nearest neighbors) for the lattice.
812    pub fn coordination_number(&self) -> usize {
813        match self.lattice {
814            LatticeType::Fcc | LatticeType::Hcp => 12,
815            LatticeType::Bcc => 8,
816            LatticeType::SimpleCubic => 6,
817        }
818    }
819    /// Compute the packing fraction from generated spheres.
820    pub fn actual_packing_fraction(&self) -> f64 {
821        if self.spheres.is_empty() {
822            return 0.0;
823        }
824        let [nx, ny, nz] = self.n_cells;
825        let a = self.lattice_parameter;
826        let volume = (nx as f64 * a) * (ny as f64 * a) * (nz as f64 * a);
827        let sphere_vol: f64 = self.spheres.iter().map(|s| s.volume()).sum();
828        sphere_vol / volume
829    }
830    /// Return the nearest-neighbor distance for the lattice.
831    pub fn nearest_neighbor_distance(&self) -> f64 {
832        2.0 * self.radius
833    }
834    /// Find neighbors within a given cutoff distance for a sphere index.
835    pub fn find_neighbors(&self, idx: usize, cutoff: f64) -> Vec<usize> {
836        let s = &self.spheres[idx];
837        self.spheres
838            .iter()
839            .enumerate()
840            .filter(|(i, other)| *i != idx && s.distance_to(other) <= cutoff)
841            .map(|(i, _)| i)
842            .collect()
843    }
844}
845/// Particle size distribution type.
846#[derive(Debug, Clone, Copy, PartialEq)]
847pub enum SizeDistribution {
848    /// Monodisperse: all spheres have the same radius.
849    Monodisperse,
850    /// Bidisperse: two sizes with a size ratio.
851    Bidisperse,
852    /// Log-normal distribution.
853    LogNormal,
854    /// Gaussian (truncated) distribution.
855    Gaussian,
856    /// Power-law distribution.
857    PowerLaw,
858}
859/// Configuration for the force-based packing optimizer.
860#[derive(Debug, Clone)]
861pub struct OptimizerConfig {
862    /// Number of optimization iterations.
863    pub max_iterations: usize,
864    /// Time step for displacement updates.
865    pub time_step: f64,
866    /// Convergence tolerance (max overlap).
867    pub tolerance: f64,
868    /// Damping coefficient to dissipate kinetic energy.
869    pub damping: f64,
870    /// Repulsion stiffness.
871    pub stiffness: f64,
872}
873/// Crystal lattice type for ordered sphere packing.
874#[derive(Debug, Clone, Copy, PartialEq)]
875pub enum LatticeType {
876    /// Face-centered cubic (FCC): packing fraction π/(3√2) ≈ 0.7405.
877    Fcc,
878    /// Hexagonally close-packed (HCP): same fraction as FCC ≈ 0.7405.
879    Hcp,
880    /// Body-centered cubic (BCC): packing fraction π√3/8 ≈ 0.6802.
881    Bcc,
882    /// Simple cubic (SC): packing fraction π/6 ≈ 0.5236.
883    SimpleCubic,
884}
885/// Force-based packing optimizer.
886///
887/// Uses a soft-sphere repulsive potential to relax overlapping spheres
888/// toward a mechanically stable configuration.
889pub struct PackingOptimizer {
890    /// Optimizer configuration.
891    pub config: OptimizerConfig,
892    /// Spheres being optimized.
893    pub spheres: Vec<PackingSphere>,
894    /// Domain bounds.
895    pub domain: [f64; 3],
896    /// Convergence history (max overlap at each iteration).
897    pub convergence: Vec<f64>,
898    /// Whether the optimizer has converged.
899    pub converged: bool,
900}
901impl PackingOptimizer {
902    /// Create a new optimizer.
903    pub fn new(spheres: Vec<PackingSphere>, domain: [f64; 3], config: OptimizerConfig) -> Self {
904        Self {
905            config,
906            spheres,
907            domain,
908            convergence: Vec::new(),
909            converged: false,
910        }
911    }
912    /// Compute the repulsive force on sphere `i` due to sphere `j`.
913    ///
914    /// Linear spring force: `f = k * overlap * normal`.
915    fn pair_force(&self, i: usize, j: usize) -> [f64; 3] {
916        let si = &self.spheres[i];
917        let sj = &self.spheres[j];
918        let dx = si.center[0] - sj.center[0];
919        let dy = si.center[1] - sj.center[1];
920        let dz = si.center[2] - sj.center[2];
921        let dist = (dx * dx + dy * dy + dz * dz).sqrt();
922        let min_dist = si.radius + sj.radius;
923        if dist < min_dist && dist > 1e-30 {
924            let overlap = min_dist - dist;
925            let k = self.config.stiffness;
926            let f = k * overlap / dist;
927            [f * dx, f * dy, f * dz]
928        } else {
929            [0.0, 0.0, 0.0]
930        }
931    }
932    /// Compute wall repulsion force to keep sphere inside domain.
933    fn wall_force(&self, i: usize) -> [f64; 3] {
934        let s = &self.spheres[i];
935        let k = self.config.stiffness;
936        let [lx, ly, lz] = self.domain;
937        let mut f = [0.0f64; 3];
938        let dims = [
939            (s.center[0], 0.0, lx),
940            (s.center[1], 0.0, ly),
941            (s.center[2], 0.0, lz),
942        ];
943        for (d, (c, lo, hi)) in dims.iter().enumerate() {
944            if c - s.radius < *lo {
945                f[d] += k * (lo + s.radius - c);
946            }
947            if c + s.radius > *hi {
948                f[d] -= k * (c + s.radius - hi);
949            }
950        }
951        f
952    }
953    /// Perform one optimization step.
954    ///
955    /// Returns the maximum overlap after the step.
956    pub fn step(&mut self) -> f64 {
957        let n = self.spheres.len();
958        let mut forces = vec![[0.0f64; 3]; n];
959        for i in 0..n {
960            for j in (i + 1)..n {
961                let f = self.pair_force(i, j);
962                for d in 0..3 {
963                    forces[i][d] += f[d];
964                    forces[j][d] -= f[d];
965                }
966            }
967            let wf = self.wall_force(i);
968            for d in 0..3 {
969                forces[i][d] += wf[d];
970            }
971        }
972        let dt = self.config.time_step;
973        let damp = self.config.damping;
974        for (i, s) in self.spheres.iter_mut().enumerate() {
975            for d in 0..3 {
976                s.center[d] += dt * (1.0 - damp) * forces[i][d];
977            }
978        }
979        let mut max_overlap = 0.0f64;
980        for i in 0..n {
981            for j in (i + 1)..n {
982                let ov = self.spheres[i].overlap_with(&self.spheres[j]);
983                max_overlap = max_overlap.max(ov);
984            }
985        }
986        max_overlap
987    }
988    /// Run the optimizer until convergence or max iterations.
989    pub fn run(&mut self) {
990        self.convergence.clear();
991        for _ in 0..self.config.max_iterations {
992            let max_ov = self.step();
993            self.convergence.push(max_ov);
994            if max_ov < self.config.tolerance {
995                self.converged = true;
996                break;
997            }
998        }
999    }
1000    /// Compute the total potential energy of the packing.
1001    pub fn potential_energy(&self) -> f64 {
1002        let n = self.spheres.len();
1003        let k = self.config.stiffness;
1004        let mut energy = 0.0;
1005        for i in 0..n {
1006            for j in (i + 1)..n {
1007                let ov = self.spheres[i].overlap_with(&self.spheres[j]);
1008                if ov > 0.0 {
1009                    energy += 0.5 * k * ov * ov;
1010                }
1011            }
1012        }
1013        energy
1014    }
1015    /// Compute the packing fraction after optimization.
1016    pub fn packing_fraction(&self) -> f64 {
1017        let [lx, ly, lz] = self.domain;
1018        let vol = lx * ly * lz;
1019        let sv: f64 = self.spheres.iter().map(|s| s.volume()).sum();
1020        sv / vol
1021    }
1022    /// Return the maximum overlap in the current configuration.
1023    pub fn max_overlap(&self) -> f64 {
1024        let n = self.spheres.len();
1025        let mut max_ov = 0.0f64;
1026        for i in 0..n {
1027            for j in (i + 1)..n {
1028                let ov = self.spheres[i].overlap_with(&self.spheres[j]);
1029                max_ov = max_ov.max(ov);
1030            }
1031        }
1032        max_ov
1033    }
1034}
1035/// Shape of the confining container.
1036#[derive(Debug, Clone, Copy, PartialEq)]
1037pub enum ConfinementShape {
1038    /// Rectangular box with given half-extents.
1039    Box,
1040    /// Cylinder with axis along Z.
1041    Cylinder,
1042    /// Sphere container.
1043    SphericalContainer,
1044}
1045/// A sphere in 3D space, described by its center and radius.
1046#[derive(Debug, Clone, PartialEq)]
1047pub struct PackingSphere {
1048    /// Center of the sphere.
1049    pub center: [f64; 3],
1050    /// Radius of the sphere.
1051    pub radius: f64,
1052    /// Unique identifier.
1053    pub id: usize,
1054}
1055impl PackingSphere {
1056    /// Create a new sphere.
1057    pub fn new(center: [f64; 3], radius: f64, id: usize) -> Self {
1058        Self { center, radius, id }
1059    }
1060    /// Compute the volume of the sphere.
1061    pub fn volume(&self) -> f64 {
1062        4.0 / 3.0 * std::f64::consts::PI * self.radius.powi(3)
1063    }
1064    /// Check if this sphere overlaps with another sphere.
1065    pub fn overlaps(&self, other: &PackingSphere) -> bool {
1066        let min_dist = self.radius + other.radius;
1067        let dx = self.center[0] - other.center[0];
1068        let dy = self.center[1] - other.center[1];
1069        let dz = self.center[2] - other.center[2];
1070        dx * dx + dy * dy + dz * dz < min_dist * min_dist
1071    }
1072    /// Compute center-to-center distance.
1073    pub fn distance_to(&self, other: &PackingSphere) -> f64 {
1074        let dx = self.center[0] - other.center[0];
1075        let dy = self.center[1] - other.center[1];
1076        let dz = self.center[2] - other.center[2];
1077        (dx * dx + dy * dy + dz * dz).sqrt()
1078    }
1079    /// Compute overlap (interpenetration) with another sphere.
1080    ///
1081    /// Returns the overlap distance (positive = overlap, negative = separation).
1082    pub fn overlap_with(&self, other: &PackingSphere) -> f64 {
1083        let min_dist = self.radius + other.radius;
1084        let dist = self.distance_to(other);
1085        min_dist - dist
1086    }
1087}