use crate::boolean::{boolean_operation, BooleanOperation, BooleanOptions};
use crate::spatial::Aabb;
use crate::topology::{make_box_brep, make_cylinder_brep, BrepSolid};
use crate::transform_topology::{transform_brep, AffineTransform};
use crate::{
make_cone_brep, make_sphere_brep, make_torus_brep, AnalyticSurface, NurbsSurface, Vec3,
};
use serde::Deserialize;
fn solid_aabb(solid: &BrepSolid) -> Aabb {
let mut bounds = Aabb::empty();
for vertex in &solid.vertices {
bounds.include_point(vertex.point);
}
bounds
}
fn is_empty_piece(solid: &BrepSolid) -> bool {
solid.shells.is_empty() || solid.shells.iter().all(|shell| shell.faces.is_empty())
}
fn frame_transform(u: Vec3, v: Vec3, n: Vec3, center: Vec3) -> Result<AffineTransform, String> {
AffineTransform::new([
u.x, v.x, n.x, center.x, u.y, v.y, n.y, center.y, u.z, v.z, n.z, center.z, 0.0, 0.0, 0.0, 1.0,
])
}
pub fn split_solid_by_plane(
solid: &BrepSolid,
plane_point: Vec3,
plane_normal: Vec3,
) -> Result<(BrepSolid, BrepSolid), String> {
let n = plane_normal.normalized()?;
let u = n.perpendicular()?;
let v = n.cross(u);
let bounds = solid_aabb(solid);
if !bounds.minimum.x.is_finite() {
return Err("split_solid_by_plane: solid has no geometry".into());
}
let diagonal = bounds.diagonal();
if diagonal <= 0.0 {
return Err("split_solid_by_plane: solid is degenerate".into());
}
let length = 3.0 * diagonal;
let half = 0.5 * length;
let center = bounds.minimum.add(bounds.maximum).scale(0.5);
let center_on_plane = center.sub(n.scale(center.sub(plane_point).dot(n)));
let cube = make_box_brep(Vec3::new(-half, -half, -half), length, length, length)?;
let below_center = center_on_plane.sub(n.scale(half));
let tool_below = transform_brep(&cube, frame_transform(u, v, n, below_center)?, false)?;
let above_center = center_on_plane.add(n.scale(half));
let tool_above = transform_brep(&cube, frame_transform(u, v, n, above_center)?, false)?;
let options = BooleanOptions::default();
let below = boolean_operation(solid, &tool_below, BooleanOperation::Intersect, &options);
let above = boolean_operation(solid, &tool_above, BooleanOperation::Intersect, &options);
match (below, above) {
(Ok(below), Ok(above)) if !is_empty_piece(&below) && !is_empty_piece(&above) => {
Ok((below, above))
}
_ => Err("split_solid_by_plane: plane does not intersect the solid".into()),
}
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum SplitSurface {
Plane { point: Vec3, normal: Vec3 },
Cylinder {
axis_point: Vec3,
axis_dir: Vec3,
radius: f64,
},
Sphere { center: Vec3, radius: f64 },
Cone {
apex: Vec3,
axis_dir: Vec3,
half_angle: f64,
},
Torus {
center: Vec3,
axis_dir: Vec3,
major_radius: f64,
minor_radius: f64,
},
}
fn solid_extent(solid: &BrepSolid) -> Result<(Aabb, f64), String> {
let bounds = solid_aabb(solid);
if !bounds.minimum.x.is_finite() {
return Err("split_solid_by_surface: solid has no geometry".into());
}
let diagonal = bounds.diagonal();
if diagonal <= 0.0 {
return Err("split_solid_by_surface: solid is degenerate".into());
}
Ok((bounds, diagonal))
}
fn build_tool_solid(solid: &BrepSolid, tool: &SplitSurface) -> Result<BrepSolid, String> {
let (_, diagonal) = solid_extent(solid)?;
let margin = diagonal.max(1.0);
match *tool {
SplitSurface::Plane { .. } => {
Err("build_tool_solid: plane is handled by the plane path".into())
}
SplitSurface::Cylinder {
axis_point,
axis_dir,
radius,
} => {
if radius <= 0.0 {
return Err("split_solid_by_surface: cylinder radius must be positive".into());
}
let axis = axis_dir.normalized()?;
let (t_min, t_max) = axis_span(solid, axis_point, axis);
let base = axis_point.add(axis.scale(t_min - margin));
let height = (t_max - t_min) + 2.0 * margin;
make_cylinder_brep(base, axis, radius, height)
}
SplitSurface::Sphere { center, radius } => {
if radius <= 0.0 {
return Err("split_solid_by_surface: sphere radius must be positive".into());
}
make_sphere_brep(center, radius, Vec3::new(0.0, 0.0, 1.0))
}
SplitSurface::Cone {
apex,
axis_dir,
half_angle,
} => {
if !(half_angle > 0.0 && half_angle < std::f64::consts::FRAC_PI_2) {
return Err("split_solid_by_surface: cone half-angle must be in (0, pi/2)".into());
}
let axis = axis_dir.normalized()?;
let (_, d_max) = axis_span(solid, apex, axis);
if d_max <= 0.0 {
return Err(
"split_solid_by_surface: cone does not reach the solid (body is behind the apex)"
.into(),
);
}
let big_h = d_max + margin;
let base = apex.add(axis.scale(big_h));
let base_radius = big_h * half_angle.tan();
make_cone_brep(base, axis.scale(-1.0), base_radius, 0.0, big_h)
}
SplitSurface::Torus {
center,
axis_dir,
major_radius,
minor_radius,
} => {
if minor_radius <= 0.0 || major_radius <= 0.0 {
return Err("split_solid_by_surface: torus radii must be positive".into());
}
make_torus_brep(center, axis_dir, major_radius, minor_radius)
}
}
}
fn axis_span(solid: &BrepSolid, origin: Vec3, axis: Vec3) -> (f64, f64) {
let mut t_min = f64::INFINITY;
let mut t_max = f64::NEG_INFINITY;
for vertex in &solid.vertices {
let t = vertex.point.sub(origin).dot(axis);
t_min = t_min.min(t);
t_max = t_max.max(t);
}
(t_min, t_max)
}
pub fn split_solid_by_surface(
solid: &BrepSolid,
tool: &SplitSurface,
) -> Result<Vec<BrepSolid>, String> {
if let SplitSurface::Plane { point, normal } = *tool {
let (below, above) = split_solid_by_plane(solid, point, normal)?;
return Ok(vec![below, above]);
}
let tool_solid = build_tool_solid(solid, tool)?;
let options = BooleanOptions::default();
let inside = boolean_operation(solid, &tool_solid, BooleanOperation::Intersect, &options);
let outside = boolean_operation(solid, &tool_solid, BooleanOperation::Subtract, &options);
match (inside, outside) {
(Ok(inside), Ok(outside))
if !is_empty_piece(&inside)
&& !is_empty_piece(&outside)
&& inside.validate().is_empty()
&& outside.validate().is_empty() =>
{
Ok(vec![inside, outside])
}
_ => Err("split_solid_by_surface: tool surface does not divide the solid".into()),
}
}
fn recognized_split_surface(surface: &NurbsSurface) -> Result<SplitSurface, String> {
let analytic = surface
.analytic()
.ok_or("split_solid_by_face_surface: selected face is not an analytic surface")?;
match analytic {
AnalyticSurface::Plane {
origin,
u_dir,
v_dir,
..
} => {
let normal = u_dir.cross(*v_dir).normalized()?;
Ok(SplitSurface::Plane {
point: *origin,
normal,
})
}
AnalyticSurface::RuledRevolution {
frame,
rho0,
rho1,
height,
} => {
let scale = rho0.abs().max(rho1.abs()).max(1.0);
if (rho0 - rho1).abs() <= 1e-9 * scale {
Ok(SplitSurface::Cylinder {
axis_point: frame.origin,
axis_dir: frame.axis,
radius: 0.5 * (rho0 + rho1),
})
} else {
let slope = (rho1 - rho0) / height;
let axial_apex = -rho0 / slope;
let apex = frame.origin.add(frame.axis.scale(axial_apex));
let axis_dir = if slope >= 0.0 {
frame.axis
} else {
frame.axis.scale(-1.0)
};
Ok(SplitSurface::Cone {
apex,
axis_dir,
half_angle: slope.abs().atan(),
})
}
}
AnalyticSurface::Sphere { frame, radius } => Ok(SplitSurface::Sphere {
center: frame.origin,
radius: *radius,
}),
AnalyticSurface::Torus {
frame,
major_radius,
minor_radius,
} => Ok(SplitSurface::Torus {
center: frame.origin,
axis_dir: frame.axis,
major_radius: *major_radius,
minor_radius: *minor_radius,
}),
AnalyticSurface::Revolution { .. } => Err(
"split_solid_by_face_surface: general revolved surfaces are not supported as a cut tool"
.into(),
),
}
}
pub fn split_solid_by_face_surface(
solid: &BrepSolid,
surface: &NurbsSurface,
) -> Result<Vec<BrepSolid>, String> {
let tool = recognized_split_surface(surface)?;
split_solid_by_surface(solid, &tool)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
boolean_semantic_disagreement, make_box_brep, solid_mass_properties, BooleanOperation,
};
fn aabb(solid: &BrepSolid) -> (Vec3, Vec3) {
let bounds = solid_aabb(solid);
(bounds.minimum, bounds.maximum)
}
fn face_count(solid: &BrepSolid) -> usize {
solid.shells.iter().map(|shell| shell.faces.len()).sum()
}
fn volume(solid: &BrepSolid) -> f64 {
solid_mass_properties(solid).unwrap().volume
}
fn assert_oracle_clean(
solid: &BrepSolid,
tool_solid: &BrepSolid,
inside: &BrepSolid,
outside: &BrepSolid,
) {
let in_report = boolean_semantic_disagreement(
solid,
tool_solid,
BooleanOperation::Intersect,
inside,
4000,
)
.unwrap();
eprintln!(
" inside oracle: considered={} disagreements={} rate={:.6}",
in_report.considered,
in_report.disagreements.len(),
in_report.disagreement_rate
);
assert!(
!in_report.is_flagged() && in_report.disagreement_rate < 0.01,
"inside piece disagrees with solid∩tool (rate {}): {:?}",
in_report.disagreement_rate,
in_report.sample_disagreement()
);
let out_report = boolean_semantic_disagreement(
solid,
tool_solid,
BooleanOperation::Subtract,
outside,
4000,
)
.unwrap();
eprintln!(
" outside oracle: considered={} disagreements={} rate={:.6}",
out_report.considered,
out_report.disagreements.len(),
out_report.disagreement_rate
);
assert!(
!out_report.is_flagged() && out_report.disagreement_rate < 0.01,
"outside piece disagrees with solid−tool (rate {}): {:?}",
out_report.disagreement_rate,
out_report.sample_disagreement()
);
}
#[test]
fn split_box_by_midplane_halves_it() {
let box_solid = make_box_brep(Vec3::new(-5.0, -5.0, -5.0), 10.0, 10.0, 10.0).unwrap();
let (below, above) =
split_solid_by_plane(&box_solid, Vec3::default(), Vec3::new(1.0, 0.0, 0.0)).unwrap();
assert!(
below.validate().is_empty(),
"below invalid: {:?}",
below.validate()
);
assert!(
above.validate().is_empty(),
"above invalid: {:?}",
above.validate()
);
assert_eq!(face_count(&below), 6);
assert_eq!(face_count(&above), 6);
let vol_below = solid_mass_properties(&below).unwrap().volume;
let vol_above = solid_mass_properties(&above).unwrap().volume;
assert!((vol_below - 500.0).abs() < 1e-3, "below volume {vol_below}");
assert!((vol_above - 500.0).abs() < 1e-3, "above volume {vol_above}");
assert!((vol_below + vol_above - 1000.0).abs() < 1e-3);
let (below_min, below_max) = aabb(&below);
assert!(
(below_min.x - (-5.0)).abs() < 1e-6,
"below min.x {}",
below_min.x
);
assert!(below_max.x.abs() < 1e-6, "below max.x {}", below_max.x);
let (above_min, above_max) = aabb(&above);
assert!(above_min.x.abs() < 1e-6, "above min.x {}", above_min.x);
assert!(
(above_max.x - 5.0).abs() < 1e-6,
"above max.x {}",
above_max.x
);
let has_cut_face = |solid: &BrepSolid| -> bool {
solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.any(|face| {
let mut points: Vec<Vec3> = Vec::new();
for coedge in face.loops.iter().flat_map(|lp| &lp.coedges) {
let Some(edge) = solid.edges.iter().find(|e| e.id == coedge.edge_id) else {
return false;
};
for vid in [edge.start_vertex_id, edge.end_vertex_id] {
if let Some(vx) = solid.vertices.iter().find(|vx| vx.id == vid) {
points.push(vx.point);
}
}
}
!points.is_empty() && points.iter().all(|p| p.x.abs() < 1e-6)
})
};
assert!(has_cut_face(&below), "below missing cut face on x=0");
assert!(has_cut_face(&above), "above missing cut face on x=0");
}
#[test]
fn generalized_plane_tool_still_splits() {
let box_solid = make_box_brep(Vec3::new(-5.0, -5.0, -5.0), 10.0, 10.0, 10.0).unwrap();
let pieces = split_solid_by_surface(
&box_solid,
&SplitSurface::Plane {
point: Vec3::new(0.0, 2.0, 0.0),
normal: Vec3::new(0.0, 1.0, 0.0),
},
)
.unwrap();
assert_eq!(pieces.len(), 2);
for piece in &pieces {
assert!(piece.validate().is_empty(), "plane piece invalid");
}
let (below, above) = (&pieces[0], &pieces[1]);
assert!(
(volume(below) - 700.0).abs() < 1e-3,
"below {}",
volume(below)
);
assert!(
(volume(above) - 300.0).abs() < 1e-3,
"above {}",
volume(above)
);
assert!((volume(below) + volume(above) - 1000.0).abs() < 1e-3);
}
#[test]
fn split_cube_by_cylinder_two_valid_solids() {
let cube = make_box_brep(Vec3::new(-5.0, -5.0, -5.0), 10.0, 10.0, 10.0).unwrap();
let tool = SplitSurface::Cylinder {
axis_point: Vec3::default(),
axis_dir: Vec3::new(0.0, 0.0, 1.0),
radius: 3.0,
};
let pieces = split_solid_by_surface(&cube, &tool).unwrap();
assert_eq!(pieces.len(), 2, "cylinder split should yield 2 pieces");
let (inside, outside) = (&pieces[0], &pieces[1]);
assert!(
inside.validate().is_empty(),
"inside invalid: {:?}",
inside.validate()
);
assert!(
outside.validate().is_empty(),
"outside invalid: {:?}",
outside.validate()
);
let vol_in = volume(inside);
let vol_out = volume(outside);
let expected_core = std::f64::consts::PI * 9.0 * 10.0;
assert!(
(vol_in - expected_core).abs() < 1e-3,
"core volume {vol_in}"
);
assert!(
(vol_in + vol_out - 1000.0).abs() < 1e-6,
"sum {}",
vol_in + vol_out
);
let tool_solid = build_tool_solid(&cube, &tool).unwrap();
assert_oracle_clean(&cube, &tool_solid, inside, outside);
}
#[test]
fn split_box_by_sphere_two_valid_solids() {
let box_solid = make_box_brep(Vec3::new(-5.0, -5.0, -5.0), 10.0, 10.0, 10.0).unwrap();
let tool = SplitSurface::Sphere {
center: Vec3::default(),
radius: 4.0,
};
let pieces = split_solid_by_surface(&box_solid, &tool).unwrap();
assert_eq!(pieces.len(), 2, "sphere split should yield 2 pieces");
let (inside, outside) = (&pieces[0], &pieces[1]);
assert!(
inside.validate().is_empty(),
"inside invalid: {:?}",
inside.validate()
);
assert!(
outside.validate().is_empty(),
"outside invalid: {:?}",
outside.validate()
);
let vol_in = volume(inside);
let vol_out = volume(outside);
let expected_ball = 4.0 / 3.0 * std::f64::consts::PI * 4.0_f64.powi(3);
assert!(
(vol_in - expected_ball).abs() < 1e-2,
"ball volume {vol_in}"
);
assert!(
(vol_in + vol_out - 1000.0).abs() < 1e-6,
"sum {}",
vol_in + vol_out
);
let tool_solid = build_tool_solid(&box_solid, &tool).unwrap();
assert_oracle_clean(&box_solid, &tool_solid, inside, outside);
}
#[test]
fn split_box_by_cone_two_valid_solids() {
let box_solid = make_box_brep(Vec3::new(-5.0, -5.0, -5.0), 10.0, 10.0, 10.0).unwrap();
let tool = SplitSurface::Cone {
apex: Vec3::new(0.0, 0.0, -6.0),
axis_dir: Vec3::new(0.0, 0.0, 1.0),
half_angle: std::f64::consts::PI / 9.0, };
let pieces = split_solid_by_surface(&box_solid, &tool).unwrap();
assert_eq!(pieces.len(), 2, "cone split should yield 2 pieces");
let (inside, outside) = (&pieces[0], &pieces[1]);
assert!(
inside.validate().is_empty(),
"inside invalid: {:?}",
inside.validate()
);
assert!(
outside.validate().is_empty(),
"outside invalid: {:?}",
outside.validate()
);
let vol_in = volume(inside);
let vol_out = volume(outside);
assert!(vol_in > 0.0 && vol_out > 0.0, "both pieces non-empty");
assert!(
(vol_in + vol_out - 1000.0).abs() < 1e-6,
"sum {}",
vol_in + vol_out
);
let tool_solid = build_tool_solid(&box_solid, &tool).unwrap();
assert_oracle_clean(&box_solid, &tool_solid, inside, outside);
}
#[test]
fn split_by_selected_cylinder_face_recognizes_and_cuts() {
let cube = make_box_brep(Vec3::new(-5.0, -5.0, -5.0), 10.0, 10.0, 10.0).unwrap();
let cyl = make_cylinder_brep(
Vec3::new(0.0, 0.0, -8.0),
Vec3::new(0.0, 0.0, 1.0),
3.0,
16.0,
)
.unwrap();
let side = cyl
.shells
.iter()
.flat_map(|s| &s.faces)
.find(|f| f.id == 105)
.expect("cylinder side face");
assert!(
matches!(
recognized_split_surface(&side.surface).unwrap(),
SplitSurface::Cylinder { radius, .. } if (radius - 3.0).abs() < 1e-9
),
"side face should recognize as an r=3 cylinder"
);
let pieces = split_solid_by_face_surface(&cube, &side.surface).unwrap();
assert_eq!(pieces.len(), 2);
for piece in &pieces {
assert!(piece.validate().is_empty(), "face-split piece invalid");
}
let sum = volume(&pieces[0]) + volume(&pieces[1]);
assert!((sum - 1000.0).abs() < 1e-6, "volumes sum {sum}");
assert!(
(volume(&pieces[0]) - std::f64::consts::PI * 9.0 * 10.0).abs() < 1e-3,
"core volume {}",
volume(&pieces[0])
);
}
#[test]
fn split_misses_body_errs() {
let cube = make_box_brep(Vec3::new(-5.0, -5.0, -5.0), 10.0, 10.0, 10.0).unwrap();
let tool = SplitSurface::Cylinder {
axis_point: Vec3::new(100.0, 0.0, 0.0),
axis_dir: Vec3::new(0.0, 0.0, 1.0),
radius: 1.0,
};
assert!(split_solid_by_surface(&cube, &tool).is_err());
}
}