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