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}