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