pub mod split;
use ahash::AHashMap;
use std::collections::BTreeMap;
use axiolid_core::{Point2, Point3, Scalar, Tolerance, Vec3};
use axiolid_mesh::{audit_mesh, EdgeAdjacency, TriMesh};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Error)]
#[non_exhaustive]
pub enum DecomposeError {
#[error("index buffer length {0} is not a multiple of 3")]
RaggedIndices(usize),
#[error("triangle {0} references vertex {1}, which is out of range")]
IndexOutOfRange(usize, u32),
#[error("input is not a closed two-manifold solid: {boundary} boundary and {non_manifold} non-manifold edges")]
NotASolid {
boundary: usize,
non_manifold: usize,
},
#[error("concavity bound {0} is not a positive finite length")]
InvalidBound(Scalar),
#[error("splitting a part failed: {0}")]
SplitFailed(String),
#[error("decomposition exceeded the {limit} part budget")]
BudgetExceeded {
limit: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum Strategy {
Exact,
Approximate {
max_concavity: Scalar,
},
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum Fidelity {
Exact,
Approximate {
requested: Scalar,
achieved: Scalar,
},
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Decomposition {
pub parts: Vec<TriMesh>,
pub fidelity: Fidelity,
pub splits: usize,
}
impl Decomposition {
pub fn is_single_part(&self) -> bool {
self.parts.len() == 1
}
}
const MAX_PARTS: usize = 4096;
pub fn convex_decompose(
mesh: &TriMesh,
strategy: Strategy,
tolerance: Tolerance,
) -> Result<Decomposition, DecomposeError> {
convex_decompose_with(mesh, strategy, tolerance, &split::Splitter::HandRolled)
}
pub fn convex_decompose_with(
mesh: &TriMesh,
strategy: Strategy,
tolerance: Tolerance,
splitter: &split::Splitter<'_>,
) -> Result<Decomposition, DecomposeError> {
if mesh.indices.len() % 3 != 0 {
return Err(DecomposeError::RaggedIndices(mesh.indices.len()));
}
let vertex_count = mesh.positions.len();
for (triangle, chunk) in mesh.indices.chunks_exact(3).enumerate() {
for &index in chunk {
if index as usize >= vertex_count {
return Err(DecomposeError::IndexOutOfRange(triangle, index));
}
}
}
let health = audit_mesh(mesh, tolerance);
if !health.is_closed_two_manifold() {
return Err(DecomposeError::NotASolid {
boundary: health.boundary_edges,
non_manifold: health.non_manifold_edges,
});
}
let bound = match strategy {
Strategy::Exact => tolerance.linear(),
Strategy::Approximate { max_concavity } => {
if !max_concavity.is_finite() || max_concavity <= 0.0 {
return Err(DecomposeError::InvalidBound(max_concavity));
}
max_concavity
}
};
let mut pending = vec![mesh.clone()];
let mut finished: Vec<TriMesh> = Vec::new();
let mut splits = 0usize;
let mut achieved: Scalar = 0.0;
while let Some(part) = pending.pop() {
if finished.len() + pending.len() + 1 > MAX_PARTS {
return Err(DecomposeError::BudgetExceeded { limit: MAX_PARTS });
}
let Some(reflex) = worst_concavity(&part.positions, &part.indices, tolerance) else {
finished.push(part);
continue;
};
if reflex.depth <= bound {
achieved = achieved.max(reflex.depth);
finished.push(part);
continue;
}
let (normal, offset) = (reflex.normal, reflex.offset);
let (front, back) = splitter.split(&part, normal, offset, tolerance)?;
match (front, back) {
(Some(front), Some(back))
if front.triangle_count() > 0 && back.triangle_count() > 0 =>
{
splits += 1;
pending.push(front);
pending.push(back);
}
_ => {
achieved = achieved.max(reflex.depth);
finished.push(part);
}
}
}
finished.sort_by(|a, b| {
let ka = order_key(&a.positions);
let kb = order_key(&b.positions);
ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal)
});
let parts = finished;
let fidelity = match strategy {
Strategy::Exact => Fidelity::Exact,
Strategy::Approximate { max_concavity } => Fidelity::Approximate {
requested: max_concavity,
achieved,
},
};
Ok(Decomposition {
parts,
fidelity,
splits,
})
}
fn order_key(points: &[Point3]) -> (Scalar, Scalar, Scalar) {
let mut best = (Scalar::INFINITY, Scalar::INFINITY, Scalar::INFINITY);
for p in points {
let key = (p.x, p.y, p.z);
if key < best {
best = key;
}
}
best
}
struct Reflex {
depth: Scalar,
apex: Point3,
normal: Vec3,
offset: Scalar,
}
fn worst_concavity(positions: &[Point3], indices: &[u32], tolerance: Tolerance) -> Option<Reflex> {
let linear = tolerance.linear();
let mut worst: Option<Reflex> = None;
let &first = positions.first()?;
let (mut low, mut high) = (first, first);
for point in positions {
low = Point3::new(low.x.min(point.x), low.y.min(point.y), low.z.min(point.z));
high = Point3::new(
high.x.max(point.x),
high.y.max(point.y),
high.z.max(point.z),
);
}
let centre = (low + high) * 0.5;
let radius = positions
.iter()
.fold(0.0, |m: Scalar, p| m.max((*p - centre).length()));
for chunk in indices.chunks_exact(3) {
let a = positions[chunk[0] as usize];
let b = positions[chunk[1] as usize];
let c = positions[chunk[2] as usize];
let normal = (b - a).cross(c - a);
let area = normal.length();
if area <= linear * linear {
continue;
}
let unit = normal / area;
let reach = centre.dot(unit) + radius - a.dot(unit);
let threshold = worst
.as_ref()
.map_or(linear, |current| (current.depth - linear).max(linear));
if reach <= threshold {
continue;
}
for (index, &point) in positions.iter().enumerate() {
let ahead = (point - a).dot(unit);
if ahead <= linear {
continue;
}
let better = match &worst {
None => true,
Some(current) => {
ahead > current.depth + linear
|| ((ahead - current.depth).abs() <= linear
&& (point.x, point.y, point.z)
< (current.apex.x, current.apex.y, current.apex.z))
}
};
if better {
let _ = index;
worst = Some(Reflex {
depth: ahead,
apex: point,
normal: unit,
offset: a.dot(unit),
});
}
}
}
worst
}
fn clip(mesh: &TriMesh, normal: Vec3, offset: Scalar, tolerance: Tolerance) -> Option<TriMesh> {
let linear = tolerance.linear();
let mut positions: Vec<Point3> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
let mut lookup: AHashMap<(u64, u64, u64), u32> = AHashMap::new();
let intern = |point: Point3, positions: &mut Vec<Point3>, lookup: &mut AHashMap<_, _>| {
let key = (
quantise(point.x, linear),
quantise(point.y, linear),
quantise(point.z, linear),
);
*lookup.entry(key).or_insert_with(|| {
positions.push(point);
(positions.len() - 1) as u32
})
};
for chunk in mesh.indices.chunks_exact(3) {
let triangle = [
mesh.positions[chunk[0] as usize],
mesh.positions[chunk[1] as usize],
mesh.positions[chunk[2] as usize],
];
let distances = [
triangle[0].dot(normal) - offset,
triangle[1].dot(normal) - offset,
triangle[2].dot(normal) - offset,
];
if distances.iter().all(|d| d.abs() <= linear) {
let face = (triangle[1] - triangle[0]).cross(triangle[2] - triangle[0]);
if face.dot(normal) > 0.0 {
let a = intern(triangle[0], &mut positions, &mut lookup);
let b = intern(triangle[1], &mut positions, &mut lookup);
let c = intern(triangle[2], &mut positions, &mut lookup);
if a != b && b != c && c != a {
indices.extend_from_slice(&[a, b, c]);
}
}
continue;
}
let mut kept: Vec<Point3> = Vec::new();
for corner in 0..3 {
let current = triangle[corner];
let next = triangle[(corner + 1) % 3];
let d_current = distances[corner];
let d_next = distances[(corner + 1) % 3];
if d_current <= linear {
kept.push(current);
}
if (d_current < -linear && d_next > linear) || (d_current > linear && d_next < -linear)
{
let t = d_current / (d_current - d_next);
kept.push(current + (next - current) * t);
}
}
if kept.len() < 3 {
continue;
}
let anchor = intern(kept[0], &mut positions, &mut lookup);
for corner in 1..kept.len() - 1 {
let b = intern(kept[corner], &mut positions, &mut lookup);
let c = intern(kept[corner + 1], &mut positions, &mut lookup);
if anchor != b && b != c && c != anchor {
indices.extend_from_slice(&[anchor, b, c]);
}
}
}
if indices.is_empty() {
return None;
}
let shell = TriMesh::new(positions.clone(), indices.clone());
let adjacency = EdgeAdjacency::build(&shell);
let open_edges: Vec<(Point3, Point3)> = adjacency
.boundary_edges()
.map(|edge| {
let (a, b) = edge.endpoints();
(positions[a as usize], positions[b as usize])
})
.collect();
if open_edges.is_empty() {
return Some(TriMesh::new(positions, indices));
}
for loop_points in stitch_loops(&open_edges, linear) {
if loop_points.len() < 3 {
continue;
}
let (axis_u, axis_v) = plane_basis(normal);
let origin = loop_points[0];
let flat: Vec<Point2> = loop_points
.iter()
.map(|p| {
let d = *p - origin;
Point2::new(d.dot(axis_u), d.dot(axis_v))
})
.collect();
let area: Scalar = flat
.iter()
.enumerate()
.map(|(k, p)| {
let q = flat[(k + 1) % flat.len()];
p.x * q.y - q.x * p.y
})
.sum();
let (flat, loop_points) = if area < 0.0 {
let mut f = flat;
let mut l = loop_points;
f.reverse();
l.reverse();
(f, l)
} else {
(flat, loop_points)
};
let Ok(fan) = axiolid_reference::polygon::triangulate_simple(&flat) else {
continue;
};
for triple in fan {
let a = intern(loop_points[triple[0] as usize], &mut positions, &mut lookup);
let b = intern(loop_points[triple[1] as usize], &mut positions, &mut lookup);
let c = intern(loop_points[triple[2] as usize], &mut positions, &mut lookup);
if a == b || b == c || c == a {
continue;
}
let wound = (positions[b as usize] - positions[a as usize])
.cross(positions[c as usize] - positions[a as usize]);
if wound.dot(normal) >= 0.0 {
indices.extend_from_slice(&[a, b, c]);
} else {
indices.extend_from_slice(&[a, c, b]);
}
}
}
Some(TriMesh::new(positions, indices))
}
fn stitch_loops(edges: &[(Point3, Point3)], linear: Scalar) -> Vec<Vec<Point3>> {
let key = |p: &Point3| {
(
quantise(p.x, linear),
quantise(p.y, linear),
quantise(p.z, linear),
)
};
let mut adjacency: BTreeMap<(u64, u64, u64), Vec<usize>> = BTreeMap::new();
for (index, (from, to)) in edges.iter().enumerate() {
adjacency.entry(key(from)).or_default().push(index);
adjacency.entry(key(to)).or_default().push(index);
}
let mut used = vec![false; edges.len()];
let mut loops = Vec::new();
for start in 0..edges.len() {
if used[start] {
continue;
}
used[start] = true;
let mut ring = vec![edges[start].0, edges[start].1];
let mut tail = edges[start].1;
while let Some(candidates) = adjacency.get(&key(&tail)) {
let mut advanced = false;
for &next in candidates {
if used[next] {
continue;
}
let (from, to) = edges[next];
let other = if key(&from) == key(&tail) {
to
} else if key(&to) == key(&tail) {
from
} else {
continue;
};
used[next] = true;
if key(&other) == key(&ring[0]) {
advanced = false;
break;
}
ring.push(other);
tail = other;
advanced = true;
break;
}
if !advanced {
break;
}
}
if ring.len() >= 3 {
loops.push(ring);
}
}
loops
}
fn plane_basis(normal: Vec3) -> (Vec3, Vec3) {
let seed = if normal.x.abs() <= normal.y.abs() && normal.x.abs() <= normal.z.abs() {
Vec3::X
} else if normal.y.abs() <= normal.z.abs() {
Vec3::Y
} else {
Vec3::Z
};
let u = normal.cross(seed).normalize();
let v = normal.cross(u);
(u, v)
}
fn quantise(value: Scalar, linear: Scalar) -> u64 {
let step = linear.max(Scalar::EPSILON);
let snapped = (value / step).round();
snapped.to_bits()
}
#[cfg(test)]
mod concavity_tests {
use super::*;
fn tol() -> Tolerance {
Tolerance::new(1e-9, 1e-12).expect("tolerance")
}
fn unpruned(positions: &[Point3], indices: &[u32], tolerance: Tolerance) -> Option<Reflex> {
let linear = tolerance.linear();
let mut worst: Option<Reflex> = None;
for chunk in indices.chunks_exact(3) {
let a = positions[chunk[0] as usize];
let b = positions[chunk[1] as usize];
let c = positions[chunk[2] as usize];
let normal = (b - a).cross(c - a);
let area = normal.length();
if area <= linear * linear {
continue;
}
let unit = normal / area;
for &point in positions.iter() {
let ahead = (point - a).dot(unit);
if ahead <= linear {
continue;
}
let better = match &worst {
None => true,
Some(current) => {
ahead > current.depth + linear
|| ((ahead - current.depth).abs() <= linear
&& (point.x, point.y, point.z)
< (current.apex.x, current.apex.y, current.apex.z))
}
};
if better {
worst = Some(Reflex {
depth: ahead,
apex: point,
normal: unit,
offset: a.dot(unit),
});
}
}
}
worst
}
fn agree(label: &str, mesh: &TriMesh) {
let want = unpruned(&mesh.positions, &mesh.indices, tol());
let got = worst_concavity(&mesh.positions, &mesh.indices, tol());
match (want, got) {
(None, None) => {}
(Some(w), Some(g)) => {
assert!((w.depth - g.depth).abs() < 1e-12, "{label}: depth");
assert_eq!(w.apex, g.apex, "{label}: apex");
assert_eq!(w.normal, g.normal, "{label}: normal");
assert!((w.offset - g.offset).abs() < 1e-12, "{label}: offset");
}
(a, b) => panic!(
"{label}: presence differs, {} vs {}",
a.is_some(),
b.is_some()
),
}
}
fn cube() -> TriMesh {
let p = vec![
Point3::new(-1.0, -1.0, -1.0),
Point3::new(1.0, -1.0, -1.0),
Point3::new(1.0, 1.0, -1.0),
Point3::new(-1.0, 1.0, -1.0),
Point3::new(-1.0, -1.0, 1.0),
Point3::new(1.0, -1.0, 1.0),
Point3::new(1.0, 1.0, 1.0),
Point3::new(-1.0, 1.0, 1.0),
];
let i = vec![
0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 2, 3, 7, 2, 7, 6, 1, 2, 6, 1, 6,
5, 0, 4, 7, 0, 7, 3u32,
];
TriMesh::new(p, i)
}
fn l_shape() -> TriMesh {
let footprint = [
(0.0, 0.0),
(2.0, 0.0),
(2.0, 1.0),
(1.0, 1.0),
(1.0, 2.0),
(0.0, 2.0),
];
let mut positions = Vec::new();
for &(x, y) in &footprint {
positions.push(Point3::new(x, y, 0.0));
}
for &(x, y) in &footprint {
positions.push(Point3::new(x, y, 1.0));
}
let n = footprint.len() as u32;
let mut indices = Vec::new();
for &(a, b, c) in &[(0u32, 1, 2), (0, 2, 3), (0, 3, 4), (0, 4, 5)] {
indices.extend_from_slice(&[a, c, b]);
indices.extend_from_slice(&[a + n, b + n, c + n]);
}
for i in 0..n {
let j = (i + 1) % n;
indices.extend_from_slice(&[i, j, j + n]);
indices.extend_from_slice(&[i, j + n, i + n]);
}
TriMesh::new(positions, indices)
}
#[test]
fn prune_agrees_on_a_convex_solid() {
agree("cube", &cube());
}
#[test]
fn prune_agrees_on_a_dented_solid() {
let mut mesh = cube();
mesh.positions[6] = Point3::new(0.1, 0.1, 0.1);
agree("dented", &mesh);
assert!(
worst_concavity(&mesh.positions, &mesh.indices, tol()).is_some(),
"the dent must register as concavity, or this proves nothing"
);
}
#[test]
fn prune_agrees_across_many_dents() {
let mut seed = 0x9E3779B97F4A7C15u64;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f64 / (1u64 << 53) as f64
};
for trial in 0..200 {
let mut mesh = cube();
for _ in 0..3 {
let which = (next() * 8.0) as usize % 8;
let scale = 0.2 + next() * 1.4;
mesh.positions[which] *= scale;
}
agree(&format!("trial {trial}"), &mesh);
}
}
#[test]
fn prune_respects_the_tie_window() {
let coarse = Tolerance::new(0.05, 1e-12).expect("tolerance");
let mesh = l_shape();
let want = unpruned(&mesh.positions, &mesh.indices, coarse);
let got = worst_concavity(&mesh.positions, &mesh.indices, coarse);
let (want, got) = (want.expect("reflex"), got.expect("reflex"));
assert!((want.depth - got.depth).abs() < 1e-12, "depth differs");
assert!(
(want.apex - got.apex).length() < 1e-12,
"same depth, different apex: the tie-break was not preserved"
);
}
#[test]
fn prune_matches_the_oracle_across_tolerances() {
let meshes = [("l", l_shape()), ("cube", cube())];
for (name, mesh) in &meshes {
let mut linear = 1e-12;
while linear < 2.0 {
let t = Tolerance::new(linear, 1e-12).expect("tolerance");
let want = unpruned(&mesh.positions, &mesh.indices, t);
let got = worst_concavity(&mesh.positions, &mesh.indices, t);
match (want, got) {
(None, None) => {}
(Some(a), Some(b)) => {
assert!(
(a.depth - b.depth).abs() < 1e-12 && (a.apex - b.apex).length() < 1e-12,
"{name} at linear={linear:e}: prune changed the answer"
);
}
(a, b) => panic!(
"{name} at linear={linear:e}: presence differs, {} vs {}",
a.is_some(),
b.is_some()
),
}
linear *= 1.5;
}
}
}
#[test]
fn prune_matches_the_oracle_on_random_solids() {
let mut seed = 0xD1B54A32D192ED03u64;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f64 / (1u64 << 53) as f64
};
for trial in 0..400 {
let mut mesh = cube();
for slot in 0..8 {
let q = |v: f64| (v * 4.0).round() / 4.0;
let p = mesh.positions[slot];
let s = 0.25 + (next() * 8.0).floor() / 4.0;
mesh.positions[slot] = Point3::new(q(p.x * s), q(p.y * s), q(p.z * s));
}
for step in 0..6 {
let linear = 0.01 * 4.0_f64.powi(step);
let t = Tolerance::new(linear, 1e-12).expect("tolerance");
let want = unpruned(&mesh.positions, &mesh.indices, t);
let got = worst_concavity(&mesh.positions, &mesh.indices, t);
match (want, got) {
(None, None) => {}
(Some(a), Some(b)) => assert!(
(a.depth - b.depth).abs() < 1e-12 && (a.apex - b.apex).length() < 1e-12,
"trial {trial} linear={linear}: prune changed the answer"
),
(a, b) => panic!(
"trial {trial} linear={linear}: presence differs, {} vs {}",
a.is_some(),
b.is_some()
),
}
}
}
}
#[test]
fn prune_keeps_faces_inside_the_tie_window() {
let t = Tolerance::new(0.25, 1e-12).expect("tolerance");
for a in 1..14 {
for b in 1..14 {
let mut mesh = cube();
let sa = 1.0 + a as Scalar * 0.125;
let sb = 1.0 + b as Scalar * 0.125;
let p4 = mesh.positions[4];
let p6 = mesh.positions[6];
mesh.positions[4] = Point3::new(p4.x * sa, p4.y * sa, p4.z * sa);
mesh.positions[6] = Point3::new(p6.x * sb, p6.y * sb, p6.z * sb);
let want = unpruned(&mesh.positions, &mesh.indices, t);
let got = worst_concavity(&mesh.positions, &mesh.indices, t);
match (want, got) {
(None, None) => {}
(Some(x), Some(y)) => assert!(
(x.depth - y.depth).abs() < 1e-12 && (x.apex - y.apex).length() < 1e-12,
"a={a} b={b}: prune changed the answer"
),
(x, y) => panic!("a={a} b={b}: {} vs {}", x.is_some(), y.is_some()),
}
}
}
}
#[test]
fn empty_input_is_none() {
assert!(worst_concavity(&[], &[], tol()).is_none());
}
#[test]
fn degenerate_faces_are_still_skipped() {
let p = vec![Point3::ZERO, Point3::ZERO, Point3::ZERO];
assert!(worst_concavity(&p, &[0, 1, 2], tol()).is_none());
}
}