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//! * a survivor loop that touches the selection is either ENTIRELY consumed by
29//!   it — the ordinary case, a hole loop that goes with the patch — or CUT, and
30//!   then the runs it keeps must splice back into one closed loop with the
31//!   other cut loops on the same carrier.
32//!
33//! The second half of that is the case where the opening has no hole loop to
34//! consume. A bore whose mouth is TANGENT to the boundary of the face it opens
35//! through pinches that face, and the arrangement splits it in two rather than
36//! hand back a loop that touches itself; the mouth then arrives as runs of two
37//! faces' loops. Dropping those runs rejoins the pieces into the face they were
38//! before the mouth pinched them — the same cap, stated for a free boundary
39//! that cuts rather than encircles. A transition STRIP also cuts its
40//! neighbours' loops and it does not splice: the runs stop at a genuine gap
41//! between two different carriers, which is what re-intersection is for, so the
42//! splice walk is the gate rather than a separate test (`splice_fragments`).
43//!
44//! The gate DECLINES one shape before it looks at a loop: a selection that
45//! encloses every survivor it borders, so that no survivor would keep a loop
46//! (`encloses_every_survivor`). A cap needs a face around the opening to drop
47//! the opening out of, and there is none — the selection is not a pocket but,
48//! say, every blend on a cube with all twelve edges rounded, whose six sides
49//! are each bounded by strips alone. The lanes after this one read it; one that
50//! none of them reads is refused by name rather than taken apart one face at a
51//! time.
52//!
53//! ## The bridged rejoin
54//!
55//! The splice above hands one cut run over to the next AT A SHARED VERTEX,
56//! which is what a pinched face's runs do. A band across a MIRRORED union does
57//! not. A fillet groove that runs over the top of a part and down its side is
58//! flanked, on both sides, by faces that are cosurface TWINS — the same plane,
59//! the same fillet cylinder, reflected — and their runs stop at the setback and
60//! tangent lines the band interrupted, a whole band's width apart.
61//!
62//! Those stops are not a gap between two DIFFERENT carriers, which is what
63//! re-intersection is for. They are one edge with its middle taken out: at
64//! such a stop the circuit doubles back on the single edge the two survivors
65//! still share. So the circuit is cut at every CORNER — every handover where
66//! it leaves one carrier for another — and the corners pair off: the circuit
67//! leaves carrier A for carrier B at one and comes back at the other, and their
68//! two spine edges are one straight line, each running away from its own
69//! corner. The BRIDGE is that line's missing middle, and with it every
70//! carrier's runs close into one loop again. Nothing is intersected and nothing
71//! is guessed — every bridge is an edge the solid already had, continued.
72//!
73//! A planar opening can also end where two survivors share only a VERTEX,
74//! rather than interrupting a spine edge. Paired corners on the same two
75//! planar carriers define a straight closing edge, checked against both
76//! rebuilt trims. This extension can shrink one survivor while another grows,
77//! so it keeps winding rather than requiring each individual area to grow.
78//! Existing imported trim discrepancies are bounded at the bridge's source
79//! vertices; they cannot be increased. Curved vertex-only corners remain
80//! deferred, and interrupted spines retain their collinearity checks.
81//!
82//! A rejoined group is the one place this lane refits. Its faces are twins
83//! whose patches were fitted apart (a mirrored plane is the same plane with
84//! reflected control points and a flipped `same_sense`, which bit-identity
85//! reads as two carriers), so the host's carrier grows over everything it now
86//! holds and every pcurve is rebuilt on it — exactly, for each carrier the
87//! growth accepts. A transition strip is untouched by all of this: its four
88//! corners wear four DIFFERENT carrier pairs and no two of them pair off, so it
89//! goes on to the chain that re-intersects it.
90//!
91//! A selection that fails that test is not thereby ONE question. A user who
92//! selects four counterbored holes AND the block's corner fillets has asked for
93//! two different heals at once, and the whole-set gate says "not a patch" only
94//! because the fillets leave part of the top face's outer loop standing. So a
95//! selection the gate declines is split where it is ALREADY split — into
96//! edge-connected COMPONENTS, each of which is put to the same gate on its own.
97//! Patch components cap, strip components chain, and the two coexist in one
98//! operation. Caps go FIRST: a cap is a pure topological drop that refits
99//! nothing, so it cannot disturb a strip the chain has yet to reach, whereas a
100//! chain rebuilds its neighbours' carriers and would move the loops a later cap
101//! resolves its positions against.
102//!
103//! Once the gate says "patch", this lane OWNS the answer, refusals included —
104//! the checks below name what is wrong instead of falling back to a heal that
105//! was written for a different shape:
106//!
107//! * a consumed loop must be a HOLE in its face, not the loop that bounds the
108//!   face's material. Selecting a pocket's three walls but not its floor makes
109//!   the floor's OUTER loop the free boundary; capping that would leave a face
110//!   with no boundary at all, so the refusal names the floor and says to select
111//!   it too. A rejoined face answers the same question by AREA: it has to take
112//!   in the region its free boundary encloses, not give up its own.
113//! * the shell must stay CONNECTED. `validate()` deliberately does not ask
114//!   (see `faces_are_connected`), and a patch whose removal severs the body is
115//!   a thing this operation cannot represent.
116//! * the GENUS must come out of the Euler accounting as a whole number ≥ 0.
117//!   Capping a blind pocket leaves the genus alone; capping a through feature
118//!   drops it by one. Neither is assumed — both fall out of the same count.
119//!
120//! A ONE-FACE selection is put to the same gate, and this is the one place the
121//! refusals above are read as "not a cap" instead: see [`delete_one_face`]. It
122//! is also why `closed_heal.rs`'s own `cap_through_wall` is now reached only
123//! from inside `delete_face_and_heal` — every route through this module answers
124//! a bore's wall with the general cap, which produces the same solid.
125
126use super::*;
127use crate::{KernelRefusal, KernelStage, OrRefuse};
128
129/// A selection that passed the structural gate: the faces to lift off, the
130/// survivor loops their free boundary consumes, and the edges that go with
131/// them.
132struct FacePatch {
133    /// The shell the patch lives in (all its faces share one).
134    shell_index: usize,
135    /// Ids of the selected faces.
136    face_ids: HashSet<u64>,
137    /// `(shell, face position, loop index)` of every survivor loop the patch's
138    /// free boundary consumes whole — the hole loops that go with it.
139    dropped_loops: Vec<(usize, usize, usize)>,
140    /// The survivor faces whose loops the free boundary only CUTS, grouped and
141    /// rebuilt — the pinch-split half of the cap (see the module docs).
142    merges: Vec<FaceMerge>,
143    /// Every edge the patch uses: its interior edges and its free boundary.
144    /// All of them lose both their coedges, so all of them go.
145    edges: HashSet<u64>,
146    /// The edges the rejoin lays ACROSS the openings the patch leaves in
147    /// survivor–survivor edges — the piece of a setback line, or of a
148    /// tangent line, that the deleted band interrupted. Empty for a pure
149    /// cap, where every survivor loop closes on the topology it already has.
150    bridges: Vec<EdgeRecord>,
151    /// A planar feature can rejoin at vertices rather than interrupted edges.
152    /// Extending its survivor planes can shrink one trim while growing another;
153    /// it is not the hole-loop cap's strictly area-increasing operation.
154    planar_vertex_rejoin: bool,
155}
156
157/// Survivor faces on ONE carrier that the free boundary's removal rejoins into
158/// a single face.
159///
160/// The host is the first face in shell order — the convention
161/// `merge_same_surface_faces` already follows: it keeps its id and its name,
162/// and the others' names disappear the way the deleted face's name does.
163struct FaceMerge {
164    /// Face positions in the patch's shell, ascending; the first is the host.
165    faces: Vec<usize>,
166    /// The host's loops after the splice: the widest loop first (`validate`
167    /// reads loop 0 as the face's outer one), then the rest — every rebuilt
168    /// loop, plus every loop of every face in the group the boundary never
169    /// touched.
170    loops: Vec<LoopRecord>,
171    /// The carrier those loops were re-fitted to, when the rejoin had to GROW
172    /// one to span the opening. `None` for the pinch rejoin, where the group's
173    /// faces already share one carrier bit-for-bit and every pcurve is the one
174    /// it was.
175    surface: Option<NurbsSurface>,
176}
177
178/// A maximal RUN of coedges that a CUT survivor loop keeps, in that loop's own
179/// traversal order.
180struct Fragment {
181    /// The survivor face's position in the patch's shell.
182    face: usize,
183    coedges: Vec<CoedgeRecord>,
184    /// Traversal-start vertex of the first coedge.
185    start: u64,
186    /// Traversal-end vertex of the last coedge.
187    end: u64,
188}
189
190/// How many coedges a given edge gets from the selection and from the rest of
191/// the solid.
192#[derive(Clone, Copy, Default)]
193struct EdgeCensus {
194    selected: usize,
195    kept: usize,
196}
197
198fn census(solid: &BrepSolid, face_ids: &HashSet<u64>) -> HashMap<u64, EdgeCensus> {
199    let mut counts: HashMap<u64, EdgeCensus> = HashMap::default();
200    for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
201        let selected = face_ids.contains(&face.id);
202        for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
203            let entry = counts.entry(coedge.edge_id).or_default();
204            if selected {
205                entry.selected += 1;
206            } else {
207                entry.kept += 1;
208            }
209        }
210    }
211    counts
212}
213
214/// Whether every survivor the selection borders is bounded by the selection
215/// ALONE: each face outside it that shares an edge with it has every coedge of
216/// every loop on such an edge.
217///
218/// That is the one shape a cap cannot be. A cap drops the free boundary out of
219/// the face AROUND the patch, and here no survivor keeps a loop to be that
220/// face. A cube with all twelve edges rounded, every blend selected, is the
221/// shape that asks: each of its six sides is bounded by four strips and
222/// nothing else, and un-rounding it is the blend network's question.
223///
224/// False when the selection borders nothing at all — a whole closed shell —
225/// which the patch gate declines on its own terms.
226fn encloses_every_survivor(
227    solid: &BrepSolid,
228    selected: &HashSet<u64>,
229    counts: &HashMap<u64, EdgeCensus>,
230) -> bool {
231    let on_selection = |coedge: &CoedgeRecord| {
232        counts
233            .get(&coedge.edge_id)
234            .is_some_and(|count| count.selected > 0)
235    };
236    // A face's own degenerate pole (an exact octant's, used by that face
237    // alone) bounds nothing: it is neither on the selection nor a boundary
238    // the face keeps against a survivor.
239    let own_pole = |coedge: &CoedgeRecord| {
240        counts.get(&coedge.edge_id).is_some_and(|count| count.selected == 0 && count.kept == 1)
241            && solid.edges.iter().any(|edge| edge.id == coedge.edge_id && edge.degenerate)
242    };
243    let mut bordered = false;
244    for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
245        if selected.contains(&face.id) {
246            continue;
247        }
248        let mut coedges = face.loops.iter().flat_map(|loop_record| &loop_record.coedges);
249        if !coedges.clone().any(on_selection) {
250            continue;
251        }
252        if !coedges.all(|coedge| on_selection(coedge) || own_pole(coedge)) {
253            return false;
254        }
255        bordered = true;
256    }
257    bordered
258}
259
260/// [`encloses_every_survivor`] for a selection, and the faces it encloses.
261/// `None` when the selection does not enclose every survivor it borders.
262pub(super) fn enclosed_survivors(solid: &BrepSolid, face_ids: &[u64]) -> Option<Vec<u64>> {
263    let selected: HashSet<u64> = face_ids.iter().copied().collect();
264    let counts = census(solid, &selected);
265    if !encloses_every_survivor(solid, &selected, &counts) {
266        return None;
267    }
268    Some(
269        solid
270            .shells
271            .iter()
272            .flat_map(|shell| &shell.faces)
273            .filter(|face| {
274                !selected.contains(&face.id)
275                    && face
276                        .loops
277                        .iter()
278                        .flat_map(|loop_record| &loop_record.coedges)
279                        .any(|coedge| counts.get(&coedge.edge_id).is_some_and(|count| count.selected > 0))
280            })
281            .map(|face| face.id)
282            .collect(),
283    )
284}
285
286/// The refusal for a selection that encloses every survivor it borders and
287/// that no lane after the cap has read: nothing is left to cap against, and
288/// one face at a time is not an answer to a set whose every neighbour is going
289/// with it.
290///
291/// Two shapes of it have a reason of their own, and the refusal names it:
292///
293/// * the selection is every face of a closed shell but ONE — five sides of a
294///   box. It is a patch on the sixth whose free boundary is that face's whole
295///   outer loop: removing it leaves one face with no boundary at all, and one
296///   face bounds no solid.
297/// * a CURVED face is among the enclosed — the eight vertex blends of a box
298///   whose twelve strips are selected. Once the strips go, the walls meet in
299///   sharp edges, and a sphere rolled against three planes stands off each of
300///   them (at `r·√2` on a cube): nothing is left to bound it, so it cannot stay.
301fn enclosed_selection_refusal(
302    solid: &BrepSolid,
303    face_ids: &[u64],
304    op: &str,
305) -> Option<KernelRefusal> {
306    let enclosed = enclosed_survivors(solid, face_ids)?;
307    let labels = |ids: &[u64]| -> String {
308        let mut labels: Vec<String> = ids
309            .iter()
310            .take(4)
311            .filter_map(|face_id| find_face(solid, *face_id))
312            .map(|(shell, position)| face_label(&solid.shells[shell].faces[position]))
313            .collect();
314        if ids.len() > labels.len() {
315            labels.push(format!("{} more", ids.len() - labels.len()));
316        }
317        labels.join(", ")
318    };
319    let selected: HashSet<u64> = face_ids.iter().copied().collect();
320    let all_but_one = enclosed.len() == 1
321        && solid.shells.iter().any(|shell| {
322            shell.faces.iter().any(|face| face.id == enclosed[0])
323                && shell
324                    .faces
325                    .iter()
326                    .all(|face| selected.contains(&face.id) || face.id == enclosed[0])
327        });
328    if all_but_one {
329        return Some(KernelRefusal::ill_posed(
330            KernelStage::Classify,
331            "all_but_one",
332            format!(
333                "{op}: the selection is every face of the closed shell but {} — a patch whose free \
334                 boundary is that face's whole outer loop, so deleting it would leave one face with \
335                 no boundary at all, and one face bounds no solid to heal to",
336                labels(&enclosed)
337            ),
338        ));
339    }
340    let tolerance = (solid_model_scale(solid) * 1e-6).max(1e-7);
341    let curved: Vec<u64> = enclosed
342        .iter()
343        .copied()
344        .filter(|face_id| {
345            find_face(solid, *face_id).is_some_and(|(shell, position)| {
346                plane_of_surface(&solid.shells[shell].faces[position].surface, tolerance, op).is_err()
347            })
348        })
349        .collect();
350    if !curved.is_empty() {
351        return Some(KernelRefusal::ill_posed(
352            KernelStage::Classify,
353            "enclosed_curved",
354            format!(
355                "{op}: {} {} bounded by the selection alone — a vertex blend whose strips are all \
356                 selected cannot stay once they go, because the walls re-intersect in sharp edges \
357                 that stand off it; select {} as well",
358                labels(&curved),
359                if curved.len() == 1 { "is a curved face" } else { "are curved faces" },
360                if curved.len() == 1 { "it" } else { "them" }
361            ),
362        ));
363    }
364    Some(KernelRefusal::unsupported(
365        KernelStage::Classify,
366        "enclosed_survivors",
367        format!(
368            "{op}: every face the selection borders ({}) is bounded by the selection alone, so no \
369             face is left around it to cap the opening against, and the selection does not read \
370             as a corner blend, a closed band or a planar blend network — refusing rather than \
371             deleting it one face at a time (deferred)",
372            labels(&enclosed)
373        ),
374    ))
375}
376
377/// Decide whether `face_ids` is a PATCH — a set whose free boundary consumes,
378/// or rejoins, whole loops of the faces around it — and if so gather what its
379/// removal takes with it. `None` routes the caller to the one-face-at-a-time
380/// chain.
381fn classify_patch(solid: &BrepSolid, face_ids: &[u64]) -> Option<FacePatch> {
382    let selected: HashSet<u64> = face_ids.iter().copied().collect();
383    let mut shells = face_ids
384        .iter()
385        .filter_map(|face_id| find_face(solid, *face_id))
386        .map(|(shell_index, _)| shell_index);
387    let shell_index = shells.next()?;
388    if shells.any(|other| other != shell_index) {
389        // A patch is a piece of ONE shell's surface.
390        return None;
391    }
392
393    let counts = census(solid, &selected);
394    let patch_edges: HashSet<u64> = counts
395        .iter()
396        .filter(|(_, count)| count.selected > 0)
397        .map(|(edge_id, _)| *edge_id)
398        .collect();
399    let edges: HashMap<u64, &EdgeRecord> = solid.edges.iter().map(|edge| (edge.id, edge)).collect();
400    // Every edge the patch touches is used exactly twice, or there is no cap
401    // question here: the splice below reads "the other side of this free
402    // boundary edge" as ONE survivor coedge, and a non-manifold edge has no
403    // such reading. `validate()` rejects such a solid outright; this is the
404    // statement that the lane depends on it. DEGENERATE edges are exempt — a
405    // pole (a drill point's apex, a dome's north pole) is one coedge of one
406    // face by construction, and it is not a cell of the complex the Euler count
407    // or the splice reads.
408    if patch_edges.iter().any(|edge_id| {
409        let count = counts[edge_id];
410        !edges.get(edge_id).is_some_and(|edge| edge.degenerate)
411            && count.selected + count.kept != 2
412    }) {
413        return None;
414    }
415    // Nothing to cap AGAINST: every survivor the selection borders would lose
416    // every loop it has. `cap_preconditions` would only refuse it ("the whole
417    // boundary of ..."), and it is not a pocket at all — it is what a blend
418    // network looks like when every face it touches is bounded by blends. So
419    // the lane declines here, and the lanes after it read the set.
420    if encloses_every_survivor(solid, &selected, &counts) {
421        return None;
422    }
423
424    let mut dropped_loops = Vec::new();
425    let mut fragments: Vec<Fragment> = Vec::new();
426    for (shell_position, shell) in solid.shells.iter().enumerate() {
427        for (face_position, face) in shell.faces.iter().enumerate() {
428            if selected.contains(&face.id) {
429                continue;
430            }
431            for (loop_index, loop_record) in face.loops.iter().enumerate() {
432                let consumed: Vec<bool> = loop_record
433                    .coedges
434                    .iter()
435                    .map(|coedge| patch_edges.contains(&coedge.edge_id))
436                    .collect();
437                if !consumed.iter().any(|flag| *flag) {
438                    continue;
439                }
440                if consumed.iter().all(|flag| *flag) {
441                    dropped_loops.push((shell_position, face_position, loop_index));
442                    continue;
443                }
444                // The selection stops part-way along this survivor's loop.
445                // Usually that means a strip between neighbours — the chain's
446                // question. But a face the free boundary is TANGENT to has no
447                // hole loop to consume: the tangency pinches the face and the
448                // arrangement splits it, so the opening's rim arrives as runs
449                // of several faces' loops instead. Those runs splice back into
450                // one loop; a strip's runs do not, and `splice_fragments` is
451                // where the two part company.
452                if shell_position != shell_index {
453                    return None;
454                }
455                fragments.extend(loop_fragments(face_position, loop_record, &consumed, &edges)?);
456            }
457        }
458    }
459    if dropped_loops.is_empty() && fragments.is_empty() {
460        // Nothing outside the selection borders it — the "patch" is a whole
461        // closed shell. Capping has nothing to cap onto.
462        return None;
463    }
464    let (merges, bridges, planar_vertex_rejoin) =
465        splice_fragments(solid, shell_index, &fragments, &patch_edges)?;
466
467    if planar_vertex_rejoin {
468        let tolerance = (solid_model_scale(solid) * 1e-7).max(1e-9);
469        if solid.shells[shell_index].faces.iter().any(|face| {
470            selected.contains(&face.id)
471                && plane_of_surface(&face.surface, tolerance, "delete_faces_and_heal").is_err()
472        }) {
473            // Planar survivors also border curved blend networks. Their
474            // vertex-only corners do not make the selected cylinders a planar
475            // opening: keep that selection in the blend-network lane, which
476            // preserves the standing strips and caps of a later round.
477            return None;
478        }
479    }
480
481    Some(FacePatch {
482        shell_index,
483        face_ids: selected,
484        dropped_loops,
485        merges,
486        edges: patch_edges,
487        bridges,
488        planar_vertex_rejoin,
489    })
490}
491
492/// The traversal-start and -end vertices of a coedge.
493fn coedge_ends(coedge: &CoedgeRecord, edges: &HashMap<u64, &EdgeRecord>) -> Option<(u64, u64)> {
494    let edge = edges.get(&coedge.edge_id)?;
495    Some(if coedge.forward {
496        (edge.start_vertex_id, edge.end_vertex_id)
497    } else {
498        (edge.end_vertex_id, edge.start_vertex_id)
499    })
500}
501
502/// The uv endpoints of a coedge's pcurve, in traversal order (a pcurve runs
503/// with its coedge, not with its edge — see `coedge_sample`). Raw parameters:
504/// a coedge riding a periodic seam carries values just past the domain, and
505/// those are exactly what the next coedge has to continue from.
506fn pcurve_ends(coedge: &CoedgeRecord) -> Option<([f64; 2], [f64; 2])> {
507    let [q0, q1] = coedge.pcurve.domain().ok()?;
508    let start = coedge.pcurve.evaluate(q0).ok()?;
509    let end = coedge.pcurve.evaluate(q1).ok()?;
510    Some(([start.x, start.y], [end.x, end.y]))
511}
512
513/// Cut `loop_record` at every consumed coedge and return the runs that survive,
514/// each in the loop's own traversal order.
515///
516/// The runs are read off the CYCLIC loop order and never re-linked by vertex
517/// adjacency: a loop may legitimately visit one vertex twice, and re-linking
518/// would scramble it.
519fn loop_fragments(
520    face: usize,
521    loop_record: &LoopRecord,
522    consumed: &[bool],
523    edges: &HashMap<u64, &EdgeRecord>,
524) -> Option<Vec<Fragment>> {
525    let count = loop_record.coedges.len();
526    let first_cut = consumed.iter().position(|flag| *flag)?;
527    let mut fragments = Vec::new();
528    let mut run: Vec<CoedgeRecord> = Vec::new();
529    for step in 1..=count {
530        let index = (first_cut + step) % count;
531        if consumed[index] {
532            if !run.is_empty() {
533                fragments.push(fragment(face, std::mem::take(&mut run), edges)?);
534            }
535            continue;
536        }
537        run.push(loop_record.coedges[index].clone());
538    }
539    // The walk ends ON `first_cut`, which flushes the last run.
540    debug_assert!(run.is_empty());
541    Some(fragments)
542}
543
544fn fragment(
545    face: usize,
546    coedges: Vec<CoedgeRecord>,
547    edges: &HashMap<u64, &EdgeRecord>,
548) -> Option<Fragment> {
549    let start = coedge_ends(coedges.first()?, edges)?.0;
550    let end = coedge_ends(coedges.last()?, edges)?.1;
551    Some(Fragment {
552        face,
553        coedges,
554        start,
555        end,
556    })
557}
558
559/// Splice the cut loops' surviving runs back into closed loops, and group the
560/// faces they came from into the faces they become.
561///
562/// A run stops where a consumed edge took over; the loop closes again through
563/// whichever run STARTS at that vertex. Everything here answers "not a cap"
564/// (`None`, so the caller's own lane gets the question) rather than refusing,
565/// because each way the reading can fail is a different shape:
566///
567/// * no run starts at a run's end — the free boundary leaves a genuine gap
568///   between neighbours, which is the transition strip the chain re-intersects;
569/// * two runs start there, or one run would have to serve two loops — the
570///   survivor stays pinched, and this operation has no representation for it;
571/// * the faces a rebuilt loop draws from do not share ONE carrier — a coedge
572///   moves with the pcurve it was fitted to, and that pcurve means nothing on
573///   another surface;
574/// * a rebuilt loop does not close in PARAMETER space. Matching vertex ids are
575///   not enough: a periodic wall's rim closes on one vertex while its uv ends
576///   sit a full period apart, and splicing there would hand back a loop that
577///   only looks closed.
578fn splice_fragments(
579    solid: &BrepSolid,
580    shell_index: usize,
581    fragments: &[Fragment],
582    patch_edges: &HashSet<u64>,
583) -> Option<(Vec<FaceMerge>, Vec<EdgeRecord>, bool)> {
584    if fragments.is_empty() {
585        return Some((Vec::new(), Vec::new(), false));
586    }
587    let mut by_start: HashMap<u64, usize> = HashMap::default();
588    for (index, fragment) in fragments.iter().enumerate() {
589        if by_start.insert(fragment.start, index).is_some() {
590            return None;
591        }
592    }
593
594    // --- Walk the runs into closed circuits --------------------------------
595    let mut used = vec![false; fragments.len()];
596    let mut circuits: Vec<Vec<usize>> = Vec::new();
597    for seed in 0..fragments.len() {
598        if used[seed] {
599            continue;
600        }
601        used[seed] = true;
602        let mut circuit = vec![seed];
603        let mut end = fragments[seed].end;
604        while end != fragments[seed].start {
605            let next = *by_start.get(&end)?;
606            if used[next] {
607                return None;
608            }
609            used[next] = true;
610            circuit.push(next);
611            end = fragments[next].end;
612        }
613        circuits.push(circuit);
614    }
615
616    let scale = solid_model_scale(solid);
617    let carriers = carrier_classes(solid, shell_index, fragments, scale)?;
618    let corners = circuit_corners(fragments, &circuits, &carriers)?;
619    let planar_vertex_rejoin = corners.iter().any(|corner| corner.spine.is_none());
620    if planar_vertex_rejoin && fragments.iter().any(|fragment| {
621        plane_of_surface(
622            &solid.shells[shell_index].faces[fragment.face].surface,
623            (scale * 1e-7).max(1e-9),
624            "delete_faces_and_heal",
625        ).is_err()
626    }) {
627        // The vertex rejoin's area rule and endpoint residual bounds are
628        // planar constructions. Mixed or curved openings keep their old gate.
629        return None;
630    }
631    let (rebuilt, bridges) = if corners.is_empty() {
632        // Every circuit stays on one carrier all the way round: the pinch
633        // rejoin, which closes on the topology it already has.
634        (
635            circuits
636                .iter()
637                .map(|circuit| {
638                    let mut coedges = Vec::new();
639                    let mut faces = Vec::new();
640                    for index in circuit {
641                        coedges.extend(fragments[*index].coedges.iter().cloned());
642                        faces.push(fragments[*index].face);
643                    }
644                    faces.sort_unstable();
645                    faces.dedup();
646                    (coedges, faces)
647                })
648                .collect(),
649            Vec::new(),
650        )
651    } else {
652        rejoin_across_corners(
653            solid,
654            shell_index,
655            fragments,
656            &circuits,
657            &carriers,
658            &corners,
659        )?
660    };
661
662    // --- Faces that share a rebuilt loop become ONE face -------------------
663    let mut parent: HashMap<usize, usize> = HashMap::default();
664    for (_, faces) in &rebuilt {
665        for face in faces {
666            parent.entry(*face).or_insert(*face);
667        }
668    }
669    for (_, faces) in &rebuilt {
670        let mut members = faces.iter();
671        let anchor = root(&mut parent, *members.next()?);
672        for face in members {
673            let other = root(&mut parent, *face);
674            if other != anchor {
675                parent.insert(other, anchor);
676            }
677        }
678    }
679    let mut grouped: HashMap<usize, Vec<usize>> = HashMap::default();
680    for face in parent.keys().copied().collect::<Vec<usize>>() {
681        let group = root(&mut parent, face);
682        grouped.entry(group).or_default().push(face);
683    }
684
685    // The carriers have to be the SAME patch, not merely the same plane: a
686    // pcurve is fitted to one parametrization. Faces split apart by the
687    // arrangement carry the identical surface, so the band is a numeric
688    // formality, not a fit tolerance. A group the BRIDGES rejoined is the one
689    // exception — its faces are cosurface twins whose patches were fitted
690    // apart — and it pays for that by re-fitting every pcurve onto one grown
691    // carrier below.
692    let carrier_tolerance = (scale * 1e-9).max(1e-12);
693    // Past every id the solid has AND every id the bridges just took.
694    let mut next_loop_id = rebuilt
695        .iter()
696        .flat_map(|(coedges, _)| coedges.iter().map(|coedge| coedge.id))
697        .chain(bridges.iter().map(|bridge| bridge.id))
698        .fold(max_topology_id(solid), u64::max)
699        + 1;
700    let mut edges: HashMap<u64, EdgeRecord> = solid
701        .edges
702        .iter()
703        .map(|edge| (edge.id, edge.clone()))
704        .collect();
705    for bridge in &bridges {
706        edges.insert(bridge.id, bridge.clone());
707    }
708    let mut groups: Vec<usize> = grouped.keys().copied().collect();
709    groups.sort_unstable();
710    let mut merges = Vec::with_capacity(groups.len());
711    for group in groups {
712        let mut faces = grouped[&group].clone();
713        faces.sort_unstable();
714        let host = &solid.shells[shell_index].faces[faces[0]];
715        for other in faces.iter().skip(1) {
716            let face = &solid.shells[shell_index].faces[*other];
717            let one_patch = face.same_sense == host.same_sense
718                && crate::face_merge::same_surface(
719                    &face.surface,
720                    &host.surface,
721                    carrier_tolerance,
722                );
723            // Without a bridge the group has to be ONE patch: the pinch rejoin
724            // moves loops between its faces with nothing refit, and a pcurve
725            // means nothing on another parametrization. A BRIDGED group is the
726            // exception, and it pays for it below.
727            let rejoinable =
728                one_patch || (!bridges.is_empty() && carriers[other] == carriers[&faces[0]]);
729            if !rejoinable {
730                return None;
731            }
732        }
733        let mut loops: Vec<LoopRecord> = Vec::new();
734        let mut bridged = false;
735        for (coedges, members) in &rebuilt {
736            if root(&mut parent, members[0]) != group {
737                continue;
738            }
739            bridged |= coedges
740                .iter()
741                .any(|coedge| bridges.iter().any(|bridge| bridge.id == coedge.edge_id));
742            loops.push(LoopRecord {
743                id: next_loop_id,
744                coedges: coedges.clone(),
745            });
746            next_loop_id += 1;
747        }
748        // Every loop the free boundary never touched comes along to the host.
749        for face_position in &faces {
750            let face = &solid.shells[shell_index].faces[*face_position];
751            for loop_record in &face.loops {
752                if loop_record
753                    .coedges
754                    .iter()
755                    .any(|coedge| patch_edges.contains(&coedge.edge_id))
756                {
757                    continue;
758                }
759                loops.push(loop_record.clone());
760            }
761        }
762        // A rejoined group rides ONE carrier grown over everything it now
763        // holds, with every pcurve re-fitted to it; a pinch rejoin keeps the
764        // host's patch and every pcurve bit-identical.
765        let surface = if bridged {
766            let mut candidate = FaceRecord {
767                id: host.id,
768                surface: host.surface.clone(),
769                same_sense: host.same_sense,
770                loops: loops.clone(),
771                name: host.name.clone(),
772            };
773            regrow_and_refit_carrier(&mut candidate, &edges, scale, "delete_faces_and_heal").ok()?;
774            // The pcurve fit interpolates whatever it is handed and reports no
775            // error for a curve that MISSES the carrier, so this is where a
776            // bridge laid on the wrong line is caught — before it becomes a
777            // face whose new boundary is not on its own surface.
778            let source_carriers: HashMap<u64, &NurbsSurface> = faces
779                .iter()
780                .flat_map(|position| {
781                    let face = &solid.shells[shell_index].faces[*position];
782                    face.loops.iter().flat_map(move |loop_record| {
783                        loop_record
784                            .coedges
785                            .iter()
786                            .map(move |coedge| (coedge.id, &face.surface))
787                    })
788                })
789                .collect();
790            if !loops_ride_carrier(
791                &candidate.surface,
792                &candidate.loops,
793                &edges,
794                &source_carriers,
795                &if planar_vertex_rejoin {
796                    planar_bridge_residuals(solid, shell_index, &faces, &bridges, scale)
797                } else {
798                    HashMap::default()
799                },
800                scale,
801            ) {
802                return None;
803            }
804            loops = candidate.loops;
805            Some(candidate.surface)
806        } else {
807            None
808        };
809        let carrier = surface.as_ref().unwrap_or(&host.surface);
810        for loop_record in &loops {
811            if !closes_in_parameter_space(carrier, &loop_record.coedges) {
812                return None;
813            }
814        }
815        // `validate` reads loop 0 as the face's OUTER loop, so the widest leads.
816        let outer = widest_loop_on(carrier, host.same_sense, host.id, &loops)?;
817        loops.swap(0, outer);
818        merges.push(FaceMerge {
819            faces,
820            loops,
821            surface,
822        });
823    }
824    Some((merges, bridges, planar_vertex_rejoin))
825}
826
827
828// ---------------------------------------------------------------------------
829// The BRIDGED rejoin — a free boundary that walks off one carrier and back on
830// to another (see the module docs).
831// ---------------------------------------------------------------------------
832
833/// Where the free boundary's circuit walks off one carrier and on to the next:
834/// the vertex it turns at, and the survivor–survivor edge both sides ride into
835/// it.
836///
837/// That edge is the whole point. A corner is not an arbitrary meeting of two
838/// carriers, it is the far end of an edge the deleted band INTERRUPTED — the
839/// arriving run's last coedge and the leaving run's first are the same edge
840/// walked once each way, because at a corner the circuit doubles back on the
841/// one edge the two survivors still share. The bridge is that edge's own line,
842/// continued across the band to the corner where it resumes.
843struct Corner {
844    /// Fragment arriving at the corner, and the one leaving it.
845    incoming: usize,
846    outgoing: usize,
847    vertex: u64,
848    /// The edge both flanking coedges ride, when the opening interrupted one.
849    /// None at a planar pocket corner where the survivors only share a vertex.
850    spine: Option<u64>,
851}
852
853/// Whether every new coedge lies on `surface`, and each retained coedge is
854/// at least as close to it as to its original carrier.
855///
856/// The rejoin's whole claim is that one grown carrier holds everything the
857/// group now carries: its twin's loops, and the bridges between them. Nothing
858/// upstream tests that claim — `faces_are_cosurface` matches carriers, not the
859/// EXTENT a patch was grown to, and the pcurve fit interpolates a curve that
860/// misses the surface as readily as one that lies on it. Imported trims can
861/// already stand off their carrier. Rejoining preserves those edges verbatim;
862/// require their pointwise residual not to increase rather than rejecting a
863/// valid bridge because of an unrelated, inherited trim discrepancy. New
864/// bridge coedges must meet the absolute tolerance, except for straight planar
865/// bridges with an explicit inherited endpoint-residual bound.
866fn loops_ride_carrier(
867    surface: &NurbsSurface,
868    loops: &[LoopRecord],
869    edges: &HashMap<u64, EdgeRecord>,
870    source_carriers: &HashMap<u64, &NurbsSurface>,
871    bridge_residuals: &HashMap<u64, f64>,
872    scale: f64,
873) -> bool {
874    let tolerance = (scale * 1e-7).max(1e-9);
875    for coedge in loops.iter().flat_map(|loop_record| &loop_record.coedges) {
876        let Some(edge) = edges.get(&coedge.edge_id) else {
877            return false;
878        };
879        if edge.degenerate {
880            continue;
881        }
882        for step in 0..=8 {
883            let t = edge.t0 + (edge.t1 - edge.t0) * step as f64 / 8.0;
884            let Ok(point) = edge.curve.evaluate(t) else {
885                return false;
886            };
887            let Ok(projection) = crate::project_point_to_surface(surface, point) else {
888                return false;
889            };
890            if projection.distance <= tolerance {
891                continue;
892            }
893            let Some(source) = source_carriers.get(&coedge.id) else {
894                if bridge_residuals
895                    .get(&edge.id)
896                    .is_some_and(|limit| projection.distance <= limit + tolerance)
897                {
898                    continue;
899                }
900                return false;
901            };
902            match crate::project_point_to_surface(source, point) {
903                Ok(previous) if projection.distance <= previous.distance + tolerance => {}
904                _ => return false,
905            }
906        }
907    }
908    true
909}
910
911/// A straight bridge on a planar rejoin may inherit a trim discrepancy at
912/// its existing endpoints. Bound it by those endpoint residuals on the original
913/// carrier; linear interpolation on a plane cannot exceed that bound. Both
914/// endpoints must belong to this group's original trim, so this is not a
915/// tolerance for an arbitrary new edge or a curved carrier.
916fn planar_bridge_residuals(
917    solid: &BrepSolid,
918    shell_index: usize,
919    faces: &[usize],
920    bridges: &[EdgeRecord],
921    scale: f64,
922) -> HashMap<u64, f64> {
923    let mut residuals = HashMap::default();
924    let host = &solid.shells[shell_index].faces[faces[0]];
925    let Ok(plane) = plane_of_surface(
926        &host.surface,
927        (scale * 1e-7).max(1e-9),
928        "delete_faces_and_heal",
929    ) else {
930        return residuals;
931    };
932    let vertices: HashSet<u64> = faces
933        .iter()
934        .flat_map(|position| {
935            solid.shells[shell_index].faces[*position]
936                .loops
937                .iter()
938                .flat_map(|l| &l.coedges)
939        })
940        .filter_map(|c| solid.edges.iter().find(|e| e.id == c.edge_id))
941        .flat_map(|e| [e.start_vertex_id, e.end_vertex_id])
942        .collect();
943    for bridge in bridges {
944        if !vertices.contains(&bridge.start_vertex_id) || !vertices.contains(&bridge.end_vertex_id)
945        {
946            continue;
947        }
948        let endpoints = [bridge.start_vertex_id, bridge.end_vertex_id].map(|id| {
949            edge_point(solid, id)
950                .ok()
951                .map(|point| point.sub(plane.origin).dot(plane.normal).abs())
952        });
953        if let [Some(a), Some(b)] = endpoints {
954            residuals.insert(bridge.id, a.max(b));
955        }
956    }
957    residuals
958}
959
960/// Which carrier each fragment's face rides, as a class index — cosurface
961/// GEOMETRICALLY (`face_merge::faces_are_cosurface`), because the far side of
962/// a band is routinely a mirrored twin: the same surface with reflected
963/// control points and a flipped `same_sense`, which bit-identity reads as two.
964fn carrier_classes(
965    solid: &BrepSolid,
966    shell_index: usize,
967    fragments: &[Fragment],
968    scale: f64,
969) -> Option<HashMap<usize, usize>> {
970    let tolerance = (scale * 1e-9).max(1e-12);
971    let mut classes: HashMap<usize, usize> = HashMap::default();
972    let mut representatives: Vec<usize> = Vec::new();
973    for fragment in fragments {
974        if classes.contains_key(&fragment.face) {
975            continue;
976        }
977        let face = &solid.shells[shell_index].faces[fragment.face];
978        let mut found = None;
979        for (index, representative) in representatives.iter().enumerate() {
980            let other = &solid.shells[shell_index].faces[*representative];
981            // A carrier this test cannot read is not thereby the same one:
982            // an Err means "cannot say", which for a CLASS is a `false`. It
983            // must not abort the classification, or a pinch rejoin that the
984            // bit-identical half already answers would be lost to it.
985            if crate::face_merge::faces_are_cosurface(other, face, tolerance).unwrap_or(false) {
986                found = Some(index);
987                break;
988            }
989        }
990        let class = found.unwrap_or_else(|| {
991            representatives.push(fragment.face);
992            representatives.len() - 1
993        });
994        classes.insert(fragment.face, class);
995    }
996    Some(classes)
997}
998
999/// Every handover in `circuits` where the circuit changes carrier.
1000///
1001/// A shared edge is the spine interrupted by a band. Without one, the runs
1002/// meet at a pocket vertex; the bridge builder accepts that case only for
1003/// paired planar carriers, and the rebuilt trims must still ride those planes.
1004fn circuit_corners(
1005    fragments: &[Fragment],
1006    circuits: &[Vec<usize>],
1007    carriers: &HashMap<usize, usize>,
1008) -> Option<Vec<Corner>> {
1009    let mut corners = Vec::new();
1010    for circuit in circuits {
1011        for (position, index) in circuit.iter().copied().enumerate() {
1012            let next = circuit[(position + 1) % circuit.len()];
1013            if next == index || carriers[&fragments[index].face] == carriers[&fragments[next].face]
1014            {
1015                continue;
1016            }
1017            let arriving = fragments[index].coedges.last()?.edge_id;
1018            let leaving = fragments[next].coedges.first()?.edge_id;
1019            corners.push(Corner {
1020                incoming: index,
1021                outgoing: next,
1022                vertex: fragments[index].end,
1023                spine: (arriving == leaving).then_some(arriving),
1024            });
1025        }
1026    }
1027    Some(corners)
1028}
1029
1030/// Cut the circuits at their corners, pair the corners off, lay a bridge along
1031/// each pair's shared line, and walk the pieces back into one closed loop per
1032/// carrier.
1033///
1034/// Two corners pair when the circuit leaves carrier A for carrier B at one and
1035/// comes back at the other, AND their spine edges are one straight line that
1036/// the bridge continues — each spine running AWAY from its own corner, so the
1037/// bridge covers exactly the piece the deleted band took and no more. Anything
1038/// else declines: a transition strip's four corners have four different carrier
1039/// pairs and no partner between them, which is how a strip keeps going to the
1040/// chain that re-intersects it.
1041fn rejoin_across_corners(
1042    solid: &BrepSolid,
1043    shell_index: usize,
1044    fragments: &[Fragment],
1045    circuits: &[Vec<usize>],
1046    carriers: &HashMap<usize, usize>,
1047    corners: &[Corner],
1048) -> Option<(Vec<(Vec<CoedgeRecord>, Vec<usize>)>, Vec<EdgeRecord>)> {
1049    // --- Pair the corners --------------------------------------------------
1050    let signature = |corner: &Corner| {
1051        (
1052            carriers[&fragments[corner.incoming].face],
1053            carriers[&fragments[corner.outgoing].face],
1054        )
1055    };
1056    let mut partner = vec![usize::MAX; corners.len()];
1057    for (index, corner) in corners.iter().enumerate() {
1058        let (arriving, leaving) = signature(corner);
1059        let mut matches = corners.iter().enumerate().filter(|(other, candidate)| {
1060            *other != index && signature(candidate) == (leaving, arriving)
1061        });
1062        let (found, _) = matches.next()?;
1063        if matches.next().is_some() {
1064            return None;
1065        }
1066        partner[index] = found;
1067    }
1068    // The pairing has to be an involution, or the loops below cannot close.
1069    if (0..corners.len()).any(|index| partner[partner[index]] != index) {
1070        return None;
1071    }
1072
1073    // --- Lay one bridge per pair -------------------------------------------
1074    let scale = solid_model_scale(solid);
1075    let tolerance = (scale * 1e-7).max(1e-9);
1076    let mut next_id = max_topology_id(solid) + 1;
1077    let mut bridges: Vec<EdgeRecord> = Vec::new();
1078    // corner -> (bridge edge, whether leaving this corner runs with the edge)
1079    let mut bridge_at: HashMap<usize, (u64, bool)> = HashMap::default();
1080    for index in 0..corners.len() {
1081        if bridge_at.contains_key(&index) {
1082            continue;
1083        }
1084        let other = partner[index];
1085        let bridge = if corners[index].spine.is_some() && corners[other].spine.is_some() {
1086            bridge_edge(solid, &corners[index], &corners[other], next_id, tolerance)?
1087        } else {
1088            // An open pocket can terminate at a vertex shared by two survivor
1089            // planes without sharing a survivor edge there. Its paired corners
1090            // still define the straight boundary of both rejoined planes.
1091            for corner in [&corners[index], &corners[other]] {
1092                for fragment in [corner.incoming, corner.outgoing] {
1093                    let face = &solid.shells[shell_index].faces[fragments[fragment].face];
1094                    plane_of_surface(&face.surface, tolerance, "delete_faces_and_heal").ok()?;
1095                }
1096            }
1097            let start = edge_point(solid, corners[index].vertex).ok()?;
1098            let end = edge_point(solid, corners[other].vertex).ok()?;
1099            if end.sub(start).length() <= tolerance {
1100                return None;
1101            }
1102            let curve = make_line(start, end).ok()?;
1103            let [t0, t1] = curve.domain().ok()?;
1104            EdgeRecord {
1105                id: next_id,
1106                curve,
1107                t0,
1108                t1,
1109                start_vertex_id: corners[index].vertex,
1110                end_vertex_id: corners[other].vertex,
1111                degenerate: false,
1112                name: None,
1113            }
1114        };
1115        next_id += 1;
1116        bridge_at.insert(index, (bridge.id, true));
1117        bridge_at.insert(other, (bridge.id, false));
1118        bridges.push(bridge);
1119    }
1120
1121    // --- Cut the circuits into arcs ----------------------------------------
1122    // An arc is a maximal run of fragments on one carrier, from the corner
1123    // that starts it to the corner that ends it.
1124    let ends_at: HashMap<usize, usize> = corners
1125        .iter()
1126        .enumerate()
1127        .map(|(index, corner)| (corner.incoming, index))
1128        .collect();
1129    let mut arcs: Vec<(usize, Vec<usize>, usize)> = Vec::new();
1130    for circuit in circuits {
1131        let length = circuit.len();
1132        let Some(first) = (0..length).find(|position| ends_at.contains_key(&circuit[*position]))
1133        else {
1134            // A circuit with no corner never left its carrier: it is a whole
1135            // rejoined loop already, and mixing it with the arcs would lose it.
1136            return None;
1137        };
1138        let mut open: Vec<usize> = Vec::new();
1139        let mut start = ends_at[&circuit[first]];
1140        for step in 1..=length {
1141            let index = circuit[(first + step) % length];
1142            open.push(index);
1143            if let Some(corner) = ends_at.get(&index) {
1144                arcs.push((start, std::mem::take(&mut open), *corner));
1145                start = *corner;
1146            }
1147        }
1148        if !open.is_empty() {
1149            return None;
1150        }
1151    }
1152    let starts_at: HashMap<usize, usize> = arcs
1153        .iter()
1154        .enumerate()
1155        .map(|(index, arc)| (arc.0, index))
1156        .collect();
1157    if starts_at.len() != arcs.len() {
1158        return None;
1159    }
1160
1161    // --- Walk arc → bridge → arc into one loop per opening -----------------
1162    let mut used = vec![false; arcs.len()];
1163    let mut rebuilt: Vec<(Vec<CoedgeRecord>, Vec<usize>)> = Vec::new();
1164    for seed in 0..arcs.len() {
1165        if used[seed] {
1166            continue;
1167        }
1168        used[seed] = true;
1169        let mut coedges: Vec<CoedgeRecord> = Vec::new();
1170        let mut faces: Vec<usize> = Vec::new();
1171        let mut current = seed;
1172        loop {
1173            for fragment in &arcs[current].1 {
1174                coedges.extend(fragments[*fragment].coedges.iter().cloned());
1175                faces.push(fragments[*fragment].face);
1176            }
1177            let (edge_id, forward) = bridge_at[&arcs[current].2];
1178            coedges.push(CoedgeRecord {
1179                id: next_id,
1180                edge_id,
1181                forward,
1182                // A placeholder: the group's whole loop is re-fitted onto one
1183                // grown carrier before it is ever read.
1184                pcurve: make_line(Vec3::default(), Vec3::new(1.0, 0.0, 0.0)).ok()?,
1185            });
1186            next_id += 1;
1187            let next = *starts_at.get(&partner[arcs[current].2])?;
1188            if next == seed {
1189                break;
1190            }
1191            if used[next] {
1192                return None;
1193            }
1194            used[next] = true;
1195            current = next;
1196        }
1197        faces.sort_unstable();
1198        faces.dedup();
1199        rebuilt.push((coedges, faces));
1200    }
1201    Some((rebuilt, bridges))
1202}
1203
1204/// The bridge between two paired corners: the straight piece of their shared
1205/// line that the deleted band took out of it.
1206///
1207/// Refuses (`None`) unless both spines are STRAIGHT, both lie on the line
1208/// through the two corner points, and each runs away from its own corner —
1209/// which together say the two spines are one interrupted edge and the bridge
1210/// is exactly its missing middle. A curved spine is deferred rather than
1211/// guessed at: continuing an arc needs its centre and sense, not two points.
1212fn bridge_edge(
1213    solid: &BrepSolid,
1214    from: &Corner,
1215    to: &Corner,
1216    id: u64,
1217    tolerance: f64,
1218) -> Option<EdgeRecord> {
1219    let start = edge_point(solid, from.vertex).ok()?;
1220    let end = edge_point(solid, to.vertex).ok()?;
1221    let span = end.sub(start);
1222    let length = span.length();
1223    if length <= tolerance {
1224        return None;
1225    }
1226    let direction = span.scale(1.0 / length);
1227    for (corner, anchor, sense) in [(from, start, -1.0f64), (to, end, 1.0f64)] {
1228        let spine = solid
1229            .edges
1230            .iter()
1231            .find(|edge| Some(edge.id) == corner.spine)?;
1232        let mut reach = 0.0f64;
1233        for step in 0..=8 {
1234            let t = spine.t0 + (spine.t1 - spine.t0) * step as f64 / 8.0;
1235            let point = spine.curve.evaluate(t).ok()?;
1236            let offset = point.sub(anchor);
1237            let along = offset.dot(direction);
1238            if offset.sub(direction.scale(along)).length() > tolerance {
1239                return None;
1240            }
1241            if along * sense < -tolerance {
1242                // The spine reaches INTO the gap the bridge is to cover: these
1243                // two are not one interrupted edge.
1244                return None;
1245            }
1246            if along * sense > reach * sense {
1247                reach = along;
1248            }
1249        }
1250        if reach * sense <= tolerance {
1251            return None;
1252        }
1253    }
1254    let curve = make_line(start, end).ok()?;
1255    let [t0, t1] = curve.domain().ok()?;
1256    Some(EdgeRecord {
1257        id,
1258        curve,
1259        t0,
1260        t1,
1261        start_vertex_id: from.vertex,
1262        end_vertex_id: to.vertex,
1263        degenerate: false,
1264        name: None,
1265    })
1266}
1267
1268/// Union-find root, over a set small enough that path compression would cost
1269/// more than it saves.
1270fn root(parent: &mut HashMap<usize, usize>, mut node: usize) -> usize {
1271    while let Some(next) = parent.get(&node).copied() {
1272        if next == node {
1273            break;
1274        }
1275        node = next;
1276    }
1277    node
1278}
1279
1280/// Whether a rebuilt loop's coedges hand over to one another in the carrier's
1281/// PARAMETER space, all the way round.
1282fn closes_in_parameter_space(surface: &NurbsSurface, coedges: &[CoedgeRecord]) -> bool {
1283    let (u_span, v_span) = match parameter_spans(surface) {
1284        Some(spans) => spans,
1285        None => return false,
1286    };
1287    // Loose enough that an honest fit's round-off passes, far tighter than the
1288    // half-period a seam jump would show.
1289    let tolerance = (u_span + v_span) * 1e-6;
1290    let mut ends: Vec<([f64; 2], [f64; 2])> = Vec::with_capacity(coedges.len());
1291    for coedge in coedges {
1292        match pcurve_ends(coedge) {
1293            Some(pair) => ends.push(pair),
1294            None => return false,
1295        }
1296    }
1297    for index in 0..ends.len() {
1298        let leaving = ends[index].1;
1299        let arriving = ends[(index + 1) % ends.len()].0;
1300        let gap = (leaving[0] - arriving[0]).hypot(leaving[1] - arriving[1]);
1301        if gap > tolerance {
1302            return false;
1303        }
1304    }
1305    true
1306}
1307
1308/// The `[u, v]` domain spans of a carrier.
1309fn parameter_spans(surface: &NurbsSurface) -> Option<(f64, f64)> {
1310    let u = crate::KnotVector::new(surface.knots_u.clone(), surface.degree_u)
1311        .ok()?
1312        .domain();
1313    let v = crate::KnotVector::new(surface.knots_v.clone(), surface.degree_v)
1314        .ok()?
1315        .domain();
1316    Some((u[1] - u[0], v[1] - v[0]))
1317}
1318
1319/// Signed parameter-space area of ONE loop, read on `host`'s carrier.
1320fn single_loop_area(host: &FaceRecord, loop_record: &LoopRecord) -> Result<f64, KernelRefusal> {
1321    parameter_space_area(&FaceRecord {
1322        id: host.id,
1323        surface: host.surface.clone(),
1324        same_sense: host.same_sense,
1325        loops: vec![loop_record.clone()],
1326        name: None,
1327    })
1328    .or_refuse(KernelStage::Classify, "parameter_space_area")
1329}
1330
1331/// The index of the loop that bounds the material — the widest in parameter
1332/// space, read on whichever carrier the loops now ride (a bridged rejoin fits
1333/// them to a GROWN one the host does not have yet).
1334fn widest_loop_on(
1335    surface: &NurbsSurface,
1336    same_sense: bool,
1337    id: u64,
1338    loops: &[LoopRecord],
1339) -> Option<usize> {
1340    let host = FaceRecord {
1341        id,
1342        surface: surface.clone(),
1343        same_sense,
1344        loops: Vec::new(),
1345        name: None,
1346    };
1347    let mut widest = (0usize, f64::NEG_INFINITY);
1348    for (index, loop_record) in loops.iter().enumerate() {
1349        let area = single_loop_area(&host, loop_record).ok()?.abs();
1350        if area > widest.1 {
1351            widest = (index, area);
1352        }
1353    }
1354    (widest.1 > f64::NEG_INFINITY).then_some(widest.0)
1355}
1356
1357/// `V - E + F - H` on the reduced complex `validate()`'s Euler check uses:
1358/// degenerate (pole) edges and the vertices only they reference are not
1359/// independent cells, and each loop past a face's first is a hole.
1360pub(super) fn euler_characteristic(solid: &BrepSolid) -> i64 {
1361    let referenced: HashSet<u64> = solid
1362        .edges
1363        .iter()
1364        .filter(|edge| !edge.degenerate)
1365        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
1366        .collect();
1367    let vertices = solid
1368        .vertices
1369        .iter()
1370        .filter(|vertex| referenced.contains(&vertex.id))
1371        .count() as i64;
1372    let edges = solid.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
1373    let faces = solid
1374        .shells
1375        .iter()
1376        .map(|shell| shell.faces.len())
1377        .sum::<usize>() as i64;
1378    let holes = solid.bounding_hole_count();
1379    vertices - edges + faces - holes
1380}
1381
1382/// How a face is named in a refusal: its persistent name when it has one, its
1383/// id otherwise.
1384fn face_label(face: &FaceRecord) -> String {
1385    match &face.name {
1386        Some(name) => format!("`{name}`"),
1387        None => format!("face {}", face.id),
1388    }
1389}
1390
1391/// Group the wholly-consumed loops by the face they belong to, because "would
1392/// this face keep a loop" is a question about the face, not about one loop.
1393fn dropped_per_face(patch: &FacePatch) -> HashMap<(usize, usize), Vec<usize>> {
1394    let mut per_face: HashMap<(usize, usize), Vec<usize>> = HashMap::default();
1395    for (shell_position, face_position, loop_index) in &patch.dropped_loops {
1396        per_face
1397            .entry((*shell_position, *face_position))
1398            .or_default()
1399            .push(*loop_index);
1400    }
1401    per_face
1402}
1403
1404/// The conditions that make a classified patch a CAP rather than a gap this
1405/// operation cannot close.
1406///
1407/// Separate from [`cap_face_patch`] because the two callers need the same
1408/// question answered in different registers. A SET selection has already said
1409/// "this is one patch", so a failure here is a refusal that names what is
1410/// wrong. A ONE-FACE selection has said nothing of the kind, and the same
1411/// failure only means "not a cap": a blind bore's wall and a dome boss's base
1412/// fillet wear the same shape here, and both belong to
1413/// [`delete_face_and_heal`](super::delete_face_and_heal) — one for the refusal
1414/// that names the floor, the other because plane x sphere re-intersects and
1415/// heals.
1416fn cap_preconditions(solid: &BrepSolid, patch: &FacePatch, op: &str) -> Result<(), KernelRefusal> {
1417    // --- Every consumed loop must be a HOLE in its face --------------------
1418    for ((shell_position, face_position), loop_indices) in &dropped_per_face(patch) {
1419        let face = &solid.shells[*shell_position].faces[*face_position];
1420        let rebuilt = *shell_position == patch.shell_index
1421            && patch
1422                .merges
1423                .iter()
1424                .any(|merge| merge.faces.contains(face_position));
1425        if !rebuilt && loop_indices.len() >= face.loops.len() {
1426            return Err(KernelRefusal::ill_posed(
1427                KernelStage::Classify,
1428                "whole_boundary",
1429                format!(
1430                    "{op}: the selection is the whole boundary of {} — it is part of the \
1431                     pocket, not the face the pocket was sunk into. Select it as well \
1432                     (a patch is capped by the face AROUND it, which has to keep a loop).",
1433                    face_label(face)
1434                ),
1435            ));
1436        }
1437        if face.loops.len() < 2 {
1438            // A face whose only loop is consumed is caught above; a rebuilt
1439            // face's remaining loops are the splice's business, not a winding
1440            // question about the loops it no longer has.
1441            continue;
1442        }
1443        let mut areas = Vec::with_capacity(face.loops.len());
1444        for index in 0..face.loops.len() {
1445            areas.push(loop_signed_area(face, index)?);
1446        }
1447        let host = (0..areas.len())
1448            .max_by(|a, b| areas[*a].abs().total_cmp(&areas[*b].abs()))
1449            .expect("the face has at least two loops here");
1450        for loop_index in loop_indices {
1451            if *loop_index == host || areas[host] * areas[*loop_index] >= 0.0 {
1452                return Err(KernelRefusal::unsupported(
1453                    KernelStage::Classify,
1454                    "outer_loop_consumed",
1455                    format!(
1456                        "{op}: the loop the selection would leave open in {} bounds that \
1457                         face's material rather than a hole in it — capping it would erase \
1458                         the face (deferred)",
1459                        face_label(face)
1460                    ),
1461                ));
1462            }
1463        }
1464    }
1465
1466    // --- A rejoined face must have GROWN by the region it takes in ---------
1467    // The spliced analogue of the winding test above: the free boundary
1468    // enclosed a region of the shared carrier, and capping fills that region
1469    // in. Parameter-space areas over one carrier are additive, so a loop that
1470    // wound the other way shows up here as a merged face that would have
1471    // swallowed its own material instead of the opening.
1472    for merge in &patch.merges {
1473        let host = &solid.shells[patch.shell_index].faces[merge.faces[0]];
1474        if let Some(surface) = &merge.surface {
1475            // The group was rejoined across bridges, so its loops ride a GROWN
1476            // carrier and its members rode twins of it: parameter-space areas
1477            // are no longer one currency and the same question is asked in
1478            // mm². The rejoined face has to take in the openings the bridges
1479            // closed, so it is strictly larger than the faces it replaces, and
1480            // it has to keep the host's winding (a loop that came back the
1481            // other way would enclose the complement).
1482            let rejoined = FaceRecord {
1483                id: host.id,
1484                surface: surface.clone(),
1485                same_sense: host.same_sense,
1486                loops: merge.loops.clone(),
1487                name: None,
1488            };
1489            let mut before = 0.0;
1490            for face_position in &merge.faces {
1491                before += crate::face_area(&solid.shells[patch.shell_index].faces[*face_position])
1492                    .or_refuse(KernelStage::Classify, "face_area")?;
1493            }
1494            let after = crate::face_area(&rejoined).or_refuse(KernelStage::Classify, "face_area")?;
1495            let winding = parameter_space_area(&rejoined)
1496                .or_refuse(KernelStage::Classify, "parameter_space_area")?;
1497            let host_winding = loop_signed_area(host, 0)?;
1498            // A vertex rejoin extends a stepped planar feature's survivors,
1499            // and one can shrink as another grows. Its winding must remain
1500            // intact; connectivity, Euler accounting and soundness still
1501            // check the completed body. Ordinary caps must strictly grow.
1502            if after <= 0.0
1503                || (!patch.planar_vertex_rejoin && after <= before * (1.0 + 1e-9))
1504                || winding * host_winding <= 0.0
1505            {
1506                return Err(KernelRefusal::unsupported(
1507                    KernelStage::Classify,
1508                    "rejoin_shrinks_grown",
1509                    format!(
1510                        "{op}: rejoining {} across the openings its free boundary leaves \
1511                         would not take in the region they enclose (area {before} -> {after}, \
1512                         winding {host_winding} -> {winding}) — that boundary bounds material \
1513                         rather than an opening (deferred)",
1514                        face_label(host)
1515                    ),
1516                ));
1517            }
1518            continue;
1519        }
1520        let mut before = 0.0;
1521        for face_position in &merge.faces {
1522            let face = &solid.shells[patch.shell_index].faces[*face_position];
1523            for loop_record in &face.loops {
1524                before += single_loop_area(host, loop_record)?;
1525            }
1526        }
1527        let mut after = 0.0;
1528        for loop_record in &merge.loops {
1529            after += single_loop_area(host, loop_record)?;
1530        }
1531        if (after - before) * before.signum() <= before.abs() * 1e-9 {
1532            return Err(KernelRefusal::unsupported(
1533                KernelStage::Classify,
1534                "rejoin_shrinks",
1535                format!(
1536                    "{op}: rejoining {} would not take in the region its free boundary \
1537                     encloses (parameter-space area {before} -> {after}) — that boundary \
1538                     bounds material rather than an opening (deferred)",
1539                    face_label(host)
1540                ),
1541            ));
1542        }
1543    }
1544    Ok(())
1545}
1546
1547/// Lift `patch` off the solid and close the opening it was sunk through: drop
1548/// the hole loops it consumed whole, and rejoin the faces whose loops it only
1549/// cut.
1550fn cap_face_patch(solid: &BrepSolid, patch: &FacePatch, op: &str) -> Result<BrepSolid, KernelRefusal> {
1551    if !patch.bridges.is_empty() {
1552        if let Some(directory) = census_directory() {
1553            let mut face_ids: Vec<u64> = patch.face_ids.iter().copied().collect();
1554            face_ids.sort_unstable();
1555            let rejoined: Vec<u64> = patch
1556                .merges
1557                .iter()
1558                .flat_map(|merge| &merge.faces)
1559                .map(|position| solid.shells[patch.shell_index].faces[*position].id)
1560                .collect();
1561            // The census reads its operation as text (`closed_heal_census.rs`
1562            // takes a `-> Result<BrepSolid, String>`), so the class is dropped
1563            // here and here only, on the diagnostic copy.
1564            let operation = |body: &BrepSolid| -> Result<BrepSolid, String> {
1565                let patch = classify_patch(body, &face_ids).ok_or("the copy is no longer a patch")?;
1566                cap_face_patch(body, &patch, op).map_err(String::from)
1567            };
1568            record_rejoin_census(&directory, solid, &face_ids, &rejoined, &operation);
1569        }
1570    }
1571    cap_preconditions(solid, patch, op)?;
1572    let per_face = dropped_per_face(patch);
1573
1574    let mut healed = solid.clone();
1575
1576    // --- Rejoin first, by the positions resolved against THIS topology -----
1577    // A merge writes the host's whole loop list, so it subsumes any dropped
1578    // loop of a face in its group; the drop below skips those faces.
1579    let mut merged_away: HashSet<u64> = HashSet::default();
1580    for merge in &patch.merges {
1581        let faces = &mut healed.shells[patch.shell_index].faces;
1582        faces[merge.faces[0]].loops = merge.loops.clone();
1583        if let Some(surface) = &merge.surface {
1584            faces[merge.faces[0]].surface = surface.clone();
1585        }
1586        for face_position in merge.faces.iter().skip(1) {
1587            merged_away.insert(faces[*face_position].id);
1588        }
1589    }
1590
1591    // --- Drop the hole loops, then the patch, then their edges -------------
1592    // Loops go by descending index within each face, so no removal disturbs a
1593    // position resolved against the original topology; dropping a loop cannot
1594    // move a face, and the faces go by id.
1595    for ((shell_position, face_position), loop_indices) in &per_face {
1596        if *shell_position == patch.shell_index
1597            && patch
1598                .merges
1599                .iter()
1600                .any(|merge| merge.faces.contains(face_position))
1601        {
1602            continue;
1603        }
1604        let mut loop_indices = loop_indices.clone();
1605        loop_indices.sort_unstable_by(|a, b| b.cmp(a));
1606        for loop_index in loop_indices {
1607            healed.shells[*shell_position].faces[*face_position]
1608                .loops
1609                .remove(loop_index);
1610        }
1611    }
1612    for shell in &mut healed.shells {
1613        shell
1614            .faces
1615            .retain(|face| !patch.face_ids.contains(&face.id) && !merged_away.contains(&face.id));
1616    }
1617    healed.edges.retain(|edge| !patch.edges.contains(&edge.id));
1618    healed.edges.extend(patch.bridges.iter().cloned());
1619    let used: HashSet<u64> = healed
1620        .edges
1621        .iter()
1622        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
1623        .collect();
1624    healed.vertices.retain(|vertex| used.contains(&vertex.id));
1625
1626    // --- The two things `validate()` will not ask for us -------------------
1627    if !faces_are_connected(&healed.shells[patch.shell_index].faces) {
1628        return Err(KernelRefusal::unsupported(
1629            KernelStage::Validate,
1630            "severs_shell",
1631            format!(
1632                "{op}: the selected faces are what joins two otherwise separate parts of \
1633                 the body — removing them would sever the solid, which this operation \
1634                 cannot represent (deferred)"
1635            ),
1636        ));
1637    }
1638    // Genus is not assumed either way: capping a blind pocket leaves it alone,
1639    // capping a through feature drops it by one, and both fall out of the same
1640    // Euler count over the reduced complex.
1641    let shift = euler_characteristic(solid) - euler_characteristic(&healed);
1642    if shift % 2 != 0 {
1643        return Err(KernelRefusal::internal(
1644            KernelStage::Validate,
1645            "odd_euler_shift",
1646            format!(
1647                "{op}: the selection does not close into whole handles \
1648                 (Euler characteristic shifts by an odd {shift}) — refusing rather than \
1649                 emitting a solid whose genus is a guess"
1650            ),
1651        ));
1652    }
1653    healed.genus += shift / 2;
1654    if healed.genus < 0 {
1655        return Err(KernelRefusal::unsupported(
1656            KernelStage::Validate,
1657            "negative_genus",
1658            format!(
1659                "{op}: capping the selection leaves genus {}, so the solid's stated genus \
1660                 did not account for the feature it carries (deferred)",
1661                healed.genus
1662            ),
1663        ));
1664    }
1665
1666    let issues = healed.validate();
1667    if !issues.is_empty() {
1668        return Err(KernelRefusal::internal(
1669            KernelStage::Validate,
1670            "validate",
1671            format!("{op}: the capped solid failed validation: {issues:?}"),
1672        ));
1673    }
1674    Ok(healed)
1675}
1676
1677/// Delete ONE face: the cap when the face's own shape says it caps,
1678/// [`delete_face_and_heal`](super::delete_face_and_heal)'s re-intersection
1679/// otherwise.
1680///
1681/// The cap gate is consulted FIRST and in full — see [`cap_preconditions`] for
1682/// why the failures have to read as "not a cap" here rather than as refusals.
1683fn delete_one_face(solid: &BrepSolid, face_id: u64, op: &str) -> Result<BrepSolid, KernelRefusal> {
1684    if let Some(patch) = classify_patch(solid, &[face_id]) {
1685        if cap_preconditions(solid, &patch, op).is_ok() {
1686            return cap_face_patch(solid, &patch, op);
1687        }
1688    }
1689    // A closed strip whose rims are RUNS of edges rather than one closed edge
1690    // each: `delete_face_and_heal_impl` reads only `[seam+, rim_a, seam-,
1691    // rim_b]` and refuses anything else on its coedge count, but the band lane
1692    // is stated for a run and heals it identically (`closed_band.rs`). The gate
1693    // declines every shape the closed strip CAN read, so that lane keeps every
1694    // case it already answered — and this is asked after the patch gate, so a
1695    // through feature's wall still caps rather than being sharpened against
1696    // two survivors that were never meant to meet.
1697    if let Some(band) = classify_closed_band(solid, &[face_id]) {
1698        return heal_closed_band(solid, &band, op);
1699    }
1700    delete_face_and_heal_impl(solid, face_id)
1701}
1702
1703/// The selection's edge-connected COMPONENTS, in the order their faces appear
1704/// in `face_ids` (both between components and within one), so a chained
1705/// component heals in exactly the order the caller listed it.
1706///
1707/// Two selected faces belong to the same component when they SHARE AN EDGE.
1708/// That is the relation the patch gate is a question about: a patch's free
1709/// boundary is a property of one contiguous piece of surface, and two pieces
1710/// that touch nothing of each other cannot make one another's boundary any
1711/// less whole.
1712pub(super) fn connected_components(solid: &BrepSolid, face_ids: &[u64]) -> Vec<Vec<u64>> {
1713    let selected: HashSet<u64> = face_ids.iter().copied().collect();
1714    let mut adjacency: HashMap<u64, Vec<u64>> = HashMap::default();
1715    let mut by_edge: HashMap<u64, Vec<u64>> = HashMap::default();
1716    for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
1717        if !selected.contains(&face.id) {
1718            continue;
1719        }
1720        for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1721            by_edge.entry(coedge.edge_id).or_default().push(face.id);
1722        }
1723    }
1724    for sharing in by_edge.values() {
1725        for a in sharing {
1726            for b in sharing {
1727                if a != b {
1728                    adjacency.entry(*a).or_default().push(*b);
1729                }
1730            }
1731        }
1732    }
1733
1734    let position: HashMap<u64, usize> = face_ids
1735        .iter()
1736        .enumerate()
1737        .map(|(index, face_id)| (*face_id, index))
1738        .collect();
1739    let mut seen: HashSet<u64> = HashSet::default();
1740    let mut components: Vec<Vec<u64>> = Vec::new();
1741    for start in face_ids {
1742        if !seen.insert(*start) {
1743            continue;
1744        }
1745        let mut component = vec![*start];
1746        let mut stack = vec![*start];
1747        while let Some(face_id) = stack.pop() {
1748            for next in adjacency.get(&face_id).into_iter().flatten() {
1749                if seen.insert(*next) {
1750                    component.push(*next);
1751                    stack.push(*next);
1752                }
1753            }
1754        }
1755        component.sort_unstable_by_key(|face_id| position[face_id]);
1756        components.push(component);
1757    }
1758    components
1759}
1760
1761/// Delete a SET of faces and heal, in one operation.
1762///
1763/// Two lanes, chosen by the selection's own shape (see the module docs):
1764///
1765/// * a PATCH — a set whose free boundary consumes whole hole loops of the
1766///   faces around it (a pocket's walls + floor, a boss's wall + top, a bore's
1767///   wall) — is CAPPED: the patch and those loops go, and nothing else is
1768///   touched;
1769/// * anything else is healed one face at a time by
1770///   [`delete_face_and_heal`](super::delete_face_and_heal), which extends and
1771///   re-intersects each face's neighbours. Face ids are stable across a heal,
1772///   so the chain re-uses the ids it was given.
1773///
1774/// A single-face selection is put to the SAME gate, in full, and falls through
1775/// to the chain when it does not hold — see `delete_one_face`. A lone face
1776/// can be a patch on its own: a bore's wall is one, and so is a bore whose
1777/// mouth is tangent to something, where the rim arrives as runs of two
1778/// pinch-split faces' loops instead of as a hole loop.
1779///
1780/// A contiguous selection that is neither a patch nor a corner but a CLOSED
1781/// BAND — an annulus whose free boundary is one closed run on each of two
1782/// survivors, like a nut pocket sunk over a bore that keeps going — is healed by
1783/// re-intersecting those two survivors (`closed_band.rs`), the same answer the
1784/// one-face closed strip gets.
1785///
1786/// A selection can be BOTH — holes and fillets picked together — and then it is
1787/// neither lane as a whole. Such a selection is split into edge-connected
1788/// components and each component takes the lane its own shape asks for; the
1789/// caps run before the chain (see the module docs). A selection that IS one
1790/// patch never reaches that split, so every set the gate already accepted is
1791/// answered by exactly the code it was answered by before.
1792pub fn delete_faces_and_heal(solid: &BrepSolid, face_ids: &[u64]) -> Result<BrepSolid, KernelRefusal> {
1793    // The soundness floor, once, on the finished body. `validate()` is an
1794    // incidence test and says nothing about a shell passing through itself;
1795    // a re-intersected rim is exactly what can produce one, and a fold is
1796    // wrong in AREA where it is right in volume, so nothing that measures
1797    // volume would notice. See `healing::accept_sound`.
1798    let healed = match delete_faces_and_heal_impl(solid, face_ids) {
1799        Ok(healed) => healed,
1800        // Only after every lane has refused, so no selection that healed
1801        // before changes (`prism_ring.rs`).
1802        Err(refusal) => match classify_prism_ring(solid, face_ids) {
1803            Some(ring) => match heal_prism_ring(solid, &ring, "delete_faces_and_heal") {
1804                Ok(healed) => coalesce_healed_edges(&healed),
1805                Err(reason) => {
1806                    return Err(refusal.with_message(|refusal| {
1807                        format!("{refusal}. The prism-ring heal was tried too and declined: {reason}")
1808                    }))
1809                }
1810            },
1811            None => return Err(refusal),
1812        },
1813    };
1814    crate::accept_sound(healed, "deleteFace")
1815}
1816
1817/// The whole selection's heal, without the soundness floor: see
1818/// [`delete_faces_and_heal`].
1819fn delete_faces_and_heal_impl(solid: &BrepSolid, face_ids: &[u64]) -> Result<BrepSolid, KernelRefusal> {
1820    let op = "delete_faces_and_heal";
1821    let mut seen: HashSet<u64> = HashSet::default();
1822    let face_ids: Vec<u64> = face_ids
1823        .iter()
1824        .copied()
1825        .filter(|face_id| seen.insert(*face_id))
1826        .collect();
1827    if face_ids.is_empty() {
1828        return Err(KernelRefusal::input(
1829            KernelStage::Collect,
1830            "selection",
1831            format!("{op}: no faces selected"),
1832        ));
1833    }
1834    for face_id in &face_ids {
1835        if find_face(solid, *face_id).is_none() {
1836            return Err(KernelRefusal::input(
1837                KernelStage::Collect,
1838                "face_id",
1839                format!("{op}: no face with id {face_id}"),
1840            ));
1841        }
1842    }
1843    // A strip a fillet capped takes its planar end caps with it, picked or not
1844    // (`blend_network_heal.rs`), so every lane below is asked about the strip
1845    // and its caps together.
1846    let face_ids =
1847        with_owned_caps(solid, &face_ids, op)?;
1848    if face_ids.len() == 1 {
1849        // One face is still a network when caps left standing by a second
1850        // round sit at its ends — a first round's cylinder whose end arcs the
1851        // second round's strips ran up to (`blend_network_heal.rs`). Every
1852        // other face is the one-face chain's, exactly as before.
1853        return match read_blend_network(solid, &face_ids, op) {
1854            NetworkRead::Network(network) => Ok(coalesce_healed_edges(
1855                &heal_blend_network(solid, &network, op)
1856                    ?,
1857            )),
1858            // The gate's refusal is typed at its origin; it rides through.
1859            NetworkRead::Refused(reason) => Err(reason),
1860            NetworkRead::NotANetwork => {
1861                Ok(coalesce_healed_edges(&delete_one_face(solid, face_ids[0], op)?))
1862            }
1863        };
1864    }
1865    if let Some(patch) = classify_patch(solid, &face_ids) {
1866        return Ok(coalesce_healed_edges(&cap_face_patch(solid, &patch, op)?));
1867    }
1868    if let Some(answer) = super::conical_cap::cap_conical_apex(solid, &face_ids) {
1869        return answer;
1870    }
1871    if let Some(group) = classify_corner_blend_group(solid, &face_ids) {
1872        return Ok(coalesce_healed_edges(
1873            &heal_corner_blend_group(solid, &group, op)?,
1874        ));
1875    }
1876    if let Some(band) = classify_closed_band(solid, &face_ids) {
1877        return Ok(coalesce_healed_edges(
1878            &heal_closed_band(solid, &band, op)?,
1879        ));
1880    }
1881    // Imported rings whose walls are cut at meridians, and a ring that overran
1882    // the strips beside it (`split_band.rs`): both are the band's question with
1883    // survivors the count above cannot read.
1884    if let Some(band) = classify_split_band(solid, &face_ids) {
1885        return Ok(coalesce_healed_edges(&heal_split_band(solid, &band, op)?));
1886    }
1887    if let Some(reduced) = reduce_ring_over_strips(solid, &face_ids, op) {
1888        let (reduced, remaining) = reduced?;
1889        return delete_faces_and_heal_impl(&reduced, &remaining);
1890    }
1891    match read_blend_network(solid, &face_ids, op) {
1892        NetworkRead::Network(network) => {
1893            return Ok(coalesce_healed_edges(
1894                &heal_blend_network(solid, &network, op)
1895                    ?,
1896            ));
1897        }
1898        // A network with a face beside it that cannot stay is refused by name:
1899        // one face at a time has no better answer for a network.
1900        NetworkRead::Refused(reason) => {
1901            return Err(reason)
1902        }
1903        NetworkRead::NotANetwork => {}
1904    }
1905
1906    // Not one patch. Ask each edge-connected component of the selection the
1907    // same question on its own, so a mixed selection is answered rather than
1908    // forced through whichever single lane the whole set happened to miss.
1909    let components = connected_components(solid, &face_ids);
1910    let mut healed = solid.clone();
1911    if components.len() < 2 {
1912        // A set that encloses every face it borders, and that no lane above
1913        // read, is refused by name: the patch gate declined it for having
1914        // nothing to cap against, and the chain would take it apart one face
1915        // at a time with every neighbour still going.
1916        if let Some(reason) = enclosed_selection_refusal(solid, &face_ids, op) {
1917            return Err(reason);
1918        }
1919        // One contiguous piece that is neither a patch nor a corner: the
1920        // chain, exactly as before, in the caller's own order.
1921        for face_id in &face_ids {
1922            healed = delete_one_face(&healed, *face_id, op)?;
1923        }
1924        return Ok(coalesce_healed_edges(&healed));
1925    }
1926
1927    // Caps first — each classified against the RUNNING solid, because a patch
1928    // carries loop positions and those are only valid for the solid they were
1929    // resolved against. Capping one component cannot unmake another's patch:
1930    // it only drops faces and hole loops the other component does not border.
1931    let mut chained: Vec<Vec<u64>> = Vec::new();
1932    for component in components {
1933        if component.len() == 1 {
1934            // A lone face goes to `delete_one_face` — the same gate, and the
1935            // same fall-through, a one-face selection gets.
1936            chained.push(component);
1937            continue;
1938        }
1939        match classify_patch(&healed, &component) {
1940            Some(patch) => healed = cap_face_patch(&healed, &patch, op)?,
1941            None => chained.push(component),
1942        }
1943    }
1944    // A corner blend is a component the chain cannot take a face at a time
1945    // (`corner_heal.rs`), and like the chain it refits carriers, so it runs in
1946    // the same phase — after every cap, before the faces the chain still owns.
1947    for (index, component) in chained.iter().enumerate() {
1948        if component.len() > 1 {
1949            if let Some(group) = classify_corner_blend_group(&healed, component) {
1950                healed = heal_corner_blend_group(&healed, &group, op)
1951                    ?;
1952                continue;
1953            }
1954            if let Some(band) = classify_closed_band(&healed, component) {
1955                healed =
1956                    heal_closed_band(&healed, &band, op)?;
1957                continue;
1958            }
1959            if let Some(band) = classify_split_band(&healed, component) {
1960                healed = heal_split_band(&healed, &band, op)?;
1961                continue;
1962            }
1963        }
1964        // A lone face with caps standing at its ends is a network, as it is when
1965        // it is the whole selection.
1966        match read_blend_network(&healed, component, op) {
1967            NetworkRead::Network(network) => {
1968                healed = heal_blend_network(&healed, &network, op)
1969                    ?;
1970                continue;
1971            }
1972            NetworkRead::Refused(reason) => {
1973                return Err(reason)
1974            }
1975            NetworkRead::NotANetwork => {}
1976        }
1977        // The same refusal, asked of everything still selected rather than of
1978        // this piece: twelve strips without their vertex blends are twelve
1979        // separate pieces, and each one alone borders survivors that the rest
1980        // of the set goes on to enclose.
1981        let remaining: Vec<u64> = chained[index..].iter().flatten().copied().collect();
1982        if let Some(reason) = enclosed_selection_refusal(&healed, &remaining, op) {
1983            return Err(reason);
1984        }
1985        for face_id in component {
1986            healed = delete_one_face(&healed, *face_id, op)?;
1987        }
1988    }
1989    Ok(coalesce_healed_edges(&healed))
1990}
1991
1992/// Collapse the tangent continuation edges the heal leaves behind.
1993///
1994/// Deleting a face REJOINS survivors: a cap drops a hole loop, and a rejoin
1995/// merges cosurface twins and bridges the edge the band interrupted. Either
1996/// way two edges that were separate only because something stood between them
1997/// end up adjacent on the same two faces — three collinear pieces of one box
1998/// edge where the mirrored union and the deleted fillet band split it, for
1999/// instance. The boolean has closed exactly this since it started merging
2000/// coplanar faces (`csg::boolean`); the heal grew the same need when it grew
2001/// the rejoin, and did not get the same finish.
2002///
2003/// The pre-coalesce solid is a complete, valid answer on its own — the merge
2004/// only removes vertices the user never asked for — so anything short of a
2005/// clean merge keeps it rather than failing the delete.
2006/// `merge_curve_continuation_edges` is conservative by construction (it accepts
2007/// a pair only when both 3D curves and both p-curves rejoin within the pcurve
2008/// contract and neither face's parameter area moves) and it deliberately does
2009/// NOT validate, because the offset shell coalesces while its boundaries are
2010/// still open. A heal's result is closed, so validate it here: each merge drops
2011/// one vertex and one edge, which leaves the Euler characteristic — and so the
2012/// genus already stamped on the solid — exactly where it was.
2013fn coalesce_healed_edges(solid: &BrepSolid) -> BrepSolid {
2014    let tolerance = (solid_model_scale(solid) * 1e-7).max(1e-9);
2015    match crate::merge_curve_continuation_edges(solid, tolerance) {
2016        Ok(merged) if merged.validate().len() <= solid.validate().len() => merged,
2017        _ => solid.clone(),
2018    }
2019}
2020