axiolid_minkowski/
lib.rs

1//! Minkowski sum and difference of planar-faced solids.
2//!
3//! # The convex case is the tractable one
4//!
5//! The Minkowski sum of two convex polyhedra is the convex hull of their
6//! pairwise vertex sums. That is exact, needs no boolean solver, and is
7//! what [`minkowski_sum`] computes.
8//!
9//! Non-convex operands have no such shortcut. The standard construction
10//! decomposes both into convex parts, sums every pair, and unions the
11//! results — which is why [`minkowski_sum_with`] takes a boolean provider.
12//! Cost is the product of the part counts, so it is budgeted rather than
13//! left to run away.
14//!
15//! # Difference is not the sum run backwards
16//!
17//! `A ⊖ B` is the set of translations that keep `B` inside `A`:
18//! `{ x : x + B ⊆ A }`. It is an erosion, not a hull of pairwise
19//! differences, and computing it as one is a common and silent error.
20//!
21//! When `A` is convex the containment test reduces to the vertices of `B`,
22//! because a convex `A` containing every `x + vᵢ` contains their hull and
23//! therefore all of `x + B`. That gives
24//!
25//! ```text
26//! A ⊖ B = ⋂ᵢ (A − vᵢ)   over the vertices vᵢ of B
27//! ```
28//!
29//! which is exact and computable with intersections alone. For non-convex
30//! `A` the reduction fails — `A` can contain each translated vertex while
31//! missing the material between them — so [`minkowski_difference_with`]
32//! refuses a non-convex subject by name rather than returning a result that
33//! is too large.
34//!
35//! # Curved operands
36//!
37//! Both operations are defined here for planar-faced solids. A curved
38//! operand is refused, matching the rest of the offset work: the sum of two
39//! curved solids is not a polyhedron and approximating it silently would
40//! misreport what the result is.
41
42use 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/// Why a Minkowski operation could not be performed.
50#[derive(Debug, Clone, PartialEq, Error)]
51#[non_exhaustive]
52pub enum MinkowskiError {
53    /// An operand is not a closed two-manifold solid.
54    #[error("{operand} is not a closed two-manifold solid: {boundary} boundary and {non_manifold} non-manifold edges")]
55    NotASolid {
56        /// Which operand failed: `"subject"` or `"tool"`.
57        operand: &'static str,
58        /// Edges with a single incident triangle.
59        boundary: usize,
60        /// Edges with more than two incident triangles.
61        non_manifold: usize,
62    },
63    /// An operand has no vertices to sum.
64    #[error("{0} is empty")]
65    EmptyOperand(&'static str),
66    /// [`minkowski_sum`] was given a non-convex operand.
67    ///
68    /// The convex-only entry point refuses rather than silently returning
69    /// the hull, which for a non-convex operand is strictly too large.
70    #[error("{operand} is not convex; use minkowski_sum_with to decompose it")]
71    NotConvex {
72        /// Which operand failed.
73        operand: &'static str,
74    },
75    /// The erosion's subject is not convex.
76    ///
77    /// Refused rather than approximated: for a non-convex subject the
78    /// vertex-wise containment test admits translations that do not
79    /// actually fit, so the result would be too large.
80    #[error("erosion requires a convex subject, and this one is not")]
81    ErosionSubjectNotConvex,
82    /// The decomposition of an operand failed.
83    #[error("decomposing {operand} failed: {reason}")]
84    DecompositionFailed {
85        /// Which operand failed.
86        operand: &'static str,
87        /// What the decomposer reported.
88        reason: String,
89    },
90    /// A hull could not be built from a pairwise vertex sum.
91    #[error("the hull of a pairwise sum failed: {0}")]
92    HullFailed(String),
93    /// A boolean step failed.
94    #[error("combining parts failed: {0}")]
95    BooleanFailed(String),
96    /// The pairwise product exceeds the budget.
97    ///
98    /// Reported rather than run: the work is the product of the two part
99    /// counts, so a modestly non-convex pair can be very expensive, and a
100    /// caller deserves to know before it starts rather than after.
101    #[error("the decomposition needs {pairs} pairwise sums, over the {limit} budget")]
102    BudgetExceeded {
103        /// Pairwise sums the request would have taken.
104        pairs: usize,
105        /// The cap that was not raised.
106        limit: usize,
107    },
108}
109
110/// Largest number of pairwise convex sums before the work is refused.
111const MAX_PAIRS: usize = 4096;
112
113/// What was done to produce a Minkowski result.
114///
115/// Reported rather than implied: a caller that asked for a sum of two
116/// solids it believed convex should be able to see whether decomposition
117/// was needed, and how much work it cost.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119#[non_exhaustive]
120pub struct MinkowskiEvidence {
121    /// Convex parts the subject was split into.
122    pub subject_parts: usize,
123    /// Convex parts the tool was split into.
124    pub tool_parts: usize,
125    /// Pairwise convex sums performed.
126    pub pairwise_sums: usize,
127    /// Boolean operations used to combine them.
128    pub boolean_operations: usize,
129}
130
131impl MinkowskiEvidence {
132    /// Whether both operands were already convex.
133    pub fn was_convex(&self) -> bool {
134        self.subject_parts == 1 && self.tool_parts == 1
135    }
136}
137
138/// A Minkowski result and what it took to produce.
139#[derive(Debug, Clone, PartialEq)]
140#[non_exhaustive]
141pub struct MinkowskiOutcome {
142    /// The resulting solid.
143    pub mesh: TriMesh,
144    /// What was done.
145    pub evidence: MinkowskiEvidence,
146}
147
148/// Minkowski sum of two CONVEX planar-faced solids.
149///
150/// The result is the convex hull of the pairwise vertex sums, which is
151/// exact for convex operands and needs no boolean solver.
152///
153/// # Errors
154///
155/// Refuses an operand that is not a closed two-manifold solid, is empty, or
156/// is not convex. For non-convex operands use [`minkowski_sum_with`].
157pub 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    // Convexity is checked by asking the decomposer: a convex solid is its
166    // own single part. Reusing that keeps one definition of convex in the
167    // codebase rather than a second, subtly different one here.
168    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
177/// Minkowski sum of two planar-faced solids, convex or not.
178///
179/// Non-convex operands are decomposed into convex parts; every pair is
180/// summed and the results unioned through `provider`.
181///
182/// # Errors
183///
184/// As [`minkowski_sum`] for malformed operands, plus decomposition, hull,
185/// boolean, and budget failures.
186pub 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
239/// Minkowski difference: the translations of `tool` that stay inside
240/// `subject`.
241///
242/// Computed as the intersection of `subject` translated by the negated
243/// vertices of `tool`, which is exact when `subject` is convex.
244///
245/// # Errors
246///
247/// Refuses malformed operands, and a non-convex subject by name: the
248/// vertex-wise containment test is only sufficient for a convex subject, so
249/// any other answer would be too large.
250pub 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    // Only the tool's distinct vertices matter: containment of the hull
264    // follows from containment of its extreme points, and repeating a
265    // vertex would repeat an identical intersection.
266    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        // An empty intersection is a legitimate answer: the tool does not
279        // fit inside the subject in any translation. Stopping early avoids
280        // intersecting an empty solid, which many backends reject.
281        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
297/// Convex hull of every pairwise sum of two point sets.
298fn 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
309/// Translate every vertex of a mesh.
310fn 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
317/// Distinct positions of a mesh, as offset vectors.
318///
319/// Never empty: callers have already established the mesh is a solid, so it
320/// has vertices.
321fn 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
338/// Whether a solid is convex, as the decomposer defines it.
339fn 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
348/// Decompose an operand into convex parts.
349fn 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
363/// Refuse an operand that is not a closed two-manifold solid.
364fn 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}