1use 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
18fn 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
27fn is_empty_piece(solid: &BrepSolid) -> bool {
30 solid.shells.is_empty() || solid.shells.iter().all(|shell| shell.faces.is_empty())
31}
32
33fn 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, u.y, v.y, n.y, center.y, u.z, v.z, n.z, center.z, 0.0, 0.0, 0.0, 1.0,
43 ])
44}
45
46pub 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 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 let length = 3.0 * diagonal;
76 let half = 0.5 * length;
77
78 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 let cube = make_box_brep(Vec3::new(-half, -half, -half), length, length, length)?;
87
88 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 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#[derive(Clone, Copy, Debug, Deserialize)]
127#[serde(tag = "type", rename_all = "lowercase")]
128pub enum SplitSurface {
129 Plane { point: Vec3, normal: Vec3 },
131 Cylinder {
134 axis_point: Vec3,
135 axis_dir: Vec3,
136 radius: f64,
137 },
138 Sphere { center: Vec3, radius: f64 },
140 Cone {
144 apex: Vec3,
145 axis_dir: Vec3,
146 half_angle: f64,
147 },
148 Torus {
150 center: Vec3,
151 axis_dir: Vec3,
152 major_radius: f64,
153 minor_radius: f64,
154 },
155}
156
157fn 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
171fn 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 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 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 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 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 make_torus_brep(center, axis_dir, major_radius, minor_radius)
239 }
240 }
241}
242
243fn 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
256pub 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
295fn 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 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 let slope = (rho1 - rho0) / height;
334 let axial_apex = -rho0 / slope;
335 let apex = frame.origin.add(frame.axis.scale(axial_apex));
336 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
370pub 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 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 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 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 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 assert_eq!(face_count(&below), 6);
480 assert_eq!(face_count(&above), 6);
481
482 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 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 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 #[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 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 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 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 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 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 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 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 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, };
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 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 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 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}