use crate::csg::{compute_boolean, OpType};
use axiolid_contracts::{
Backend, BackendDescriptor, BackendId, CancellationGranularity, Determinism, ExecutionOptions,
ExecutionTarget, GeomError, GeomResult, ScratchRequirement,
};
use axiolid_core::BooleanOperator;
use axiolid_mesh::{AttributeChannel, AttributeFate, Blend, DropReason, TriMesh};
use axiolid_mesh_boolean_contract::{
merge_fates, symmetric_difference_via_composition, BooleanEvidence, BooleanOutcome, MeshBoolean,
};
use crate::attributes::{carry, sources_by_plane, FaceSource};
use crate::convert::{from_boolean_mesh, six_signed_volume, to_manifold};
#[derive(Debug, Clone, Copy, Default)]
pub struct BoolmeshBoolean;
impl BoolmeshBoolean {
pub const ID: BackendId = BackendId::new("boolmesh");
pub const fn new() -> Self {
Self
}
pub fn subtract_boxes_analytic(
&self,
subject: &TriMesh,
tools: &[TriMesh],
options: &ExecutionOptions,
max_cells: usize,
) -> GeomResult<Option<BooleanOutcome>> {
options.check_cancelled()?;
let d = subject.bounds().diagonal();
let eps = d.x.max(d.y).max(d.z) * 1e-9;
let Some(host) = crate::box_detect::recognise(subject, eps) else {
return Ok(None);
};
let mut cutters = Vec::with_capacity(tools.len());
for tool in tools {
let Some(b) = crate::box_detect::recognise(tool, eps) else {
return Ok(None);
};
cutters.push(b);
}
let Some(mut result) = crate::cellular::subtract_boxes(&host, &cutters, max_cells) else {
return Ok(None);
};
check_result(&result, BooleanOperator::Difference)?;
let tool_refs: Vec<&TriMesh> = tools.iter().collect();
let fates = carry_analytic(subject, tools, &mut result);
let evidence = evidence_for(subject, &tool_refs, &result, 1)
.with_analytic_path(true)
.with_attribute_fates(fates);
Ok(Some(BooleanOutcome::new(result, evidence)))
}
}
impl Backend for BoolmeshBoolean {
fn descriptor(&self) -> BackendDescriptor {
BackendDescriptor::new(Self::ID, ExecutionTarget::PortableCpu)
}
}
fn check_result(result: &TriMesh, operation: BooleanOperator) -> GeomResult<()> {
if result.indices.is_empty() {
return Ok(());
}
let six_volume = six_signed_volume(&result.positions, &result.indices);
if !six_volume.is_finite() {
return Err(GeomError::BackendContractViolation {
backend: BoolmeshBoolean::ID,
detail: format!("{operation:?} returned non-finite signed volume"),
});
}
if six_volume < 0.0 {
return Err(GeomError::BackendContractViolation {
backend: BoolmeshBoolean::ID,
detail: format!(
"{operation:?} returned an inside-out solid (signed volume {:.6})",
six_volume / 6.0
),
});
}
Ok(())
}
impl MeshBoolean for BoolmeshBoolean {
fn determinism(&self) -> Determinism {
Determinism::Topological
}
fn scratch_requirement(&self) -> ScratchRequirement {
ScratchRequirement::PerElement {
bytes_per_element: 4096,
}
}
fn cancellation_granularity(&self) -> CancellationGranularity {
CancellationGranularity::BetweenOperations
}
fn subtract_many(
&self,
subject: &TriMesh,
tools: &[TriMesh],
options: &ExecutionOptions,
) -> GeomResult<BooleanOutcome> {
self.subtract_grouped(subject, tools, options)
}
fn union_many(
&self,
solids: &[TriMesh],
options: &ExecutionOptions,
) -> GeomResult<BooleanOutcome> {
self.union_tree(solids, options)
}
fn boolean(
&self,
subject: &TriMesh,
tool: &TriMesh,
operation: BooleanOperator,
options: &ExecutionOptions,
) -> GeomResult<BooleanOutcome> {
self.boolean_impl(subject, tool, operation, options, false)
}
}
impl BoolmeshBoolean {
pub fn boolean_fast(
&self,
subject: &TriMesh,
tool: &TriMesh,
operation: BooleanOperator,
options: &ExecutionOptions,
) -> GeomResult<BooleanOutcome> {
if operation == BooleanOperator::SymmetricDifference {
return Err(GeomError::Unsupported {
backend: BoolmeshBoolean::ID,
operation: axiolid_contracts::Operation::MeshBoolean,
});
}
self.boolean_impl(subject, tool, operation, options, true)
}
fn boolean_impl(
&self,
subject: &TriMesh,
tool: &TriMesh,
operation: BooleanOperator,
options: &ExecutionOptions,
fast_winding: bool,
) -> GeomResult<BooleanOutcome> {
if operation == BooleanOperator::SymmetricDifference {
return symmetric_difference_via_composition(self, subject, tool, options);
}
let op = match operation {
BooleanOperator::Union => OpType::Add,
BooleanOperator::Intersection => OpType::Intersect,
BooleanOperator::Difference => OpType::Subtract,
BooleanOperator::SymmetricDifference => unreachable!("composed above"),
_ => {
return Err(GeomError::Unsupported {
backend: BoolmeshBoolean::ID,
operation: axiolid_contracts::Operation::MeshBoolean,
})
}
};
options.check_cancelled()?;
let subject_manifold = to_manifold(subject, "subject")?;
let tool_manifold = to_manifold(tool, "tool")?;
let output = match compute_boolean(&subject_manifold, &tool_manifold, op, fast_winding) {
Ok(output) => output,
Err(reason) if is_empty_result(&reason) => {
let mut empty = TriMesh::new(Vec::new(), Vec::new());
let fates = carry(subject, tool, &mut empty, &[]);
let evidence =
evidence_for(subject, &[tool], &empty, 1).with_attribute_fates(fates);
return Ok(BooleanOutcome::new(empty, evidence));
}
Err(reason) => {
return Err(GeomError::BackendContractViolation {
backend: BoolmeshBoolean::ID,
detail: format!("{operation:?} failed inside the solve: {reason}"),
})
}
};
let mut result = from_boolean_mesh(&output);
check_result(&result, operation)?;
let sources: Vec<FaceSource> = output
.src
.iter()
.map(|&(operand, triangle)| FaceSource { operand, triangle })
.collect();
let fates = carry(subject, tool, &mut result, &sources);
let evidence = evidence_for(subject, &[tool], &result, 1).with_attribute_fates(fates);
Ok(BooleanOutcome::new(result, evidence))
}
}
fn is_empty_result(reason: &impl std::fmt::Display) -> bool {
let text = reason.to_string().to_lowercase();
text.contains("empty pos matrix") || text.contains("empty mesh")
}
fn evidence_for(
subject: &TriMesh,
tools: &[&TriMesh],
result: &TriMesh,
sub_operations: usize,
) -> BooleanEvidence {
let disjoint = tools
.iter()
.filter(|tool| !subject.bounds().intersects(&tool.bounds()))
.count();
let evidence = BooleanEvidence::record(
subject.triangle_count(),
tools.iter().map(|t| t.triangle_count()).sum(),
result.triangle_count(),
axiolid_mesh::component_count(result),
)
.with_disjoint_tools(disjoint)
.with_sub_operations(sub_operations)
.with_coincident_faces(false)
.with_attribute_fates(
subject
.attributes
.iter()
.map(|channel| {
let reason = match channel.blend {
axiolid_mesh::Blend::None => axiolid_mesh::DropReason::NotBlendable,
_ => axiolid_mesh::DropReason::ProviderLimitation,
};
(channel.name.clone(), AttributeFate::Dropped(reason))
})
.collect(),
);
match relative_overlap(subject, tools) {
Some(ratio) => evidence.with_relative_overlap(ratio),
None => evidence,
}
}
fn carry_analytic(
subject: &TriMesh,
tools: &[TriMesh],
result: &mut TriMesh,
) -> Vec<(String, AttributeFate)> {
let members: Vec<&TriMesh> = tools.iter().collect();
let tool = crate::grouping::fuse_with_channels(&members, &subject.attributes);
match sources_by_plane(result, &[(subject, 1.0), (&tool, -1.0)]) {
Some(sources) => carry(subject, &tool, result, &sources),
None => dropped_fates(subject, DropReason::ProviderLimitation),
}
}
fn dropped_fates(subject: &TriMesh, reason: DropReason) -> Vec<(String, AttributeFate)> {
subject
.attributes
.iter()
.map(|c| {
let r = match c.blend {
Blend::None => DropReason::NotBlendable,
_ => reason,
};
(c.name.clone(), AttributeFate::Dropped(r))
})
.collect()
}
type Fates = Vec<(String, AttributeFate)>;
fn seed_fates(subject: &TriMesh) -> Fates {
subject
.attributes
.iter()
.map(|c| (c.name.clone(), AttributeFate::Preserved))
.collect()
}
fn conform(mesh: &TriMesh, template: &[AttributeChannel]) -> TriMesh {
let mut out = mesh.clone();
out.attributes = crate::grouping::fuse_with_channels(&[mesh], template).attributes;
out
}
fn strip_dropped(mesh: &mut TriMesh, fates: &[(String, AttributeFate)]) {
mesh.attributes.retain(|c| {
!fates
.iter()
.any(|(n, f)| n == &c.name && matches!(f, AttributeFate::Dropped(_)))
});
}
fn relative_overlap(subject: &TriMesh, tools: &[&TriMesh]) -> Option<f64> {
let subject_bounds = subject.bounds();
let mut worst: Option<f64> = None;
for tool in tools {
let tool_bounds = tool.bounds();
if !subject_bounds.intersects(&tool_bounds) {
continue;
}
let lo = subject_bounds.min.max(tool_bounds.min);
let hi = subject_bounds.max.min(tool_bounds.max);
let overlap = hi - lo;
let scale = subject_bounds
.diagonal()
.length()
.max(tool_bounds.diagonal().length());
if !scale.is_finite() || scale <= 0.0 {
continue;
}
let thinnest = overlap.x.min(overlap.y).min(overlap.z);
if !thinnest.is_finite() {
continue;
}
let ratio = (thinnest / scale).max(0.0);
worst = Some(worst.map_or(ratio, |w: f64| w.min(ratio)));
}
worst
}
impl BoolmeshBoolean {
pub(crate) fn subtract_grouped(
&self,
subject: &TriMesh,
tools: &[TriMesh],
options: &ExecutionOptions,
) -> GeomResult<BooleanOutcome> {
let bounds: Vec<_> = tools.iter().map(TriMesh::bounds).collect();
let groups = crate::grouping::disjoint_groups(&bounds);
let mut current = subject.clone();
let mut sub_operations = 0;
let mut fates = seed_fates(subject);
for group in &groups {
options.check_cancelled()?;
sub_operations += 1;
let step = if let [only] = group.as_slice() {
self.difference(¤t, &tools[*only], options)?
} else {
let members: Vec<&TriMesh> = group.iter().map(|&i| &tools[i]).collect();
let fused = crate::grouping::fuse_with_channels(&members, &subject.attributes);
self.difference(¤t, &fused, options)?
};
fates = merge_fates(&fates, step.evidence.attribute_fates);
current = step.mesh;
}
let borrowed: Vec<&TriMesh> = tools.iter().collect();
let evidence =
evidence_for(subject, &borrowed, ¤t, sub_operations).with_attribute_fates(fates);
Ok(BooleanOutcome::new(current, evidence))
}
pub(crate) fn union_tree(
&self,
solids: &[TriMesh],
options: &ExecutionOptions,
) -> GeomResult<BooleanOutcome> {
if solids.is_empty() {
let borrowed: Vec<&TriMesh> = Vec::new();
let empty = TriMesh::default();
let evidence = evidence_for(&empty, &borrowed, &empty, 0);
return Ok(BooleanOutcome::new(empty, evidence));
}
let template = &solids[0].attributes;
let mut level: Vec<(TriMesh, Fates)> = solids
.iter()
.map(|s| (conform(s, template), seed_fates(&solids[0])))
.collect();
let mut sub_operations = 0;
while level.len() > 1 {
let mut pairs = level.chunks_exact(2);
let step = |pair: &[(TriMesh, Fates)]| -> GeomResult<(TriMesh, Fates)> {
let out = self.union(&pair[0].0, &pair[1].0, options)?;
let history = merge_fates(&pair[0].1, pair[1].1.clone());
Ok((
out.mesh,
merge_fates(&history, out.evidence.attribute_fates),
))
};
options.check_cancelled()?;
#[cfg(feature = "parallel-batch")]
let mut next: Vec<(TriMesh, Fates)> = {
use rayon::prelude::*;
let chunks: Vec<&[(TriMesh, Fates)]> = pairs.clone().collect();
let merged: Result<Vec<(TriMesh, Fates)>, GeomError> =
chunks.par_iter().map(|pair| step(pair)).collect();
merged?
};
#[cfg(not(feature = "parallel-batch"))]
let mut next: Vec<(TriMesh, Fates)> = {
let mut acc = Vec::with_capacity(level.len().div_ceil(2));
for pair in pairs.clone() {
acc.push(step(pair)?);
}
acc
};
sub_operations += next.len();
for _ in pairs.by_ref() {}
if let Some(last) = pairs.remainder().first() {
next.push(last.clone());
}
level = next;
}
let (mut result, fates) = level.into_iter().next().expect("non-empty input");
strip_dropped(&mut result, &fates);
let borrowed: Vec<&TriMesh> = solids.iter().collect();
let (subject, tools) = borrowed.split_first().expect("non-empty input");
let evidence =
evidence_for(subject, tools, &result, sub_operations).with_attribute_fates(fates);
Ok(BooleanOutcome::new(result, evidence))
}
fn union(
&self,
subject: &TriMesh,
tool: &TriMesh,
options: &ExecutionOptions,
) -> GeomResult<BooleanOutcome> {
self.boolean(subject, tool, BooleanOperator::Union, options)
}
fn difference(
&self,
subject: &TriMesh,
tool: &TriMesh,
options: &ExecutionOptions,
) -> GeomResult<BooleanOutcome> {
self.boolean(subject, tool, BooleanOperator::Difference, options)
}
}
#[cfg(test)]
mod tests {
use super::*;
use axiolid_core::Point3;
fn inside_out_tetrahedron() -> TriMesh {
let positions = vec![
Point3::new(0.0, 0.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
Point3::new(0.0, 1.0, 0.0),
Point3::new(0.0, 0.0, 1.0),
];
let indices = vec![0, 1, 2, 0, 3, 1, 0, 2, 3, 1, 3, 2];
TriMesh::new(positions, indices)
}
fn positive_infinite_volume() -> TriMesh {
let large = 1.0e308;
let small = 1.0e-308;
TriMesh::new(
vec![
Point3::new(small, small, 1.0),
Point3::new(large, 1.0, small),
Point3::new(small, large, 1.0),
],
vec![0, 1, 2],
)
}
#[test]
fn an_inside_out_result_is_blamed_on_the_backend() {
let error = check_result(&inside_out_tetrahedron(), BooleanOperator::Difference)
.expect_err("an inside-out result must be rejected");
match error {
GeomError::BackendContractViolation { backend, detail } => {
assert_eq!(backend, BoolmeshBoolean::ID);
assert!(detail.contains("inside-out"), "{detail}");
}
other => panic!("must blame the backend, not the caller: {other:?}"),
}
}
#[test]
fn a_non_finite_result_is_blamed_on_the_backend() {
let error = check_result(&positive_infinite_volume(), BooleanOperator::Union)
.expect_err("a non-finite result volume must be rejected");
assert!(
matches!(error, GeomError::BackendContractViolation { backend, ref detail }
if backend == BoolmeshBoolean::ID && detail.contains("non-finite signed volume")),
"must blame the backend, got {error:?}"
);
}
#[test]
fn an_empty_result_is_accepted() {
assert!(check_result(&TriMesh::default(), BooleanOperator::Difference).is_ok());
}
#[test]
fn an_outward_result_is_accepted() {
let mut mesh = inside_out_tetrahedron();
for corner in mesh.indices.chunks_exact_mut(3) {
corner.swap(1, 2);
}
assert!(check_result(&mesh, BooleanOperator::Difference).is_ok());
}
}