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