1use axiolid_contracts::ExecutionOptions;
43use axiolid_core::{BooleanOperator, Point3, Scalar, Tolerance, Vec3};
44use axiolid_decompose::{convex_decompose_with, split::Splitter, Strategy};
45use axiolid_mesh::{audit_mesh, TriMesh};
46use axiolid_mesh_boolean_contract::MeshBoolean;
47use thiserror::Error;
48
49#[derive(Debug, Clone, PartialEq, Error)]
51#[non_exhaustive]
52pub enum MinkowskiError {
53 #[error("{operand} is not a closed two-manifold solid: {boundary} boundary and {non_manifold} non-manifold edges")]
55 NotASolid {
56 operand: &'static str,
58 boundary: usize,
60 non_manifold: usize,
62 },
63 #[error("{0} is empty")]
65 EmptyOperand(&'static str),
66 #[error("{operand} is not convex; use minkowski_sum_with to decompose it")]
71 NotConvex {
72 operand: &'static str,
74 },
75 #[error("erosion requires a convex subject, and this one is not")]
81 ErosionSubjectNotConvex,
82 #[error("decomposing {operand} failed: {reason}")]
84 DecompositionFailed {
85 operand: &'static str,
87 reason: String,
89 },
90 #[error("the hull of a pairwise sum failed: {0}")]
92 HullFailed(String),
93 #[error("combining parts failed: {0}")]
95 BooleanFailed(String),
96 #[error("the decomposition needs {pairs} pairwise sums, over the {limit} budget")]
102 BudgetExceeded {
103 pairs: usize,
105 limit: usize,
107 },
108}
109
110const MAX_PAIRS: usize = 4096;
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119#[non_exhaustive]
120pub struct MinkowskiEvidence {
121 pub subject_parts: usize,
123 pub tool_parts: usize,
125 pub pairwise_sums: usize,
127 pub boolean_operations: usize,
129}
130
131impl MinkowskiEvidence {
132 pub fn was_convex(&self) -> bool {
134 self.subject_parts == 1 && self.tool_parts == 1
135 }
136}
137
138#[derive(Debug, Clone, PartialEq)]
140#[non_exhaustive]
141pub struct MinkowskiOutcome {
142 pub mesh: TriMesh,
144 pub evidence: MinkowskiEvidence,
146}
147
148pub fn minkowski_sum(
158 subject: &TriMesh,
159 tool: &TriMesh,
160 tolerance: Tolerance,
161) -> Result<TriMesh, MinkowskiError> {
162 require_solid(subject, "subject", tolerance)?;
163 require_solid(tool, "tool", tolerance)?;
164
165 if !is_convex(subject, tolerance)? {
169 return Err(MinkowskiError::NotConvex { operand: "subject" });
170 }
171 if !is_convex(tool, tolerance)? {
172 return Err(MinkowskiError::NotConvex { operand: "tool" });
173 }
174 convex_sum(&subject.positions, &tool.positions)
175}
176
177pub fn minkowski_sum_with(
187 subject: &TriMesh,
188 tool: &TriMesh,
189 tolerance: Tolerance,
190 provider: &dyn MeshBoolean,
191) -> Result<MinkowskiOutcome, MinkowskiError> {
192 require_solid(subject, "subject", tolerance)?;
193 require_solid(tool, "tool", tolerance)?;
194
195 let splitter = Splitter::Provider(provider);
196 let subject_parts = decompose(subject, "subject", tolerance, &splitter)?;
197 let tool_parts = decompose(tool, "tool", tolerance, &splitter)?;
198
199 let pairs = subject_parts.len().saturating_mul(tool_parts.len());
200 if pairs > MAX_PAIRS {
201 return Err(MinkowskiError::BudgetExceeded {
202 pairs,
203 limit: MAX_PAIRS,
204 });
205 }
206
207 let mut summed: Vec<TriMesh> = Vec::with_capacity(pairs);
208 for left in &subject_parts {
209 for right in &tool_parts {
210 summed.push(convex_sum(&left.positions, &right.positions)?);
211 }
212 }
213
214 let options = ExecutionOptions::new(tolerance);
215 let mut boolean_operations = 0usize;
216 let mut result = summed
217 .first()
218 .cloned()
219 .ok_or(MinkowskiError::EmptyOperand("subject"))?;
220 for piece in summed.iter().skip(1) {
221 let outcome = provider
222 .boolean(&result, piece, BooleanOperator::Union, &options)
223 .map_err(|error| MinkowskiError::BooleanFailed(error.to_string()))?;
224 boolean_operations += 1;
225 result = outcome.mesh;
226 }
227
228 Ok(MinkowskiOutcome {
229 mesh: result,
230 evidence: MinkowskiEvidence {
231 subject_parts: subject_parts.len(),
232 tool_parts: tool_parts.len(),
233 pairwise_sums: summed.len(),
234 boolean_operations,
235 },
236 })
237}
238
239pub fn minkowski_difference_with(
251 subject: &TriMesh,
252 tool: &TriMesh,
253 tolerance: Tolerance,
254 provider: &dyn MeshBoolean,
255) -> Result<MinkowskiOutcome, MinkowskiError> {
256 require_solid(subject, "subject", tolerance)?;
257 require_solid(tool, "tool", tolerance)?;
258
259 if !is_convex(subject, tolerance)? {
260 return Err(MinkowskiError::ErosionSubjectNotConvex);
261 }
262
263 let offsets = distinct_points(&tool.positions, tolerance);
267 let options = ExecutionOptions::new(tolerance);
268
269 let mut result = translated(subject, -offsets[0]);
270 let mut boolean_operations = 0usize;
271 for offset in offsets.iter().skip(1) {
272 let shifted = translated(subject, -*offset);
273 let outcome = provider
274 .boolean(&result, &shifted, BooleanOperator::Intersection, &options)
275 .map_err(|error| MinkowskiError::BooleanFailed(error.to_string()))?;
276 boolean_operations += 1;
277 result = outcome.mesh;
278 if result.indices.is_empty() {
282 break;
283 }
284 }
285
286 Ok(MinkowskiOutcome {
287 mesh: result,
288 evidence: MinkowskiEvidence {
289 subject_parts: 1,
290 tool_parts: offsets.len(),
291 pairwise_sums: 0,
292 boolean_operations,
293 },
294 })
295}
296
297fn convex_sum(left: &[Point3], right: &[Point3]) -> Result<TriMesh, MinkowskiError> {
299 let mut sums = Vec::with_capacity(left.len() * right.len());
300 for a in left {
301 for b in right {
302 sums.push(*a + Vec3::new(b.x, b.y, b.z));
303 }
304 }
305 axiolid_construct::hull::convex_hull(&sums)
306 .map_err(|error| MinkowskiError::HullFailed(error.to_string()))
307}
308
309fn translated(mesh: &TriMesh, offset: Vec3) -> TriMesh {
311 TriMesh::new(
312 mesh.positions.iter().map(|p| *p + offset).collect(),
313 mesh.indices.clone(),
314 )
315}
316
317fn distinct_points(positions: &[Point3], tolerance: Tolerance) -> Vec<Vec3> {
322 let step = tolerance.linear().max(Scalar::EPSILON);
323 let mut seen = std::collections::BTreeSet::new();
324 let mut out = Vec::new();
325 for p in positions {
326 let key = (
327 (p.x / step).round() as i64,
328 (p.y / step).round() as i64,
329 (p.z / step).round() as i64,
330 );
331 if seen.insert(key) {
332 out.push(Vec3::new(p.x, p.y, p.z));
333 }
334 }
335 out
336}
337
338fn is_convex(mesh: &TriMesh, tolerance: Tolerance) -> Result<bool, MinkowskiError> {
340 let decomposition = axiolid_decompose::convex_decompose(mesh, Strategy::Exact, tolerance)
341 .map_err(|error| MinkowskiError::DecompositionFailed {
342 operand: "operand",
343 reason: error.to_string(),
344 })?;
345 Ok(decomposition.is_single_part())
346}
347
348fn decompose(
350 mesh: &TriMesh,
351 operand: &'static str,
352 tolerance: Tolerance,
353 splitter: &Splitter<'_>,
354) -> Result<Vec<TriMesh>, MinkowskiError> {
355 convex_decompose_with(mesh, Strategy::Exact, tolerance, splitter)
356 .map(|decomposition| decomposition.parts)
357 .map_err(|error| MinkowskiError::DecompositionFailed {
358 operand,
359 reason: error.to_string(),
360 })
361}
362
363fn require_solid(
365 mesh: &TriMesh,
366 operand: &'static str,
367 tolerance: Tolerance,
368) -> Result<(), MinkowskiError> {
369 if mesh.positions.is_empty() || mesh.indices.is_empty() {
370 return Err(MinkowskiError::EmptyOperand(operand));
371 }
372 let health = audit_mesh(mesh, tolerance);
373 if !health.is_closed_two_manifold() {
374 return Err(MinkowskiError::NotASolid {
375 operand,
376 boundary: health.boundary_edges,
377 non_manifold: health.non_manifold_edges,
378 });
379 }
380 Ok(())
381}