1use std::collections::{HashMap, HashSet};
23
24use geop_core_geometry::{intersection::curve_surface_intersect, nurb_surface::NurbSurface3D};
25use geop_core_math::{
26 geop_error::{GeopError, GeopResult, WithContext},
27 scalars::Scalar,
28 union_find::UnionFind,
29 vector::Vector3,
30 with_context,
31};
32use geop_core_topology::{
33 EdgeId, FaceId, Model, SolidId, VertexId,
34 contains::{
35 face::face_interior_point_where,
36 shell::{Classified, PointClassification as ShellPoint, solid_contains, solid_face_at},
37 },
38};
39use geop_ops_model::{BooleanOp, NamedModel, Namer};
40
41use crate::naming::{name_lone_pieces, origins_by_name};
42use crate::remesh::{Origins, RemeshParams, remesh};
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub(crate) enum FaceClassification {
47 Inside,
49 Outside,
51 OnSameNormal,
54 OnOppositeNormal,
57}
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61enum Keep {
62 AsIs,
64 Reversed,
67 Drop,
69}
70
71fn keeps(op: BooleanOp, class: FaceClassification, from_a: bool) -> Keep {
86 use FaceClassification::*;
87 match (op, class) {
88 (BooleanOp::Union, Outside) => Keep::AsIs,
90 (BooleanOp::Union, OnSameNormal) if from_a => Keep::AsIs,
91 (BooleanOp::Union, Inside | OnSameNormal | OnOppositeNormal) => Keep::Drop,
92
93 (BooleanOp::Intersection, Inside) => Keep::AsIs,
95 (BooleanOp::Intersection, OnSameNormal) if from_a => Keep::AsIs,
96 (BooleanOp::Intersection, Outside | OnSameNormal | OnOppositeNormal) => Keep::Drop,
97
98 (BooleanOp::Difference, Outside | OnOppositeNormal) if from_a => Keep::AsIs,
103 (BooleanOp::Difference, Inside) if !from_a => Keep::Reversed,
104 (BooleanOp::Difference, Inside | Outside | OnSameNormal | OnOppositeNormal) => Keep::Drop,
105
106 (BooleanOp::SymmetricDifference, Outside) => Keep::AsIs,
112 (BooleanOp::SymmetricDifference, Inside) => Keep::Reversed,
113 (BooleanOp::SymmetricDifference, OnSameNormal | OnOppositeNormal) => Keep::Drop,
114 }
115}
116
117const SEED: u64 = 0xB001_EA47_0000_0001;
122
123pub fn boolean(
138 model: &mut NamedModel,
139 namer: &Namer,
140 solid_a: SolidId,
141 solid_b: SolidId,
142 op: BooleanOp,
143 params: RemeshParams,
144) -> GeopResult<Option<SolidId>> {
145 combine(model, namer, solid_a, solid_b, op, None, params)?.stopped()
146}
147
148#[derive(Clone, Copy, Debug)]
153struct ToolEnds<'a> {
154 start: &'a str,
155 end: &'a str,
156 alone: bool,
157}
158
159#[derive(Debug)]
165pub enum Reach<T> {
166 Stopped(T),
167 RunsOn(GeopError),
168}
169
170impl<T> Reach<T> {
171 pub fn stopped(self) -> GeopResult<T> {
173 match self {
174 Reach::Stopped(t) => Ok(t),
175 Reach::RunsOn(e) => Err(e),
176 }
177 }
178}
179
180pub fn boolean_up_to_next(
192 model: &mut NamedModel,
193 namer: &Namer,
194 target: SolidId,
195 tool: SolidId,
196 op: BooleanOp,
197 (start, end): (&str, &str),
198 params: RemeshParams,
199) -> GeopResult<Reach<Option<SolidId>>> {
200 let up_to_next = ToolEnds {
201 start,
202 end,
203 alone: false,
204 };
205 combine(model, namer, target, tool, op, Some(up_to_next), params)
206}
207
208pub fn piece_up_to_next(
217 model: &mut NamedModel,
218 namer: &Namer,
219 tool: SolidId,
220 stops: &[SolidId],
221 (start, end): (&str, &str),
222 params: RemeshParams,
223) -> GeopResult<Reach<SolidId>> {
224 let ctx = with_context!(
225 "piece_up_to_next(name={}, tool={tool}, stops={stops:?})",
226 namer.root()
227 );
228 let copy = copy_of(model, namer, stops).with_context(&ctx)?;
229 let up_to_next = ToolEnds {
232 start,
233 end,
234 alone: true,
235 };
236 let reach = combine(
237 model,
238 namer,
239 copy,
240 tool,
241 BooleanOp::Union,
242 Some(up_to_next),
243 params,
244 )
245 .with_context(&ctx)?;
246 Ok(match reach {
247 Reach::Stopped(piece) => Reach::Stopped(
248 piece.ok_or_else(|| ctx(GeopError::new("up to next: nothing of the tool is left")))?,
249 ),
250 Reach::RunsOn(e) => Reach::RunsOn(ctx(e)),
251 })
252}
253
254pub(crate) fn copy_of(
258 model: &mut NamedModel,
259 namer: &Namer,
260 solids: &[SolidId],
261) -> GeopResult<SolidId> {
262 let mut faces = Vec::new();
263 for &solid in solids {
264 faces.extend(model.topology().solid_faces(solid)?);
265 }
266 let copy = model.copy_faces(&faces, Some(namer.name(&["copy"])), |name| {
267 namer.name(&["copy", name])
268 })?;
269 Ok(copy.solid.expect("built as a solid"))
270}
271
272fn combine(
275 model: &mut NamedModel,
276 namer: &Namer,
277 solid_a: SolidId,
278 solid_b: SolidId,
279 op: BooleanOp,
280 up_to_next: Option<ToolEnds>,
281 params: RemeshParams,
282) -> GeopResult<Reach<Option<SolidId>>> {
283 let ctx = with_context!(
284 "boolean(name={}, solid_a={solid_a}, solid_b={solid_b}, op={op:?}, up_to_next={up_to_next:?})",
285 namer.root()
286 );
287 let (parted, between_two) = given_topology(model, &[solid_a, solid_b]).with_context(&ctx)?;
288
289 let origins = remesh(model, namer, solid_a, solid_b, params)
292 .map_err(|e| {
293 let names = model.names_mentioned(&e.to_string());
294 e.with_context(format!("where it names {}", names.join(", ")))
295 })
296 .with_context(&ctx)?;
297 let topology = model.topology();
298
299 let faces_a = topology.solid_faces(solid_a).with_context(&ctx)?;
300 let faces_b = topology.solid_faces(solid_b).with_context(&ctx)?;
301
302 let mut decisions: HashMap<FaceId, (FaceClassification, Keep)> = HashMap::new();
303 let mut keep: Vec<FaceId> = Vec::new();
304 let mut reverse: Vec<FaceId> = Vec::new();
305 let mut classified = Classified::default();
306 let (patches_a, patches_b) = (
317 trimmed_patches(topology, &faces_a).with_context(&ctx)?,
318 trimmed_patches(topology, &faces_b).with_context(&ctx)?,
319 );
320 for (patches, from_a, other, others) in [
321 (&patches_a, true, solid_b, &patches_b),
322 (&patches_b, false, solid_a, &patches_a),
323 ] {
324 let classify = |face_id: FaceId, classified: &mut Classified| {
325 classify_face(topology, face_id, other, params, classified)
326 .map_err(|e| {
327 let names = model.names_mentioned(&e.to_string());
328 e.with_context(format!("where it names {}", names.join(", ")))
329 })
330 .with_context(&ctx)
331 .with_context(with_context!(
332 "classifying face {face_id} ({:?}, of {})",
333 model.name_of(face_id),
334 if from_a {
335 "the first solid"
336 } else {
337 "the second solid"
338 }
339 ))
340 };
341 let mut apart = std::collections::BTreeSet::new();
342 let mut sided = std::collections::VecDeque::new();
343 for (face_id, patch) in patches {
344 if others.iter().any(|(_, o)| patch.could_overlap(o)) {
345 let class = classify(*face_id, &mut classified)?;
346 decisions.insert(*face_id, (class, keeps(op, class, from_a)));
347 sided.push_back((*face_id, class));
348 } else {
349 apart.insert(*face_id);
350 }
351 }
352 loop {
353 while let Some((face_id, class)) = sided.pop_front() {
354 if !matches!(
355 class,
356 FaceClassification::Inside | FaceClassification::Outside
357 ) {
358 continue;
359 }
360 for edge in topology.face_edges(face_id) {
361 for coedge in topology.coedges_of_edge(edge) {
362 let next = topology.get_coedge(coedge).with_context(&ctx)?.face;
363 if apart.remove(&next) {
364 decisions.insert(next, (class, keeps(op, class, from_a)));
365 sided.push_back((next, class));
366 }
367 }
368 }
369 }
370 let Some(face_id) = apart.pop_first() else {
371 break;
372 };
373 let class = classify(face_id, &mut classified)?;
374 decisions.insert(face_id, (class, keeps(op, class, from_a)));
375 sided.push_back((face_id, class));
376 }
377 }
378 if let Some(ToolEnds { start, end, alone }) = up_to_next {
379 let tool: HashSet<FaceId> = faces_b.iter().copied().collect();
380 let (wanted_inside, keep_untouched) = match op {
385 BooleanOp::Union => (false, !alone),
386 BooleanOp::Difference => (true, true),
387 BooleanOp::Intersection => (true, false),
388 BooleanOp::SymmetricDifference => {
389 return Err(ctx(GeopError::new(
390 "up to next: a symmetric difference keeps every piece of the tool, so it reaches no next face; use a blind extent",
391 )));
392 }
393 };
394 let reached = match reach_up_to_next(
395 topology,
396 &tool,
397 (wanted_inside, keep_untouched),
398 &origins.faces,
399 (start, end),
400 &mut decisions,
401 )
402 .with_context(&ctx)?
403 {
404 Reach::Stopped(reached) => reached,
405 Reach::RunsOn(e) => return Ok(Reach::RunsOn(ctx(e))),
406 };
407 if alone {
408 keep_alone(&reached, &mut decisions);
409 }
410 }
411 for &face_id in faces_a.iter().chain(&faces_b) {
414 match decisions[&face_id].1 {
415 Keep::AsIs => keep.push(face_id),
416 Keep::Reversed => {
417 keep.push(face_id);
418 reverse.push(face_id);
419 }
420 Keep::Drop => {}
421 }
422 }
423 check_closed(model, &decisions).with_context(&ctx)?;
424
425 for &face_id in &reverse {
426 model.reverse_face(face_id).with_context(&ctx)?;
427 }
428
429 let Some(solid) = model
430 .assemble_solid(&[solid_a.into(), solid_b.into()], &keep, namer.root())
431 .with_context(&ctx)?
432 else {
433 return Ok(Reach::Stopped(None));
434 };
435 let by_name = origins_by_name(model, &origins, solid).with_context(&ctx)?;
443 let stays = edges_parting_as_given(model, solid, &origins, &parted).with_context(&ctx)?;
444 let merged = model
445 .merge_faces_on_one_surface(
446 solid.into(),
447 |e| !stays.contains(&e),
448 |v| !between_two.contains(&v),
449 )
450 .with_context(&ctx)?;
451 match merged {
452 geop_core_topology::Body::Solid(solid) => {
453 name_lone_pieces(model, &by_name).with_context(&ctx)?;
454 Ok(Reach::Stopped(Some(solid)))
455 }
456 geop_core_topology::Body::Sheet(_) => Err(ctx(GeopError::new(
457 "merging the faces of the result on one surface left no solid",
458 ))),
459 }
460}
461
462type GivenTopology = (HashMap<String, [String; 2]>, HashSet<VertexId>);
464
465fn given_topology(model: &NamedModel, solids: &[SolidId]) -> GeopResult<GivenTopology> {
469 let topology = model.topology();
470 let mut parted: HashMap<String, Vec<String>> = HashMap::new();
471 let mut edges_at: HashMap<VertexId, HashSet<EdgeId>> = HashMap::new();
472 for &solid in solids {
473 for face in topology.solid_faces(solid)? {
474 let name = model.name(face)?;
475 for edge in topology.face_edges(face) {
476 parted
477 .entry(model.name(edge)?.to_string())
478 .or_default()
479 .push(name.to_string());
480 let e = topology.get_edge(edge)?;
481 for v in [e.start_vertex, e.end_vertex] {
482 edges_at.entry(v).or_default().insert(edge);
483 }
484 }
485 }
486 }
487 let parted = (parted.into_iter())
488 .filter_map(|(edge, mut faces)| {
489 faces.sort();
490 let [f, g] = <[String; 2]>::try_from(faces).ok()?;
491 Some((edge, [f, g]))
492 })
493 .collect();
494 let between_two = (edges_at.into_iter())
495 .filter(|(_, edges)| edges.len() == 2)
496 .map(|(v, _)| v)
497 .collect();
498 Ok((parted, between_two))
499}
500
501fn edges_parting_as_given(
507 model: &NamedModel,
508 solid: SolidId,
509 origins: &Origins,
510 parted: &HashMap<String, [String; 2]>,
511) -> GeopResult<HashSet<EdgeId>> {
512 let topology = model.topology();
513 let mut faces_of: HashMap<EdgeId, Vec<&String>> = HashMap::new();
514 for face in topology.solid_faces(solid)? {
515 if let Some(origin) = origins.faces.get(&face) {
516 for edge in topology.face_edges(face) {
517 faces_of.entry(edge).or_default().push(origin);
518 }
519 }
520 }
521 Ok((faces_of.into_iter())
522 .filter_map(|(edge, mut faces)| {
523 let given = parted.get(origins.edges.get(&edge)?)?;
524 faces.sort();
525 (faces.len() == 2 && *faces[0] == given[0] && *faces[1] == given[1]).then_some(edge)
526 })
527 .collect())
528}
529
530fn reach_up_to_next(
551 model: &Model,
552 tool: &HashSet<FaceId>,
553 (wanted_inside, keep_untouched): (bool, bool),
554 origins: &HashMap<FaceId, String>,
555 (start, end): (&str, &str),
556 decisions: &mut HashMap<FaceId, (FaceClassification, Keep)>,
557) -> GeopResult<Reach<Reached>> {
558 use FaceClassification::*;
559 let inside = |class: FaceClassification| matches!(class, Inside | OnSameNormal);
561 let mut tool_faces: Vec<FaceId> = tool.iter().copied().collect();
562 tool_faces.sort_by_key(|f| f.0);
563 let mut between: Vec<FaceId> = decisions
564 .iter()
565 .filter(|(face, (class, _))| !tool.contains(face) && *class == Inside)
566 .map(|(&face, _)| face)
567 .collect();
568 between.sort_by_key(|f| f.0);
569 let (n, m) = (tool_faces.len(), between.len());
572 let (inner, outer) = (|j: usize| n + j, |j: usize| n + m + j);
573 let tool_index: HashMap<FaceId, usize> = tool_faces
574 .iter()
575 .enumerate()
576 .map(|(i, &f)| (f, i))
577 .collect();
578 let between_index: HashMap<FaceId, usize> =
579 between.iter().enumerate().map(|(j, &f)| (f, j)).collect();
580 let class = |face: &FaceId| decisions[face].0;
581
582 let mut users: HashMap<EdgeId, Vec<FaceId>> = HashMap::new();
583 for &face in tool_faces.iter().chain(&between) {
584 for edge in model.face_edges(face) {
585 users.entry(edge).or_default().push(face);
586 }
587 }
588 let mut pieces = UnionFind::new(n + 2 * m);
589 for faces in users.values() {
590 let (mut in_node, mut out_node) = (None, None);
591 let join = |slot: &mut Option<usize>, node: usize, pieces: &mut UnionFind| match *slot {
592 Some(other) => pieces.union(other, node),
593 None => *slot = Some(node),
594 };
595 for face in faces {
596 if let Some(&i) = tool_index.get(face) {
597 let slot = if inside(class(face)) {
598 &mut in_node
599 } else {
600 &mut out_node
601 };
602 join(slot, i, &mut pieces);
603 } else {
604 let j = between_index[face];
605 join(&mut in_node, inner(j), &mut pieces);
606 join(&mut out_node, outer(j), &mut pieces);
607 }
608 }
609 }
610 let piece_inside = |node: usize| match node {
613 i if i < n => inside(class(&tool_faces[i])),
614 i => i < n + m,
615 };
616 let mut kind: HashMap<usize, bool> = HashMap::new();
617 for node in 0..n + 2 * m {
618 kind.insert(pieces.find(node), piece_inside(node));
619 }
620 let mut neighbours: HashMap<usize, HashSet<usize>> = HashMap::new();
621 for j in 0..m {
622 let (a, b) = (pieces.find(inner(j)), pieces.find(outer(j)));
623 neighbours.entry(a).or_default().insert(b);
624 neighbours.entry(b).or_default().insert(a);
625 }
626
627 let origin_is = |face: &FaceId, name: &str| origins.get(face).is_some_and(|o| o == name);
628 let start_pieces: HashSet<usize> = tool_faces
635 .iter()
636 .enumerate()
637 .filter(|(_, face)| origin_is(face, start))
638 .map(|(i, _)| pieces.find(i))
639 .collect();
640 let mut reached: HashSet<usize> = start_pieces
641 .iter()
642 .copied()
643 .filter(|p| kind[p] == wanted_inside)
644 .collect();
645 if reached.is_empty() {
646 for piece in &start_pieces {
647 let next = neighbours.get(piece).into_iter().flatten();
648 reached.extend(next.filter(|p| kind[p] == wanted_inside));
649 }
650 }
651 if reached.is_empty() {
652 let starts: Vec<String> = tool_faces
653 .iter()
654 .enumerate()
655 .filter(|(_, face)| origin_is(face, start))
656 .map(|(i, face)| {
657 let piece = pieces.find(i);
658 format!(
659 "{face} ({:?}, in an {} piece with {} neighbour(s))",
660 class(face),
661 if kind[&piece] { "inside" } else { "outside" },
662 neighbours.get(&piece).map_or(0, HashSet::len)
663 )
664 })
665 .collect();
666 let mut tool_origins: Vec<&str> = tool_faces
667 .iter()
668 .filter_map(|f| origins.get(f).map(String::as_str))
669 .collect();
670 tool_origins.sort_unstable();
671 tool_origins.dedup();
672 return Err(GeopError::new(format!(
673 "up to next: from its start {start:?}, the profile meets nothing of the target to {} — does it go the right way? (the start's faces: [{}]; {} piece(s), {m} target face(s) between them; the tool's faces come from {tool_origins:?})",
674 if wanted_inside {
675 "cut into"
676 } else {
677 "grow up to"
678 },
679 starts.join(", "),
680 kind.len(),
681 )));
682 }
683 for (i, face) in tool_faces.iter().enumerate() {
684 let in_reach = reached.contains(&pieces.find(i));
685 if in_reach && origin_is(face, end) {
686 return Ok(Reach::RunsOn(GeopError::new(format!(
687 "up to next: nothing stops the profile: it reaches the end {end:?} without meeting a face of the target all around"
688 ))));
689 }
690 if !in_reach {
691 decisions.get_mut(face).expect("classified").1 = Keep::Drop;
692 }
693 }
694 let mut bounding = Reached {
695 tool: HashSet::new(),
696 target: HashSet::new(),
697 };
698 for (i, face) in tool_faces.iter().enumerate() {
699 if reached.contains(&pieces.find(i)) {
700 bounding.tool.insert(*face);
701 }
702 }
703 for (j, face) in between.iter().enumerate() {
704 let facing = if wanted_inside { inner(j) } else { outer(j) };
705 if reached.contains(&pieces.find(facing)) {
706 bounding.target.insert(*face);
707 } else {
708 decisions.get_mut(face).expect("classified").1 = if keep_untouched {
711 Keep::AsIs
712 } else {
713 Keep::Drop
714 };
715 }
716 }
717 Ok(Reach::Stopped(bounding))
718}
719
720struct Reached {
723 tool: HashSet<FaceId>,
724 target: HashSet<FaceId>,
725}
726
727fn keep_alone(reached: &Reached, decisions: &mut HashMap<FaceId, (FaceClassification, Keep)>) {
733 use FaceClassification::*;
734 for (face, (class, keep)) in decisions.iter_mut() {
735 *keep = if reached.tool.contains(face) && matches!(class, Outside | OnOppositeNormal) {
736 Keep::AsIs
737 } else if reached.target.contains(face) {
738 Keep::Reversed
739 } else {
740 Keep::Drop
741 };
742 }
743}
744
745fn check_closed(
753 model: &NamedModel,
754 decisions: &HashMap<FaceId, (FaceClassification, Keep)>,
755) -> GeopResult<()> {
756 let topology = model.topology();
757 let mut uses: HashMap<EdgeId, usize> = HashMap::new();
758 for (&face_id, &(_, decision)) in decisions {
759 if decision == Keep::Drop {
760 continue;
761 }
762 for edge in topology.face_edges(face_id) {
763 *uses.entry(edge).or_default() += 1;
764 }
765 }
766 let Some((&edge, &count)) = uses
767 .iter()
768 .filter(|(_, n)| *n % 2 != 0)
769 .min_by_key(|(e, _)| e.0)
770 else {
771 return Ok(());
772 };
773 let e = topology.get_edge(edge)?;
774 let faces: Vec<String> = topology
775 .coedges_of_edge(edge)
776 .into_iter()
777 .map(|coedge| {
778 let face = topology.get_coedge(coedge)?.face;
779 let name = model.name_of(face).unwrap_or("?");
780 let decision = match decisions.get(&face) {
781 Some((class, decision)) => format!("{class:?} -> {decision:?}"),
782 None => "not in either solid".to_string(),
783 };
784 Ok(format!(
785 "{face} {name:?}: {decision}, bounded by {:?}",
786 topology.face_corners(face)?
787 ))
788 })
789 .collect::<GeopResult<_>>()?;
790 Err(GeopError::new(format!(
791 "boolean: the kept faces use edge {edge} (from {:?} to {:?}) {count} time(s), leaving the result open there; faces along it: [{}]",
792 topology.get_vertex(e.start_vertex)?.point,
793 topology.get_vertex(e.end_vertex)?.point,
794 faces.join(", ")
795 )))
796}
797
798fn trimmed_patches(model: &Model, faces: &[FaceId]) -> GeopResult<Vec<(FaceId, NurbSurface3D)>> {
805 faces
806 .iter()
807 .map(|&face_id| {
808 let surface = &model.get_face(face_id)?.surface;
809 let mut bounds: Option<[Scalar; 2]> = None;
810 for coedge in model.iterate_face_coedges(face_id) {
811 for p in &model.get_coedge(coedge)?.pcurve.control_points {
812 let uv = [p[0].div(p[2])?, p[1].div(p[2])?];
813 bounds = Some(match bounds {
814 None => uv,
815 Some(b) => [b[0].union(uv[0]), b[1].union(uv[1])],
816 });
817 }
818 }
819 let patch = match bounds {
820 Some([u, v]) => {
821 surface.sub_surface((u.lower(), u.upper()), (v.lower(), v.upper()))?
822 }
823 None => surface.clone(),
824 };
825 Ok((face_id, patch))
826 })
827 .collect()
828}
829
830thread_local! {
831 static FACES_CLASSIFIED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
832}
833
834pub fn faces_classified() -> usize {
839 FACES_CLASSIFIED.get()
840}
841
842pub(crate) fn classify_face(
871 model: &Model,
872 face_id: FaceId,
873 other_solid: SolidId,
874 params: RemeshParams,
875 classified: &mut Classified,
876) -> GeopResult<FaceClassification> {
877 FACES_CLASSIFIED.set(FACES_CLASSIFIED.get() + 1);
878 let face = model.get_face(face_id)?;
879 let mut decided: Vec<(FaceClassification, Vector3)> = Vec::new();
880 let mut on_boundary = Vec::new();
881 let (max_nodes, epsilon) = (params.max_nodes, params.curve_curve_min_subdivision_size);
882 face_interior_point_where(model, face_id, max_nodes, epsilon, SEED, |u, v| {
883 let point = face.surface.evaluate(u, v)?;
884 match solid_contains(
885 model,
886 other_solid,
887 point,
888 max_nodes,
889 epsilon,
890 SEED,
891 classified,
892 )? {
893 ShellPoint::Inside => decided.push((FaceClassification::Inside, point)),
894 ShellPoint::Outside => decided.push((FaceClassification::Outside, point)),
895 ShellPoint::OnFace | ShellPoint::OnEdge | ShellPoint::OnVertex => {
896 on_boundary.push((u, v, point));
897 }
898 }
899 Ok(false)
900 })?;
901 if let Some(&(classification, point)) = decided.first() {
902 if let Some((other, other_point)) = decided.iter().find(|(c, _)| *c != classification) {
903 let loops = model.face_corners(face_id)?;
906 let other_edges: HashSet<EdgeId> = model.iter_body_edges(other_solid)?.collect();
911 let bounding: HashSet<EdgeId> = model.face_edges(face_id).collect();
912 let mut corners: Vec<_> = loops.iter().flatten().map(|&(v, _)| v).collect();
913 corners.sort();
914 corners.dedup();
915 let mut leaving = Vec::new();
916 for vertex in corners {
917 for edge in model.edges_at_vertex(vertex) {
918 if !other_edges.contains(&edge) || bounding.contains(&edge) {
919 continue;
920 }
921 let found = curve_surface_intersect(
922 &model.get_edge(edge)?.curve,
923 &face.surface,
924 params.max_edge_intersections,
925 max_nodes,
926 epsilon,
927 );
928 leaving.push(format!("{edge} at {vertex}: {found:?}"));
929 }
930 }
931 return Err(GeopError::new(format!(
932 "classify_face: face {face_id} straddles solid {other_solid}'s boundary — {point:?} is {classification:?}, {other_point:?} is {other:?} — so remesh left an intersection curve unimprinted; the face is bounded by (corners, outer loop first) {loops:?}; the other solid's edges at its corners, against its surface: {leaving:?}"
933 )));
934 }
935 return Ok(classification);
936 }
937
938 let mut undefined = None;
945 for &(u, v, point) in &on_boundary {
946 let normals = face
947 .surface
948 .normal(u, v)
949 .and_then(|n| Ok((n, solid_normal_at(model, other_solid, &point, params)?)));
950 let (this_normal, other_normal) = match normals {
951 Ok(normals) => normals,
952 Err(e) => {
953 undefined = Some(e);
954 continue;
955 }
956 };
957 let alignment = this_normal.prod_dot(&other_normal);
958 return if alignment.definitely_greater(Scalar::ZERO) {
959 Ok(FaceClassification::OnSameNormal)
960 } else if alignment.definitely_less(Scalar::ZERO) {
961 Ok(FaceClassification::OnOppositeNormal)
962 } else {
963 Err(GeopError::new(format!(
964 "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"
965 )))
966 };
967 }
968 Err(match undefined {
969 Some(e) => e.with_context(format!(
970 "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",
971 on_boundary.len()
972 )),
973 None => GeopError::new(format!(
974 "classify_face: face {face_id} yielded interior points, yet none was classified or set aside"
975 )),
976 })
977}
978
979fn solid_normal_at(
981 model: &Model,
982 solid_id: SolidId,
983 point: &Vector3,
984 params: RemeshParams,
985) -> GeopResult<Vector3> {
986 let at = solid_face_at(
987 model,
988 solid_id,
989 point,
990 params.max_nodes,
991 params.curve_curve_min_subdivision_size,
992 SEED,
993 )?;
994 match at {
995 Some((face_id, u, v)) => model.get_face(face_id)?.surface.normal(u, v),
996 None => Err(GeopError::new(format!(
997 "boolean: no face of solid {solid_id} contains {point:?}, although the solid reported the point on its boundary"
998 ))),
999 }
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004 use super::{BooleanOp, Classified, FaceClassification, boolean, classify_face};
1005 use crate::{
1006 remesh::RemeshParams,
1007 scenes::{scene, validation},
1008 };
1009 use geop_core_math::{scalars::Scalar, vector::Vector3};
1010 use geop_core_topology::{contains::rng::Rng, validation::validate_fast};
1011 use geop_lib_sweep::shapes::{Axis, block, v3};
1012 use geop_ops_model::Namer;
1013
1014 fn fresh_id() -> String {
1017 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1018 format!(
1019 "op{}",
1020 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1021 )
1022 }
1023
1024 fn namer() -> Namer {
1025 Namer::new("boolean", &fresh_id()).unwrap()
1026 }
1027
1028 #[test]
1031 fn classify_face_separates_inside_from_outside() {
1032 let mut s = scene("box_cylinder_drilled_hole_through");
1033 let params = RemeshParams::default();
1034 crate::remesh::remesh(&mut s.model, &namer(), s.solid_a, s.solid_b, params).unwrap();
1035
1036 let mut inside = 0;
1037 let mut outside = 0;
1038 for face_id in s.model.topology().solid_faces(s.solid_a).unwrap() {
1039 let mut classified = Classified::default();
1040 match classify_face(
1041 s.model.topology(),
1042 face_id,
1043 s.solid_b,
1044 params,
1045 &mut classified,
1046 )
1047 .unwrap()
1048 {
1049 FaceClassification::Inside => inside += 1,
1050 FaceClassification::Outside => outside += 1,
1051 _ => {}
1052 }
1053 }
1054 assert!(
1055 inside > 0 && outside > 0,
1056 "a cylinder drilled through a cube must leave cube faces on both sides: {inside} inside, {outside} outside"
1057 );
1058 }
1059
1060 #[test]
1063 fn every_remeshed_face_has_an_interior_point() {
1064 let mut s = scene("box_cylinder_drilled_hole_through");
1065 let params = RemeshParams::default();
1066 crate::remesh::remesh(&mut s.model, &namer(), s.solid_a, s.solid_b, params).unwrap();
1067
1068 for solid in [s.solid_a, s.solid_b] {
1069 for face_id in s.model.topology().solid_faces(solid).unwrap() {
1070 let (u, v) = geop_core_topology::contains::face::face_interior_point(
1071 s.model.topology(),
1072 face_id,
1073 params.max_nodes,
1074 params.curve_curve_min_subdivision_size,
1075 1234,
1076 )
1077 .unwrap_or_else(|e| panic!("face {face_id}: {e}"));
1078 let _ = s
1079 .model
1080 .topology()
1081 .get_face(face_id)
1082 .unwrap()
1083 .surface
1084 .evaluate(u, v)
1085 .unwrap();
1086 }
1087 }
1088 }
1089
1090 fn check_op(name: &str, op: BooleanOp) {
1091 let mut s = scene(name);
1092 let params = RemeshParams::default();
1093 let result = boolean(&mut s.model, &namer(), s.solid_a, s.solid_b, op, params)
1094 .unwrap_or_else(|e| panic!("{name} {op:?}: {e}"))
1095 .unwrap_or_else(|| panic!("{name} {op:?}: result is empty"));
1096
1097 assert!(
1098 !s.model.topology().solid_faces(result).unwrap().is_empty(),
1099 "{name} {op:?}: result has no faces"
1100 );
1101 if let Err(errors) = validate_fast(&validation(), s.model.topology()) {
1102 panic!(
1103 "{name} {op:?}: {} validate_fast error(s): {}",
1104 errors.len(),
1105 errors[0]
1106 );
1107 }
1108 }
1109
1110 fn contains(
1112 model: &geop_core_topology::Model,
1113 solid: geop_core_topology::SolidId,
1114 p: (f64, f64, f64),
1115 ) -> bool {
1116 let params = RemeshParams::default();
1117 let point = geop_core_math::vector::Vector3::from_array([
1118 Scalar::from_f64(p.0),
1119 Scalar::from_f64(p.1),
1120 Scalar::from_f64(p.2),
1121 ]);
1122 let shell = model.get_solid(solid).unwrap().shells[0];
1123 matches!(
1124 geop_core_topology::contains::shell::shell_contains(
1125 model,
1126 shell,
1127 point,
1128 params.max_nodes,
1129 params.curve_curve_min_subdivision_size,
1130 0xA5A5_1234,
1131 )
1132 .unwrap(),
1133 geop_core_topology::contains::shell::PointClassification::Inside
1134 )
1135 }
1136
1137 const IN_CUBE_ONLY: (f64, f64, f64) = (0.4, 0.4, 0.0);
1143 const IN_CYLINDER_ONLY: (f64, f64, f64) = (0.0, 0.0, 0.8);
1144 const IN_BOTH: (f64, f64, f64) = (0.0, 0.0, 0.0);
1145
1146 fn run(op: BooleanOp) -> (geop_core_topology::Model, geop_core_topology::SolidId) {
1147 let mut s = scene("box_cylinder_drilled_hole_through");
1148 let params = RemeshParams::default();
1149 let result = boolean(&mut s.model, &namer(), s.solid_a, s.solid_b, op, params)
1150 .unwrap()
1151 .expect("this scene's operands overlap, so no operator is empty");
1152 (s.model.topology().clone(), result)
1153 }
1154
1155 #[test]
1163 fn box_grid_n1p00_n0p50_n1p00_difference_succeeds() {
1164 let mut s = scene("box_grid_n1p00_n0p50_n1p00");
1165 let params = RemeshParams::default();
1166 boolean(
1167 &mut s.model,
1168 &namer(),
1169 s.solid_a,
1170 s.solid_b,
1171 BooleanOp::Difference,
1172 params,
1173 )
1174 .unwrap_or_else(|e| panic!("{e}"));
1175 }
1176
1177 fn check_boolean_matches_point_membership(name: &str, op: BooleanOp, seed: u64) {
1192 let mut s = scene(name);
1193 let params = RemeshParams::default();
1194 let (solid_a, solid_b) = (s.solid_a, s.solid_b);
1195
1196 let mut rng = Rng::new(seed);
1198 let mut samples = Vec::new();
1199 while samples.len() < SAMPLE_COUNT {
1200 let p = Vector3::from_array([
1201 Scalar::from_f64(rng.next_f64() * 4.0 - 2.0),
1202 Scalar::from_f64(rng.next_f64() * 4.0 - 2.0),
1203 Scalar::from_f64(rng.next_f64() * 4.0 - 2.0),
1204 ]);
1205 let (Some(in_a), Some(in_b)) = (
1206 strictly_inside(s.model.topology(), solid_a, p),
1207 strictly_inside(s.model.topology(), solid_b, p),
1208 ) else {
1209 continue;
1210 };
1211 samples.push((p, in_a, in_b));
1212 }
1213
1214 let result = boolean(&mut s.model, &namer(), solid_a, solid_b, op, params)
1215 .unwrap_or_else(|e| panic!("{name} {op:?}: {e}"));
1216
1217 for (p, in_a, in_b) in samples {
1218 let expected = match op {
1219 BooleanOp::Union => in_a || in_b,
1220 BooleanOp::Intersection => in_a && in_b,
1221 BooleanOp::Difference => in_a && !in_b,
1222 BooleanOp::SymmetricDifference => in_a != in_b,
1223 };
1224 let actual = match result {
1225 Some(solid) => match strictly_inside(s.model.topology(), solid, p) {
1226 Some(inside) => inside,
1227 None => continue,
1229 },
1230 None => false,
1232 };
1233 assert_eq!(
1234 actual,
1235 expected,
1236 "{name} {op:?}: point {p:?} is {} solid A and {} solid B, so the result should {} contain it",
1237 if in_a { "inside" } else { "outside" },
1238 if in_b { "inside" } else { "outside" },
1239 if expected { "" } else { "not" }
1240 );
1241 }
1242 }
1243
1244 const SAMPLE_COUNT: usize = 20;
1246
1247 fn strictly_inside(
1251 model: &geop_core_topology::Model,
1252 solid: geop_core_topology::SolidId,
1253 p: Vector3,
1254 ) -> Option<bool> {
1255 let params = RemeshParams::default();
1256 let mut inside = false;
1257 for shell in model.get_solid(solid).ok()?.shells.clone() {
1258 match geop_core_topology::contains::shell::shell_contains(
1259 model,
1260 shell,
1261 p,
1262 params.max_nodes,
1263 params.curve_curve_min_subdivision_size,
1264 0x5A3D_1234,
1265 ) {
1266 Ok(geop_core_topology::contains::shell::PointClassification::Inside) => {
1267 inside = true
1268 }
1269 Ok(geop_core_topology::contains::shell::PointClassification::Outside) => {}
1270 _ => return None,
1271 }
1272 }
1273 Some(inside)
1274 }
1275
1276 const MEMBERSHIP_SCENES: &[&str] = &[
1279 "box_cylinder_drilled_hole_through",
1280 "box_cylinder_blind_hole",
1281 "figure8_cylinder_through_neck",
1282 ];
1283
1284 #[test]
1285 fn union_matches_point_membership() {
1286 for (i, name) in MEMBERSHIP_SCENES.iter().enumerate() {
1287 check_boolean_matches_point_membership(name, BooleanOp::Union, 0xB001 + i as u64);
1288 }
1289 }
1290
1291 #[test]
1295 fn symmetric_difference_matches_point_membership() {
1296 for (i, name) in MEMBERSHIP_SCENES.iter().enumerate() {
1297 check_boolean_matches_point_membership(
1298 name,
1299 BooleanOp::SymmetricDifference,
1300 0xB201 + i as u64,
1301 );
1302 }
1303 }
1304
1305 #[test]
1306 fn intersection_matches_point_membership() {
1307 for (i, name) in MEMBERSHIP_SCENES.iter().enumerate() {
1308 check_boolean_matches_point_membership(
1309 name,
1310 BooleanOp::Intersection,
1311 0xB101 + i as u64,
1312 );
1313 }
1314 }
1315
1316 #[test]
1317 fn difference_matches_point_membership() {
1318 for (i, name) in MEMBERSHIP_SCENES.iter().enumerate() {
1319 check_boolean_matches_point_membership(name, BooleanOp::Difference, 0xB201 + i as u64);
1320 }
1321 }
1322
1323 #[test]
1324 fn union_contains_either_operand() {
1325 let (model, r) = run(BooleanOp::Union);
1326 assert!(contains(&model, r, IN_CUBE_ONLY), "cube-only point");
1327 assert!(contains(&model, r, IN_CYLINDER_ONLY), "cylinder-only point");
1328 assert!(contains(&model, r, IN_BOTH), "shared point");
1329 }
1330
1331 #[test]
1332 fn intersection_contains_only_the_overlap() {
1333 let (model, r) = run(BooleanOp::Intersection);
1334 assert!(
1335 !contains(&model, r, IN_CUBE_ONLY),
1336 "cube-only point must be out"
1337 );
1338 assert!(
1339 !contains(&model, r, IN_CYLINDER_ONLY),
1340 "cylinder-only point must be out"
1341 );
1342 assert!(contains(&model, r, IN_BOTH), "shared point must be in");
1343 }
1344
1345 #[test]
1346 fn difference_removes_the_second_operand() {
1347 let (model, r) = run(BooleanOp::Difference);
1348 assert!(
1349 contains(&model, r, IN_CUBE_ONLY),
1350 "cube-only point must remain"
1351 );
1352 assert!(
1353 !contains(&model, r, IN_CYLINDER_ONLY),
1354 "cylinder-only point must be out"
1355 );
1356 assert!(
1357 !contains(&model, r, IN_BOTH),
1358 "the drilled-out region must be gone"
1359 );
1360 }
1361
1362 #[test]
1363 fn union_of_box_and_cylinder_is_valid() {
1364 check_op("box_cylinder_drilled_hole_through", BooleanOp::Union);
1365 }
1366
1367 #[test]
1368 fn intersection_of_box_and_cylinder_is_valid() {
1369 check_op("box_cylinder_drilled_hole_through", BooleanOp::Intersection);
1370 }
1371
1372 #[test]
1373 fn difference_of_box_and_cylinder_is_valid() {
1374 check_op("box_cylinder_drilled_hole_through", BooleanOp::Difference);
1375 }
1376
1377 #[test]
1384 fn rims_of_a_bore_whether_flush_or_standing_out() {
1385 use geop_core_topology::Body;
1386 let radius = 0.3;
1387 for (base, height) in [(0.0, 1.0), (-0.5, 2.0)] {
1388 let mut model = M::new();
1389 let block = cube(&mut model, [0., 0., 0.], [1., 1., 1.]);
1390 let bore = cylinder(&mut model, [0.5, 0.5, base], radius, height, Axis::Z);
1391 let result = op(&mut model, block, bore, BooleanOp::Difference);
1392 let topology = model.topology();
1393 let curved = topology
1394 .iter_body_edges(Body::Solid(result))
1395 .unwrap()
1396 .filter(|&e| {
1397 topology
1398 .get_edge(e)
1399 .unwrap()
1400 .curve
1401 .as_line()
1402 .unwrap()
1403 .is_none()
1404 })
1405 .count();
1406 assert_eq!(curved, 8, "base {base}");
1408 let r = Scalar::from_f64(radius);
1409 let expected = Scalar::ONE.sub(Scalar::PI.mul(r).mul(r));
1410 let volume = topology.mass_properties(result).unwrap().volume;
1411 assert!(volume.could_be_equal(expected), "base {base}: {volume:?}");
1412 }
1413 }
1414
1415 type M = geop_ops_model::NamedModel;
1423
1424 fn cube(model: &mut M, offset: [f64; 3], dims: [f64; 3]) -> geop_core_topology::SolidId {
1427 let max = [0, 1, 2].map(|k| offset[k] + dims[k]);
1428 block(model, &fresh_id(), offset, max)
1429 }
1430
1431 fn sphere(model: &mut M, center: [f64; 3], r: f64) -> geop_core_topology::SolidId {
1433 let [x, y, z] = center;
1434 let r = Scalar::from_f64(r);
1435 geop_lib_sweep::shapes::sphere_solid(model, &fresh_id(), v3(x, y, z), r).unwrap()
1436 }
1437
1438 fn cylinder(
1440 model: &mut M,
1441 base: [f64; 3],
1442 r: f64,
1443 h: f64,
1444 axis: Axis,
1445 ) -> geop_core_topology::SolidId {
1446 let [x, y, z] = base;
1447 geop_lib_sweep::shapes::revolved_cylinder_along_axis(
1448 model,
1449 &fresh_id(),
1450 v3(x, y, z),
1451 Scalar::from_f64(r),
1452 Scalar::from_f64(h),
1453 axis,
1454 )
1455 .unwrap()
1456 }
1457
1458 fn op(
1461 model: &mut M,
1462 a: geop_core_topology::SolidId,
1463 b: geop_core_topology::SolidId,
1464 op: BooleanOp,
1465 ) -> geop_core_topology::SolidId {
1466 let result = boolean(model, &namer(), a, b, op, RemeshParams::default())
1467 .unwrap_or_else(|e| panic!("{op:?} failed: {e:?}"))
1468 .unwrap_or_else(|| panic!("{op:?} produced an empty solid"));
1469 model.check_names().unwrap();
1470 let topology = model.topology();
1471 if let Err(errors) = validate_fast(&validation(), topology) {
1472 panic!(
1473 "{op:?}: {} validate_fast error(s): {}",
1474 errors.len(),
1475 errors[0]
1476 );
1477 }
1478 if let Err(errors) =
1484 geop_core_topology::validation::validate_manifold(&validation(), topology)
1485 {
1486 panic!("{op:?}: {} validate error(s): {}", errors.len(), errors[0]);
1487 }
1488 for face in topology.solid_faces(result).unwrap() {
1492 for coedge in topology.iterate_face_coedges(face) {
1493 let Ok(edge) = topology.get_coedge(coedge).unwrap().edge() else {
1495 continue;
1496 };
1497 let uses = topology.coedges_of_edge(edge).len();
1498 if !uses.is_multiple_of(2) {
1499 let e = topology.get_edge(edge).unwrap();
1500 let at = |v| {
1501 let p = topology.get_vertex(v).unwrap().point;
1502 [p[0].to_f64(), p[1].to_f64(), p[2].to_f64()]
1503 };
1504 panic!(
1505 "{op:?}: edge {edge} of face {face}, from {:?} to {:?}, is used by {uses} coedge(s) — the solid is open there",
1506 at(e.start_vertex),
1507 at(e.end_vertex)
1508 );
1509 }
1510 }
1511 }
1512 result
1513 }
1514
1515 #[test]
1522 #[ignore = "slow: 6 s — run with `cargo test -- --ignored`"]
1523 fn bored_cube_plus_inscribed_sphere_minus_half() {
1524 let mut model = M::new();
1525 let block = cube(&mut model, [-0.5, -0.5, -0.5], [1.0, 1.0, 1.0]);
1526 let bore = cylinder(&mut model, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
1527 let bored = op(&mut model, block, bore, BooleanOp::Difference);
1528 let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1529 let filled = op(&mut model, bored, ball, BooleanOp::Union);
1530 let half = cube(&mut model, [-0.5, 0.0, -0.5], [1.0, 1.0, 1.0]);
1531 op(&mut model, filled, half, BooleanOp::Difference);
1532 }
1533
1534 #[test]
1542 #[ignore = "slow: 5 s — run with `cargo test -- --ignored`"]
1543 fn flush_bored_cube_plus_inscribed_sphere_section() {
1544 let mut model = M::new();
1545 let block = cube(&mut model, CORNER, UNIT);
1546 let bore = cylinder(&mut model, [0.0, 0.0, -0.5], 0.5, 1.0, Axis::Z);
1547 let bored = op(&mut model, block, bore, BooleanOp::Difference);
1548 let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1549 let filled = op(&mut model, bored, ball, BooleanOp::Union);
1550 let cutter = cube(&mut model, [-1.45, -1.05, -0.82], [1.5, 2.1, 1.65]);
1551 op(&mut model, filled, cutter, BooleanOp::Difference);
1552 }
1553
1554 fn op_empty(
1556 model: &mut M,
1557 a: geop_core_topology::SolidId,
1558 b: geop_core_topology::SolidId,
1559 op: BooleanOp,
1560 ) {
1561 let result = boolean(model, &namer(), a, b, op, RemeshParams::default())
1562 .unwrap_or_else(|e| panic!("{op:?} failed: {e:?}"));
1563 assert!(result.is_none(), "{op:?} should be empty");
1564 }
1565
1566 const UNIT: [f64; 3] = [1.0, 1.0, 1.0];
1567 const CORNER: [f64; 3] = [-0.5, -0.5, -0.5];
1568
1569 #[test]
1577 fn cube_minus_inscribed_cylinder() {
1578 let mut model = M::new();
1579 let block = cube(&mut model, CORNER, UNIT);
1580 let bore = cylinder(&mut model, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
1581 op(&mut model, block, bore, BooleanOp::Difference);
1582 }
1583
1584 #[test]
1589 fn cube_minus_flush_inscribed_cylinder() {
1590 let mut model = M::new();
1591 let block = cube(&mut model, CORNER, UNIT);
1592 let bore = cylinder(&mut model, [0.0, 0.0, -0.5], 0.5, 1.0, Axis::Z);
1593 op(&mut model, block, bore, BooleanOp::Difference);
1594 }
1595
1596 #[test]
1598 fn cube_minus_inscribed_sphere() {
1599 let mut model = M::new();
1600 let block = cube(&mut model, CORNER, UNIT);
1601 let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1602 op(&mut model, block, ball, BooleanOp::Difference);
1603 }
1604
1605 #[test]
1607 fn cube_intersect_circumscribed_sphere() {
1608 let mut model = M::new();
1609 let block = cube(&mut model, CORNER, UNIT);
1610 let ball = sphere(&mut model, [0.0, 0.0, 0.0], 3f64.sqrt() / 2.0);
1611 op(&mut model, block, ball, BooleanOp::Intersection);
1612 }
1613
1614 #[test]
1616 fn cube_intersect_cylinder_through_its_edges() {
1617 let mut model = M::new();
1618 let block = cube(&mut model, CORNER, UNIT);
1619 let tube = cylinder(&mut model, [0.0, 0.0, -1.0], 0.5f64.sqrt(), 2.0, Axis::Z);
1620 op(&mut model, block, tube, BooleanOp::Intersection);
1621 }
1622
1623 #[test]
1626 fn sphere_union_tangent_cylinder() {
1627 let mut model = M::new();
1628 let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1629 let tube = cylinder(&mut model, [0.0, 0.0, -1.0], 0.5, 2.0, Axis::Z);
1630 op(&mut model, ball, tube, BooleanOp::Union);
1631 }
1632
1633 #[test]
1636 fn sphere_union_cube_touching_its_pole() {
1637 let mut model = M::new();
1638 let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1639 let block = cube(&mut model, [-0.5, -0.5, 0.5], UNIT);
1640 op(&mut model, ball, block, BooleanOp::Union);
1641 }
1642
1643 #[test]
1645 fn cube_union_cylinder_lying_on_top() {
1646 let mut model = M::new();
1647 let block = cube(&mut model, CORNER, UNIT);
1648 let log = cylinder(&mut model, [-1.0, 0.0, 0.75], 0.25, 2.0, Axis::X);
1649 op(&mut model, block, log, BooleanOp::Union);
1650 }
1651
1652 #[test]
1656 fn cubes_sharing_a_face_union() {
1657 let mut model = M::new();
1658 let a = cube(&mut model, CORNER, UNIT);
1659 let b = cube(&mut model, [0.5, -0.5, -0.5], UNIT);
1660 op(&mut model, a, b, BooleanOp::Union);
1661 }
1662
1663 #[test]
1664 fn cubes_sharing_an_edge_union() {
1665 let mut model = M::new();
1666 let a = cube(&mut model, CORNER, UNIT);
1667 let b = cube(&mut model, [0.5, 0.5, -0.5], UNIT);
1668 op(&mut model, a, b, BooleanOp::Union);
1669 }
1670
1671 #[test]
1672 fn cubes_sharing_a_corner_union() {
1673 let mut model = M::new();
1674 let a = cube(&mut model, CORNER, UNIT);
1675 let b = cube(&mut model, [0.5, 0.5, 0.5], UNIT);
1676 op(&mut model, a, b, BooleanOp::Union);
1677 }
1678
1679 #[test]
1682 fn cube_minus_offset_cube_with_coplanar_faces() {
1683 let mut model = M::new();
1684 let a = cube(&mut model, CORNER, UNIT);
1685 let b = cube(&mut model, [0.0, -0.5, -0.5], UNIT);
1686 op(&mut model, a, b, BooleanOp::Difference);
1687 }
1688
1689 #[test]
1691 fn cube_minus_flush_cylinder() {
1692 let mut model = M::new();
1693 let block = cube(&mut model, CORNER, UNIT);
1694 let bore = cylinder(&mut model, [0.0, 0.0, -0.5], 0.3, 1.0, Axis::Z);
1695 op(&mut model, block, bore, BooleanOp::Difference);
1696 }
1697
1698 fn plate_and_blind_hole(
1702 model: &mut M,
1703 ) -> (geop_core_topology::SolidId, geop_core_topology::SolidId) {
1704 let plate = cube(model, [0.0, 0.0, 0.0], [6.0, 3.0, 0.5]);
1705 let hole = cylinder(model, [3.0, 1.5, 0.2], 0.33, 0.3, Axis::Z);
1706 (plate, hole)
1707 }
1708
1709 fn plate_and_blind_hole_remeshed(
1712 model: &mut M,
1713 ) -> (geop_core_topology::SolidId, geop_core_topology::FaceId) {
1714 let (plate, hole) = plate_and_blind_hole(model);
1715 crate::remesh::remesh(model, &namer(), plate, hole, RemeshParams::default()).unwrap();
1716 let topology = model.topology();
1717 if let Err(errors) = validate_fast(&validation(), topology) {
1718 panic!("{} validate_fast error(s): {}", errors.len(), errors[0]);
1719 }
1720 let in_rim = |p: &[f64; 3]| (p[0] - 3.0).hypot(p[1] - 1.5) < 0.34 && p[2] > 0.49;
1721 let disc = topology
1722 .solid_faces(plate)
1723 .unwrap()
1724 .into_iter()
1725 .find(|&face| {
1726 let corners = topology.face_corners(face).unwrap();
1727 corners.len() == 1 && corners[0].iter().all(|(_, p)| in_rim(p))
1728 })
1729 .expect("a face of the plate inside the hole's rim");
1730 (hole, disc)
1731 }
1732
1733 #[test]
1737 fn plate_minus_blind_hole_from_its_top() {
1738 let mut model = M::new();
1739 let (plate, hole) = plate_and_blind_hole(&mut model);
1740 op(&mut model, plate, hole, BooleanOp::Difference);
1741 }
1742
1743 #[test]
1754 fn blind_hole_whose_cap_seam_crosses_the_caps_seam_beside_its_centre() {
1755 let mut model = M::new();
1756 let disc = cylinder(&mut model, [0.0, 0.0, 0.0], 40.0, 6.0, Axis::Z);
1757 let hole = cylinder(
1758 &mut model,
1759 [-0.000_355_652_98, 27.999_383_99, 3.0],
1760 3.3,
1761 3.0,
1762 Axis::Z,
1763 );
1764 op(&mut model, disc, hole, BooleanOp::Difference);
1765 }
1766
1767 #[test]
1770 fn blind_hole_cap_covers_the_disc_cut_from_the_top() {
1771 use geop_core_topology::contains::{
1772 face::face_interior_point_where,
1773 shell::{PointClassification, shell_contains},
1774 };
1775 let mut model = M::new();
1776 let (hole, disc) = plate_and_blind_hole_remeshed(&mut model);
1777 let topology = model.topology();
1778 let params = RemeshParams::default();
1779 let shell = topology.get_solid(hole).unwrap().shells[0];
1780 let mut checked = 0;
1781 face_interior_point_where(
1782 topology,
1783 disc,
1784 params.max_nodes,
1785 params.curve_curve_min_subdivision_size,
1786 super::SEED,
1787 |u, v| {
1788 let point = topology.get_face(disc)?.surface.evaluate(u, v)?;
1789 let class = shell_contains(
1790 topology,
1791 shell,
1792 point,
1793 params.max_nodes,
1794 params.curve_curve_min_subdivision_size,
1795 super::SEED,
1796 )?;
1797 assert!(
1798 !matches!(
1799 class,
1800 PointClassification::Inside | PointClassification::Outside
1801 ),
1802 "{point:?} of the disc, at uv=({u:?}, {v:?}), is {class:?}"
1803 );
1804 checked += 1;
1805 Ok(false)
1806 },
1807 )
1808 .unwrap();
1809 assert!(checked > 0);
1810 }
1811
1812 #[test]
1818 fn point_beside_the_disc_cut_from_the_top_is_outside_it() {
1819 use geop_core_topology::contains::face::{PointClassification, face_contains};
1820 let mut model = M::new();
1821 let (_, disc) = plate_and_blind_hole_remeshed(&mut model);
1822 let topology = model.topology();
1823 let (u, v) = (
1824 Scalar::from_f64(0.42698015887332674),
1825 Scalar::from_f64(0.5220523370528238),
1826 );
1827 let point = topology
1828 .get_face(disc)
1829 .unwrap()
1830 .surface
1831 .evaluate(u, v)
1832 .unwrap();
1833 assert!(
1834 (point[0].to_f64() - 3.0).hypot(point[1].to_f64() - 1.5) > 0.4,
1835 "{point:?}"
1836 );
1837 let params = RemeshParams::default();
1838 let epsilon = params.curve_curve_min_subdivision_size;
1839 let neighbourhood = |t: Scalar| t.sub(epsilon).union(t.add(epsilon));
1840 let mut wrong = Vec::new();
1841 for (u, v) in [(u, v), (neighbourhood(u), neighbourhood(v))] {
1842 for seed in (0..64).chain([super::SEED]) {
1843 let class =
1844 face_contains(topology, disc, u, v, params.max_nodes, epsilon, seed).unwrap();
1845 if class != PointClassification::Outside {
1846 wrong.push(format!("{class:?} at ({u:?}, {v:?}) with seed {seed}"));
1847 }
1848 }
1849 }
1850 assert!(wrong.is_empty(), "{}", wrong.join("\n"));
1851 }
1852
1853 #[test]
1854 fn identical_spheres_union() {
1855 let mut model = M::new();
1856 let a = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1857 let b = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1858 op(&mut model, a, b, BooleanOp::Union);
1859 }
1860
1861 #[test]
1862 fn identical_cubes_difference_is_empty() {
1863 let mut model = M::new();
1864 let a = cube(&mut model, CORNER, UNIT);
1865 let b = cube(&mut model, CORNER, UNIT);
1866 op_empty(&mut model, a, b, BooleanOp::Difference);
1867 }
1868
1869 fn check_duplicated_shape(make: impl Fn(&mut M) -> geop_core_topology::SolidId) {
1875 let probes = [(0.0, 0.0, 0.0), (0.2, -0.1, 0.15), (0.9, 0.0, 0.0)];
1876 let inside_original = {
1877 let mut model = M::new();
1878 let shape = make(&mut model);
1879 probes.map(|p| contains(model.topology(), shape, p))
1880 };
1881 for operator in [BooleanOp::Union, BooleanOp::Intersection] {
1882 let mut model = M::new();
1883 let a = make(&mut model);
1884 let b = make(&mut model);
1885 let result = op(&mut model, a, b, operator);
1886 for (p, inside) in probes.iter().zip(inside_original) {
1887 assert_eq!(
1888 contains(model.topology(), result, *p),
1889 inside,
1890 "{operator:?} of two copies must contain {p:?} exactly when one copy does"
1891 );
1892 }
1893 }
1894 let mut model = M::new();
1895 let a = make(&mut model);
1896 let b = make(&mut model);
1897 op_empty(&mut model, a, b, BooleanOp::Difference);
1898 }
1899
1900 #[test]
1901 fn duplicated_cube_all_operators() {
1902 check_duplicated_shape(|model| cube(model, CORNER, UNIT));
1903 }
1904
1905 #[test]
1906 #[ignore = "slow: 6 s — run with `cargo test -- --ignored`"]
1907 fn duplicated_sphere_all_operators() {
1908 check_duplicated_shape(|model| sphere(model, [0.0, 0.0, 0.0], 0.5));
1909 }
1910
1911 #[test]
1912 #[ignore = "slow: 8 s — run with `cargo test -- --ignored`"]
1913 fn duplicated_cylinder_all_operators() {
1914 check_duplicated_shape(|model| cylinder(model, [0.0, 0.0, -0.5], 0.5, 1.0, Axis::Z));
1915 }
1916
1917 #[test]
1920 fn cube_minus_sphere_on_its_corner() {
1921 let mut model = M::new();
1922 let block = cube(&mut model, CORNER, UNIT);
1923 let ball = sphere(&mut model, [0.5, 0.5, 0.5], 0.5);
1924 op(&mut model, block, ball, BooleanOp::Difference);
1925 }
1926
1927 #[test]
1932 fn steinmetz_cylinders_union() {
1933 let mut model = M::new();
1934 let a = cylinder(&mut model, [0.0, 0.0, -1.0], 0.5, 2.0, Axis::Z);
1935 let b = cylinder(&mut model, [-1.0, 0.0, 0.0], 0.5, 2.0, Axis::X);
1936 op(&mut model, a, b, BooleanOp::Union);
1937 }
1938
1939 #[test]
1940 fn steinmetz_cylinders_intersection() {
1941 let mut model = M::new();
1942 let a = cylinder(&mut model, [0.0, 0.0, -1.0], 0.5, 2.0, Axis::Z);
1943 let b = cylinder(&mut model, [-1.0, 0.0, 0.0], 0.5, 2.0, Axis::X);
1944 op(&mut model, a, b, BooleanOp::Intersection);
1945 }
1946
1947 #[test]
1949 fn sphere_minus_cylinder_through_its_poles() {
1950 let mut model = M::new();
1951 let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1952 let bore = cylinder(&mut model, [0.0, 0.0, -1.0], 0.2, 2.0, Axis::Z);
1953 op(&mut model, ball, bore, BooleanOp::Difference);
1954 }
1955
1956 #[test]
1959 fn cylinder_union_sphere_on_its_cap() {
1960 let mut model = M::new();
1961 let tube = cylinder(&mut model, [0.0, 0.0, -1.0], 0.5, 1.0, Axis::Z);
1962 let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.3);
1963 op(&mut model, tube, ball, BooleanOp::Union);
1964 }
1965
1966 #[test]
1970 fn cylinder_union_equal_sphere_on_its_cap() {
1971 let mut model = M::new();
1972 let tube = cylinder(&mut model, [0.0, 0.0, -1.0], 0.5, 1.0, Axis::Z);
1973 let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
1974 op(&mut model, tube, ball, BooleanOp::Union);
1975 }
1976
1977 #[test]
1979 fn cube_union_sphere_touching_a_face() {
1980 let mut model = M::new();
1981 let block = cube(&mut model, CORNER, UNIT);
1982 let ball = sphere(&mut model, [1.0, 0.0, 0.0], 0.5);
1983 op(&mut model, block, ball, BooleanOp::Union);
1984 }
1985
1986 #[test]
1988 fn cube_union_sphere_on_a_face() {
1989 let mut model = M::new();
1990 let block = cube(&mut model, CORNER, UNIT);
1991 let ball = sphere(&mut model, [0.5, 0.0, 0.0], 0.3);
1992 op(&mut model, block, ball, BooleanOp::Union);
1993 }
1994
1995 #[test]
2000 #[ignore = "slow: 4 s — run with `cargo test -- --ignored`"]
2001 fn cube_minus_two_inscribed_bores() {
2002 let mut model = M::new();
2003 let block = cube(&mut model, CORNER, UNIT);
2004 let z = cylinder(&mut model, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
2005 let a = op(&mut model, block, z, BooleanOp::Difference);
2006 let x = cylinder(&mut model, [-0.875, 0.0, 0.0], 0.5, 1.75, Axis::X);
2007 op(&mut model, a, x, BooleanOp::Difference);
2008 }
2009
2010 fn check_remesh_imprints(
2021 model: &mut M,
2022 a: geop_core_topology::SolidId,
2023 b: geop_core_topology::SolidId,
2024 arcs: &[[[f64; 3]; 2]],
2025 ) {
2026 crate::remesh::remesh(model, &namer(), a, b, RemeshParams::default())
2027 .unwrap_or_else(|e| panic!("remesh failed: {e}"));
2028 let topology = model.topology();
2029 let faces_a = topology.solid_faces(a).unwrap();
2030 let faces_b = topology.solid_faces(b).unwrap();
2031 let mut shared = Vec::new();
2032 for (&edge_id, edge) in &topology.edges {
2033 let faces: Vec<_> = topology
2034 .coedges_of_edge(edge_id)
2035 .iter()
2036 .map(|&c| topology.get_coedge(c).unwrap().face)
2037 .collect();
2038 if faces.iter().any(|f| faces_a.contains(f))
2039 && faces.iter().any(|f| faces_b.contains(f))
2040 {
2041 shared.push([edge.start_vertex, edge.end_vertex].map(|v| {
2042 let p = topology.get_vertex(v).unwrap().point;
2043 [p[0].to_f64(), p[1].to_f64(), p[2].to_f64()]
2044 }));
2045 }
2046 }
2047 let near = |p: [f64; 3], q: [f64; 3]| (0..3).all(|i| (p[i] - q[i]).abs() < 1e-4);
2051 let missing: Vec<_> = arcs
2052 .iter()
2053 .filter(|[p, q]| {
2054 !shared
2055 .iter()
2056 .any(|[s, e]| (near(*s, *p) && near(*e, *q)) || (near(*s, *q) && near(*e, *p)))
2057 })
2058 .collect();
2059 assert!(
2060 missing.is_empty(),
2061 "no edge shared by both solids between {missing:?}; the shared edges are {shared:?}"
2062 );
2063
2064 if let Err(errors) = geop_core_topology::validation::validate(&validation(), topology) {
2065 let all: Vec<String> = errors.iter().map(|e| format!("{e}")).collect();
2066 panic!(
2067 "{} validate error(s):\n{}",
2068 errors.len(),
2069 all.join("\n---\n")
2070 );
2071 }
2072 }
2073
2074 #[test]
2080 #[ignore = "slow: 5 s — run with `cargo test -- --ignored`"]
2081 fn two_inscribed_bores_remesh_imprints_every_arc() {
2082 let mut model = M::new();
2083 let block = cube(&mut model, CORNER, UNIT);
2084 let z = cylinder(&mut model, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
2085 let a = op(&mut model, block, z, BooleanOp::Difference);
2086 let x = cylinder(&mut model, [-0.875, 0.0, 0.0], 0.5, 1.75, Axis::X);
2087 let mut arcs = Vec::new();
2088 for (mx, mz) in [(0.5, 0.5), (0.5, -0.5), (-0.5, 0.5), (-0.5, -0.5)] {
2089 for cy in [0.5, -0.5] {
2090 arcs.push([[mx, 0.0, mz], [0.0, cy, 0.0]]);
2091 }
2092 }
2093 check_remesh_imprints(&mut model, a, x, &arcs);
2094 }
2095
2096 #[test]
2103 #[ignore = "slow: 9 s — run with `cargo test -- --ignored`"]
2104 fn three_inscribed_bores_remesh_imprints_every_arc() {
2105 let mut model = M::new();
2106 let block = cube(&mut model, CORNER, UNIT);
2107 let z = cylinder(&mut model, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
2108 let a = op(&mut model, block, z, BooleanOp::Difference);
2109 let x = cylinder(&mut model, [-0.875, 0.0, 0.0], 0.5, 1.75, Axis::X);
2110 let b = op(&mut model, a, x, BooleanOp::Difference);
2111 let y = cylinder(&mut model, [0.0, -0.875, 0.0], 0.5, 1.75, Axis::Y);
2112 let t = 0.125f64.sqrt();
2113 let mut arcs = Vec::new();
2114 for sx in [1.0, -1.0] {
2115 for sy in [1.0, -1.0] {
2116 for sz in [1.0, -1.0] {
2117 let triple = [sx * t, sy * t, sz * t];
2118 arcs.push([triple, [0.0, sy * 0.5, sz * 0.5]]);
2119 arcs.push([triple, [sx * 0.5, sy * 0.5, 0.0]]);
2120 }
2121 }
2122 }
2123 check_remesh_imprints(&mut model, b, y, &arcs);
2124 }
2125
2126 #[test]
2130 #[ignore = "slow: 8 s — run with `cargo test -- --ignored`"]
2131 fn cube_minus_three_inscribed_bores() {
2132 let mut model = M::new();
2133 let block = cube(&mut model, CORNER, UNIT);
2134 let z = cylinder(&mut model, [0.0, 0.0, -0.875], 0.5, 1.75, Axis::Z);
2135 let a = op(&mut model, block, z, BooleanOp::Difference);
2136 let x = cylinder(&mut model, [-0.875, 0.0, 0.0], 0.5, 1.75, Axis::X);
2137 let b = op(&mut model, a, x, BooleanOp::Difference);
2138 let y = cylinder(&mut model, [0.0, -0.875, 0.0], 0.5, 1.75, Axis::Y);
2139 op(&mut model, b, y, BooleanOp::Difference);
2140 }
2141
2142 #[test]
2144 fn cube_minus_two_crossing_bores() {
2145 let mut model = M::new();
2146 let block = cube(&mut model, CORNER, UNIT);
2147 let z = cylinder(&mut model, [0.0, 0.0, -0.875], 0.3, 1.75, Axis::Z);
2148 let a = op(&mut model, block, z, BooleanOp::Difference);
2149 let x = cylinder(&mut model, [-0.875, 0.0, 0.0], 0.3, 1.75, Axis::X);
2150 op(&mut model, a, x, BooleanOp::Difference);
2151 }
2152
2153 #[test]
2157 fn touching_spheres_union() {
2158 let mut model = M::new();
2159 let a = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
2160 let b = sphere(&mut model, [1.0, 0.0, 0.0], 0.5);
2161 op(&mut model, a, b, BooleanOp::Union);
2162 }
2163
2164 #[test]
2167 fn cylinder_minus_inscribed_sphere() {
2168 let mut model = M::new();
2169 let tube = cylinder(&mut model, [0.0, 0.0, -1.0], 0.5, 2.0, Axis::Z);
2170 let ball = sphere(&mut model, [0.0, 0.0, 0.0], 0.5);
2171 op(&mut model, tube, ball, BooleanOp::Difference);
2172 }
2173
2174 #[test]
2176 fn touching_parallel_cylinders_union() {
2177 let mut model = M::new();
2178 let a = cylinder(&mut model, [0.0, 0.0, -0.5], 0.5, 1.0, Axis::Z);
2179 let b = cylinder(&mut model, [1.0, 0.0, -0.5], 0.5, 1.0, Axis::Z);
2180 op(&mut model, a, b, BooleanOp::Union);
2181 }
2182
2183 #[test]
2185 fn cube_union_sphere_touching_an_edge() {
2186 let mut model = M::new();
2187 let block = cube(&mut model, CORNER, UNIT);
2188 let d = 0.5 + 0.5 / 2f64.sqrt();
2189 let ball = sphere(&mut model, [d, d, 0.0], 0.5);
2190 op(&mut model, block, ball, BooleanOp::Union);
2191 }
2192
2193 #[test]
2196 fn plate_with_boss_hole_and_cap() {
2197 let mut model = M::new();
2198 let plate = cube(&mut model, [-1.0, -1.0, -0.25], [2.0, 2.0, 0.5]);
2199 let boss = cylinder(&mut model, [0.0, 0.0, 0.25], 0.5, 0.5, Axis::Z);
2200 let a = op(&mut model, plate, boss, BooleanOp::Union);
2201 let cap = sphere(&mut model, [0.0, 0.0, 0.75], 0.5);
2202 let b = op(&mut model, a, cap, BooleanOp::Union);
2203 let hole = cylinder(&mut model, [0.0, 0.0, -0.5], 0.25, 1.5, Axis::Z);
2204 op(&mut model, b, hole, BooleanOp::Difference);
2205 }
2206
2207 #[test]
2213 fn chained_slots_point_above_the_block_is_outside() {
2214 let mut model = M::new();
2215 let a = cube(&mut model, [-0.50, -0.50, -0.50], [1.00, 1.00, 1.00]);
2216 let b = cube(&mut model, [-1.13, -0.30, -0.33], [2.25, 0.60, 0.65]);
2217 let c = op(&mut model, a, b, BooleanOp::Difference);
2218 let d = cube(&mut model, [-0.33, -0.28, -1.15], [0.65, 0.55, 2.30]);
2219 crate::remesh::remesh(&mut model, &namer(), c, d, RemeshParams::default()).unwrap();
2220
2221 let params = RemeshParams::default();
2222 let shell = model.topology().get_solid(c).unwrap().shells[0];
2223 let classification = geop_core_topology::contains::shell::shell_contains(
2224 model.topology(),
2225 shell,
2226 v3(-0.005, -0.28, 0.825),
2227 params.max_nodes,
2228 params.curve_curve_min_subdivision_size,
2229 super::SEED,
2230 )
2231 .unwrap();
2232 assert_eq!(
2233 classification,
2234 geop_core_topology::contains::shell::PointClassification::Outside
2235 );
2236 }
2237
2238 #[test]
2239 fn chained_differences_block_with_two_slots_and_a_sphere() {
2240 let f = Scalar::from_f64;
2241 let mut model = M::new();
2242 let a = cube(&mut model, [-0.50, -0.50, -0.50], [1.00, 1.00, 1.00]);
2243 let b = cube(&mut model, [-1.13, -0.30, -0.33], [2.25, 0.60, 0.65]);
2244 let params = RemeshParams::default();
2245 let c = boolean(&mut model, &namer(), a, b, BooleanOp::Difference, params)
2246 .unwrap()
2247 .expect("block minus the first slot must be non-empty");
2248
2249 let d = cube(&mut model, [-0.33, -0.28, -1.15], [0.65, 0.55, 2.30]);
2250 let e = boolean(&mut model, &namer(), c, d, BooleanOp::Difference, params)
2251 .unwrap()
2252 .expect("minus the second slot must be non-empty");
2253
2254 let sphere =
2255 geop_lib_sweep::shapes::sphere_solid(&mut model, "s", Vector3::zero(), f(0.45))
2256 .unwrap();
2257 let result = boolean(
2258 &mut model,
2259 &namer(),
2260 e,
2261 sphere,
2262 BooleanOp::Difference,
2263 params,
2264 )
2265 .unwrap()
2266 .expect("minus the sphere must be non-empty");
2267 model.check_names().unwrap();
2268
2269 let topology = model.topology();
2270 assert!(!topology.solid_faces(result).unwrap().is_empty());
2271 if let Err(errors) = validate_fast(&validation(), topology) {
2272 panic!("{} validate_fast error(s): {}", errors.len(), errors[0]);
2273 }
2274
2275 let scene = geop_core_rasterize::rasterize(topology, 8)
2276 .unwrap()
2277 .scene(|_| geop_core_rasterize::debug::Color10::Blue);
2278 std::fs::create_dir_all("outputs").unwrap();
2279 scene
2280 .save_to_file("outputs/chained_differences_block_with_two_slots_and_a_sphere.html")
2281 .unwrap();
2282 }
2283
2284 #[test]
2300 fn chained_differences_with_anchored_shapes_and_thin_slab_cutter_succeeds() {
2301 let mut model = M::new();
2302 let block = cube(&mut model, [-0.5, -0.5, -0.5], [1.0, 1.0, 1.0]);
2303 let [x, y, z] = [-0.5000000000000001, 0.5000000000000002, 0.5000000000000001];
2304 let second = cube(&mut model, [x - 0.5, y - 0.5, z - 0.5], [1.0, 1.0, 1.0]);
2305 let center = [0.5000000000000003, 0.5000000000000002, 0.5000000000000001];
2306 let ball = sphere(&mut model, center, 0.5);
2307 let difference = |model: &mut M, a, b| {
2311 boolean(
2312 model,
2313 &namer(),
2314 a,
2315 b,
2316 BooleanOp::Difference,
2317 RemeshParams::default(),
2318 )
2319 .unwrap()
2320 .expect("the result is not empty")
2321 };
2322 let cut = difference(&mut model, block, second);
2323 let cut = difference(&mut model, cut, ball);
2324 let slab = cube(&mut model, [-1.13, -0.10, -0.15], [2.25, 0.20, 0.30]);
2325 difference(&mut model, cut, slab);
2326 model.check_names().unwrap();
2327 }
2328
2329 #[test]
2356 fn cube_minus_z_cylinder_with_coplanar_cap_is_fast_and_correct() {
2357 let mut model = M::new();
2358 let a = cube(&mut model, [-0.5, -0.5, -0.5], [1.0, 1.0, 1.0]);
2359 let b = cylinder(&mut model, [0.0, 0.0, -0.5], 0.3, 1.0, Axis::Z);
2360 let params = RemeshParams::default();
2361
2362 let t0 = std::time::Instant::now();
2363 let result = boolean(&mut model, &namer(), a, b, BooleanOp::Difference, params)
2364 .unwrap()
2365 .unwrap();
2366 let elapsed = t0.elapsed();
2367 let topology = model.topology();
2368 assert!(
2376 elapsed.as_secs() < 20,
2377 "boolean took {elapsed:?}, expected well under 20s"
2378 );
2379
2380 let shell = topology.get_solid(result).unwrap().shells[0];
2385 for z in [-0.45, 0.45] {
2386 let p = v3(0.0, 0.0, z);
2387 let outside = matches!(
2388 geop_core_topology::contains::shell::shell_contains(
2389 topology,
2390 shell,
2391 p,
2392 params.max_nodes,
2393 params.curve_curve_min_subdivision_size,
2394 0xC7D1
2395 )
2396 .unwrap(),
2397 geop_core_topology::contains::shell::PointClassification::Outside
2398 );
2399 assert!(
2400 outside,
2401 "point {p:?} (near a cap's bore) should be outside the drilled result"
2402 );
2403 }
2404
2405 let scene = geop_core_rasterize::rasterize(topology, 8)
2406 .unwrap()
2407 .scene(|_| geop_core_rasterize::debug::Color10::Blue);
2408 std::fs::create_dir_all("outputs").unwrap();
2409 scene
2410 .save_to_file("outputs/cube_minus_coplanar_cap_cylinder.html")
2411 .unwrap();
2412 }
2413
2414 #[test]
2421 fn bore_renders_as_a_hole() {
2422 let mut model = M::new();
2423 let block = cube(&mut model, [-1.0, -1.0, 0.0], [2.0, 2.0, 0.5]);
2424 let bore = cylinder(&mut model, [0.0, 0.0, -0.5], 0.5, 1.5, Axis::Z);
2428 op(&mut model, block, bore, BooleanOp::Difference);
2429
2430 let rasterized = geop_core_rasterize::rasterize(model.topology(), 24).unwrap();
2431 for triangle in rasterized.faces.values().flatten() {
2432 for p in [triangle.a, triangle.b, triangle.c] {
2433 let (x, y, z) = (p[0].to_f64(), p[1].to_f64(), p[2].to_f64());
2434 let r = x.hypot(y);
2435 assert!(
2440 r > 0.45 || !(0.01..0.49).contains(&z),
2441 "a triangle corner sits inside the bore at ({x}, {y}, {z})"
2442 );
2443 }
2444 }
2445 }
2446
2447 #[test]
2458 fn cube_minus_box_with_wide_corners() {
2459 let wide = |x: f64| Scalar::new(x - 1e-12, x + 1e-12);
2460 let corner = |p: [f64; 3]| Vector3::from_array(p.map(wide));
2461 let mut model = M::new();
2462 let block = cube(&mut model, [0.0, 0.0, 0.0], [2.0, 2.0, 1.0]);
2463 let tool = geop_lib_sweep::shapes::cube_solid(
2464 &mut model,
2465 &fresh_id(),
2466 corner([1.72, 0.1, 0.88]),
2467 corner([1.9, 1.9, 1.08]),
2468 )
2469 .unwrap();
2470 op(&mut model, block, tool, BooleanOp::Difference);
2471 }
2472}