BREP_kernel 0.4.0

A boundary representation (BREP) geometry kernel for building CAD applications.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
//! Deleting a SET of faces — the multi-select half of Golovanov §6.12.
//!
//! [`delete_face_and_heal`](super::delete_face_and_heal) removes ONE face and
//! heals by re-intersecting its neighbours. That is the right heal for a
//! transition strip, and the wrong question entirely for the case the app
//! sends most often: a user has selected every face of a POCKET — its walls and
//! its floor — and wants the pocket gone.
//!
//! Such a selection is not a strip between neighbours; it is a PATCH. Its free
//! boundary is not four edges belonging to four other faces, it is one whole
//! HOLE LOOP of one surviving face — the mouth the pocket was sunk through. So
//! the heal is not to re-intersect anything, it is to CAP: drop the patch, drop
//! the hole loop, and the face the pocket was sunk into is the whole face
//! again. Nothing is refit; every surviving carrier and loop is bit-identical
//! to what it was.
//!
//! This is the same closed form [`cap_through_wall`](super::closed_heal) already
//! uses for a bore's wall, stated for an arbitrary set of faces instead of one:
//! a bore wall is simply the patch whose free boundary is TWO hole loops, in the
//! two faces the drill went in and out of. A blind pocket, a blind bore (wall +
//! floor disc), and a BOSS (a pad's wall + top, whose mouth is a hole loop in
//! the face it was grown from) are all the same operation.
//!
//! ## The lane gate
//!
//! Structural, decided before any geometry is touched:
//!
//! * every survivor loop that touches the selection must be ENTIRELY consumed
//!   by it. A loop that keeps some of its edges means the selection cut a face
//!   rather than lifting a patch off it — that is the transition-strip
//!   question, and it routes to the one-face-at-a-time chain.
//!
//! Once the gate says "patch", this lane OWNS the answer, refusals included —
//! the checks below name what is wrong instead of falling back to a heal that
//! was written for a different shape:
//!
//! * a consumed loop must be a HOLE in its face, not the loop that bounds the
//!   face's material. Selecting a pocket's three walls but not its floor makes
//!   the floor's OUTER loop the free boundary; capping that would leave a face
//!   with no boundary at all, so the refusal names the floor and says to select
//!   it too.
//! * the shell must stay CONNECTED. `validate()` deliberately does not ask
//!   (see `faces_are_connected`), and a patch whose removal severs the body is
//!   a thing this operation cannot represent.
//! * the GENUS must come out of the Euler accounting as a whole number ≥ 0.
//!   Capping a blind pocket leaves the genus alone; capping a through feature
//!   drops it by one. Neither is assumed — both fall out of the same count.

use super::*;

/// A selection that passed the structural gate: the faces to lift off, the
/// survivor loops their free boundary consumes, and the edges that go with
/// them.
struct FacePatch {
    /// The shell the patch lives in (all its faces share one).
    shell_index: usize,
    /// Ids of the selected faces.
    face_ids: HashSet<u64>,
    /// `(shell, face position, loop index)` of every survivor loop the patch's
    /// free boundary consumes whole — the hole loops that go with it.
    dropped_loops: Vec<(usize, usize, usize)>,
    /// Every edge the patch uses: its interior edges and its free boundary.
    /// All of them lose both their coedges, so all of them go.
    edges: HashSet<u64>,
}

/// How many coedges a given edge gets from the selection and from the rest of
/// the solid.
#[derive(Clone, Copy, Default)]
struct EdgeCensus {
    selected: usize,
    kept: usize,
}

fn census(solid: &BrepSolid, face_ids: &HashSet<u64>) -> HashMap<u64, EdgeCensus> {
    let mut counts: HashMap<u64, EdgeCensus> = HashMap::default();
    for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
        let selected = face_ids.contains(&face.id);
        for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
            let entry = counts.entry(coedge.edge_id).or_default();
            if selected {
                entry.selected += 1;
            } else {
                entry.kept += 1;
            }
        }
    }
    counts
}

/// Decide whether `face_ids` is a PATCH — a set whose free boundary consumes
/// whole loops of the faces around it — and if so gather what its removal
/// takes with it. `None` routes the caller to the one-face-at-a-time chain.
fn classify_patch(solid: &BrepSolid, face_ids: &[u64]) -> Option<FacePatch> {
    let selected: HashSet<u64> = face_ids.iter().copied().collect();
    let mut shells = face_ids
        .iter()
        .filter_map(|face_id| find_face(solid, *face_id))
        .map(|(shell_index, _)| shell_index);
    let shell_index = shells.next()?;
    if shells.any(|other| other != shell_index) {
        // A patch is a piece of ONE shell's surface.
        return None;
    }

    let counts = census(solid, &selected);
    let mut dropped_loops = Vec::new();
    for (shell_position, shell) in solid.shells.iter().enumerate() {
        for (face_position, face) in shell.faces.iter().enumerate() {
            if selected.contains(&face.id) {
                continue;
            }
            for (loop_index, loop_record) in face.loops.iter().enumerate() {
                let mut touches = false;
                let mut whole = true;
                for coedge in &loop_record.coedges {
                    if counts
                        .get(&coedge.edge_id)
                        .is_some_and(|count| count.selected > 0)
                    {
                        touches = true;
                    } else {
                        whole = false;
                    }
                }
                if !touches {
                    continue;
                }
                if !whole {
                    // The selection stops part-way along a survivor's loop: it
                    // is a strip between neighbours, not a patch lifted off
                    // them. The chain owns that question.
                    return None;
                }
                dropped_loops.push((shell_position, face_position, loop_index));
            }
        }
    }
    if dropped_loops.is_empty() {
        // Nothing outside the selection borders it — the "patch" is a whole
        // closed shell. Capping has nothing to cap onto.
        return None;
    }

    let edges: HashSet<u64> = counts
        .iter()
        .filter(|(_, count)| count.selected > 0)
        .map(|(edge_id, _)| *edge_id)
        .collect();
    Some(FacePatch {
        shell_index,
        face_ids: selected,
        dropped_loops,
        edges,
    })
}

/// `V - E + F - H` on the reduced complex `validate()`'s Euler check uses:
/// degenerate (pole) edges and the vertices only they reference are not
/// independent cells, and each loop past a face's first is a hole.
fn euler_characteristic(solid: &BrepSolid) -> i64 {
    let referenced: HashSet<u64> = solid
        .edges
        .iter()
        .filter(|edge| !edge.degenerate)
        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
        .collect();
    let vertices = solid
        .vertices
        .iter()
        .filter(|vertex| referenced.contains(&vertex.id))
        .count() as i64;
    let edges = solid.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
    let faces = solid
        .shells
        .iter()
        .map(|shell| shell.faces.len())
        .sum::<usize>() as i64;
    let holes: i64 = solid
        .shells
        .iter()
        .flat_map(|shell| &shell.faces)
        .map(|face| face.loops.len().saturating_sub(1) as i64)
        .sum();
    vertices - edges + faces - holes
}

/// How a face is named in a refusal: its persistent name when it has one, its
/// id otherwise.
fn face_label(face: &FaceRecord) -> String {
    match &face.name {
        Some(name) => format!("`{name}`"),
        None => format!("face {}", face.id),
    }
}

/// Lift `patch` off the solid and close the hole loops it was sunk through.
fn cap_face_patch(solid: &BrepSolid, patch: &FacePatch, op: &str) -> Result<BrepSolid, String> {
    // --- Every consumed loop must be a HOLE in its face --------------------
    // Grouped per face, because "would this face keep a loop" is a question
    // about the face, not about one loop.
    let mut per_face: HashMap<(usize, usize), Vec<usize>> = HashMap::default();
    for (shell_position, face_position, loop_index) in &patch.dropped_loops {
        per_face
            .entry((*shell_position, *face_position))
            .or_default()
            .push(*loop_index);
    }
    for ((shell_position, face_position), loop_indices) in &per_face {
        let face = &solid.shells[*shell_position].faces[*face_position];
        if loop_indices.len() >= face.loops.len() {
            return Err(format!(
                "{op}: the selection is the whole boundary of {} — it is part of the \
                 pocket, not the face the pocket was sunk into. Select it as well \
                 (a patch is capped by the face AROUND it, which has to keep a loop).",
                face_label(face)
            ));
        }
        let mut areas = Vec::with_capacity(face.loops.len());
        for index in 0..face.loops.len() {
            areas.push(loop_signed_area(face, index)?);
        }
        let host = (0..areas.len())
            .max_by(|a, b| areas[*a].abs().total_cmp(&areas[*b].abs()))
            .expect("the face has at least two loops here");
        for loop_index in loop_indices {
            if *loop_index == host || areas[host] * areas[*loop_index] >= 0.0 {
                return Err(format!(
                    "{op}: the loop the selection would leave open in {} bounds that \
                     face's material rather than a hole in it — capping it would erase \
                     the face (deferred)",
                    face_label(face)
                ));
            }
        }
    }

    let mut healed = solid.clone();

    // --- Drop the hole loops, then the patch, then their edges -------------
    // Loops go first, by descending index within each face, so no removal
    // disturbs a position resolved against the original topology; dropping a
    // loop cannot move a face, and the faces go by id.
    for ((shell_position, face_position), loop_indices) in &per_face {
        let mut loop_indices = loop_indices.clone();
        loop_indices.sort_unstable_by(|a, b| b.cmp(a));
        for loop_index in loop_indices {
            healed.shells[*shell_position].faces[*face_position]
                .loops
                .remove(loop_index);
        }
    }
    for shell in &mut healed.shells {
        shell.faces.retain(|face| !patch.face_ids.contains(&face.id));
    }
    healed.edges.retain(|edge| !patch.edges.contains(&edge.id));
    let used: HashSet<u64> = healed
        .edges
        .iter()
        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
        .collect();
    healed.vertices.retain(|vertex| used.contains(&vertex.id));

    // --- The two things `validate()` will not ask for us -------------------
    if !faces_are_connected(&healed.shells[patch.shell_index].faces) {
        return Err(format!(
            "{op}: the selected faces are what joins two otherwise separate parts of \
             the body — removing them would sever the solid, which this operation \
             cannot represent (deferred)"
        ));
    }
    // Genus is not assumed either way: capping a blind pocket leaves it alone,
    // capping a through feature drops it by one, and both fall out of the same
    // Euler count over the reduced complex.
    let shift = euler_characteristic(solid) - euler_characteristic(&healed);
    if shift % 2 != 0 {
        return Err(format!(
            "{op}: the selection does not close into whole handles \
             (Euler characteristic shifts by an odd {shift}) — refusing rather than \
             emitting a solid whose genus is a guess"
        ));
    }
    healed.genus += shift / 2;
    if healed.genus < 0 {
        return Err(format!(
            "{op}: capping the selection leaves genus {}, so the solid's stated genus \
             did not account for the feature it carries (deferred)",
            healed.genus
        ));
    }

    let issues = healed.validate();
    if !issues.is_empty() {
        return Err(format!("{op}: the capped solid failed validation: {issues:?}"));
    }
    Ok(healed)
}

/// Delete a SET of faces and heal, in one operation.
///
/// Two lanes, chosen by the selection's own shape (see the module docs):
///
/// * a PATCH — a set whose free boundary consumes whole hole loops of the
///   faces around it (a pocket's walls + floor, a boss's wall + top, a bore's
///   wall) — is CAPPED: the patch and those loops go, and nothing else is
///   touched;
/// * anything else is healed one face at a time by
///   [`delete_face_and_heal`](super::delete_face_and_heal), which extends and
///   re-intersects each face's neighbours. Face ids are stable across a heal,
///   so the chain re-uses the ids it was given.
///
/// A single-face selection always takes the second lane — a lone face is
/// exactly the question `delete_face_and_heal` was written for, and its
/// through-wall cap already covers the one-face patch there is.
pub fn delete_faces_and_heal(solid: &BrepSolid, face_ids: &[u64]) -> Result<BrepSolid, String> {
    let op = "delete_faces_and_heal";
    let mut seen: HashSet<u64> = HashSet::default();
    let face_ids: Vec<u64> = face_ids
        .iter()
        .copied()
        .filter(|face_id| seen.insert(*face_id))
        .collect();
    if face_ids.is_empty() {
        return Err(format!("{op}: no faces selected"));
    }
    for face_id in &face_ids {
        if find_face(solid, *face_id).is_none() {
            return Err(format!("{op}: no face with id {face_id}"));
        }
    }
    if face_ids.len() == 1 {
        return delete_face_and_heal(solid, face_ids[0]);
    }
    if let Some(patch) = classify_patch(solid, &face_ids) {
        return cap_face_patch(solid, &patch, op);
    }
    let mut healed = solid.clone();
    for face_id in &face_ids {
        healed = delete_face_and_heal(&healed, *face_id)?;
    }
    Ok(healed)
}

// ---------------------------------------------------------------------------
// Tests — the lane gate and each named refusal, on the smallest solids that
// wear the signature. The reported document itself is pinned end-to-end
// through the feature engine in
// `tests/suites/inbox_20260905_cone_pocket_delete_faces.rs`.
// ---------------------------------------------------------------------------
#[cfg(test)]
mod delete_faces_tests {
    use super::*;
    use crate::{
        boolean_operation, chamfer_edge, make_box_brep, make_cylinder_brep,
        solid_mass_properties, BooleanOperation, BooleanOptions,
    };

    fn volume(solid: &BrepSolid) -> f64 {
        solid_mass_properties(solid)
            .expect("mass properties")
            .volume
    }

    fn face_count(solid: &BrepSolid) -> usize {
        solid.shells.iter().map(|shell| shell.faces.len()).sum()
    }

    /// A 20 mm cube with a square blind pocket sunk 5 mm into its top face:
    /// four walls and a floor, whose mouth is a hole loop in `Box_PZ`.
    fn cube_with_square_pocket() -> BrepSolid {
        let cube = make_box_brep(Vec3::default(), 20.0, 20.0, 20.0).unwrap();
        // The cutter starts ABOVE the top face and reaches down to z = 15, so
        // the pocket is blind: its floor is inside the material.
        let cutter = make_box_brep(Vec3::new(5.0, 5.0, 15.0), 8.0, 8.0, 10.0).unwrap();
        let cut = boolean_operation(
            &cube,
            &cutter,
            BooleanOperation::Subtract,
            &BooleanOptions::default(),
        )
        .unwrap();
        assert!(cut.validate().is_empty(), "{:?}", cut.validate());
        cut
    }

    /// The ids of every face whose WHOLE trimmed boundary satisfies `inside` —
    /// the honest way to pick a feature's faces, since a face's carrier plane
    /// reaches well past the region it is trimmed to.
    fn faces_bounded_within(solid: &BrepSolid, inside: impl Fn(Vec3) -> bool) -> Vec<u64> {
        solid
            .shells
            .iter()
            .flat_map(|shell| &shell.faces)
            .filter(|face| {
                face.loops
                    .iter()
                    .flat_map(|loop_record| &loop_record.coedges)
                    .all(|coedge| {
                        solid
                            .edges
                            .iter()
                            .find(|edge| edge.id == coedge.edge_id)
                            .and_then(|edge| edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).ok())
                            .map(&inside)
                            .unwrap_or(false)
                    })
            })
            .map(|face| face.id)
            .collect()
    }

    /// The pocket's five faces: everything trimmed to inside the pocket's
    /// footprint and at or above its floor.
    fn pocket_faces(solid: &BrepSolid) -> Vec<u64> {
        faces_bounded_within(solid, |point| {
            point.x > 4.0 && point.x < 14.0 && point.y > 4.0 && point.y < 14.0 && point.z > 14.0
        })
    }

    /// The reported operation, on the simplest solid that wears its shape:
    /// select every face of a blind pocket and the pocket is gone, the face it
    /// was sunk through closes, and the cube is back.
    #[test]
    fn capping_a_blind_pocket_restores_the_cube() {
        let cut = cube_with_square_pocket();
        let pocket = pocket_faces(&cut);
        assert_eq!(pocket.len(), 5, "four walls and a floor");
        assert_eq!(face_count(&cut), 11, "the cube's six plus the pocket's five");

        let healed = delete_faces_and_heal(&cut, &pocket).expect("the pocket caps");
        assert!(healed.validate().is_empty(), "{:?}", healed.validate());
        assert_eq!(face_count(&healed), 6, "the cube's six faces");
        assert!(
            healed
                .shells
                .iter()
                .flat_map(|shell| &shell.faces)
                .all(|face| face.loops.len() == 1),
            "no face keeps the pocket's mouth loop"
        );
        assert_eq!(healed.genus, 0, "a pocket is not a handle");
        // Nothing on this path is refit, so the volume is the cube's to the
        // mass integrator's own round-off over six planes.
        assert!(
            (volume(&healed) - 8000.0).abs() < 1e-9,
            "the cube is back: {}",
            volume(&healed)
        );
    }

    /// The same pocket with the FLOOR left out of the selection. The floor's
    /// only loop would be the free boundary, and capping it would erase the
    /// face — so the refusal names the floor and says to select it.
    #[test]
    fn refuses_a_pocket_whose_floor_was_not_selected() {
        let cut = cube_with_square_pocket();
        let pocket = pocket_faces(&cut);
        let floor = cut
            .shells
            .iter()
            .flat_map(|shell| &shell.faces)
            .find(|face| {
                pocket.contains(&face.id)
                    && face
                        .surface
                        .evaluate(0.5, 0.5)
                        .map(|point| (point.z - 15.0).abs() < 1e-9)
                        .unwrap_or(false)
            })
            .expect("the pocket floor")
            .id;
        let walls: Vec<u64> = pocket.iter().copied().filter(|id| *id != floor).collect();
        assert_eq!(walls.len(), 4);

        let error = delete_faces_and_heal(&cut, &walls).unwrap_err();
        assert!(
            error.contains("Select it as well"),
            "the refusal must say what to do: {error}"
        );
    }

    /// A selection that is NOT a patch — the two rim chamfers of a cylinder —
    /// still goes through the one-at-a-time chain, and still heals. The gate
    /// must not swallow the case the chain was written for.
    #[test]
    fn a_non_patch_selection_still_chains() {
        let cylinder =
            make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 4.0, 6.0).unwrap();
        let full = volume(&cylinder);
        let rim_at = |solid: &BrepSolid, z: f64| {
            solid
                .edges
                .iter()
                .find(|edge| {
                    !edge.degenerate
                        && edge.start_vertex_id == edge.end_vertex_id
                        && edge
                            .curve
                            .evaluate(0.5 * (edge.t0 + edge.t1))
                            .map(|point| (point.z - z).abs() < 1e-9)
                            .unwrap_or(false)
                })
                .map(|edge| edge.id)
                .expect("closed rim")
        };
        let top = rim_at(&cylinder, 6.0);
        let chamfered = chamfer_edge(&cylinder, top, 1.0, Some("C1")).unwrap();
        let bottom = rim_at(&chamfered, 0.0);
        let chamfered = chamfer_edge(&chamfered, bottom, 1.0, Some("C2")).unwrap();
        assert!(chamfered.validate().is_empty(), "{:?}", chamfered.validate());

        let strips: Vec<u64> = chamfered
            .shells
            .iter()
            .flat_map(|shell| &shell.faces)
            .filter(|face| matches!(face.name.as_deref(), Some("C1") | Some("C2")))
            .map(|face| face.id)
            .collect();
        assert_eq!(strips.len(), 2, "both chamfer strips are named");
        // Not a patch: each strip's rims are shared with loops its neighbours
        // keep the rest of, so the gate hands this to the chain.
        assert!(
            classify_patch(&chamfered, &strips).is_none(),
            "two chamfer strips are not a patch"
        );

        let healed = delete_faces_and_heal(&chamfered, &strips).expect("the chain heals both");
        assert!(healed.validate().is_empty(), "{:?}", healed.validate());
        assert_eq!(face_count(&healed), 3, "wall, top, bottom");
        assert!(
            (volume(&healed) - full).abs() <= 1e-6 * full,
            "the cylinder is back: {} vs {full}",
            volume(&healed)
        );
    }

    /// The patch lane must not be confined to a blind pocket: a through bore's
    /// wall is the patch whose free boundary is TWO hole loops, and the same
    /// count that leaves a pocket's genus alone drops this one's by the handle
    /// it closes. Selected here WITH a second, unrelated pocket so the
    /// selection is genuinely a multi-face one.
    #[test]
    fn capping_a_through_bore_and_a_pocket_together_drops_one_handle() {
        let plate = make_box_brep(Vec3::default(), 20.0, 20.0, 20.0).unwrap();
        let drill = make_cylinder_brep(
            Vec3::new(5.0, 5.0, -5.0),
            Vec3::new(0.0, 0.0, 1.0),
            2.0,
            30.0,
        )
        .unwrap();
        let drilled = boolean_operation(
            &plate,
            &drill,
            BooleanOperation::Subtract,
            &BooleanOptions::default(),
        )
        .unwrap();
        let pocket_cutter = make_box_brep(Vec3::new(12.0, 12.0, 15.0), 5.0, 5.0, 10.0).unwrap();
        let cut = boolean_operation(
            &drilled,
            &pocket_cutter,
            BooleanOperation::Subtract,
            &BooleanOptions::default(),
        )
        .unwrap();
        assert!(cut.validate().is_empty(), "{:?}", cut.validate());
        assert_eq!(cut.genus, 1, "the bore is a handle");

        // Everything that is not one of the plate's six outer planes.
        let outer = |point: Vec3| {
            point.x.abs() < 1e-9
                || (point.x - 20.0).abs() < 1e-9
                || point.y.abs() < 1e-9
                || (point.y - 20.0).abs() < 1e-9
                || point.z.abs() < 1e-9
                || (point.z - 20.0).abs() < 1e-9
        };
        let selection: Vec<u64> = cut
            .shells
            .iter()
            .flat_map(|shell| &shell.faces)
            .filter(|face| !face.surface.evaluate(0.5, 0.5).map(outer).unwrap_or(true))
            .map(|face| face.id)
            .collect();
        assert_eq!(selection.len(), 6, "the bore wall plus the pocket's five");

        let healed = delete_faces_and_heal(&cut, &selection).expect("both cap");
        assert!(healed.validate().is_empty(), "{:?}", healed.validate());
        assert_eq!(face_count(&healed), 6, "the plate's six faces");
        assert_eq!(healed.genus, 0, "closing the bore removes the handle");
        assert!(
            (volume(&healed) - 8000.0).abs() < 1e-6,
            "the plate is back: {}",
            volume(&healed)
        );
    }

    /// A post joining two plates wears a patch's exact signature — its four
    /// walls are bounded by one hole loop in each plate — and capping it would
    /// leave two closed surfaces in one shell record, which `validate()`
    /// accepts. The connectivity check is what refuses it.
    #[test]
    fn refuses_a_patch_whose_removal_would_sever_the_body() {
        let lower = make_box_brep(Vec3::new(0.0, 0.0, 0.0), 20.0, 20.0, 4.0).unwrap();
        let upper = make_box_brep(Vec3::new(0.0, 0.0, 12.0), 20.0, 20.0, 4.0).unwrap();
        // A SQUARE post, so its wall is four faces: the patch lane's question,
        // not the single-face cap's.
        let post = make_box_brep(Vec3::new(8.0, 8.0, 4.0), 4.0, 4.0, 8.0).unwrap();
        let options = BooleanOptions::default();
        let joined = boolean_operation(&lower, &post, BooleanOperation::Union, &options)
            .and_then(|solid| {
                boolean_operation(&solid, &upper, BooleanOperation::Union, &options)
            })
            .expect("the sandwich unions");
        assert!(joined.validate().is_empty(), "{:?}", joined.validate());

        let walls = faces_bounded_within(&joined, |point| {
            point.x >= 7.9 && point.x <= 12.1 && point.y >= 7.9 && point.y <= 12.1
        });
        assert_eq!(walls.len(), 4, "the post's four walls");
        assert!(
            classify_patch(&joined, &walls).is_some(),
            "the post's walls ARE a patch — the refusal has to come from the \
             connectivity check, not from the gate"
        );

        let error = delete_faces_and_heal(&joined, &walls).unwrap_err();
        assert!(
            error.contains("sever the solid"),
            "unexpected refusal: {error}"
        );
    }

    #[test]
    fn refuses_a_face_that_does_not_exist() {
        let cube = make_box_brep(Vec3::default(), 1.0, 1.0, 1.0).unwrap();
        let error = delete_faces_and_heal(&cube, &[999_999, 1]).unwrap_err();
        assert!(
            error.contains("no face with id 999999"),
            "unexpected refusal: {error}"
        );
    }

    #[test]
    fn refuses_an_empty_selection() {
        let cube = make_box_brep(Vec3::default(), 1.0, 1.0, 1.0).unwrap();
        let error = delete_faces_and_heal(&cube, &[]).unwrap_err();
        assert!(error.contains("no faces selected"), "{error}");
    }
}