mod ffi;
use std::os::raw::{c_double, c_int};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Hyperplane3d {
pub normal: [f64; 3],
pub offset: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub(crate) enum HullError {
#[error("convex hull input contains a non-finite coordinate")]
NonFiniteInput,
#[error("convex hull input is degenerate or has fewer than 4 affinely-independent points")]
Degenerate,
#[error("qhull convex-hull computation failed")]
Compute,
#[error("convex hull allocation failed")]
Alloc,
#[error("qhull shim returned unknown status code {0}")]
UnknownStatus(c_int),
}
struct PlaneBuffer {
ptr: *mut c_double,
}
impl Drop for PlaneBuffer {
fn drop(&mut self) {
unsafe { ffi::cobre_qhull_free(self.ptr) };
}
}
pub(crate) fn convex_hull_3d(points: &[[f64; 3]]) -> Result<Vec<Hyperplane3d>, HullError> {
if points.iter().any(|p| p.iter().any(|c| !c.is_finite())) {
return Err(HullError::NonFiniteInput);
}
let mut sorted: Vec<[f64; 3]> = points.to_vec();
sorted.sort_by(|a, b| {
a[0].total_cmp(&b[0])
.then_with(|| a[1].total_cmp(&b[1]))
.then_with(|| a[2].total_cmp(&b[2]))
});
let n_points = sorted.len();
let mut flat: Vec<f64> = Vec::with_capacity(n_points * 3);
for p in &sorted {
flat.extend_from_slice(p);
}
let Ok(n_points_c) = c_int::try_from(n_points) else {
return Err(HullError::Compute);
};
let mut out_planes: *mut c_double = std::ptr::null_mut();
let mut out_n_facets: c_int = 0;
let status = unsafe {
ffi::cobre_qhull_convex_hull_3d(
flat.as_ptr(),
n_points_c,
std::ptr::addr_of_mut!(out_planes),
std::ptr::addr_of_mut!(out_n_facets),
)
};
if status != ffi::COBRE_QHULL_OK {
return Err(match status {
ffi::COBRE_QHULL_ERR_DEGENERATE => HullError::Degenerate,
ffi::COBRE_QHULL_ERR_COMPUTE => HullError::Compute,
ffi::COBRE_QHULL_ERR_ALLOC => HullError::Alloc,
other => HullError::UnknownStatus(other),
});
}
let buffer = PlaneBuffer { ptr: out_planes };
let Ok(n_facets) = usize::try_from(out_n_facets) else {
return Err(HullError::Compute);
};
let mut facets: Vec<Hyperplane3d> = Vec::with_capacity(n_facets);
if n_facets > 0 {
let planes: &[c_double] = unsafe { std::slice::from_raw_parts(buffer.ptr, n_facets * 4) };
for q in planes.chunks_exact(4) {
facets.push(Hyperplane3d {
normal: [q[0], q[1], q[2]],
offset: q[3],
});
}
}
facets.sort_by(|a, b| {
a.normal[0]
.total_cmp(&b.normal[0])
.then_with(|| a.normal[1].total_cmp(&b.normal[1]))
.then_with(|| a.normal[2].total_cmp(&b.normal[2]))
.then_with(|| a.offset.total_cmp(&b.offset))
});
Ok(facets)
}
#[cfg(test)]
mod tests {
use super::{HullError, Hyperplane3d, convex_hull_3d};
const TETRA: [[f64; 3]; 4] = [
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
];
struct SplitMix64(u64);
impl SplitMix64 {
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn below(&mut self, bound: usize) -> usize {
usize::try_from((u128::from(self.next_u64()) * (bound as u128)) >> 64)
.expect("multiply-shift reduction is < bound, which fits usize")
}
}
fn shuffle(points: &mut [[f64; 3]], rng: &mut SplitMix64) {
for i in (1..points.len()).rev() {
let j = rng.below(i + 1);
points.swap(i, j);
}
}
fn representative_cloud() -> Vec<[f64; 3]> {
const V_MAX: f64 = 100.0;
const Q_MAX: f64 = 50.0;
const GRID: u32 = 6;
let span = f64::from(GRID - 1);
let mut cloud = Vec::with_capacity((GRID * GRID + 1) as usize);
for iv in 0..GRID {
for iq in 0..GRID {
let v = V_MAX * f64::from(iv) / span;
let q = Q_MAX * f64::from(iq) / span;
let z = 0.8 * v.sqrt() + 1.2 * q.sqrt() - 0.001 * v * q;
cloud.push([v, q, z]);
}
}
cloud.push([V_MAX, Q_MAX, 0.0]);
cloud
}
fn facets_bit_identical(a: &[Hyperplane3d], b: &[Hyperplane3d]) -> bool {
a.len() == b.len()
&& a.iter().zip(b).all(|(p, q)| {
p.normal[0].to_bits() == q.normal[0].to_bits()
&& p.normal[1].to_bits() == q.normal[1].to_bits()
&& p.normal[2].to_bits() == q.normal[2].to_bits()
&& p.offset.to_bits() == q.offset.to_bits()
})
}
fn matches_plane(plane: &Hyperplane3d, nx: f64, ny: f64, nz: f64, d: f64) -> bool {
let want = [nx, ny, nz];
let want_norm = (nx * nx + ny * ny + nz * nz).sqrt();
let got = plane.normal;
let got_norm = (got[0] * got[0] + got[1] * got[1] + got[2] * got[2]).sqrt();
if got_norm == 0.0 || want_norm == 0.0 {
return false;
}
for sign in [1.0_f64, -1.0_f64] {
let normal_matches =
(0..3).all(|i| (got[i] / got_norm - sign * want[i] / want_norm).abs() < 1e-9);
let offset_matches = (plane.offset / got_norm - sign * d / want_norm).abs() < 1e-9;
if normal_matches && offset_matches {
return true;
}
}
false
}
#[test]
fn tetrahedron_returns_four_faces_matching_hand_computed_planes() {
let hull = convex_hull_3d(&TETRA).expect("tetrahedron is a valid 3-D hull");
assert_eq!(hull.len(), 4, "tetrahedron has exactly 4 triangular faces");
let expected = [
(1.0, 0.0, 0.0, 0.0),
(0.0, 1.0, 0.0, 0.0),
(0.0, 0.0, 1.0, 0.0),
(1.0, 1.0, 1.0, -1.0),
];
for (nx, ny, nz, d) in expected {
assert!(
hull.iter().any(|p| matches_plane(p, nx, ny, nz, d)),
"expected a facet matching plane ({nx},{ny},{nz},{d}); got {hull:?}"
);
}
}
#[test]
fn shuffled_input_yields_bit_identical_facets() {
let baseline = convex_hull_3d(&TETRA).expect("baseline hull");
let shuffled = [
[0.0, 0.0, 1.0],
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
];
let permuted = convex_hull_3d(&shuffled).expect("shuffled hull");
assert_eq!(
baseline, permuted,
"canonical sort-in/out must make the facet Vec element-for-element bit-identical"
);
}
#[test]
fn cube_returns_six_faces_matching_hand_computed_planes() {
let cube = [
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
[1.0, 1.0, 0.0],
[1.0, 0.0, 1.0],
[0.0, 1.0, 1.0],
[1.0, 1.0, 1.0],
];
let hull = convex_hull_3d(&cube).expect("unit cube is a valid 3-D hull");
let expected = [
(1.0, 0.0, 0.0, 0.0),
(1.0, 0.0, 0.0, -1.0),
(0.0, 1.0, 0.0, 0.0),
(0.0, 1.0, 0.0, -1.0),
(0.0, 0.0, 1.0, 0.0),
(0.0, 0.0, 1.0, -1.0),
];
for (nx, ny, nz, d) in expected {
assert!(
hull.iter().any(|p| matches_plane(p, nx, ny, nz, d)),
"expected a facet matching plane ({nx},{ny},{nz},{d}); got {hull:?}"
);
}
assert!(
hull.iter().all(|p| expected
.iter()
.any(|&(nx, ny, nz, d)| matches_plane(p, nx, ny, nz, d))),
"every returned facet must lie on one of the 6 cube faces; got {hull:?}"
);
}
#[test]
fn representative_cloud_is_bit_identical_across_seeded_shuffles() {
let base = representative_cloud();
let reference = convex_hull_3d(&base).expect("representative cloud is a valid 3-D hull");
assert!(
reference.len() >= 4,
"representative cloud must yield a multi-facet hull to stress facet \
ordering; got {} facets",
reference.len()
);
for seed in 0u64..32 {
let mut rng = SplitMix64(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(1));
let mut permuted = base.clone();
shuffle(&mut permuted, &mut rng);
let result =
convex_hull_3d(&permuted).expect("permuted representative cloud stays valid");
assert!(
facets_bit_identical(&reference, &result),
"BLOCKING: qhull output is NOT bit-identical across input orderings \
(permutation seed = {seed}). The canonical sort-in/sort-out must \
make the facet Vec element-for-element to_bits()-equal to the \
reference. reference = {reference:?}, permuted = {result:?}"
);
}
}
#[test]
fn coplanar_points_return_degenerate_error() {
let coplanar = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
let result = convex_hull_3d(&coplanar);
assert_eq!(
result,
Err(HullError::Degenerate),
"a degenerate cloud must return the typed Degenerate error, not panic"
);
}
#[test]
fn coplanar_quad_is_handled_without_garbage_normals() {
let coplanar = [
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[1.0, 1.0, 0.0],
];
match convex_hull_3d(&coplanar) {
Ok(facets) => {
for f in &facets {
assert!(
f.normal.iter().all(|c| c.is_finite()) && f.offset.is_finite(),
"coplanar hull must not emit non-finite plane coefficients; got {f:?}"
);
}
}
Err(HullError::Degenerate | HullError::Compute) => {}
Err(other) => panic!("unexpected error variant for coplanar input: {other:?}"),
}
}
}