Skip to main content

axiolid_decompose/
split.rs

1//! Splitting a solid by a plane, either hand-rolled or via a boolean provider.
2//!
3//! # Why both exist
4//!
5//! Decomposition needs one primitive: cut a solid with a plane and keep both
6//! halves as closed solids. The hand-rolled clipper does this with no
7//! dependencies, which matters because `axiolid-decompose` should be usable
8//! without pulling in a boolean backend.
9//!
10//! But deciding what to do with a face lying ON the cut plane is a global
11//! question about which side the material is on, and a boolean solver
12//! answers it properly. So a caller that already has one can pass it in.
13//!
14//! # Why a contract and not a direct dependency
15//!
16//! `axiolid-decompose` is an `algorithms` crate and `boolmesh` is a
17//! `providers` crate; that dependency edge is forbidden and the
18//! architecture gate enforces it. Both layers may depend on `contracts`,
19//! so the provider arrives as a `&dyn MeshBoolean` and any implementation
20//! works. The choice is per call, not per build.
21
22use axiolid_contracts::ExecutionOptions;
23use axiolid_core::{BooleanOperator, Point3, Scalar, Tolerance, Vec3};
24use axiolid_mesh::TriMesh;
25use axiolid_mesh_boolean_contract::MeshBoolean;
26
27use crate::DecomposeError;
28
29/// How a solid gets cut in two.
30///
31/// The variants are deliberately not a build-time switch: a caller may use
32/// the hand-rolled clipper for one solid and a provider for the next.
33pub enum Splitter<'a> {
34    /// Clip in-crate, with no boolean backend.
35    HandRolled,
36    /// Delegate to a mesh boolean provider.
37    Provider(&'a dyn MeshBoolean),
38}
39
40impl std::fmt::Debug for Splitter<'_> {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::HandRolled => f.write_str("Splitter::HandRolled"),
44            Self::Provider(_) => f.write_str("Splitter::Provider"),
45        }
46    }
47}
48
49impl Splitter<'_> {
50    /// Cut `mesh` by the plane `normal · x = offset`.
51    ///
52    /// Returns the part behind the plane and the part in front, each closed.
53    /// `None` for a side means the plane did not separate any material there.
54    pub fn split(
55        &self,
56        mesh: &TriMesh,
57        normal: Vec3,
58        offset: Scalar,
59        tolerance: Tolerance,
60    ) -> Result<(Option<TriMesh>, Option<TriMesh>), DecomposeError> {
61        match self {
62            Self::HandRolled => Ok((
63                crate::clip(mesh, normal, offset, tolerance),
64                crate::clip(mesh, -normal, -offset, tolerance),
65            )),
66            Self::Provider(provider) => {
67                let behind = intersect_half_space(*provider, mesh, normal, offset, tolerance)?;
68                let front = intersect_half_space(*provider, mesh, -normal, -offset, tolerance)?;
69                Ok((behind, front))
70            }
71        }
72    }
73}
74
75/// Intersect a solid with the half-space `normal · x <= offset`.
76///
77/// A boolean provider works on solids, not half-spaces, so the half-space
78/// is realised as a box large enough to contain the mesh with room to
79/// spare. Sizing it from the mesh's own extent rather than a fixed constant
80/// keeps the construction scale-free: a model in millimetres and the same
81/// model in kilometres both get a box that comfortably encloses them.
82fn intersect_half_space(
83    provider: &dyn MeshBoolean,
84    mesh: &TriMesh,
85    normal: Vec3,
86    offset: Scalar,
87    tolerance: Tolerance,
88) -> Result<Option<TriMesh>, DecomposeError> {
89    let Some(reach) = enclosing_reach(mesh) else {
90        return Ok(None);
91    };
92
93    let tool = half_space_box(normal, offset, reach);
94    let options = ExecutionOptions::new(tolerance);
95    let outcome = provider
96        .boolean(mesh, &tool, BooleanOperator::Intersection, &options)
97        .map_err(|error| DecomposeError::SplitFailed(error.to_string()))?;
98
99    // An empty intersection is a legitimate answer: the plane missed this
100    // part entirely. It is reported as "no material on that side" rather
101    // than as an error, because the caller's next move differs.
102    if outcome.mesh.indices.is_empty() {
103        return Ok(None);
104    }
105    Ok(Some(outcome.mesh))
106}
107
108/// Half-diagonal of the mesh's bounding box, plus a margin.
109///
110/// `None` when the mesh has no extent, which means there is nothing to cut.
111fn enclosing_reach(mesh: &TriMesh) -> Option<Scalar> {
112    if mesh.positions.is_empty() {
113        return None;
114    }
115    let mut min = Point3::new(Scalar::INFINITY, Scalar::INFINITY, Scalar::INFINITY);
116    let mut max = Point3::new(
117        Scalar::NEG_INFINITY,
118        Scalar::NEG_INFINITY,
119        Scalar::NEG_INFINITY,
120    );
121    for p in &mesh.positions {
122        min = Point3::new(min.x.min(p.x), min.y.min(p.y), min.z.min(p.z));
123        max = Point3::new(max.x.max(p.x), max.y.max(p.y), max.z.max(p.z));
124    }
125    let span = max - min;
126    if !span.is_finite() {
127        return None;
128    }
129    let diagonal = span.length();
130    if diagonal <= 0.0 {
131        return None;
132    }
133    // Doubling keeps every face of the tool clear of the subject, so the
134    // only surface the boolean has to resolve is the cut plane itself.
135    Some(diagonal * 2.0)
136}
137
138/// A box filling `normal · x <= offset` out to `reach` in every direction.
139fn half_space_box(normal: Vec3, offset: Scalar, reach: Scalar) -> TriMesh {
140    let unit = normal.normalize();
141    // Any orthonormal pair spanning the plane; the box's shape does not
142    // depend on which, only its orientation about the normal.
143    let seed = if unit.x.abs() <= unit.y.abs() && unit.x.abs() <= unit.z.abs() {
144        Vec3::X
145    } else if unit.y.abs() <= unit.z.abs() {
146        Vec3::Y
147    } else {
148        Vec3::Z
149    };
150    let u = unit.cross(seed).normalize();
151    let v = unit.cross(u);
152
153    // Front face sits ON the cut plane; the box extends backwards from it.
154    let centre = unit * offset;
155    let corner = |du: Scalar, dv: Scalar, dn: Scalar| centre + u * du + v * dv + unit * dn;
156
157    let positions = vec![
158        corner(-reach, -reach, 0.0),
159        corner(reach, -reach, 0.0),
160        corner(reach, reach, 0.0),
161        corner(-reach, reach, 0.0),
162        corner(-reach, -reach, -reach * 2.0),
163        corner(reach, -reach, -reach * 2.0),
164        corner(reach, reach, -reach * 2.0),
165        corner(-reach, reach, -reach * 2.0),
166    ];
167    // Wound outward: the front face (on the cut plane) points along `unit`.
168    let indices = vec![
169        0, 1, 2, 0, 2, 3, // front, on the plane
170        4, 6, 5, 4, 7, 6, // back
171        0, 4, 5, 0, 5, 1, // sides
172        1, 5, 6, 1, 6, 2, //
173        2, 6, 7, 2, 7, 3, //
174        3, 7, 4, 3, 4, 0, //
175    ];
176    TriMesh::new(positions, indices)
177}