Skip to main content

brepkit_operations/
split.rs

1//! Split a solid into two halves along a cutting plane.
2//!
3//! Divides a solid into two new solids along the specified cutting plane.
4
5use brepkit_math::tolerance::Tolerance;
6use brepkit_math::vec::{Point3, Vec3};
7use brepkit_topology::Topology;
8use brepkit_topology::solid::SolidId;
9
10use crate::boolean::{FaceSpec, assemble_solid_mixed};
11use crate::dot_normal_point;
12
13/// Result of splitting a solid: two halves.
14#[derive(Debug)]
15pub struct SplitResult {
16    /// The half on the positive side of the cutting plane (same side as normal).
17    pub positive: SolidId,
18    /// The half on the negative side of the cutting plane.
19    pub negative: SolidId,
20}
21
22/// Split a solid into two halves along a plane.
23///
24/// The cutting plane is defined by a point and a normal. The `positive`
25/// half contains geometry on the side the normal points toward; the
26/// `negative` half contains the rest.
27///
28/// # Algorithm
29///
30/// For each face of the solid:
31/// 1. Classify vertices as above (+), below (-), or on the plane
32/// 2. Faces entirely on one side go to that half
33/// 3. Faces straddling the plane are clipped into two fragments
34/// 4. A cap face (the cross-section) is added to close each half
35///
36/// # Errors
37///
38/// Returns an error if the plane doesn't intersect the solid, any face
39/// is NURBS, or the result cannot be assembled.
40#[allow(clippy::too_many_lines)]
41pub fn split(
42    topo: &mut Topology,
43    solid: SolidId,
44    plane_point: Point3,
45    plane_normal: Vec3,
46) -> Result<SplitResult, crate::OperationsError> {
47    let tol = Tolerance::new();
48    let normal = plane_normal.normalize()?;
49    let d = dot_normal_point(normal, plane_point);
50
51    let solid_data = topo.solid(solid)?;
52    let shell = topo.shell(solid_data.outer_shell())?;
53    let face_ids: Vec<brepkit_topology::face::FaceId> = shell.faces().to_vec();
54
55    let mut positive_specs: Vec<FaceSpec> = Vec::new();
56    let mut negative_specs: Vec<FaceSpec> = Vec::new();
57    let mut cap_points: Vec<Point3> = Vec::new();
58
59    for &fid in &face_ids {
60        let face = topo.face(fid)?;
61        let surface = face.surface().clone();
62        let verts = crate::boolean::face_polygon(topo, fid)?;
63        let dists: Vec<f64> = verts
64            .iter()
65            .map(|v| dot_normal_point(normal, *v) - d)
66            .collect();
67
68        let all_pos = dists.iter().all(|&di| di > -tol.linear);
69        let all_neg = dists.iter().all(|&di| di < tol.linear);
70
71        // Helper to create FaceSpec preserving the surface type.
72        let make_spec = |v: Vec<Point3>, surf: &brepkit_topology::face::FaceSurface| -> FaceSpec {
73            match surf {
74                brepkit_topology::face::FaceSurface::Plane { normal: fn_, d: fd } => {
75                    FaceSpec::Planar {
76                        vertices: v,
77                        normal: *fn_,
78                        d: *fd,
79                        inner_wires: vec![],
80                    }
81                }
82                other => FaceSpec::Surface {
83                    vertices: v,
84                    surface: other.clone(),
85                    reversed: false,
86                    inner_wires: vec![],
87                },
88            }
89        };
90
91        if all_pos && !all_neg {
92            positive_specs.push(make_spec(verts, &surface));
93        } else if all_neg && !all_pos {
94            negative_specs.push(make_spec(verts, &surface));
95        } else if all_pos && all_neg {
96            positive_specs.push(make_spec(verts.clone(), &surface));
97            negative_specs.push(make_spec(verts, &surface));
98        } else {
99            // Mixed: clip the face polygon. Vertex positions are interpolated
100            // along polygon edges (linear approximation of the actual
101            // surface-plane intersection curve). For curved faces, we
102            // preserve the original surface type so that downstream
103            // operations (tessellation, booleans) handle curvature correctly.
104            let (pos_verts, neg_verts, crossings) = clip_polygon(&verts, &dists, tol);
105
106            if pos_verts.len() >= 3 {
107                positive_specs.push(make_spec(pos_verts, &surface));
108            }
109            if neg_verts.len() >= 3 {
110                negative_specs.push(make_spec(neg_verts, &surface));
111            }
112
113            cap_points.extend(crossings);
114        }
115    }
116
117    if positive_specs.is_empty() || negative_specs.is_empty() {
118        return Err(crate::OperationsError::InvalidInput {
119            reason: "cutting plane does not split the solid (entirely on one side)".into(),
120        });
121    }
122
123    let cap = build_cap_polygon(&cap_points, normal, d, tol);
124
125    if let Some((cap_verts, cap_normal, cap_d)) = cap {
126        positive_specs.push(FaceSpec::Planar {
127            vertices: cap_verts.clone(),
128            normal: -cap_normal,
129            d: -cap_d,
130            inner_wires: vec![],
131        });
132        negative_specs.push(FaceSpec::Planar {
133            vertices: cap_verts,
134            normal: cap_normal,
135            d: cap_d,
136            inner_wires: vec![],
137        });
138    }
139
140    let pos_solid = assemble_solid_mixed(topo, &positive_specs, tol)?;
141    let neg_solid = assemble_solid_mixed(topo, &negative_specs, tol)?;
142
143    Ok(SplitResult {
144        positive: pos_solid,
145        negative: neg_solid,
146    })
147}
148
149/// Clip a polygon by a plane, producing positive and negative fragments.
150///
151/// Returns `(positive_verts, negative_verts, crossing_points)`.
152fn clip_polygon(
153    verts: &[Point3],
154    dists: &[f64],
155    tol: Tolerance,
156) -> (Vec<Point3>, Vec<Point3>, Vec<Point3>) {
157    let n = verts.len();
158    let mut pos_verts = Vec::new();
159    let mut neg_verts = Vec::new();
160    let mut crossings = Vec::new();
161
162    for i in 0..n {
163        let j = (i + 1) % n;
164        let di = dists[i];
165        let dj = dists[j];
166
167        if di >= -tol.linear {
168            pos_verts.push(verts[i]);
169        }
170        if di <= tol.linear {
171            neg_verts.push(verts[i]);
172        }
173
174        if (di > tol.linear && dj < -tol.linear) || (di < -tol.linear && dj > tol.linear) {
175            let t = di / (di - dj);
176            let pi = verts[i];
177            let pj = verts[j];
178            let ix = Point3::new(
179                (pj.x() - pi.x()).mul_add(t, pi.x()),
180                (pj.y() - pi.y()).mul_add(t, pi.y()),
181                (pj.z() - pi.z()).mul_add(t, pi.z()),
182            );
183            pos_verts.push(ix);
184            neg_verts.push(ix);
185            crossings.push(ix);
186        }
187    }
188
189    (pos_verts, neg_verts, crossings)
190}
191
192/// Build a cap polygon from crossing points.
193///
194/// Orders the points by angle around the centroid in the cutting plane.
195fn build_cap_polygon(
196    points: &[Point3],
197    normal: Vec3,
198    d: f64,
199    tol: Tolerance,
200) -> Option<(Vec<Point3>, Vec3, f64)> {
201    let mut unique = Vec::new();
202    for p in points {
203        if !unique
204            .iter()
205            .any(|q: &Point3| (*p - *q).length_squared() < tol.linear * tol.linear)
206        {
207            unique.push(*p);
208        }
209    }
210
211    if unique.len() < 3 {
212        return None;
213    }
214
215    #[allow(clippy::cast_precision_loss)]
216    let inv_n = 1.0 / unique.len() as f64;
217    let (cx, cy, cz) = unique.iter().fold((0.0, 0.0, 0.0), |(ax, ay, az), p| {
218        (ax + p.x(), ay + p.y(), az + p.z())
219    });
220    let centroid = Point3::new(cx * inv_n, cy * inv_n, cz * inv_n);
221
222    let candidate = if normal.x().abs() < 0.9 {
223        Vec3::new(1.0, 0.0, 0.0)
224    } else {
225        Vec3::new(0.0, 1.0, 0.0)
226    };
227    let u_axis = normal.cross(candidate);
228    let u_len = u_axis.length();
229    if u_len < tol.linear {
230        return None;
231    }
232    let u_axis = Vec3::new(u_axis.x() / u_len, u_axis.y() / u_len, u_axis.z() / u_len);
233    let v_axis = normal.cross(u_axis);
234
235    let mut angles: Vec<(f64, usize)> = unique
236        .iter()
237        .enumerate()
238        .map(|(i, p)| {
239            let offset = *p - centroid;
240            let u = u_axis.dot(offset);
241            let v = v_axis.dot(offset);
242            (v.atan2(u), i)
243        })
244        .collect();
245    angles.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
246
247    let ordered: Vec<Point3> = angles.iter().map(|&(_, i)| unique[i]).collect();
248
249    Some((ordered, normal, d))
250}
251
252#[cfg(test)]
253mod tests {
254    #![allow(clippy::unwrap_used)]
255
256    use brepkit_math::tolerance::Tolerance;
257    use brepkit_math::vec::{Point3, Vec3};
258    use brepkit_topology::Topology;
259    use brepkit_topology::test_utils::make_unit_cube_manifold;
260
261    use super::*;
262
263    #[test]
264    fn split_cube_at_half_height() {
265        let mut topo = Topology::new();
266        let cube = make_unit_cube_manifold(&mut topo);
267
268        let result = split(
269            &mut topo,
270            cube,
271            Point3::new(0.0, 0.0, 0.5),
272            Vec3::new(0.0, 0.0, 1.0),
273        )
274        .unwrap();
275
276        let vol_pos = crate::measure::solid_volume(&topo, result.positive, 0.1).unwrap();
277        let vol_neg = crate::measure::solid_volume(&topo, result.negative, 0.1).unwrap();
278
279        assert!(
280            vol_pos > 0.1,
281            "positive half should have volume, got {vol_pos}"
282        );
283        assert!(
284            vol_neg > 0.1,
285            "negative half should have volume, got {vol_neg}"
286        );
287
288        let tol = Tolerance::loose();
289        let total = vol_pos + vol_neg;
290        assert!(
291            tol.approx_eq(total, 1.0),
292            "halves should sum to ~1.0, got {total} ({vol_pos} + {vol_neg})"
293        );
294    }
295
296    #[test]
297    fn split_box_at_quarter() {
298        let mut topo = Topology::new();
299        let solid = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
300
301        // Box extends from (0,0,0) to (2,2,2). Cut at z=0.5 (quarter height).
302        let result = split(
303            &mut topo,
304            solid,
305            Point3::new(0.0, 0.0, 0.5),
306            Vec3::new(0.0, 0.0, 1.0),
307        )
308        .unwrap();
309
310        let vol_pos = crate::measure::solid_volume(&topo, result.positive, 0.1).unwrap();
311        let vol_neg = crate::measure::solid_volume(&topo, result.negative, 0.1).unwrap();
312
313        // Box from 0 to 2. Cut at z=0.5: positive is 3/4, negative is 1/4.
314        let total = vol_pos + vol_neg;
315        let tol = Tolerance::loose();
316        assert!(
317            tol.approx_eq(total, 8.0),
318            "halves should sum to ~8.0, got {total}"
319        );
320    }
321
322    #[test]
323    fn split_plane_misses_solid() {
324        let mut topo = Topology::new();
325        let cube = make_unit_cube_manifold(&mut topo);
326
327        // Plane above the cube.
328        let result = split(
329            &mut topo,
330            cube,
331            Point3::new(0.0, 0.0, 5.0),
332            Vec3::new(0.0, 0.0, 1.0),
333        );
334        assert!(result.is_err(), "plane above cube should fail");
335    }
336
337    #[test]
338    fn split_along_x_axis() {
339        let mut topo = Topology::new();
340        let cube = make_unit_cube_manifold(&mut topo);
341
342        let result = split(
343            &mut topo,
344            cube,
345            Point3::new(0.5, 0.0, 0.0),
346            Vec3::new(1.0, 0.0, 0.0),
347        )
348        .unwrap();
349
350        let vol_pos = crate::measure::solid_volume(&topo, result.positive, 0.1).unwrap();
351        let vol_neg = crate::measure::solid_volume(&topo, result.negative, 0.1).unwrap();
352
353        let total = vol_pos + vol_neg;
354        let tol = Tolerance::loose();
355        assert!(
356            tol.approx_eq(total, 1.0),
357            "halves should sum to ~1.0, got {total}"
358        );
359    }
360}