spherical_cow/lib.rs
1//! **Spherical Cow**: *A high volume fraction sphere packing library*.
2//!
3//! # Usage
4//!
5//! First, add `spherical-cow` to the dependencies in your project's `Cargo.toml`.
6//!
7//! ```toml
8//! [dependencies]
9//! spherical-cow = "0.1"
10//! ```
11//!
12//! If you'd like to enable serialization through `serde` add this line instead to turn on that feature
13//!
14//! ```toml
15//! spherical-cow = { version = "0.1", features = ["serde-1"] }
16//! ```
17//!
18//! To calculate the `volume_fraction` of a spherical container with radius 2 filled with spheres of radii between 0.05 and 0.1 is straightforward:
19//!
20//! ```rust,no_run
21//! use spherical_cow::shapes::Sphere;
22//! use spherical_cow::PackedVolume;
23//! use rand::distributions::Uniform;
24//! use nalgebra::{Matrix, Point3};
25//!
26//! fn main() {
27//! // Pack spheres with radii between 0.05 and 0.1 into a spherical container of radius 2,
28//! // output quantitative analysis data.
29//! let boundary = Sphere::new(Point3::origin(), 2.0).unwrap();
30//! let mut sizes = Uniform::new(0.05, 0.1);
31//!
32//! let packed = PackedVolume::new(boundary, &mut sizes).unwrap();
33//!
34//! println!("Volume Fraction: {:.2}%", packed.volume_fraction() * 100.);
35//! }
36//! ```
37//!
38//! A full list of examples can be found in the [examples](https://github.com/Libbum/spherical-cow/tree/master/examples) directory.
39//!
40//! # Research
41//!
42//! The method implemented herein is an advancing front algorithm from
43//! Valera *et al.*, [Computational Particle Mechanics 2, 161 (2015)](https://doi.org/10.1007/s40571-015-0045-8).
44
45#![warn(missing_docs)]
46#![allow(clippy::needless_doctest_main)]
47
48pub mod errors;
49#[cfg(feature = "serde-1")]
50mod serialization;
51pub mod shapes;
52pub mod util;
53
54use crate::errors::SphericalCowError as Error;
55use crate::shapes::Sphere;
56use float_cmp::ApproxEqRatio;
57use itertools::Itertools;
58use nalgebra::core::{Matrix, Matrix3};
59use nalgebra::Point3;
60use rand::distributions::Distribution;
61use rand::prelude::SliceRandom;
62
63/// The `Container` trait must be implemented for all shapes you wish to pack spheres into.
64/// Standard shapes such as spheres and cuboids already derrive this trait. More complicated
65/// shapes such as a triangular mesh are also straightforward to implement, examples
66/// of such can be seen in the
67/// [`show_in_emerald`](https://github.com/Libbum/spherical-cow/blob/master/examples/show_in_emerald.rs)
68/// and [`show_in_cow`](https://github.com/Libbum/spherical-cow/blob/master/examples/show_in_cow.rs) files.
69pub trait Container {
70 /// Checks if a sphere exists inside some bounding geometry.
71 fn contains(&self, sphere: &Sphere) -> bool;
72 /// Calculates the volume of this container in normalised units.
73 fn volume(&self) -> f32;
74}
75
76/// To obtain quantitative values of your packing effectiveness, `PackedVolume` provides
77/// a number of useful indicators of such.
78#[derive(Debug)]
79pub struct PackedVolume<C> {
80 /// A set of spheres generated by a call to [pack_spheres](fn.pack_spheres.html).
81 pub spheres: Vec<Sphere>,
82 /// The container in which spheres have been packed.
83 pub container: C,
84}
85
86impl<C: Container> PackedVolume<C> {
87 /// Creates a new `PackedVolume` by calling [pack_spheres](fn.pack_spheres.html) with a given distribution of sphere sizes
88 /// and a `container` to pack into.
89 pub fn new<D: Distribution<f64>>(
90 container: C,
91 mut size_distribution: &mut D,
92 ) -> Result<PackedVolume<C>, Error> {
93 let spheres = pack_spheres::<C, D>(&container, &mut size_distribution)?;
94 Ok(PackedVolume::<C> { spheres, container })
95 }
96
97 /// Creates a `PackedVolume` from a pre calculated cluster of `spheres`. Useful for gathering statistics from
98 /// packings generated elsewhere for comparison to the current algorithm. Also used for deserialization.
99 /// This method is currently unchecked, so use with caution.
100 pub fn from_vec(spheres: Vec<Sphere>, container: C) -> PackedVolume<C> {
101 PackedVolume::<C> { spheres, container }
102 }
103
104 /// Calculates the volume fraction ν = Vs/V: the volume of all spheres packed into a container
105 /// divided by the volume of said container.
106 ///
107 /// The [Kepler Conjecture](http://mathworld.wolfram.com/KeplerConjecture.html) suggests that the
108 /// densest possible volume fraction for equal sized spheres is ~74.05%. However, the higest possible
109 /// volume fraction for any random radii distribution is currently unknown to
110 /// mathematicians. The algorithm implemented herin obtains 59.29% in a cube with side lengths of
111 /// 90 with sphere radii between 0.01 and 0.02, compared with a long standing and well known
112 /// [geometric compression algorithm](10.1016/j.powtec.2005.04.055) which achieved 52.89%.
113 pub fn volume_fraction(&self) -> f32 {
114 let vol_spheres: f32 = self.spheres.iter().map(|sphere| sphere.volume()).sum();
115 vol_spheres / self.container.volume()
116 }
117
118 /// Calculates the void ratio e = Vv/Vs: the volume of all void space divided by the volume of
119 /// solids in the container. Here we take 'solids' to mean volumes of all packed spheres.
120 pub fn void_ratio(&self) -> f32 {
121 let vol_spheres: f32 = self.spheres.iter().map(|sphere| sphere.volume()).sum();
122 let vol_total = self.container.volume();
123 (vol_total - vol_spheres) / vol_spheres
124 }
125
126 /// The coordination number indicates the connectivity of the packing.
127 /// For any given sphere in the packing, its coordination number is defined as
128 /// the number of spheres it is in contact with. This function returns the
129 /// arethmetic mean of all coordination numbers in the packing, yielding a
130 /// overall coordination number of the system.
131 pub fn coordination_number(&self) -> f32 {
132 let num_particles = self.spheres.len() as f32;
133 let mut coordinations = 0;
134 for idx in 0..self.spheres.len() {
135 coordinations += self.sphere_contacts_count(idx);
136 }
137 coordinations as f32 / num_particles
138 }
139
140 /// Generates the fabric tensor of the packing. The sum of all eigenvalues phi_i,j will always equal 1.
141 /// Perfectly isotropic packing should see the diagonals of this matrix = 1/3. Deviations from this value
142 /// indicates the amount of anisotropy in the system.
143 pub fn fabric_tensor(&self) -> Matrix3<f32> {
144 let phi = |i: usize, j: usize| {
145 let mut sum_all = 0.;
146 for idx in 0..self.spheres.len() {
147 let center = self.spheres[idx].center.coords;
148 // The set of all spheres in contact with the current sphere
149 let p_c = self.sphere_contacts(idx);
150 // Number of spheres in contact with the current sphere
151 let m_p = p_c.len() as f32;
152 let mut sum_vec = 0.;
153 for c in &p_c {
154 let vec_n_pc = Matrix::cross(¢er, &c.center.coords);
155 // The unit vector pointing from the center of the current sphere to
156 // the center of a connecting sphere
157 let n_pc = vec_n_pc / Matrix::norm(&vec_n_pc);
158 sum_vec += n_pc[i] * n_pc[j];
159 }
160 sum_all += sum_vec / m_p;
161 }
162 // phiᵢⱼ
163 1. / self.spheres.len() as f32 * sum_all
164 };
165 Matrix3::from_fn(phi)
166 }
167
168 /// Returns a set of spheres connected to the sphere at a chosen index.
169 fn sphere_contacts(&self, sphere_idx: usize) -> Vec<Sphere> {
170 let center = self.spheres[sphere_idx].center;
171 let radius = self.spheres[sphere_idx].radius;
172 self.spheres
173 .iter()
174 .cloned()
175 .filter(|sphere| {
176 nalgebra::distance(¢er, &sphere.center)
177 .approx_eq_ratio(&(radius + sphere.radius), 0.0001)
178 })
179 .collect()
180 }
181
182 /// Calculates the number of contacts a sphere has with the rest of the packed set.
183 fn sphere_contacts_count(&self, sphere_idx: usize) -> usize {
184 let center = self.spheres[sphere_idx].center;
185 let radius = self.spheres[sphere_idx].radius;
186 self.spheres
187 .iter()
188 .filter(|sphere| {
189 nalgebra::distance(¢er, &sphere.center)
190 .approx_eq_ratio(&(radius + sphere.radius), 0.0001)
191 })
192 .count()
193 }
194}
195
196/// Packs all habitat spheres to be as dense as possible.
197/// Requires a `containter` and a distribution of radii sizes.
198///
199/// Generally, a uniform distribution is chosen, although this library
200/// accepts any distribution implementing `rand`s `Distribution` trait.
201/// This [example](https://github.com/Libbum/spherical-cow/blob/master/examples/count_sphere_normal.rs)
202/// uses a normally distributed radii range. Note that the packing is sub optimal in this case, and
203/// attention must be paid when using such distributions that radii values do not become negagive.
204pub fn pack_spheres<C: Container, D: Distribution<f64>>(
205 container: &C,
206 size_distribution: &mut D,
207) -> Result<Vec<Sphere>, Error> {
208 // Distribution is already derrived for all distributions in `rand` with f64,
209 // so we just downsample here instead of implementing traits on f32 for everything.
210 let mut rng = rand::thread_rng();
211
212 // Radii of three initial spheres, taken from the input distribution
213 let init_radii: [f32; 3] = [
214 size_distribution.sample(&mut rng) as f32,
215 size_distribution.sample(&mut rng) as f32,
216 size_distribution.sample(&mut rng) as f32,
217 ];
218
219 // S := {s₁, s₂, s₃}
220 let mut spheres = init_spheres(&init_radii, container)?;
221
222 // F := {s₁, s₂, s₃}
223 let mut front = spheres.clone();
224
225 // Radius of new sphere to be added to the current front, taken from the input distribution
226 let mut new_radius = size_distribution.sample(&mut rng) as f32;
227
228 let mut set_v = Vec::new();
229 let mut set_f = Vec::new();
230 'outer: while !front.is_empty() {
231 // s₀ := s(c₀, r₀) picked at random from F
232 let curr_sphere = front.choose(&mut rng).ok_or(Error::NoneFront)?.clone();
233 // V := {s(c', r') ∈ S : d(c₀, c') ≤ r₀ + r' + 2r}
234 set_v.clear();
235 set_v = spheres
236 .iter()
237 .cloned()
238 .filter(|s_dash| {
239 s_dash != &curr_sphere
240 && nalgebra::distance(&curr_sphere.center, &s_dash.center)
241 <= curr_sphere.radius + s_dash.radius + 2. * new_radius
242 })
243 .collect::<Vec<_>>();
244
245 for (s_i, s_j) in set_v.iter().tuple_combinations::<(&Sphere, &Sphere)>() {
246 set_f.clear();
247 identify_f(
248 &mut set_f,
249 &curr_sphere,
250 s_i,
251 s_j,
252 container,
253 &set_v,
254 new_radius,
255 )?;
256 if !set_f.is_empty() {
257 // Found at least one position to place the sphere,
258 // choose one and move on
259 let s_new = set_f.choose(&mut rng).ok_or(Error::NoneSetF)?;
260 front.push(s_new.clone());
261 spheres.push(s_new.clone());
262 new_radius = size_distribution.sample(&mut rng) as f32;
263 continue 'outer;
264 }
265 }
266 if let Some(i) = front.iter().position(|s| s == &curr_sphere) {
267 front.remove(i);
268 }
269 }
270 Ok(spheres)
271}
272
273/// Creates three initial spheres that are tangent pairwise. The incenter of the triangle formed
274/// by verticies located at the centers of each sphere is aligned at the origin.
275fn init_spheres<C: Container>(radii: &[f32; 3], container: &C) -> Result<Vec<Sphere>, Error> {
276 let mut init = Vec::new();
277
278 // C (x,y)
279 // ^
280 // / \
281 // b / \ a
282 // / \
283 // / \
284 // A (0,0)--------- B (c,0)
285 // c
286
287 // Sphere A can sit at the origin, sphere B extends outward along the x axis
288 // sphere C extends outward along the y axis and complete the triangle
289 let radius_a = radii[0];
290 let radius_b = radii[1];
291 let radius_c = radii[2];
292 let distance_c = radius_a + radius_b;
293 let distance_b = radius_a + radius_c;
294 let distance_a = radius_c + radius_b;
295
296 let b_2 = distance_b.powi(2);
297 let x = (b_2 + distance_c.powi(2) - distance_a.powi(2)) / (2. * distance_c);
298 let y = (b_2 - x.powi(2)).sqrt();
299
300 // Find incenter
301 let perimeter = distance_a + distance_b + distance_c;
302 let incenter_x = (distance_b * distance_c + distance_c * x) / perimeter;
303 let incenter_y = (distance_c * y) / perimeter;
304
305 // Create spheres at positions shown in the diagram above, but offset such
306 // that the incenter is now the origin. This offset attempts to minimise
307 // bounding box issues in the sense that c may be close to or over the
308 // bb boundary already
309 let s_1 = Sphere::new(Point3::new(-incenter_x, -incenter_y, 0.), radius_a)?;
310 if container.contains(&s_1) {
311 init.push(s_1);
312 } else {
313 return Err(Error::Uncontained);
314 }
315 let s_2 = Sphere::new(
316 Point3::new(distance_c - incenter_x, -incenter_y, 0.),
317 radius_b,
318 )?;
319 if container.contains(&s_2) {
320 init.push(s_2);
321 } else {
322 return Err(Error::Uncontained);
323 }
324 let s_3 = Sphere::new(Point3::new(x - incenter_x, y - incenter_y, 0.), radius_c)?;
325 if container.contains(&s_3) {
326 init.push(s_3);
327 } else {
328 return Err(Error::Uncontained);
329 }
330
331 Ok(init)
332}
333
334/// $f$ is as a set of spheres (or the empty set) such that they have a known `radius`,
335/// are in outer contact with `s_1`, `s_2` and `s_3` simultaneously, are completely
336/// contained in `container` and do not overlap with any element of `set_v`.
337/// The set f has at most two elements, because there exist at most two spheres with
338/// `radius` in outer contact with `s_1`, `s_2` and `s_3` simultaneously.
339fn identify_f<C: Container>(
340 set_f: &mut Vec<Sphere>,
341 s_1: &Sphere,
342 s_2: &Sphere,
343 s_3: &Sphere,
344 container: &C,
345 set_v: &[Sphere],
346 radius: f32,
347) -> Result<(), Error> {
348 //The center points of s_1, s_2, s_3 are verticies of a tetrahedron,
349 //and the distances d_1, d_2, d_3 can be defined as the distances from these points to
350 //a fourth vertex s_4, whose coordinates x,y,z must be found. This satisfies the equations
351 // (x-x_1)^2+(y-y_1)^2+(z-z_1)^2=d_1^2 (1)
352 // (x-x_2)^2+(y-y_2)^2+(z-z_2)^2=d_2^2 (2)
353 // (x-x_3)^2+(y-y_3)^2+(z-z_3)^2=d_3^2 (3)
354
355 //To solve this system, we subtract (1) from (2) & (3), to obtain the (linear) equations of two planes.
356 //Coupling these planes to (1) we yield a quadratic system which takes the form
357 // \vec u\cdot\vec r=a
358 // \vec v\cdot\vec r=b
359 // \vec r\cdot\vec r+\vec w\cdot\vec r=c
360
361 // With a little bit of magic following https://axiomatic.neophilus.net/posts/2018-01-16-clustering-tangent-spheres.html
362 // we can solve this system to identify r in the form
363 // \vec r=\alpha\vec u+\beta\vec v+\gamma\vec t
364 // Where \gamma has a quadratic solution identifying our two solutions.
365
366 let distance_14 = s_1.radius + radius;
367 let distance_24 = s_2.radius + radius;
368 let distance_34 = s_3.radius + radius;
369
370 let vector_u = s_1.center - s_2.center;
371 let unitvector_u = vector_u / Matrix::norm(&vector_u);
372 let vector_v = s_1.center - s_3.center;
373 let unitvector_v = vector_v / Matrix::norm(&vector_v);
374 let cross_uv = Matrix::cross(&vector_u, &vector_v);
375 let unitvector_t = cross_uv / Matrix::norm(&cross_uv);
376 let vector_w = -2. * s_1.center.coords;
377
378 let distance_c =
379 distance_14.powi(2) - s_1.center.x.powi(2) - s_1.center.y.powi(2) - s_1.center.z.powi(2);
380 let distance_a = (distance_24.powi(2)
381 - distance_c
382 - s_2.center.x.powi(2)
383 - s_2.center.y.powi(2)
384 - s_2.center.z.powi(2))
385 / (2. * Matrix::norm(&vector_u));
386 let distance_b = (distance_34.powi(2)
387 - distance_c
388 - s_3.center.x.powi(2)
389 - s_3.center.y.powi(2)
390 - s_3.center.z.powi(2))
391 / (2. * Matrix::norm(&vector_v));
392
393 let dot_uv = Matrix::dot(&unitvector_u, &unitvector_v);
394 let dot_wt = Matrix::dot(&vector_w, &unitvector_t);
395 let dot_uw = Matrix::dot(&unitvector_u, &vector_w);
396 let dot_vw = Matrix::dot(&unitvector_v, &vector_w);
397
398 let denominator = 1. - dot_uv.powi(2);
399 let alpha = (distance_a - distance_b * dot_uv) / denominator;
400 let beta = (distance_b - distance_a * dot_uv) / denominator;
401 let value_d =
402 alpha.powi(2) + beta.powi(2) + 2. * alpha * beta * dot_uv + alpha * dot_uw + beta * dot_vw
403 - distance_c;
404
405 // There is a possiblity of obtaining imaginary solutions in gamma,
406 // so we must check this comparison. TODO: Would be nice to have
407 // some quick way of verifying this configuration and deny it early.
408 let dot_wt_2 = dot_wt.powi(2);
409 let value_4d = 4. * value_d;
410 if dot_wt_2 > value_4d {
411 let gamma_pos = 0.5 * (-dot_wt + (dot_wt_2 - value_4d).sqrt());
412 let gamma_neg = 0.5 * (-dot_wt - (dot_wt_2 - value_4d).sqrt());
413
414 let s_4_positive = Sphere::new(
415 Point3::from(alpha * unitvector_u + beta * unitvector_v + gamma_pos * unitvector_t),
416 radius,
417 )?;
418 let s_4_negative = Sphere::new(
419 Point3::from(alpha * unitvector_u + beta * unitvector_v + gamma_neg * unitvector_t),
420 radius,
421 )?;
422
423 // Make sure the spheres are bounded by the containing geometry and do not overlap any spheres in V
424 if container.contains(&s_4_positive) && !set_v.iter().any(|v| v.overlaps(&s_4_positive)) {
425 set_f.push(s_4_positive);
426 }
427 if container.contains(&s_4_negative) && !set_v.iter().any(|v| v.overlaps(&s_4_negative)) {
428 set_f.push(s_4_negative);
429 }
430 }
431 Ok(())
432}
433
434#[test]
435fn init_spheres_err() {
436 let container = Sphere::new(Point3::origin(), 0.1).unwrap();
437 assert!(init_spheres(&[10., 15., 20.], &container).is_err());
438}
439
440#[test]
441fn identify_f_known() {
442 let one = Sphere::new(Point3::new(0.5, -0.28112677, 0.0), 0.5).unwrap();
443 let two = Sphere::new(Point3::new(0.058333218, 0.44511732, 0.0), 0.35).unwrap();
444 let three = Sphere::new(Point3::new(-0.70000005, -0.28112677, 0.0), 0.7).unwrap();
445 let container = Sphere::new(Point3::origin(), 20.0).unwrap();
446
447 let four_p = Sphere::new(Point3::new(0.06666666, 0.12316025, 0.6773287), 0.4).unwrap();
448 let four_n = Sphere::new(Point3::new(0.06666666, 0.12316025, -0.6773287), 0.4).unwrap();
449
450 let mut found = Vec::new();
451 identify_f::<Sphere>(&mut found, &one, &two, &three, &container, &Vec::new(), 0.4).unwrap();
452
453 println!("{:?}", found);
454 assert!(found.contains(&four_p));
455 assert!(found.contains(&four_n));
456}