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