Skip to main content

brep_kernel/edit/direct_edit/
delete_faces.rs

1//! Deleting a SET of faces — the multi-select half of Golovanov §6.12.
2//!
3//! [`delete_face_and_heal`](super::delete_face_and_heal) removes ONE face and
4//! heals by re-intersecting its neighbours. That is the right heal for a
5//! transition strip, and the wrong question entirely for the case the app
6//! sends most often: a user has selected every face of a POCKET — its walls and
7//! its floor — and wants the pocket gone.
8//!
9//! Such a selection is not a strip between neighbours; it is a PATCH. Its free
10//! boundary is not four edges belonging to four other faces, it is one whole
11//! HOLE LOOP of one surviving face — the mouth the pocket was sunk through. So
12//! the heal is not to re-intersect anything, it is to CAP: drop the patch, drop
13//! the hole loop, and the face the pocket was sunk into is the whole face
14//! again. Nothing is refit; every surviving carrier and loop is bit-identical
15//! to what it was.
16//!
17//! This is the same closed form [`cap_through_wall`](super::closed_heal) already
18//! uses for a bore's wall, stated for an arbitrary set of faces instead of one:
19//! a bore wall is simply the patch whose free boundary is TWO hole loops, in the
20//! two faces the drill went in and out of. A blind pocket, a blind bore (wall +
21//! floor disc), and a BOSS (a pad's wall + top, whose mouth is a hole loop in
22//! the face it was grown from) are all the same operation.
23//!
24//! ## The lane gate
25//!
26//! Structural, decided before any geometry is touched:
27//!
28//! * every survivor loop that touches the selection must be ENTIRELY consumed
29//!   by it. A loop that keeps some of its edges means the selection cut a face
30//!   rather than lifting a patch off it — that is the transition-strip
31//!   question, and it routes to the one-face-at-a-time chain.
32//!
33//! Once the gate says "patch", this lane OWNS the answer, refusals included —
34//! the checks below name what is wrong instead of falling back to a heal that
35//! was written for a different shape:
36//!
37//! * a consumed loop must be a HOLE in its face, not the loop that bounds the
38//!   face's material. Selecting a pocket's three walls but not its floor makes
39//!   the floor's OUTER loop the free boundary; capping that would leave a face
40//!   with no boundary at all, so the refusal names the floor and says to select
41//!   it too.
42//! * the shell must stay CONNECTED. `validate()` deliberately does not ask
43//!   (see `faces_are_connected`), and a patch whose removal severs the body is
44//!   a thing this operation cannot represent.
45//! * the GENUS must come out of the Euler accounting as a whole number ≥ 0.
46//!   Capping a blind pocket leaves the genus alone; capping a through feature
47//!   drops it by one. Neither is assumed — both fall out of the same count.
48
49use super::*;
50
51/// A selection that passed the structural gate: the faces to lift off, the
52/// survivor loops their free boundary consumes, and the edges that go with
53/// them.
54struct FacePatch {
55    /// The shell the patch lives in (all its faces share one).
56    shell_index: usize,
57    /// Ids of the selected faces.
58    face_ids: HashSet<u64>,
59    /// `(shell, face position, loop index)` of every survivor loop the patch's
60    /// free boundary consumes whole — the hole loops that go with it.
61    dropped_loops: Vec<(usize, usize, usize)>,
62    /// Every edge the patch uses: its interior edges and its free boundary.
63    /// All of them lose both their coedges, so all of them go.
64    edges: HashSet<u64>,
65}
66
67/// How many coedges a given edge gets from the selection and from the rest of
68/// the solid.
69#[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
91/// Decide whether `face_ids` is a PATCH — a set whose free boundary consumes
92/// whole loops of the faces around it — and if so gather what its removal
93/// takes with it. `None` routes the caller to the one-face-at-a-time chain.
94fn 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        // A patch is a piece of ONE shell's surface.
103        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                    // The selection stops part-way along a survivor's loop: it
131                    // is a strip between neighbours, not a patch lifted off
132                    // them. The chain owns that question.
133                    return None;
134                }
135                dropped_loops.push((shell_position, face_position, loop_index));
136            }
137        }
138    }
139    if dropped_loops.is_empty() {
140        // Nothing outside the selection borders it — the "patch" is a whole
141        // closed shell. Capping has nothing to cap onto.
142        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
158/// `V - E + F - H` on the reduced complex `validate()`'s Euler check uses:
159/// degenerate (pole) edges and the vertices only they reference are not
160/// independent cells, and each loop past a face's first is a hole.
161fn 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
188/// How a face is named in a refusal: its persistent name when it has one, its
189/// id otherwise.
190fn face_label(face: &FaceRecord) -> String {
191    match &face.name {
192        Some(name) => format!("`{name}`"),
193        None => format!("face {}", face.id),
194    }
195}
196
197/// Lift `patch` off the solid and close the hole loops it was sunk through.
198fn cap_face_patch(solid: &BrepSolid, patch: &FacePatch, op: &str) -> Result<BrepSolid, String> {
199    // --- Every consumed loop must be a HOLE in its face --------------------
200    // Grouped per face, because "would this face keep a loop" is a question
201    // about the face, not about one loop.
202    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    // --- Drop the hole loops, then the patch, then their edges -------------
241    // Loops go first, by descending index within each face, so no removal
242    // disturbs a position resolved against the original topology; dropping a
243    // loop cannot move a face, and the faces go by id.
244    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    // --- The two things `validate()` will not ask for us -------------------
265    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    // Genus is not assumed either way: capping a blind pocket leaves it alone,
273    // capping a through feature drops it by one, and both fall out of the same
274    // Euler count over the reduced complex.
275    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
299/// Delete a SET of faces and heal, in one operation.
300///
301/// Two lanes, chosen by the selection's own shape (see the module docs):
302///
303/// * a PATCH — a set whose free boundary consumes whole hole loops of the
304///   faces around it (a pocket's walls + floor, a boss's wall + top, a bore's
305///   wall) — is CAPPED: the patch and those loops go, and nothing else is
306///   touched;
307/// * anything else is healed one face at a time by
308///   [`delete_face_and_heal`](super::delete_face_and_heal), which extends and
309///   re-intersects each face's neighbours. Face ids are stable across a heal,
310///   so the chain re-uses the ids it was given.
311///
312/// A single-face selection always takes the second lane — a lone face is
313/// exactly the question `delete_face_and_heal` was written for, and its
314/// through-wall cap already covers the one-face patch there is.
315pub 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// ---------------------------------------------------------------------------
345// Tests — the lane gate and each named refusal, on the smallest solids that
346// wear the signature. The reported document itself is pinned end-to-end
347// through the feature engine in
348// `tests/suites/inbox_20260905_cone_pocket_delete_faces.rs`.
349// ---------------------------------------------------------------------------
350#[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    /// A 20 mm cube with a square blind pocket sunk 5 mm into its top face:
369    /// four walls and a floor, whose mouth is a hole loop in `Box_PZ`.
370    fn cube_with_square_pocket() -> BrepSolid {
371        let cube = make_box_brep(Vec3::default(), 20.0, 20.0, 20.0).unwrap();
372        // The cutter starts ABOVE the top face and reaches down to z = 15, so
373        // the pocket is blind: its floor is inside the material.
374        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    /// The ids of every face whose WHOLE trimmed boundary satisfies `inside` —
387    /// the honest way to pick a feature's faces, since a face's carrier plane
388    /// reaches well past the region it is trimmed to.
389    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    /// The pocket's five faces: everything trimmed to inside the pocket's
413    /// footprint and at or above its floor.
414    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    /// The reported operation, on the simplest solid that wears its shape:
421    /// select every face of a blind pocket and the pocket is gone, the face it
422    /// was sunk through closes, and the cube is back.
423    #[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        // Nothing on this path is refit, so the volume is the cube's to the
443        // mass integrator's own round-off over six planes.
444        assert!(
445            (volume(&healed) - 8000.0).abs() < 1e-9,
446            "the cube is back: {}",
447            volume(&healed)
448        );
449    }
450
451    /// The same pocket with the FLOOR left out of the selection. The floor's
452    /// only loop would be the free boundary, and capping it would erase the
453    /// face — so the refusal names the floor and says to select it.
454    #[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    /// A selection that is NOT a patch — the two rim chamfers of a cylinder —
483    /// still goes through the one-at-a-time chain, and still heals. The gate
484    /// must not swallow the case the chain was written for.
485    #[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        // Not a patch: each strip's rims are shared with loops its neighbours
521        // keep the rest of, so the gate hands this to the chain.
522        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    /// The patch lane must not be confined to a blind pocket: a through bore's
538    /// wall is the patch whose free boundary is TWO hole loops, and the same
539    /// count that leaves a pocket's genus alone drops this one's by the handle
540    /// it closes. Selected here WITH a second, unrelated pocket so the
541    /// selection is genuinely a multi-face one.
542    #[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        // Everything that is not one of the plate's six outer planes.
571        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    /// A post joining two plates wears a patch's exact signature — its four
600    /// walls are bounded by one hole loop in each plate — and capping it would
601    /// leave two closed surfaces in one shell record, which `validate()`
602    /// accepts. The connectivity check is what refuses it.
603    #[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        // A SQUARE post, so its wall is four faces: the patch lane's question,
608        // not the single-face cap's.
609        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}