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