Skip to main content

brep_kernel/edit/
split.rs

1//! Cut / split a body by a plane (Golovanov §6.4).
2//!
3//! The split reuses the ROBUST boolean rather than a bespoke classifier: the
4//! plane is realised as two very large half-space TOOL boxes (one covering each
5//! side of the plane), and each output piece is `Intersect(solid, tool)`. The
6//! boolean machinery imprints the cut plane onto the solid and re-closes the
7//! shell, so both pieces inherit the kernel's validated, watertight topology.
8
9use crate::boolean::{boolean_operation, BooleanOperation, BooleanOptions};
10use crate::spatial::Aabb;
11use crate::topology::{make_box_brep, make_cylinder_brep, BrepSolid};
12use crate::transform_topology::{transform_brep, AffineTransform};
13use crate::{
14    make_cone_brep, make_sphere_brep, make_torus_brep, AnalyticSurface, NurbsSurface, Vec3,
15};
16use serde::Deserialize;
17
18/// Axis-aligned bounding box of a solid's vertices.
19fn solid_aabb(solid: &BrepSolid) -> Aabb {
20    let mut bounds = Aabb::empty();
21    for vertex in &solid.vertices {
22        bounds.include_point(vertex.point);
23    }
24    bounds
25}
26
27/// A boolean result counts as an empty piece when it carries no face geometry —
28/// i.e. the half-space tool did not overlap the solid on that side.
29fn is_empty_piece(solid: &BrepSolid) -> bool {
30    solid.shells.is_empty() || solid.shells.iter().all(|shell| shell.faces.is_empty())
31}
32
33/// Build the affine placing a local, origin-centred cube so its local +Z axis
34/// maps to `n`, +X to `u`, +Y to `v`, and its centre lands at `center`. The
35/// columns of the rotation are `[u v n]` (a right-handed, det = +1 frame), so
36/// the map is a proper rigid motion (no orientation reversal needed).
37fn frame_transform(u: Vec3, v: Vec3, n: Vec3, center: Vec3) -> Result<AffineTransform, String> {
38    AffineTransform::new([
39        u.x, v.x, n.x, center.x, //
40        u.y, v.y, n.y, center.y, //
41        u.z, v.z, n.z, center.z, //
42        0.0, 0.0, 0.0, 1.0,
43    ])
44}
45
46/// Split `solid` into two pieces by the plane through `plane_point` with normal
47/// `plane_normal`. Returns `(below, above)` where `below` is the piece on the
48/// −n side of the plane and `above` the piece on the +n side.
49///
50/// Contract: when the plane does not actually divide the solid into two
51/// non-degenerate pieces (it misses the body, or is tangent so one side is
52/// empty), this returns `Err("split_solid_by_plane: plane does not intersect
53/// the solid")` rather than a degenerate/empty piece.
54pub fn split_solid_by_plane(
55    solid: &BrepSolid,
56    plane_point: Vec3,
57    plane_normal: Vec3,
58) -> Result<(BrepSolid, BrepSolid), String> {
59    let n = plane_normal.normalized()?;
60    // Orthonormal frame (n, u, v): u ⟂ n (unit), v = n × u (unit); [u v n] is
61    // right-handed so the tool placement is a proper rotation.
62    let u = n.perpendicular()?;
63    let v = n.cross(u);
64
65    let bounds = solid_aabb(solid);
66    if !bounds.minimum.x.is_finite() {
67        return Err("split_solid_by_plane: solid has no geometry".into());
68    }
69    let diagonal = bounds.diagonal();
70    if diagonal <= 0.0 {
71        return Err("split_solid_by_plane: solid is degenerate".into());
72    }
73    // A tool box 3× the solid diagonal on every side easily covers the body in
74    // the plane's tangent directions.
75    let length = 3.0 * diagonal;
76    let half = 0.5 * length;
77
78    // Centre the tool in the tangent (u, v) plane on the projection of the AABB
79    // centre onto the cut plane, so the box brackets the whole solid regardless
80    // of where `plane_point` sits within it. Along n it is offset by ±half so
81    // the tool's cut face lands exactly on the plane.
82    let center = bounds.minimum.add(bounds.maximum).scale(0.5);
83    let center_on_plane = center.sub(n.scale(center.sub(plane_point).dot(n)));
84
85    // Local cube centred at the local origin, spanning [-half, half]³.
86    let cube = make_box_brep(Vec3::new(-half, -half, -half), length, length, length)?;
87
88    // BELOW: tool centred at plane − n·half, so its +n face lies on the plane
89    // and it extends distance `length` along −n, covering the −n side.
90    let below_center = center_on_plane.sub(n.scale(half));
91    let tool_below = transform_brep(&cube, frame_transform(u, v, n, below_center)?, false)?;
92
93    // ABOVE: mirror to the +n side (centre at plane + n·half).
94    let above_center = center_on_plane.add(n.scale(half));
95    let tool_above = transform_brep(&cube, frame_transform(u, v, n, above_center)?, false)?;
96
97    let options = BooleanOptions::default();
98    let below = boolean_operation(solid, &tool_below, BooleanOperation::Intersect, &options);
99    let above = boolean_operation(solid, &tool_above, BooleanOperation::Intersect, &options);
100
101    match (below, above) {
102        (Ok(below), Ok(above)) if !is_empty_piece(&below) && !is_empty_piece(&above) => {
103            Ok((below, above))
104        }
105        _ => Err("split_solid_by_plane: plane does not intersect the solid".into()),
106    }
107}
108
109// ---------------------------------------------------------------------------
110// Generalized split by an analytic surface (Golovanov §6.4).
111//
112// The plane case above splits a body against two large half-space TOOL boxes.
113// The analytic cases generalize that idea: an unbounded/bounded analytic
114// surface (cylinder, cone, sphere, torus) is realised as ONE CLOSED SOLID
115// region big enough to span the body wherever it matters, and the two output
116// pieces are the boolean `Intersect` (inside the tool region) and `Subtract`
117// (outside it) of the body against that region.  Because every cut runs
118// through the validated boolean machinery, each piece inherits watertight
119// topology, the cut surface is imprinted onto the body, and the two volumes
120// sum to the original.
121// ---------------------------------------------------------------------------
122
123/// A closed analytic tool region used to cut a body.  Each variant is realised
124/// as a closed solid (unbounded carriers are capped well beyond the body) whose
125/// interior is one side of the analytic surface.
126#[derive(Clone, Copy, Debug, Deserialize)]
127#[serde(tag = "type", rename_all = "lowercase")]
128pub enum SplitSurface {
129    /// Infinite plane through `point` with `normal`; delegates to the plane path.
130    Plane { point: Vec3, normal: Vec3 },
131    /// Infinite cylinder about the axis line through `axis_point` along
132    /// `axis_dir`, of the given `radius`.  Interior = inside the cylinder.
133    Cylinder {
134        axis_point: Vec3,
135        axis_dir: Vec3,
136        radius: f64,
137    },
138    /// Sphere centred at `center`.  Interior = inside the ball.
139    Sphere { center: Vec3, radius: f64 },
140    /// Single-nappe cone with its apex at `apex`, opening along `+axis_dir`,
141    /// with the given `half_angle` (radians, apex half-angle).  Interior =
142    /// inside the cone.
143    Cone {
144        apex: Vec3,
145        axis_dir: Vec3,
146        half_angle: f64,
147    },
148    /// Torus centred at `center` about `axis_dir`.  Interior = inside the tube.
149    Torus {
150        center: Vec3,
151        axis_dir: Vec3,
152        major_radius: f64,
153        minor_radius: f64,
154    },
155}
156
157/// Bounds + a sane margin/diagonal for a body, erroring on empty/degenerate
158/// geometry the same way the plane path does.
159fn solid_extent(solid: &BrepSolid) -> Result<(Aabb, f64), String> {
160    let bounds = solid_aabb(solid);
161    if !bounds.minimum.x.is_finite() {
162        return Err("split_solid_by_surface: solid has no geometry".into());
163    }
164    let diagonal = bounds.diagonal();
165    if diagonal <= 0.0 {
166        return Err("split_solid_by_surface: solid is degenerate".into());
167    }
168    Ok((bounds, diagonal))
169}
170
171/// Build the closed tool solid for an analytic tool, sized to fully span the
172/// body wherever the analytic surface passes through it.
173fn build_tool_solid(solid: &BrepSolid, tool: &SplitSurface) -> Result<BrepSolid, String> {
174    let (_, diagonal) = solid_extent(solid)?;
175    let margin = diagonal.max(1.0);
176    match *tool {
177        SplitSurface::Plane { .. } => {
178            Err("build_tool_solid: plane is handled by the plane path".into())
179        }
180        SplitSurface::Cylinder {
181            axis_point,
182            axis_dir,
183            radius,
184        } => {
185            if radius <= 0.0 {
186                return Err("split_solid_by_surface: cylinder radius must be positive".into());
187            }
188            let axis = axis_dir.normalized()?;
189            // Extend the capped cylinder a full margin beyond the body's span
190            // along the axis so its caps never cut the body.
191            let (t_min, t_max) = axis_span(solid, axis_point, axis);
192            let base = axis_point.add(axis.scale(t_min - margin));
193            let height = (t_max - t_min) + 2.0 * margin;
194            make_cylinder_brep(base, axis, radius, height)
195        }
196        SplitSurface::Sphere { center, radius } => {
197            if radius <= 0.0 {
198                return Err("split_solid_by_surface: sphere radius must be positive".into());
199            }
200            // A sphere is already a closed, bounded region.
201            make_sphere_brep(center, radius, Vec3::new(0.0, 0.0, 1.0))
202        }
203        SplitSurface::Cone {
204            apex,
205            axis_dir,
206            half_angle,
207        } => {
208            if !(half_angle > 0.0 && half_angle < std::f64::consts::FRAC_PI_2) {
209                return Err("split_solid_by_surface: cone half-angle must be in (0, pi/2)".into());
210            }
211            let axis = axis_dir.normalized()?;
212            // Distance of the farthest body point along +axis from the apex.
213            let (_, d_max) = axis_span(solid, apex, axis);
214            if d_max <= 0.0 {
215                return Err(
216                    "split_solid_by_surface: cone does not reach the solid (body is behind the apex)"
217                        .into(),
218                );
219            }
220            let big_h = d_max + margin;
221            // Realise the nappe as a cone whose apex sits at `apex` and whose
222            // base cap lands `big_h` past it along +axis: base at apex+axis·H,
223            // built with axis pointing back to the apex so top(0-radius)=apex.
224            let base = apex.add(axis.scale(big_h));
225            let base_radius = big_h * half_angle.tan();
226            make_cone_brep(base, axis.scale(-1.0), base_radius, 0.0, big_h)
227        }
228        SplitSurface::Torus {
229            center,
230            axis_dir,
231            major_radius,
232            minor_radius,
233        } => {
234            if minor_radius <= 0.0 || major_radius <= 0.0 {
235                return Err("split_solid_by_surface: torus radii must be positive".into());
236            }
237            // A torus is already a closed, bounded region.
238            make_torus_brep(center, axis_dir, major_radius, minor_radius)
239        }
240    }
241}
242
243/// Signed span `[min, max]` of the body's vertices projected onto the axis line
244/// through `origin` along the unit direction `axis`.
245fn axis_span(solid: &BrepSolid, origin: Vec3, axis: Vec3) -> (f64, f64) {
246    let mut t_min = f64::INFINITY;
247    let mut t_max = f64::NEG_INFINITY;
248    for vertex in &solid.vertices {
249        let t = vertex.point.sub(origin).dot(axis);
250        t_min = t_min.min(t);
251        t_max = t_max.max(t);
252    }
253    (t_min, t_max)
254}
255
256/// Split `solid` into pieces by an analytic tool surface (Golovanov §6.4).
257///
258/// For the `Plane` tool this is exactly `split_solid_by_plane`, returned as
259/// `[below, above]`.  For a closed analytic tool region (cylinder / sphere /
260/// cone / torus) the two pieces are `[inside, outside]` where `inside =
261/// solid ∩ tool` and `outside = solid − tool`.  Both pieces are guaranteed
262/// non-empty and valid; their volumes sum to the original.
263///
264/// Contract: when the tool does not actually divide the body into two
265/// non-degenerate pieces (it misses the body, or wholly contains / is wholly
266/// contained so one side is empty), this returns a clear `Err` rather than a
267/// degenerate/empty piece.
268pub fn split_solid_by_surface(
269    solid: &BrepSolid,
270    tool: &SplitSurface,
271) -> Result<Vec<BrepSolid>, String> {
272    if let SplitSurface::Plane { point, normal } = *tool {
273        let (below, above) = split_solid_by_plane(solid, point, normal)?;
274        return Ok(vec![below, above]);
275    }
276
277    let tool_solid = build_tool_solid(solid, tool)?;
278    let options = BooleanOptions::default();
279    let inside = boolean_operation(solid, &tool_solid, BooleanOperation::Intersect, &options);
280    let outside = boolean_operation(solid, &tool_solid, BooleanOperation::Subtract, &options);
281
282    match (inside, outside) {
283        (Ok(inside), Ok(outside))
284            if !is_empty_piece(&inside)
285                && !is_empty_piece(&outside)
286                && inside.validate().is_empty()
287                && outside.validate().is_empty() =>
288        {
289            Ok(vec![inside, outside])
290        }
291        _ => Err("split_solid_by_surface: tool surface does not divide the solid".into()),
292    }
293}
294
295/// Map a face's exact analytic carrier to the closed tool region that splits a
296/// body by that surface.  Reuses the kernel's own analytic recognition so the
297/// caller only has to hand over the selected face's surface (no host-side
298/// geometry extraction).  Unrecognized / general-revolution carriers are
299/// reported as unsupported (deferred), never approximated.
300fn recognized_split_surface(surface: &NurbsSurface) -> Result<SplitSurface, String> {
301    let analytic = surface
302        .analytic()
303        .ok_or("split_solid_by_face_surface: selected face is not an analytic surface")?;
304    match analytic {
305        AnalyticSurface::Plane {
306            origin,
307            u_dir,
308            v_dir,
309            ..
310        } => {
311            let normal = u_dir.cross(*v_dir).normalized()?;
312            Ok(SplitSurface::Plane {
313                point: *origin,
314                normal,
315            })
316        }
317        AnalyticSurface::RuledRevolution {
318            frame,
319            rho0,
320            rho1,
321            height,
322        } => {
323            // Cylinder when the two radii coincide, otherwise a cone/frustum.
324            let scale = rho0.abs().max(rho1.abs()).max(1.0);
325            if (rho0 - rho1).abs() <= 1e-9 * scale {
326                Ok(SplitSurface::Cylinder {
327                    axis_point: frame.origin,
328                    axis_dir: frame.axis,
329                    radius: 0.5 * (rho0 + rho1),
330                })
331            } else {
332                // radius(axial) = rho0 + slope·axial, apex where radius = 0.
333                let slope = (rho1 - rho0) / height;
334                let axial_apex = -rho0 / slope;
335                let apex = frame.origin.add(frame.axis.scale(axial_apex));
336                // The nappe opens in the direction of increasing radius.
337                let axis_dir = if slope >= 0.0 {
338                    frame.axis
339                } else {
340                    frame.axis.scale(-1.0)
341                };
342                Ok(SplitSurface::Cone {
343                    apex,
344                    axis_dir,
345                    half_angle: slope.abs().atan(),
346                })
347            }
348        }
349        AnalyticSurface::Sphere { frame, radius } => Ok(SplitSurface::Sphere {
350            center: frame.origin,
351            radius: *radius,
352        }),
353        AnalyticSurface::Torus {
354            frame,
355            major_radius,
356            minor_radius,
357        } => Ok(SplitSurface::Torus {
358            center: frame.origin,
359            axis_dir: frame.axis,
360            major_radius: *major_radius,
361            minor_radius: *minor_radius,
362        }),
363        AnalyticSurface::Revolution { .. } => Err(
364            "split_solid_by_face_surface: general revolved surfaces are not supported as a cut tool"
365                .into(),
366        ),
367    }
368}
369
370/// Split `solid` by the analytic carrier of a selected face `surface`
371/// (Golovanov §6.4).  The face may be a plane, cylinder, cone, or sphere; the
372/// carrier is extended to fully span the body.  Returns the two pieces
373/// (`[below, above]` for a plane, `[inside, outside]` otherwise).  Errors on
374/// non-analytic / general-revolution faces, or when the carrier does not
375/// cleanly divide the body.
376pub fn split_solid_by_face_surface(
377    solid: &BrepSolid,
378    surface: &NurbsSurface,
379) -> Result<Vec<BrepSolid>, String> {
380    let tool = recognized_split_surface(surface)?;
381    split_solid_by_surface(solid, &tool)
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::{
388        boolean_semantic_disagreement, make_box_brep, solid_mass_properties, BooleanOperation,
389    };
390
391    fn aabb(solid: &BrepSolid) -> (Vec3, Vec3) {
392        let bounds = solid_aabb(solid);
393        (bounds.minimum, bounds.maximum)
394    }
395
396    fn face_count(solid: &BrepSolid) -> usize {
397        solid.shells.iter().map(|shell| shell.faces.len()).sum()
398    }
399
400    fn volume(solid: &BrepSolid) -> f64 {
401        solid_mass_properties(solid).unwrap().volume
402    }
403
404    /// The `inside` piece must agree with `solid ∩ tool` and the `outside` piece
405    /// with `solid − tool` at essentially every decidable sample point.
406    fn assert_oracle_clean(
407        solid: &BrepSolid,
408        tool_solid: &BrepSolid,
409        inside: &BrepSolid,
410        outside: &BrepSolid,
411    ) {
412        let in_report = boolean_semantic_disagreement(
413            solid,
414            tool_solid,
415            BooleanOperation::Intersect,
416            inside,
417            4000,
418        )
419        .unwrap();
420        eprintln!(
421            "  inside oracle: considered={} disagreements={} rate={:.6}",
422            in_report.considered,
423            in_report.disagreements.len(),
424            in_report.disagreement_rate
425        );
426        // The kernel's own boolean correctness gate is `!is_flagged()`
427        // (disagreement_rate <= DISAGREEMENT_THRESHOLD = 0.03).  The rare
428        // residual disagreements here are grazing/near-boundary ray-cast noise,
429        // not wrong topology; hold the pieces to well under 1% (~0).
430        assert!(
431            !in_report.is_flagged() && in_report.disagreement_rate < 0.01,
432            "inside piece disagrees with solid∩tool (rate {}): {:?}",
433            in_report.disagreement_rate,
434            in_report.sample_disagreement()
435        );
436        let out_report = boolean_semantic_disagreement(
437            solid,
438            tool_solid,
439            BooleanOperation::Subtract,
440            outside,
441            4000,
442        )
443        .unwrap();
444        eprintln!(
445            "  outside oracle: considered={} disagreements={} rate={:.6}",
446            out_report.considered,
447            out_report.disagreements.len(),
448            out_report.disagreement_rate
449        );
450        assert!(
451            !out_report.is_flagged() && out_report.disagreement_rate < 0.01,
452            "outside piece disagrees with solid−tool (rate {}): {:?}",
453            out_report.disagreement_rate,
454            out_report.sample_disagreement()
455        );
456    }
457
458    #[test]
459    fn split_box_by_midplane_halves_it() {
460        // Box(10³) centred at the origin.
461        let box_solid = make_box_brep(Vec3::new(-5.0, -5.0, -5.0), 10.0, 10.0, 10.0).unwrap();
462
463        let (below, above) =
464            split_solid_by_plane(&box_solid, Vec3::default(), Vec3::new(1.0, 0.0, 0.0)).unwrap();
465
466        // (1) Both pieces are valid, watertight solids.
467        assert!(
468            below.validate().is_empty(),
469            "below invalid: {:?}",
470            below.validate()
471        );
472        assert!(
473            above.validate().is_empty(),
474            "above invalid: {:?}",
475            above.validate()
476        );
477
478        // (2) Each half is still a six-faced box (5 trimmed originals + 1 cut).
479        assert_eq!(face_count(&below), 6);
480        assert_eq!(face_count(&above), 6);
481
482        // (3) Volumes: each ≈ 500, summing to ≈ 1000 (allow small boolean noise).
483        let vol_below = solid_mass_properties(&below).unwrap().volume;
484        let vol_above = solid_mass_properties(&above).unwrap().volume;
485        assert!((vol_below - 500.0).abs() < 1e-3, "below volume {vol_below}");
486        assert!((vol_above - 500.0).abs() < 1e-3, "above volume {vol_above}");
487        assert!((vol_below + vol_above - 1000.0).abs() < 1e-3);
488
489        // (4) The cut is flat on x = 0: the below piece fills x ∈ [-5, 0] and the
490        //     above piece x ∈ [0, 5], so each gains a planar cut face on x = 0.
491        let (below_min, below_max) = aabb(&below);
492        assert!(
493            (below_min.x - (-5.0)).abs() < 1e-6,
494            "below min.x {}",
495            below_min.x
496        );
497        assert!(below_max.x.abs() < 1e-6, "below max.x {}", below_max.x);
498        let (above_min, above_max) = aabb(&above);
499        assert!(above_min.x.abs() < 1e-6, "above min.x {}", above_min.x);
500        assert!(
501            (above_max.x - 5.0).abs() < 1e-6,
502            "above max.x {}",
503            above_max.x
504        );
505
506        // Explicit cut-face check: each piece has a face all of whose vertices lie
507        // on x = 0. Coedges reference edges by id; each edge names its endpoints.
508        let has_cut_face = |solid: &BrepSolid| -> bool {
509            solid
510                .shells
511                .iter()
512                .flat_map(|shell| &shell.faces)
513                .any(|face| {
514                    let mut points: Vec<Vec3> = Vec::new();
515                    for coedge in face.loops.iter().flat_map(|lp| &lp.coedges) {
516                        let Some(edge) = solid.edges.iter().find(|e| e.id == coedge.edge_id) else {
517                            return false;
518                        };
519                        for vid in [edge.start_vertex_id, edge.end_vertex_id] {
520                            if let Some(vx) = solid.vertices.iter().find(|vx| vx.id == vid) {
521                                points.push(vx.point);
522                            }
523                        }
524                    }
525                    !points.is_empty() && points.iter().all(|p| p.x.abs() < 1e-6)
526                })
527        };
528        assert!(has_cut_face(&below), "below missing cut face on x=0");
529        assert!(has_cut_face(&above), "above missing cut face on x=0");
530    }
531
532    /// The generalized entry with a Plane tool must reproduce the plane path
533    /// exactly (a new plane case still splits).
534    #[test]
535    fn generalized_plane_tool_still_splits() {
536        let box_solid = make_box_brep(Vec3::new(-5.0, -5.0, -5.0), 10.0, 10.0, 10.0).unwrap();
537        // Cut on y = 2 with a +y normal.
538        let pieces = split_solid_by_surface(
539            &box_solid,
540            &SplitSurface::Plane {
541                point: Vec3::new(0.0, 2.0, 0.0),
542                normal: Vec3::new(0.0, 1.0, 0.0),
543            },
544        )
545        .unwrap();
546        assert_eq!(pieces.len(), 2);
547        for piece in &pieces {
548            assert!(piece.validate().is_empty(), "plane piece invalid");
549        }
550        let (below, above) = (&pieces[0], &pieces[1]);
551        // below = y ∈ [-5, 2] (vol 700), above = y ∈ [2, 5] (vol 300).
552        assert!(
553            (volume(below) - 700.0).abs() < 1e-3,
554            "below {}",
555            volume(below)
556        );
557        assert!(
558            (volume(above) - 300.0).abs() < 1e-3,
559            "above {}",
560            volume(above)
561        );
562        assert!((volume(below) + volume(above) - 1000.0).abs() < 1e-3);
563    }
564
565    #[test]
566    fn split_cube_by_cylinder_two_valid_solids() {
567        // Cube [-5,5]³, volume 1000.  A cylinder of radius 3 about the Z axis
568        // passes fully through it (through the interior, capped beyond the box).
569        let cube = make_box_brep(Vec3::new(-5.0, -5.0, -5.0), 10.0, 10.0, 10.0).unwrap();
570        let tool = SplitSurface::Cylinder {
571            axis_point: Vec3::default(),
572            axis_dir: Vec3::new(0.0, 0.0, 1.0),
573            radius: 3.0,
574        };
575        let pieces = split_solid_by_surface(&cube, &tool).unwrap();
576        assert_eq!(pieces.len(), 2, "cylinder split should yield 2 pieces");
577        let (inside, outside) = (&pieces[0], &pieces[1]);
578
579        // (1) Both pieces valid, watertight.
580        assert!(
581            inside.validate().is_empty(),
582            "inside invalid: {:?}",
583            inside.validate()
584        );
585        assert!(
586            outside.validate().is_empty(),
587            "outside invalid: {:?}",
588            outside.validate()
589        );
590
591        // (2) Volumes sum to the cube.  inside = the r=3 cylinder core of
592        //     height 10 = π·9·10 ≈ 282.743; outside = the rest.
593        let vol_in = volume(inside);
594        let vol_out = volume(outside);
595        let expected_core = std::f64::consts::PI * 9.0 * 10.0;
596        assert!(
597            (vol_in - expected_core).abs() < 1e-3,
598            "core volume {vol_in}"
599        );
600        assert!(
601            (vol_in + vol_out - 1000.0).abs() < 1e-6,
602            "sum {}",
603            vol_in + vol_out
604        );
605
606        // (3) Semantic oracle: inside == cube∩cyl, outside == cube−cyl.
607        let tool_solid = build_tool_solid(&cube, &tool).unwrap();
608        assert_oracle_clean(&cube, &tool_solid, inside, outside);
609    }
610
611    #[test]
612    fn split_box_by_sphere_two_valid_solids() {
613        // Box [-5,5]³, volume 1000.  A sphere of radius 4 centred at the origin
614        // lies entirely inside → inside = the ball, outside = box with cavity.
615        let box_solid = make_box_brep(Vec3::new(-5.0, -5.0, -5.0), 10.0, 10.0, 10.0).unwrap();
616        let tool = SplitSurface::Sphere {
617            center: Vec3::default(),
618            radius: 4.0,
619        };
620        let pieces = split_solid_by_surface(&box_solid, &tool).unwrap();
621        assert_eq!(pieces.len(), 2, "sphere split should yield 2 pieces");
622        let (inside, outside) = (&pieces[0], &pieces[1]);
623
624        assert!(
625            inside.validate().is_empty(),
626            "inside invalid: {:?}",
627            inside.validate()
628        );
629        assert!(
630            outside.validate().is_empty(),
631            "outside invalid: {:?}",
632            outside.validate()
633        );
634
635        let vol_in = volume(inside);
636        let vol_out = volume(outside);
637        let expected_ball = 4.0 / 3.0 * std::f64::consts::PI * 4.0_f64.powi(3);
638        assert!(
639            (vol_in - expected_ball).abs() < 1e-2,
640            "ball volume {vol_in}"
641        );
642        assert!(
643            (vol_in + vol_out - 1000.0).abs() < 1e-6,
644            "sum {}",
645            vol_in + vol_out
646        );
647
648        let tool_solid = build_tool_solid(&box_solid, &tool).unwrap();
649        assert_oracle_clean(&box_solid, &tool_solid, inside, outside);
650    }
651
652    #[test]
653    fn split_box_by_cone_two_valid_solids() {
654        // Box [-5,5]³.  A cone apexed at (0,0,-6), opening +Z at 20°, cuts the
655        // box interior: it enters through the bottom face as a tiny circle and
656        // exits through the top face as an r≈4 circle, both within the box
657        // cross-section — so it cleanly divides the body (like the cylinder).
658        let box_solid = make_box_brep(Vec3::new(-5.0, -5.0, -5.0), 10.0, 10.0, 10.0).unwrap();
659        let tool = SplitSurface::Cone {
660            apex: Vec3::new(0.0, 0.0, -6.0),
661            axis_dir: Vec3::new(0.0, 0.0, 1.0),
662            half_angle: std::f64::consts::PI / 9.0, // 20°
663        };
664        let pieces = split_solid_by_surface(&box_solid, &tool).unwrap();
665        assert_eq!(pieces.len(), 2, "cone split should yield 2 pieces");
666        let (inside, outside) = (&pieces[0], &pieces[1]);
667
668        assert!(
669            inside.validate().is_empty(),
670            "inside invalid: {:?}",
671            inside.validate()
672        );
673        assert!(
674            outside.validate().is_empty(),
675            "outside invalid: {:?}",
676            outside.validate()
677        );
678
679        let vol_in = volume(inside);
680        let vol_out = volume(outside);
681        assert!(vol_in > 0.0 && vol_out > 0.0, "both pieces non-empty");
682        assert!(
683            (vol_in + vol_out - 1000.0).abs() < 1e-6,
684            "sum {}",
685            vol_in + vol_out
686        );
687
688        let tool_solid = build_tool_solid(&box_solid, &tool).unwrap();
689        assert_oracle_clean(&box_solid, &tool_solid, inside, outside);
690    }
691
692    #[test]
693    fn split_by_selected_cylinder_face_recognizes_and_cuts() {
694        // Take the side face of a real cylinder solid and use ITS surface as
695        // the cut tool for a cube — exercises the analytic-recognition entry
696        // the app uses when the user selects a cylindrical face.
697        let cube = make_box_brep(Vec3::new(-5.0, -5.0, -5.0), 10.0, 10.0, 10.0).unwrap();
698        let cyl = make_cylinder_brep(
699            Vec3::new(0.0, 0.0, -8.0),
700            Vec3::new(0.0, 0.0, 1.0),
701            3.0,
702            16.0,
703        )
704        .unwrap();
705        // Face id 105 is the cylindrical side (see make_cylinder_brep).
706        let side = cyl
707            .shells
708            .iter()
709            .flat_map(|s| &s.faces)
710            .find(|f| f.id == 105)
711            .expect("cylinder side face");
712        assert!(
713            matches!(
714                recognized_split_surface(&side.surface).unwrap(),
715                SplitSurface::Cylinder { radius, .. } if (radius - 3.0).abs() < 1e-9
716            ),
717            "side face should recognize as an r=3 cylinder"
718        );
719        let pieces = split_solid_by_face_surface(&cube, &side.surface).unwrap();
720        assert_eq!(pieces.len(), 2);
721        for piece in &pieces {
722            assert!(piece.validate().is_empty(), "face-split piece invalid");
723        }
724        let sum = volume(&pieces[0]) + volume(&pieces[1]);
725        assert!((sum - 1000.0).abs() < 1e-6, "volumes sum {sum}");
726        assert!(
727            (volume(&pieces[0]) - std::f64::consts::PI * 9.0 * 10.0).abs() < 1e-3,
728            "core volume {}",
729            volume(&pieces[0])
730        );
731    }
732
733    #[test]
734    fn split_misses_body_errs() {
735        // A cylinder whose axis and radius keep it clear of the cube entirely →
736        // no split, clean Err (not a bad/empty solid).
737        let cube = make_box_brep(Vec3::new(-5.0, -5.0, -5.0), 10.0, 10.0, 10.0).unwrap();
738        let tool = SplitSurface::Cylinder {
739            axis_point: Vec3::new(100.0, 0.0, 0.0),
740            axis_dir: Vec3::new(0.0, 0.0, 1.0),
741            radius: 1.0,
742        };
743        assert!(split_solid_by_surface(&cube, &tool).is_err());
744    }
745}