1use super::*;
50
51struct FacePatch {
55 shell_index: usize,
57 face_ids: HashSet<u64>,
59 dropped_loops: Vec<(usize, usize, usize)>,
62 edges: HashSet<u64>,
65}
66
67#[derive(Clone, Copy, Default)]
70struct EdgeCensus {
71 selected: usize,
72 kept: usize,
73}
74
75fn census(solid: &BrepSolid, face_ids: &HashSet<u64>) -> HashMap<u64, EdgeCensus> {
76 let mut counts: HashMap<u64, EdgeCensus> = HashMap::default();
77 for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
78 let selected = face_ids.contains(&face.id);
79 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
80 let entry = counts.entry(coedge.edge_id).or_default();
81 if selected {
82 entry.selected += 1;
83 } else {
84 entry.kept += 1;
85 }
86 }
87 }
88 counts
89}
90
91fn classify_patch(solid: &BrepSolid, face_ids: &[u64]) -> Option<FacePatch> {
95 let selected: HashSet<u64> = face_ids.iter().copied().collect();
96 let mut shells = face_ids
97 .iter()
98 .filter_map(|face_id| find_face(solid, *face_id))
99 .map(|(shell_index, _)| shell_index);
100 let shell_index = shells.next()?;
101 if shells.any(|other| other != shell_index) {
102 return None;
104 }
105
106 let counts = census(solid, &selected);
107 let mut dropped_loops = Vec::new();
108 for (shell_position, shell) in solid.shells.iter().enumerate() {
109 for (face_position, face) in shell.faces.iter().enumerate() {
110 if selected.contains(&face.id) {
111 continue;
112 }
113 for (loop_index, loop_record) in face.loops.iter().enumerate() {
114 let mut touches = false;
115 let mut whole = true;
116 for coedge in &loop_record.coedges {
117 if counts
118 .get(&coedge.edge_id)
119 .is_some_and(|count| count.selected > 0)
120 {
121 touches = true;
122 } else {
123 whole = false;
124 }
125 }
126 if !touches {
127 continue;
128 }
129 if !whole {
130 return None;
134 }
135 dropped_loops.push((shell_position, face_position, loop_index));
136 }
137 }
138 }
139 if dropped_loops.is_empty() {
140 return None;
143 }
144
145 let edges: HashSet<u64> = counts
146 .iter()
147 .filter(|(_, count)| count.selected > 0)
148 .map(|(edge_id, _)| *edge_id)
149 .collect();
150 Some(FacePatch {
151 shell_index,
152 face_ids: selected,
153 dropped_loops,
154 edges,
155 })
156}
157
158fn euler_characteristic(solid: &BrepSolid) -> i64 {
162 let referenced: HashSet<u64> = solid
163 .edges
164 .iter()
165 .filter(|edge| !edge.degenerate)
166 .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
167 .collect();
168 let vertices = solid
169 .vertices
170 .iter()
171 .filter(|vertex| referenced.contains(&vertex.id))
172 .count() as i64;
173 let edges = solid.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
174 let faces = solid
175 .shells
176 .iter()
177 .map(|shell| shell.faces.len())
178 .sum::<usize>() as i64;
179 let holes: i64 = solid
180 .shells
181 .iter()
182 .flat_map(|shell| &shell.faces)
183 .map(|face| face.loops.len().saturating_sub(1) as i64)
184 .sum();
185 vertices - edges + faces - holes
186}
187
188fn face_label(face: &FaceRecord) -> String {
191 match &face.name {
192 Some(name) => format!("`{name}`"),
193 None => format!("face {}", face.id),
194 }
195}
196
197fn cap_face_patch(solid: &BrepSolid, patch: &FacePatch, op: &str) -> Result<BrepSolid, String> {
199 let mut per_face: HashMap<(usize, usize), Vec<usize>> = HashMap::default();
203 for (shell_position, face_position, loop_index) in &patch.dropped_loops {
204 per_face
205 .entry((*shell_position, *face_position))
206 .or_default()
207 .push(*loop_index);
208 }
209 for ((shell_position, face_position), loop_indices) in &per_face {
210 let face = &solid.shells[*shell_position].faces[*face_position];
211 if loop_indices.len() >= face.loops.len() {
212 return Err(format!(
213 "{op}: the selection is the whole boundary of {} — it is part of the \
214 pocket, not the face the pocket was sunk into. Select it as well \
215 (a patch is capped by the face AROUND it, which has to keep a loop).",
216 face_label(face)
217 ));
218 }
219 let mut areas = Vec::with_capacity(face.loops.len());
220 for index in 0..face.loops.len() {
221 areas.push(loop_signed_area(face, index)?);
222 }
223 let host = (0..areas.len())
224 .max_by(|a, b| areas[*a].abs().total_cmp(&areas[*b].abs()))
225 .expect("the face has at least two loops here");
226 for loop_index in loop_indices {
227 if *loop_index == host || areas[host] * areas[*loop_index] >= 0.0 {
228 return Err(format!(
229 "{op}: the loop the selection would leave open in {} bounds that \
230 face's material rather than a hole in it — capping it would erase \
231 the face (deferred)",
232 face_label(face)
233 ));
234 }
235 }
236 }
237
238 let mut healed = solid.clone();
239
240 for ((shell_position, face_position), loop_indices) in &per_face {
245 let mut loop_indices = loop_indices.clone();
246 loop_indices.sort_unstable_by(|a, b| b.cmp(a));
247 for loop_index in loop_indices {
248 healed.shells[*shell_position].faces[*face_position]
249 .loops
250 .remove(loop_index);
251 }
252 }
253 for shell in &mut healed.shells {
254 shell.faces.retain(|face| !patch.face_ids.contains(&face.id));
255 }
256 healed.edges.retain(|edge| !patch.edges.contains(&edge.id));
257 let used: HashSet<u64> = healed
258 .edges
259 .iter()
260 .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
261 .collect();
262 healed.vertices.retain(|vertex| used.contains(&vertex.id));
263
264 if !faces_are_connected(&healed.shells[patch.shell_index].faces) {
266 return Err(format!(
267 "{op}: the selected faces are what joins two otherwise separate parts of \
268 the body — removing them would sever the solid, which this operation \
269 cannot represent (deferred)"
270 ));
271 }
272 let shift = euler_characteristic(solid) - euler_characteristic(&healed);
276 if shift % 2 != 0 {
277 return Err(format!(
278 "{op}: the selection does not close into whole handles \
279 (Euler characteristic shifts by an odd {shift}) — refusing rather than \
280 emitting a solid whose genus is a guess"
281 ));
282 }
283 healed.genus += shift / 2;
284 if healed.genus < 0 {
285 return Err(format!(
286 "{op}: capping the selection leaves genus {}, so the solid's stated genus \
287 did not account for the feature it carries (deferred)",
288 healed.genus
289 ));
290 }
291
292 let issues = healed.validate();
293 if !issues.is_empty() {
294 return Err(format!("{op}: the capped solid failed validation: {issues:?}"));
295 }
296 Ok(healed)
297}
298
299pub fn delete_faces_and_heal(solid: &BrepSolid, face_ids: &[u64]) -> Result<BrepSolid, String> {
316 let op = "delete_faces_and_heal";
317 let mut seen: HashSet<u64> = HashSet::default();
318 let face_ids: Vec<u64> = face_ids
319 .iter()
320 .copied()
321 .filter(|face_id| seen.insert(*face_id))
322 .collect();
323 if face_ids.is_empty() {
324 return Err(format!("{op}: no faces selected"));
325 }
326 for face_id in &face_ids {
327 if find_face(solid, *face_id).is_none() {
328 return Err(format!("{op}: no face with id {face_id}"));
329 }
330 }
331 if face_ids.len() == 1 {
332 return delete_face_and_heal(solid, face_ids[0]);
333 }
334 if let Some(patch) = classify_patch(solid, &face_ids) {
335 return cap_face_patch(solid, &patch, op);
336 }
337 let mut healed = solid.clone();
338 for face_id in &face_ids {
339 healed = delete_face_and_heal(&healed, *face_id)?;
340 }
341 Ok(healed)
342}
343
344#[cfg(test)]
351mod delete_faces_tests {
352 use super::*;
353 use crate::{
354 boolean_operation, chamfer_edge, make_box_brep, make_cylinder_brep,
355 solid_mass_properties, BooleanOperation, BooleanOptions,
356 };
357
358 fn volume(solid: &BrepSolid) -> f64 {
359 solid_mass_properties(solid)
360 .expect("mass properties")
361 .volume
362 }
363
364 fn face_count(solid: &BrepSolid) -> usize {
365 solid.shells.iter().map(|shell| shell.faces.len()).sum()
366 }
367
368 fn cube_with_square_pocket() -> BrepSolid {
371 let cube = make_box_brep(Vec3::default(), 20.0, 20.0, 20.0).unwrap();
372 let cutter = make_box_brep(Vec3::new(5.0, 5.0, 15.0), 8.0, 8.0, 10.0).unwrap();
375 let cut = boolean_operation(
376 &cube,
377 &cutter,
378 BooleanOperation::Subtract,
379 &BooleanOptions::default(),
380 )
381 .unwrap();
382 assert!(cut.validate().is_empty(), "{:?}", cut.validate());
383 cut
384 }
385
386 fn faces_bounded_within(solid: &BrepSolid, inside: impl Fn(Vec3) -> bool) -> Vec<u64> {
390 solid
391 .shells
392 .iter()
393 .flat_map(|shell| &shell.faces)
394 .filter(|face| {
395 face.loops
396 .iter()
397 .flat_map(|loop_record| &loop_record.coedges)
398 .all(|coedge| {
399 solid
400 .edges
401 .iter()
402 .find(|edge| edge.id == coedge.edge_id)
403 .and_then(|edge| edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).ok())
404 .map(&inside)
405 .unwrap_or(false)
406 })
407 })
408 .map(|face| face.id)
409 .collect()
410 }
411
412 fn pocket_faces(solid: &BrepSolid) -> Vec<u64> {
415 faces_bounded_within(solid, |point| {
416 point.x > 4.0 && point.x < 14.0 && point.y > 4.0 && point.y < 14.0 && point.z > 14.0
417 })
418 }
419
420 #[test]
424 fn capping_a_blind_pocket_restores_the_cube() {
425 let cut = cube_with_square_pocket();
426 let pocket = pocket_faces(&cut);
427 assert_eq!(pocket.len(), 5, "four walls and a floor");
428 assert_eq!(face_count(&cut), 11, "the cube's six plus the pocket's five");
429
430 let healed = delete_faces_and_heal(&cut, &pocket).expect("the pocket caps");
431 assert!(healed.validate().is_empty(), "{:?}", healed.validate());
432 assert_eq!(face_count(&healed), 6, "the cube's six faces");
433 assert!(
434 healed
435 .shells
436 .iter()
437 .flat_map(|shell| &shell.faces)
438 .all(|face| face.loops.len() == 1),
439 "no face keeps the pocket's mouth loop"
440 );
441 assert_eq!(healed.genus, 0, "a pocket is not a handle");
442 assert!(
445 (volume(&healed) - 8000.0).abs() < 1e-9,
446 "the cube is back: {}",
447 volume(&healed)
448 );
449 }
450
451 #[test]
455 fn refuses_a_pocket_whose_floor_was_not_selected() {
456 let cut = cube_with_square_pocket();
457 let pocket = pocket_faces(&cut);
458 let floor = cut
459 .shells
460 .iter()
461 .flat_map(|shell| &shell.faces)
462 .find(|face| {
463 pocket.contains(&face.id)
464 && face
465 .surface
466 .evaluate(0.5, 0.5)
467 .map(|point| (point.z - 15.0).abs() < 1e-9)
468 .unwrap_or(false)
469 })
470 .expect("the pocket floor")
471 .id;
472 let walls: Vec<u64> = pocket.iter().copied().filter(|id| *id != floor).collect();
473 assert_eq!(walls.len(), 4);
474
475 let error = delete_faces_and_heal(&cut, &walls).unwrap_err();
476 assert!(
477 error.contains("Select it as well"),
478 "the refusal must say what to do: {error}"
479 );
480 }
481
482 #[test]
486 fn a_non_patch_selection_still_chains() {
487 let cylinder =
488 make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 4.0, 6.0).unwrap();
489 let full = volume(&cylinder);
490 let rim_at = |solid: &BrepSolid, z: f64| {
491 solid
492 .edges
493 .iter()
494 .find(|edge| {
495 !edge.degenerate
496 && edge.start_vertex_id == edge.end_vertex_id
497 && edge
498 .curve
499 .evaluate(0.5 * (edge.t0 + edge.t1))
500 .map(|point| (point.z - z).abs() < 1e-9)
501 .unwrap_or(false)
502 })
503 .map(|edge| edge.id)
504 .expect("closed rim")
505 };
506 let top = rim_at(&cylinder, 6.0);
507 let chamfered = chamfer_edge(&cylinder, top, 1.0, Some("C1")).unwrap();
508 let bottom = rim_at(&chamfered, 0.0);
509 let chamfered = chamfer_edge(&chamfered, bottom, 1.0, Some("C2")).unwrap();
510 assert!(chamfered.validate().is_empty(), "{:?}", chamfered.validate());
511
512 let strips: Vec<u64> = chamfered
513 .shells
514 .iter()
515 .flat_map(|shell| &shell.faces)
516 .filter(|face| matches!(face.name.as_deref(), Some("C1") | Some("C2")))
517 .map(|face| face.id)
518 .collect();
519 assert_eq!(strips.len(), 2, "both chamfer strips are named");
520 assert!(
523 classify_patch(&chamfered, &strips).is_none(),
524 "two chamfer strips are not a patch"
525 );
526
527 let healed = delete_faces_and_heal(&chamfered, &strips).expect("the chain heals both");
528 assert!(healed.validate().is_empty(), "{:?}", healed.validate());
529 assert_eq!(face_count(&healed), 3, "wall, top, bottom");
530 assert!(
531 (volume(&healed) - full).abs() <= 1e-6 * full,
532 "the cylinder is back: {} vs {full}",
533 volume(&healed)
534 );
535 }
536
537 #[test]
543 fn capping_a_through_bore_and_a_pocket_together_drops_one_handle() {
544 let plate = make_box_brep(Vec3::default(), 20.0, 20.0, 20.0).unwrap();
545 let drill = make_cylinder_brep(
546 Vec3::new(5.0, 5.0, -5.0),
547 Vec3::new(0.0, 0.0, 1.0),
548 2.0,
549 30.0,
550 )
551 .unwrap();
552 let drilled = boolean_operation(
553 &plate,
554 &drill,
555 BooleanOperation::Subtract,
556 &BooleanOptions::default(),
557 )
558 .unwrap();
559 let pocket_cutter = make_box_brep(Vec3::new(12.0, 12.0, 15.0), 5.0, 5.0, 10.0).unwrap();
560 let cut = boolean_operation(
561 &drilled,
562 &pocket_cutter,
563 BooleanOperation::Subtract,
564 &BooleanOptions::default(),
565 )
566 .unwrap();
567 assert!(cut.validate().is_empty(), "{:?}", cut.validate());
568 assert_eq!(cut.genus, 1, "the bore is a handle");
569
570 let outer = |point: Vec3| {
572 point.x.abs() < 1e-9
573 || (point.x - 20.0).abs() < 1e-9
574 || point.y.abs() < 1e-9
575 || (point.y - 20.0).abs() < 1e-9
576 || point.z.abs() < 1e-9
577 || (point.z - 20.0).abs() < 1e-9
578 };
579 let selection: Vec<u64> = cut
580 .shells
581 .iter()
582 .flat_map(|shell| &shell.faces)
583 .filter(|face| !face.surface.evaluate(0.5, 0.5).map(outer).unwrap_or(true))
584 .map(|face| face.id)
585 .collect();
586 assert_eq!(selection.len(), 6, "the bore wall plus the pocket's five");
587
588 let healed = delete_faces_and_heal(&cut, &selection).expect("both cap");
589 assert!(healed.validate().is_empty(), "{:?}", healed.validate());
590 assert_eq!(face_count(&healed), 6, "the plate's six faces");
591 assert_eq!(healed.genus, 0, "closing the bore removes the handle");
592 assert!(
593 (volume(&healed) - 8000.0).abs() < 1e-6,
594 "the plate is back: {}",
595 volume(&healed)
596 );
597 }
598
599 #[test]
604 fn refuses_a_patch_whose_removal_would_sever_the_body() {
605 let lower = make_box_brep(Vec3::new(0.0, 0.0, 0.0), 20.0, 20.0, 4.0).unwrap();
606 let upper = make_box_brep(Vec3::new(0.0, 0.0, 12.0), 20.0, 20.0, 4.0).unwrap();
607 let post = make_box_brep(Vec3::new(8.0, 8.0, 4.0), 4.0, 4.0, 8.0).unwrap();
610 let options = BooleanOptions::default();
611 let joined = boolean_operation(&lower, &post, BooleanOperation::Union, &options)
612 .and_then(|solid| {
613 boolean_operation(&solid, &upper, BooleanOperation::Union, &options)
614 })
615 .expect("the sandwich unions");
616 assert!(joined.validate().is_empty(), "{:?}", joined.validate());
617
618 let walls = faces_bounded_within(&joined, |point| {
619 point.x >= 7.9 && point.x <= 12.1 && point.y >= 7.9 && point.y <= 12.1
620 });
621 assert_eq!(walls.len(), 4, "the post's four walls");
622 assert!(
623 classify_patch(&joined, &walls).is_some(),
624 "the post's walls ARE a patch — the refusal has to come from the \
625 connectivity check, not from the gate"
626 );
627
628 let error = delete_faces_and_heal(&joined, &walls).unwrap_err();
629 assert!(
630 error.contains("sever the solid"),
631 "unexpected refusal: {error}"
632 );
633 }
634
635 #[test]
636 fn refuses_a_face_that_does_not_exist() {
637 let cube = make_box_brep(Vec3::default(), 1.0, 1.0, 1.0).unwrap();
638 let error = delete_faces_and_heal(&cube, &[999_999, 1]).unwrap_err();
639 assert!(
640 error.contains("no face with id 999999"),
641 "unexpected refusal: {error}"
642 );
643 }
644
645 #[test]
646 fn refuses_an_empty_selection() {
647 let cube = make_box_brep(Vec3::default(), 1.0, 1.0, 1.0).unwrap();
648 let error = delete_faces_and_heal(&cube, &[]).unwrap_err();
649 assert!(error.contains("no faces selected"), "{error}");
650 }
651}