1use geop_core_math::{
19 geop_error::{GeopError, GeopResult, WithContext},
20 scalars::Scalar,
21 vector::Vector3,
22};
23use geop_core_part::{Namer, Part};
24use geop_core_topology::{
25 FaceId, Model, ShellId, SolidId,
26 contains::{
27 face::{PointClassification as FacePoint, face_contains, face_interior_point_where},
28 shell::{PointClassification as ShellPoint, shell_contains},
29 },
30};
31use serde::{Deserialize, Serialize};
32
33use crate::remesh::remesh::{RemeshParams, remesh};
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum BooleanOp {
39 Union,
41 Intersection,
43 Difference,
45}
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum FaceClassification {
50 Inside,
52 Outside,
54 OnSameNormal,
57 OnOppositeNormal,
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64enum Keep {
65 AsIs,
67 Reversed,
70 Drop,
72}
73
74impl BooleanOp {
75 fn keeps(self, class: FaceClassification, from_a: bool) -> Keep {
90 use FaceClassification::*;
91 match (self, class) {
92 (BooleanOp::Union, Outside) => Keep::AsIs,
94 (BooleanOp::Union, Inside) => Keep::Drop,
95 (BooleanOp::Union, OnSameNormal) => {
96 if from_a {
97 Keep::AsIs
98 } else {
99 Keep::Drop
100 }
101 }
102 (BooleanOp::Union, OnOppositeNormal) => Keep::Drop,
103
104 (BooleanOp::Intersection, Inside) => Keep::AsIs,
106 (BooleanOp::Intersection, Outside) => Keep::Drop,
107 (BooleanOp::Intersection, OnSameNormal) => {
108 if from_a {
109 Keep::AsIs
110 } else {
111 Keep::Drop
112 }
113 }
114 (BooleanOp::Intersection, OnOppositeNormal) => Keep::Drop,
115
116 (BooleanOp::Difference, Outside) if from_a => Keep::AsIs,
121 (BooleanOp::Difference, Inside) if from_a => Keep::Drop,
122 (BooleanOp::Difference, Inside) => Keep::Reversed,
123 (BooleanOp::Difference, Outside) => Keep::Drop,
124 (BooleanOp::Difference, OnSameNormal) => Keep::Drop,
125 (BooleanOp::Difference, OnOppositeNormal) => {
126 if from_a {
127 Keep::AsIs
128 } else {
129 Keep::Drop
130 }
131 }
132 }
133 }
134}
135
136const SEED: u64 = 0xB001_EA47_0000_0001;
141
142pub fn boolean<S: Scalar>(
157 part: &mut Part<S>,
158 namer: &Namer,
159 solid_a: SolidId,
160 solid_b: SolidId,
161 op: BooleanOp,
162 params: RemeshParams<S>,
163) -> GeopResult<Option<SolidId>> {
164 let ctx = |e: GeopError| {
165 e.with_context(format!(
166 "boolean(name={}, solid_a={solid_a}, solid_b={solid_b}, op={op:?})",
167 namer.root()
168 ))
169 };
170
171 remesh(part, namer, solid_a, solid_b, params).with_context(&ctx)?;
172 let model = part.topology();
173
174 let faces_a = model.solid_faces(solid_a).with_context(&ctx)?;
175 let faces_b = model.solid_faces(solid_b).with_context(&ctx)?;
176
177 let mut keep: Vec<FaceId> = Vec::new();
178 let mut reverse: Vec<FaceId> = Vec::new();
179 for (faces, from_a, other) in [(&faces_a, true, solid_b), (&faces_b, false, solid_a)] {
180 for &face_id in faces {
181 let class = classify_face(model, face_id, other, params)
182 .with_context(&ctx)
183 .with_context(&|e: GeopError| {
184 e.with_context(format!("classifying face {face_id}"))
185 })?;
186 match op.keeps(class, from_a) {
187 Keep::AsIs => keep.push(face_id),
188 Keep::Reversed => {
189 keep.push(face_id);
190 reverse.push(face_id);
191 }
192 Keep::Drop => {}
193 }
194 }
195 }
196
197 for &face_id in &reverse {
198 part.reverse_face(face_id).with_context(&ctx)?;
199 }
200
201 part.assemble_solid(&[solid_a, solid_b], &keep, namer.root())
202 .with_context(&ctx)
203}
204
205pub fn classify_face<S: Scalar>(
224 model: &Model<S>,
225 face_id: FaceId,
226 other_solid: SolidId,
227 params: RemeshParams<S>,
228) -> GeopResult<FaceClassification> {
229 let face = model.get_face(face_id)?;
230 let shells = model.get_solid(other_solid)?.shells.clone();
231 let mut decided = None;
232 let mut on_boundary = Vec::new();
233 face_interior_point_where(
234 model,
235 face_id,
236 params.max_nodes,
237 params.curve_curve_min_subdivision_size,
238 SEED,
239 |u, v| {
240 let point = face.surface.evaluate(u, v)?;
241 for &shell_id in &shells {
242 match shell_contains(
243 model,
244 shell_id,
245 point,
246 params.max_nodes,
247 params.curve_curve_min_subdivision_size,
248 SEED,
249 )? {
250 ShellPoint::Inside => {
251 decided = Some(FaceClassification::Inside);
252 return Ok(true);
253 }
254 ShellPoint::Outside => continue,
255 ShellPoint::OnFace | ShellPoint::OnEdge | ShellPoint::OnVertex => {
256 on_boundary.push((u, v, point, shell_id));
257 return Ok(false);
258 }
259 }
260 }
261 decided = Some(FaceClassification::Outside);
262 Ok(true)
263 },
264 )?;
265 if let Some(classification) = decided {
266 return Ok(classification);
267 }
268
269 let mut undefined = None;
276 for &(u, v, point, shell_id) in &on_boundary {
277 let normals = face
278 .surface
279 .normal(u, v)
280 .and_then(|n| Ok((n, shell_normal_at(model, shell_id, &point, params)?)));
281 let (this_normal, other_normal) = match normals {
282 Ok(normals) => normals,
283 Err(e) => {
284 undefined = Some(e);
285 continue;
286 }
287 };
288 let alignment = this_normal.prod_dot(&other_normal);
289 return if alignment.definitely_greater(S::ZERO) {
290 Ok(FaceClassification::OnSameNormal)
291 } else if alignment.definitely_less(S::ZERO) {
292 Ok(FaceClassification::OnOppositeNormal)
293 } else {
294 Err(GeopError::new(format!(
295 "boolean: face {face_id} lies on solid {other_solid}'s boundary at {point:?} (its interior point uv=({u:?}, {v:?})), but the two normals ({this_normal:?} and {other_normal:?}) are too close to perpendicular to tell which side is which"
296 )))
297 };
298 }
299 Err(match undefined {
300 Some(e) => e.with_context(format!(
301 "classify_face: face {face_id} lies on solid {other_solid}'s boundary at each of its {} interior points tried, and no normal comparison could be made at any of them",
302 on_boundary.len()
303 )),
304 None => GeopError::new(format!(
305 "classify_face: face {face_id} yielded interior points, yet none was classified or set aside"
306 )),
307 })
308}
309
310fn shell_normal_at<S: Scalar>(
312 model: &Model<S>,
313 shell_id: ShellId,
314 point: &Vector3<S>,
315 params: RemeshParams<S>,
316) -> GeopResult<Vector3<S>> {
317 for &face_id in &model.get_shell(shell_id)?.faces {
318 let surface = &model.get_face(face_id)?.surface;
319 let Some((u, v)) = geop_core_geometry::contains::surface::surface_could_contain(
320 surface,
321 point,
322 params.max_nodes,
323 params.curve_curve_min_subdivision_size,
324 )?
325 else {
326 continue;
327 };
328 if !matches!(
329 face_contains(
330 model,
331 face_id,
332 u,
333 v,
334 params.max_nodes,
335 params.curve_curve_min_subdivision_size,
336 SEED,
337 )?,
338 FacePoint::Outside
339 ) {
340 return surface.normal(u, v);
341 }
342 }
343 Err(GeopError::new(format!(
344 "boolean: no face of shell {shell_id} contains {point:?}, although the shell reported the point on its boundary"
345 )))
346}
347
348#[cfg(test)]
349mod tests {
350 use super::{BooleanOp, FaceClassification, boolean, classify_face};
351 use crate::{remesh::remesh::RemeshParams, scenes::all_scenes};
352 use geop_core_math::{scalars::ScalInF64, scalars::Scalar, vector::Vector3};
353 use geop_core_part::Namer;
354 use geop_core_topology::{
355 contains::rng::Rng,
356 validation::{ValidationParameters, validate_fast},
357 };
358
359 fn scene(name: &str) -> crate::scenes::TestScene<ScalInF64> {
360 all_scenes::<ScalInF64>()
361 .into_iter()
362 .find(|s| s.name == name)
363 .unwrap_or_else(|| panic!("scene {name} must exist"))
364 }
365
366 fn fresh_id() -> String {
369 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
370 format!(
371 "op{}",
372 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
373 )
374 }
375
376 fn namer() -> Namer {
377 Namer::new("boolean", &fresh_id()).unwrap()
378 }
379
380 fn validation() -> ValidationParameters<ScalInF64> {
381 let params = RemeshParams::<ScalInF64>::default();
382 ValidationParameters {
383 max_nodes: params.max_nodes,
384 min_subdivision_size: params.curve_curve_min_subdivision_size,
385 ..ValidationParameters::default()
386 }
387 }
388
389 #[test]
392 fn classify_face_separates_inside_from_outside() {
393 let mut s = scene("box_cylinder_drilled_hole_through");
394 let params = RemeshParams::<ScalInF64>::default();
395 crate::remesh::remesh::remesh(&mut s.part, &namer(), s.solid_a, s.solid_b, params).unwrap();
396
397 let mut inside = 0;
398 let mut outside = 0;
399 for face_id in s.part.topology().solid_faces(s.solid_a).unwrap() {
400 match classify_face(s.part.topology(), face_id, s.solid_b, params).unwrap() {
401 FaceClassification::Inside => inside += 1,
402 FaceClassification::Outside => outside += 1,
403 _ => {}
404 }
405 }
406 assert!(
407 inside > 0 && outside > 0,
408 "a cylinder drilled through a cube must leave cube faces on both sides: {inside} inside, {outside} outside"
409 );
410 }
411
412 #[test]
415 fn every_remeshed_face_has_an_interior_point() {
416 let mut s = scene("box_cylinder_drilled_hole_through");
417 let params = RemeshParams::<ScalInF64>::default();
418 crate::remesh::remesh::remesh(&mut s.part, &namer(), s.solid_a, s.solid_b, params).unwrap();
419
420 for solid in [s.solid_a, s.solid_b] {
421 for face_id in s.part.topology().solid_faces(solid).unwrap() {
422 let (u, v) = geop_core_topology::contains::face::face_interior_point(
423 s.part.topology(),
424 face_id,
425 params.max_nodes,
426 params.curve_curve_min_subdivision_size,
427 1234,
428 )
429 .unwrap_or_else(|e| panic!("face {face_id}: {e}"));
430 let _ = s
431 .part
432 .topology()
433 .get_face(face_id)
434 .unwrap()
435 .surface
436 .evaluate(u, v)
437 .unwrap();
438 }
439 }
440 }
441
442 fn check_op(name: &str, op: BooleanOp) {
443 let mut s = scene(name);
444 let params = RemeshParams::<ScalInF64>::default();
445 let result = boolean(&mut s.part, &namer(), s.solid_a, s.solid_b, op, params)
446 .unwrap_or_else(|e| panic!("{name} {op:?}: {e}"))
447 .unwrap_or_else(|| panic!("{name} {op:?}: result is empty"));
448
449 assert!(
450 !s.part.topology().solid_faces(result).unwrap().is_empty(),
451 "{name} {op:?}: result has no faces"
452 );
453 if let Err(errors) = validate_fast(&validation(), s.part.topology()) {
454 panic!(
455 "{name} {op:?}: {} validate_fast error(s): {}",
456 errors.len(),
457 errors[0]
458 );
459 }
460 }
461
462 fn contains(
464 model: &geop_core_topology::Model<ScalInF64>,
465 solid: geop_core_topology::SolidId,
466 p: (f64, f64, f64),
467 ) -> bool {
468 let params = RemeshParams::<ScalInF64>::default();
469 let point = geop_core_math::vector::Vector3::from_array([
470 ScalInF64::from_f64(p.0),
471 ScalInF64::from_f64(p.1),
472 ScalInF64::from_f64(p.2),
473 ]);
474 let shell = model.get_solid(solid).unwrap().shells[0];
475 matches!(
476 geop_core_topology::contains::shell::shell_contains(
477 model,
478 shell,
479 point,
480 params.max_nodes,
481 params.curve_curve_min_subdivision_size,
482 0xA5A5_1234,
483 )
484 .unwrap(),
485 geop_core_topology::contains::shell::PointClassification::Inside
486 )
487 }
488
489 const IN_CUBE_ONLY: (f64, f64, f64) = (0.4, 0.4, 0.0);
495 const IN_CYLINDER_ONLY: (f64, f64, f64) = (0.0, 0.0, 0.8);
496 const IN_BOTH: (f64, f64, f64) = (0.0, 0.0, 0.0);
497
498 fn run(
499 op: BooleanOp,
500 ) -> (
501 geop_core_topology::Model<ScalInF64>,
502 geop_core_topology::SolidId,
503 ) {
504 let mut s = scene("box_cylinder_drilled_hole_through");
505 let params = RemeshParams::<ScalInF64>::default();
506 let result = boolean(&mut s.part, &namer(), s.solid_a, s.solid_b, op, params)
507 .unwrap()
508 .expect("this scene's operands overlap, so no operator is empty");
509 (s.part.topology().clone(), result)
510 }
511
512 #[test]
520 fn box_grid_n1p00_n0p50_n1p00_difference_succeeds() {
521 let mut s = scene("box_grid_n1p00_n0p50_n1p00");
522 let params = RemeshParams::<ScalInF64>::default();
523 boolean(
524 &mut s.part,
525 &namer(),
526 s.solid_a,
527 s.solid_b,
528 BooleanOp::Difference,
529 params,
530 )
531 .unwrap_or_else(|e| panic!("{e}"));
532 }
533
534 fn check_boolean_matches_point_membership(name: &str, op: BooleanOp, seed: u64) {
549 let mut s = scene(name);
550 let params = RemeshParams::<ScalInF64>::default();
551 let (solid_a, solid_b) = (s.solid_a, s.solid_b);
552
553 let mut rng = Rng::new(seed);
555 let mut samples = Vec::new();
556 while samples.len() < SAMPLE_COUNT {
557 let p = Vector3::from_array([
558 ScalInF64::from_f64(rng.next_f64() * 4.0 - 2.0),
559 ScalInF64::from_f64(rng.next_f64() * 4.0 - 2.0),
560 ScalInF64::from_f64(rng.next_f64() * 4.0 - 2.0),
561 ]);
562 let (Some(in_a), Some(in_b)) = (
563 strictly_inside(s.part.topology(), solid_a, p),
564 strictly_inside(s.part.topology(), solid_b, p),
565 ) else {
566 continue;
567 };
568 samples.push((p, in_a, in_b));
569 }
570
571 let result = boolean(&mut s.part, &namer(), solid_a, solid_b, op, params)
572 .unwrap_or_else(|e| panic!("{name} {op:?}: {e}"));
573
574 for (p, in_a, in_b) in samples {
575 let expected = match op {
576 BooleanOp::Union => in_a || in_b,
577 BooleanOp::Intersection => in_a && in_b,
578 BooleanOp::Difference => in_a && !in_b,
579 };
580 let actual = match result {
581 Some(solid) => match strictly_inside(s.part.topology(), solid, p) {
582 Some(inside) => inside,
583 None => continue,
585 },
586 None => false,
588 };
589 assert_eq!(
590 actual,
591 expected,
592 "{name} {op:?}: point {p:?} is {} solid A and {} solid B, so the result should {} contain it",
593 if in_a { "inside" } else { "outside" },
594 if in_b { "inside" } else { "outside" },
595 if expected { "" } else { "not" }
596 );
597 }
598 }
599
600 const SAMPLE_COUNT: usize = 20;
602
603 fn strictly_inside(
607 model: &geop_core_topology::Model<ScalInF64>,
608 solid: geop_core_topology::SolidId,
609 p: Vector3<ScalInF64>,
610 ) -> Option<bool> {
611 let params = RemeshParams::<ScalInF64>::default();
612 let mut inside = false;
613 for shell in model.get_solid(solid).ok()?.shells.clone() {
614 match geop_core_topology::contains::shell::shell_contains(
615 model,
616 shell,
617 p,
618 params.max_nodes,
619 params.curve_curve_min_subdivision_size,
620 0x5A3D_1234,
621 ) {
622 Ok(geop_core_topology::contains::shell::PointClassification::Inside) => {
623 inside = true
624 }
625 Ok(geop_core_topology::contains::shell::PointClassification::Outside) => {}
626 _ => return None,
627 }
628 }
629 Some(inside)
630 }
631
632 const MEMBERSHIP_SCENES: &[&str] = &[
635 "box_cylinder_drilled_hole_through",
636 "box_cylinder_blind_hole",
637 "figure8_cylinder_through_neck",
638 ];
639
640 #[test]
641 fn union_matches_point_membership() {
642 for (i, name) in MEMBERSHIP_SCENES.iter().enumerate() {
643 check_boolean_matches_point_membership(name, BooleanOp::Union, 0xB001 + i as u64);
644 }
645 }
646
647 #[test]
648 fn intersection_matches_point_membership() {
649 for (i, name) in MEMBERSHIP_SCENES.iter().enumerate() {
650 check_boolean_matches_point_membership(
651 name,
652 BooleanOp::Intersection,
653 0xB101 + i as u64,
654 );
655 }
656 }
657
658 #[test]
659 fn difference_matches_point_membership() {
660 for (i, name) in MEMBERSHIP_SCENES.iter().enumerate() {
661 check_boolean_matches_point_membership(name, BooleanOp::Difference, 0xB201 + i as u64);
662 }
663 }
664
665 #[test]
666 fn union_contains_either_operand() {
667 let (model, r) = run(BooleanOp::Union);
668 assert!(contains(&model, r, IN_CUBE_ONLY), "cube-only point");
669 assert!(contains(&model, r, IN_CYLINDER_ONLY), "cylinder-only point");
670 assert!(contains(&model, r, IN_BOTH), "shared point");
671 }
672
673 #[test]
674 fn intersection_contains_only_the_overlap() {
675 let (model, r) = run(BooleanOp::Intersection);
676 assert!(
677 !contains(&model, r, IN_CUBE_ONLY),
678 "cube-only point must be out"
679 );
680 assert!(
681 !contains(&model, r, IN_CYLINDER_ONLY),
682 "cylinder-only point must be out"
683 );
684 assert!(contains(&model, r, IN_BOTH), "shared point must be in");
685 }
686
687 #[test]
688 fn difference_removes_the_second_operand() {
689 let (model, r) = run(BooleanOp::Difference);
690 assert!(
691 contains(&model, r, IN_CUBE_ONLY),
692 "cube-only point must remain"
693 );
694 assert!(
695 !contains(&model, r, IN_CYLINDER_ONLY),
696 "cylinder-only point must be out"
697 );
698 assert!(
699 !contains(&model, r, IN_BOTH),
700 "the drilled-out region must be gone"
701 );
702 }
703
704 #[test]
705 fn union_of_box_and_cylinder_is_valid() {
706 check_op("box_cylinder_drilled_hole_through", BooleanOp::Union);
707 }
708
709 #[test]
710 fn intersection_of_box_and_cylinder_is_valid() {
711 check_op("box_cylinder_drilled_hole_through", BooleanOp::Intersection);
712 }
713
714 #[test]
715 fn difference_of_box_and_cylinder_is_valid() {
716 check_op("box_cylinder_drilled_hole_through", BooleanOp::Difference);
717 }
718
719 type M = geop_core_part::Part<ScalInF64>;
736
737 fn v(x: f64, y: f64, z: f64) -> Vector3<ScalInF64> {
738 Vector3::from_array([x, y, z].map(ScalInF64::from_f64))
739 }
740
741 fn cube(part: &mut M, offset: [f64; 3], dims: [f64; 3]) -> geop_core_topology::SolidId {
744 let [x, y, z] = offset;
745 let [dx, dy, dz] = dims;
746 let (min, max) = (v(x, y, z), v(x + dx, y + dy, z + dz));
747 geop_ops_extrude_revolve::cube_solid(part, &fresh_id(), min, max).unwrap()
748 }
749
750 fn sphere(part: &mut M, center: [f64; 3], r: f64) -> geop_core_topology::SolidId {
752 let [x, y, z] = center;
753 let r = ScalInF64::from_f64(r);
754 geop_ops_extrude_revolve::sphere::sphere_solid(part, &fresh_id(), v(x, y, z), r).unwrap()
755 }
756
757 fn cylinder(
759 part: &mut M,
760 base: [f64; 3],
761 r: f64,
762 h: f64,
763 axis: geop_ops_extrude_revolve::cylinder::Axis,
764 ) -> geop_core_topology::SolidId {
765 let [x, y, z] = base;
766 geop_ops_extrude_revolve::cylinder::revolved_cylinder_along_axis(
767 part,
768 &fresh_id(),
769 v(x, y, z),
770 ScalInF64::from_f64(r),
771 ScalInF64::from_f64(h),
772 axis,
773 )
774 .unwrap()
775 }
776
777 fn op(
780 part: &mut M,
781 a: geop_core_topology::SolidId,
782 b: geop_core_topology::SolidId,
783 op: BooleanOp,
784 ) -> geop_core_topology::SolidId {
785 let result = boolean(part, &namer(), a, b, op, RemeshParams::default())
786 .unwrap_or_else(|e| panic!("{op:?} failed: {e:?}"))
787 .unwrap_or_else(|| panic!("{op:?} produced an empty solid"));
788 part.check_names().unwrap();
789 let model = part.topology();
790 if let Err(errors) = validate_fast(&validation(), model) {
791 panic!(
792 "{op:?}: {} validate_fast error(s): {}",
793 errors.len(),
794 errors[0]
795 );
796 }
797 if let Err(errors) = geop_core_topology::validation::validate(&validation(), model) {
803 panic!("{op:?}: {} validate error(s): {}", errors.len(), errors[0]);
804 }
805 for face in model.solid_faces(result).unwrap() {
809 for coedge in model.iterate_face_coedges(face) {
810 let Ok(edge) = model.get_coedge(coedge).unwrap().edge() else {
812 continue;
813 };
814 let uses = model.coedges_of_edge(edge).len();
815 if uses % 2 != 0 {
816 let e = model.get_edge(edge).unwrap();
817 let at = |v| {
818 let p = model.get_vertex(v).unwrap().point;
819 [p[0].to_f64(), p[1].to_f64(), p[2].to_f64()]
820 };
821 panic!(
822 "{op:?}: edge {edge} of face {face}, from {:?} to {:?}, is used by {uses} coedge(s) — the solid is open there",
823 at(e.start_vertex),
824 at(e.end_vertex)
825 );
826 }
827 }
828 }
829 result
830 }
831
832 #[test]
839 fn bored_cube_plus_inscribed_sphere_minus_half() {
840 use geop_ops_extrude_revolve::cylinder::Axis;
841 let mut part = M::new();
842 let block = cube(&mut part, [-0.5, -0.5, -0.5], [1.0, 1.0, 1.0]);
843 let bore = cylinder(&mut part, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
844 let bored = op(&mut part, block, bore, BooleanOp::Difference);
845 let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
846 let filled = op(&mut part, bored, ball, BooleanOp::Union);
847 let half = cube(&mut part, [-0.5, 0.0, -0.5], [1.0, 1.0, 1.0]);
848 op(&mut part, filled, half, BooleanOp::Difference);
849 }
850
851 #[test]
859 #[ignore = "still fails: the section plane x = 0.05 cuts the sphere in a circle of radius \
860 0.4975, exactly the bore wall's y = ±0.4975, so the circle touches the wall's section \
861 lines tangentially at (0.05, ±0.4975, 0). Splitting the section face there leaves two \
862 kept faces overlapping on the thin strip between the cube side and the wall (the \
863 spurious triangles), with a wall-section edge used by 3 coedges. Fixed so far: the \
864 missing top/bottom corner faces (stale start-point face; a curve shorter than the \
865 tracer's first step never getting a direction)."]
866 fn flush_bored_cube_plus_inscribed_sphere_section() {
867 use geop_ops_extrude_revolve::cylinder::Axis;
868 let mut part = M::new();
869 let block = cube(&mut part, CORNER, UNIT);
870 let bore = cylinder(&mut part, [0.0, 0.0, -0.5], 0.5, 1.0, Axis::Z);
871 let bored = op(&mut part, block, bore, BooleanOp::Difference);
872 let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
873 let filled = op(&mut part, bored, ball, BooleanOp::Union);
874 let cutter = cube(&mut part, [-1.45, -1.05, -0.82], [1.5, 2.1, 1.65]);
875 op(&mut part, filled, cutter, BooleanOp::Difference);
876 }
877
878 fn op_empty(
880 part: &mut M,
881 a: geop_core_topology::SolidId,
882 b: geop_core_topology::SolidId,
883 op: BooleanOp,
884 ) {
885 let result = boolean(part, &namer(), a, b, op, RemeshParams::default())
886 .unwrap_or_else(|e| panic!("{op:?} failed: {e:?}"));
887 assert!(result.is_none(), "{op:?} should be empty");
888 }
889
890 const UNIT: [f64; 3] = [1.0, 1.0, 1.0];
891 const CORNER: [f64; 3] = [-0.5, -0.5, -0.5];
892
893 #[test]
901 fn cube_minus_inscribed_cylinder() {
902 use geop_ops_extrude_revolve::cylinder::Axis;
903 let mut part = M::new();
904 let block = cube(&mut part, CORNER, UNIT);
905 let bore = cylinder(&mut part, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
906 op(&mut part, block, bore, BooleanOp::Difference);
907 }
908
909 #[test]
914 fn cube_minus_flush_inscribed_cylinder() {
915 use geop_ops_extrude_revolve::cylinder::Axis;
916 let mut part = M::new();
917 let block = cube(&mut part, CORNER, UNIT);
918 let bore = cylinder(&mut part, [0.0, 0.0, -0.5], 0.5, 1.0, Axis::Z);
919 op(&mut part, block, bore, BooleanOp::Difference);
920 }
921
922 #[test]
924 fn cube_minus_inscribed_sphere() {
925 let mut part = M::new();
926 let block = cube(&mut part, CORNER, UNIT);
927 let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
928 op(&mut part, block, ball, BooleanOp::Difference);
929 }
930
931 #[test]
933 fn cube_intersect_circumscribed_sphere() {
934 let mut part = M::new();
935 let block = cube(&mut part, CORNER, UNIT);
936 let ball = sphere(&mut part, [0.0, 0.0, 0.0], 3f64.sqrt() / 2.0);
937 op(&mut part, block, ball, BooleanOp::Intersection);
938 }
939
940 #[test]
942 fn cube_intersect_cylinder_through_its_edges() {
943 use geop_ops_extrude_revolve::cylinder::Axis;
944 let mut part = M::new();
945 let block = cube(&mut part, CORNER, UNIT);
946 let tube = cylinder(&mut part, [0.0, 0.0, -1.0], 0.5f64.sqrt(), 2.0, Axis::Z);
947 op(&mut part, block, tube, BooleanOp::Intersection);
948 }
949
950 #[test]
953 fn sphere_union_tangent_cylinder() {
954 use geop_ops_extrude_revolve::cylinder::Axis;
955 let mut part = M::new();
956 let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
957 let tube = cylinder(&mut part, [0.0, 0.0, -1.0], 0.5, 2.0, Axis::Z);
958 op(&mut part, ball, tube, BooleanOp::Union);
959 }
960
961 #[test]
964 fn sphere_union_cube_touching_its_pole() {
965 let mut part = M::new();
966 let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
967 let block = cube(&mut part, [-0.5, -0.5, 0.5], UNIT);
968 op(&mut part, ball, block, BooleanOp::Union);
969 }
970
971 #[test]
973 fn cube_union_cylinder_lying_on_top() {
974 use geop_ops_extrude_revolve::cylinder::Axis;
975 let mut part = M::new();
976 let block = cube(&mut part, CORNER, UNIT);
977 let log = cylinder(&mut part, [-1.0, 0.0, 0.75], 0.25, 2.0, Axis::X);
978 op(&mut part, block, log, BooleanOp::Union);
979 }
980
981 #[test]
985 fn cubes_sharing_a_face_union() {
986 let mut part = M::new();
987 let a = cube(&mut part, CORNER, UNIT);
988 let b = cube(&mut part, [0.5, -0.5, -0.5], UNIT);
989 op(&mut part, a, b, BooleanOp::Union);
990 }
991
992 #[test]
993 fn cubes_sharing_an_edge_union() {
994 let mut part = M::new();
995 let a = cube(&mut part, CORNER, UNIT);
996 let b = cube(&mut part, [0.5, 0.5, -0.5], UNIT);
997 op(&mut part, a, b, BooleanOp::Union);
998 }
999
1000 #[test]
1001 fn cubes_sharing_a_corner_union() {
1002 let mut part = M::new();
1003 let a = cube(&mut part, CORNER, UNIT);
1004 let b = cube(&mut part, [0.5, 0.5, 0.5], UNIT);
1005 op(&mut part, a, b, BooleanOp::Union);
1006 }
1007
1008 #[test]
1011 fn cube_minus_offset_cube_with_coplanar_faces() {
1012 let mut part = M::new();
1013 let a = cube(&mut part, CORNER, UNIT);
1014 let b = cube(&mut part, [0.0, -0.5, -0.5], UNIT);
1015 op(&mut part, a, b, BooleanOp::Difference);
1016 }
1017
1018 #[test]
1020 fn cube_minus_flush_cylinder() {
1021 use geop_ops_extrude_revolve::cylinder::Axis;
1022 let mut part = M::new();
1023 let block = cube(&mut part, CORNER, UNIT);
1024 let bore = cylinder(&mut part, [0.0, 0.0, -0.5], 0.3, 1.0, Axis::Z);
1025 op(&mut part, block, bore, BooleanOp::Difference);
1026 }
1027
1028 #[test]
1029 fn identical_spheres_union() {
1030 let mut part = M::new();
1031 let a = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
1032 let b = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
1033 op(&mut part, a, b, BooleanOp::Union);
1034 }
1035
1036 #[test]
1037 fn identical_cubes_difference_is_empty() {
1038 let mut part = M::new();
1039 let a = cube(&mut part, CORNER, UNIT);
1040 let b = cube(&mut part, CORNER, UNIT);
1041 op_empty(&mut part, a, b, BooleanOp::Difference);
1042 }
1043
1044 #[test]
1047 fn cube_minus_sphere_on_its_corner() {
1048 let mut part = M::new();
1049 let block = cube(&mut part, CORNER, UNIT);
1050 let ball = sphere(&mut part, [0.5, 0.5, 0.5], 0.5);
1051 op(&mut part, block, ball, BooleanOp::Difference);
1052 }
1053
1054 #[test]
1059 fn steinmetz_cylinders_union() {
1060 use geop_ops_extrude_revolve::cylinder::Axis;
1061 let mut part = M::new();
1062 let a = cylinder(&mut part, [0.0, 0.0, -1.0], 0.5, 2.0, Axis::Z);
1063 let b = cylinder(&mut part, [-1.0, 0.0, 0.0], 0.5, 2.0, Axis::X);
1064 op(&mut part, a, b, BooleanOp::Union);
1065 }
1066
1067 #[test]
1068 fn steinmetz_cylinders_intersection() {
1069 use geop_ops_extrude_revolve::cylinder::Axis;
1070 let mut part = M::new();
1071 let a = cylinder(&mut part, [0.0, 0.0, -1.0], 0.5, 2.0, Axis::Z);
1072 let b = cylinder(&mut part, [-1.0, 0.0, 0.0], 0.5, 2.0, Axis::X);
1073 op(&mut part, a, b, BooleanOp::Intersection);
1074 }
1075
1076 #[test]
1078 fn sphere_minus_cylinder_through_its_poles() {
1079 use geop_ops_extrude_revolve::cylinder::Axis;
1080 let mut part = M::new();
1081 let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
1082 let bore = cylinder(&mut part, [0.0, 0.0, -1.0], 0.2, 2.0, Axis::Z);
1083 op(&mut part, ball, bore, BooleanOp::Difference);
1084 }
1085
1086 #[test]
1089 fn cylinder_union_sphere_on_its_cap() {
1090 use geop_ops_extrude_revolve::cylinder::Axis;
1091 let mut part = M::new();
1092 let tube = cylinder(&mut part, [0.0, 0.0, -1.0], 0.5, 1.0, Axis::Z);
1093 let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.3);
1094 op(&mut part, tube, ball, BooleanOp::Union);
1095 }
1096
1097 #[test]
1101 fn cylinder_union_equal_sphere_on_its_cap() {
1102 use geop_ops_extrude_revolve::cylinder::Axis;
1103 let mut part = M::new();
1104 let tube = cylinder(&mut part, [0.0, 0.0, -1.0], 0.5, 1.0, Axis::Z);
1105 let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
1106 op(&mut part, tube, ball, BooleanOp::Union);
1107 }
1108
1109 #[test]
1111 fn cube_union_sphere_touching_a_face() {
1112 let mut part = M::new();
1113 let block = cube(&mut part, CORNER, UNIT);
1114 let ball = sphere(&mut part, [1.0, 0.0, 0.0], 0.5);
1115 op(&mut part, block, ball, BooleanOp::Union);
1116 }
1117
1118 #[test]
1120 fn cube_union_sphere_on_a_face() {
1121 let mut part = M::new();
1122 let block = cube(&mut part, CORNER, UNIT);
1123 let ball = sphere(&mut part, [0.5, 0.0, 0.0], 0.3);
1124 op(&mut part, block, ball, BooleanOp::Union);
1125 }
1126
1127 #[test]
1131 #[ignore = "still fails: after the third bore one face's normal points into the solid \
1132 (face_orientation check); not yet investigated. The bores are tangent to the cube's \
1133 faces and cross each other at Steinmetz points, the same degeneracies as elsewhere."]
1134 fn cube_minus_three_inscribed_bores() {
1135 use geop_ops_extrude_revolve::cylinder::Axis;
1136 let mut part = M::new();
1137 let block = cube(&mut part, CORNER, UNIT);
1138 let z = cylinder(&mut part, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
1139 let a = op(&mut part, block, z, BooleanOp::Difference);
1140 let x = cylinder(&mut part, [-0.875, 0.0, 0.0], 0.5, 1.75, Axis::X);
1141 let b = op(&mut part, a, x, BooleanOp::Difference);
1142 let y = cylinder(&mut part, [0.0, -0.875, 0.0], 0.5, 1.75, Axis::Y);
1143 op(&mut part, b, y, BooleanOp::Difference);
1144 }
1145
1146 #[test]
1148 fn cube_minus_two_crossing_bores() {
1149 use geop_ops_extrude_revolve::cylinder::Axis;
1150 let mut part = M::new();
1151 let block = cube(&mut part, CORNER, UNIT);
1152 let z = cylinder(&mut part, [0.0, 0.0, -0.875], 0.3, 1.75, Axis::Z);
1153 let a = op(&mut part, block, z, BooleanOp::Difference);
1154 let x = cylinder(&mut part, [-0.875, 0.0, 0.0], 0.3, 1.75, Axis::X);
1155 op(&mut part, a, x, BooleanOp::Difference);
1156 }
1157
1158 #[test]
1162 fn touching_spheres_union() {
1163 let mut part = M::new();
1164 let a = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
1165 let b = sphere(&mut part, [1.0, 0.0, 0.0], 0.5);
1166 op(&mut part, a, b, BooleanOp::Union);
1167 }
1168
1169 #[test]
1172 fn cylinder_minus_inscribed_sphere() {
1173 use geop_ops_extrude_revolve::cylinder::Axis;
1174 let mut part = M::new();
1175 let tube = cylinder(&mut part, [0.0, 0.0, -1.0], 0.5, 2.0, Axis::Z);
1176 let ball = sphere(&mut part, [0.0, 0.0, 0.0], 0.5);
1177 op(&mut part, tube, ball, BooleanOp::Difference);
1178 }
1179
1180 #[test]
1182 fn touching_parallel_cylinders_union() {
1183 use geop_ops_extrude_revolve::cylinder::Axis;
1184 let mut part = M::new();
1185 let a = cylinder(&mut part, [0.0, 0.0, -0.5], 0.5, 1.0, Axis::Z);
1186 let b = cylinder(&mut part, [1.0, 0.0, -0.5], 0.5, 1.0, Axis::Z);
1187 op(&mut part, a, b, BooleanOp::Union);
1188 }
1189
1190 #[test]
1192 fn cube_union_sphere_touching_an_edge() {
1193 let mut part = M::new();
1194 let block = cube(&mut part, CORNER, UNIT);
1195 let d = 0.5 + 0.5 / 2f64.sqrt();
1196 let ball = sphere(&mut part, [d, d, 0.0], 0.5);
1197 op(&mut part, block, ball, BooleanOp::Union);
1198 }
1199
1200 #[test]
1203 fn plate_with_boss_hole_and_cap() {
1204 use geop_ops_extrude_revolve::cylinder::Axis;
1205 let mut part = M::new();
1206 let plate = cube(&mut part, [-1.0, -1.0, -0.25], [2.0, 2.0, 0.5]);
1207 let boss = cylinder(&mut part, [0.0, 0.0, 0.25], 0.5, 0.5, Axis::Z);
1208 let a = op(&mut part, plate, boss, BooleanOp::Union);
1209 let cap = sphere(&mut part, [0.0, 0.0, 0.75], 0.5);
1210 let b = op(&mut part, a, cap, BooleanOp::Union);
1211 let hole = cylinder(&mut part, [0.0, 0.0, -0.5], 0.25, 1.5, Axis::Z);
1212 op(&mut part, b, hole, BooleanOp::Difference);
1213 }
1214
1215 #[test]
1216 fn chained_differences_block_with_two_slots_and_a_sphere() {
1217 let f = ScalInF64::from_f64;
1218 let corner = |x: f64, y: f64, z: f64, dx: f64, dy: f64, dz: f64| {
1219 (
1220 Vector3::from_array([f(x), f(y), f(z)]),
1221 Vector3::from_array([f(x + dx), f(y + dy), f(z + dz)]),
1222 )
1223 };
1224
1225 let mut part = M::new();
1226 let (min_a, max_a) = corner(-0.50, -0.50, -0.50, 1.00, 1.00, 1.00);
1227 let a = geop_ops_extrude_revolve::cube_solid(&mut part, "a", min_a, max_a).unwrap();
1228 let (min_b, max_b) = corner(-1.13, -0.30, -0.33, 2.25, 0.60, 0.65);
1229 let b = geop_ops_extrude_revolve::cube_solid(&mut part, "b", min_b, max_b).unwrap();
1230 let params = RemeshParams::<ScalInF64>::default();
1231 let c = boolean(&mut part, &namer(), a, b, BooleanOp::Difference, params)
1232 .unwrap()
1233 .expect("block minus the first slot must be non-empty");
1234
1235 let (min_d, max_d) = corner(-0.33, -0.28, -1.15, 0.65, 0.55, 2.30);
1236 let d = geop_ops_extrude_revolve::cube_solid(&mut part, "d", min_d, max_d).unwrap();
1237 let e = boolean(&mut part, &namer(), c, d, BooleanOp::Difference, params)
1238 .unwrap()
1239 .expect("minus the second slot must be non-empty");
1240
1241 let sphere = geop_ops_extrude_revolve::sphere::sphere_solid(
1242 &mut part,
1243 "s",
1244 Vector3::zero(),
1245 f(0.45),
1246 )
1247 .unwrap();
1248 let result = boolean(
1249 &mut part,
1250 &namer(),
1251 e,
1252 sphere,
1253 BooleanOp::Difference,
1254 params,
1255 )
1256 .unwrap()
1257 .expect("minus the sphere must be non-empty");
1258 part.check_names().unwrap();
1259
1260 let model = part.topology();
1261 assert!(!model.solid_faces(result).unwrap().is_empty());
1262 if let Err(errors) = validate_fast(&validation(), model) {
1263 panic!("{} validate_fast error(s): {}", errors.len(), errors[0]);
1264 }
1265
1266 let scene = geop_ops_rasterize::rasterize_model(model, 8).unwrap();
1267 std::fs::create_dir_all("outputs").unwrap();
1268 scene
1269 .save_to_file("outputs/chained_differences_block_with_two_slots_and_a_sphere.html")
1270 .unwrap();
1271 }
1272
1273 #[test]
1288 fn chained_differences_with_anchored_shapes_and_thin_slab_cutter_succeeds() {
1289 let mut part = M::new();
1290 let block = cube(&mut part, [-0.5, -0.5, -0.5], [1.0, 1.0, 1.0]);
1291 let corner = |part: &M, id: u64| {
1292 let p = part
1293 .topology()
1294 .get_vertex(geop_core_topology::VertexId(id))
1295 .unwrap()
1296 .point;
1297 [p[0].to_f64(), p[1].to_f64(), p[2].to_f64()]
1298 };
1299 let [x, y, z] = corner(&part, 13);
1300 let second = cube(&mut part, [x - 0.5, y - 0.5, z - 0.5], [1.0, 1.0, 1.0]);
1301 let center = corner(&part, 9);
1302 let ball = sphere(&mut part, center, 0.5);
1303 let difference = |part: &mut M, a, b| {
1307 boolean(
1308 part,
1309 &namer(),
1310 a,
1311 b,
1312 BooleanOp::Difference,
1313 RemeshParams::default(),
1314 )
1315 .unwrap()
1316 .expect("the result is not empty")
1317 };
1318 let cut = difference(&mut part, block, second);
1319 let cut = difference(&mut part, cut, ball);
1320 let slab = cube(&mut part, [-1.13, -0.10, -0.15], [2.25, 0.20, 0.30]);
1321 difference(&mut part, cut, slab);
1322 part.check_names().unwrap();
1323 }
1324
1325 #[test]
1352 fn cube_minus_z_cylinder_with_coplanar_cap_is_fast_and_correct() {
1353 let f = ScalInF64::from_f64;
1354 let mut part = M::new();
1355 let a = geop_ops_extrude_revolve::cube_solid(
1356 &mut part,
1357 "a",
1358 Vector3::from_array([f(-0.5), f(-0.5), f(-0.5)]),
1359 Vector3::from_array([f(0.5), f(0.5), f(0.5)]),
1360 )
1361 .unwrap();
1362 let b = geop_ops_extrude_revolve::cylinder::revolved_cylinder_along_axis(
1363 &mut part,
1364 "b",
1365 Vector3::from_array([f(0.0), f(0.0), f(-0.5)]),
1366 f(0.3),
1367 f(1.0),
1368 geop_ops_extrude_revolve::cylinder::Axis::Z,
1369 )
1370 .unwrap();
1371 let params = RemeshParams::<ScalInF64>::default();
1372
1373 let t0 = std::time::Instant::now();
1374 let result = boolean(&mut part, &namer(), a, b, BooleanOp::Difference, params)
1375 .unwrap()
1376 .unwrap();
1377 let elapsed = t0.elapsed();
1378 let model = part.topology();
1379 assert!(
1387 elapsed.as_secs() < 20,
1388 "boolean took {elapsed:?}, expected well under 20s"
1389 );
1390
1391 let shell = model.get_solid(result).unwrap().shells[0];
1396 for z in [f(-0.45), f(0.45)] {
1397 let p = Vector3::from_array([f(0.0), f(0.0), z]);
1398 let outside = matches!(
1399 geop_core_topology::contains::shell::shell_contains(
1400 model,
1401 shell,
1402 p,
1403 params.max_nodes,
1404 params.curve_curve_min_subdivision_size,
1405 0xC7D1
1406 )
1407 .unwrap(),
1408 geop_core_topology::contains::shell::PointClassification::Outside
1409 );
1410 assert!(
1411 outside,
1412 "point {p:?} (near a cap's bore) should be outside the drilled result"
1413 );
1414 }
1415
1416 let scene = geop_ops_rasterize::rasterize_model(model, 8).unwrap();
1417 std::fs::create_dir_all("outputs").unwrap();
1418 scene
1419 .save_to_file("outputs/cube_minus_coplanar_cap_cylinder.html")
1420 .unwrap();
1421 }
1422
1423 #[test]
1430 fn bore_renders_as_a_hole() {
1431 let mut part = M::new();
1432 let block = cube(&mut part, [-1.0, -1.0, 0.0], [2.0, 2.0, 0.5]);
1433 let bore = cylinder(
1437 &mut part,
1438 [0.0, 0.0, -0.5],
1439 0.5,
1440 1.5,
1441 geop_ops_extrude_revolve::cylinder::Axis::Z,
1442 );
1443 op(&mut part, block, bore, BooleanOp::Difference);
1444
1445 let scene = geop_ops_rasterize::rasterize_model(part.topology(), 24).unwrap();
1446 for (triangle, _) in &scene.triangles {
1447 for p in [triangle.a, triangle.b, triangle.c] {
1448 let (x, y, z) = (p[0].to_f64(), p[1].to_f64(), p[2].to_f64());
1449 let r = x.hypot(y);
1450 assert!(
1455 r > 0.45 || !(0.01..0.49).contains(&z),
1456 "a triangle corner sits inside the bore at ({x}, {y}, {z})"
1457 );
1458 }
1459 }
1460 }
1461}