brep_kernel/healing/accept.rs
1//! Is a constructed result SOUND enough to return — and if not, can it be
2//! repaired rather than refused?
3//!
4//! `validate()` is an incidence test and says nothing about a body passing
5//! through itself. [`crate::solid_self_intersections`] does, in two forms: two
6//! faces CROSSING, and one face FOLDING through itself. Neither changes the
7//! volume in a way a closed-form oracle can see — a shape can enclose exactly
8//! the right amount of space while passing through itself — so a lane that
9//! returns one reports success for a different result. That is the failure
10//! mode this module exists to stop.
11//!
12//! ## Repair first, refuse second
13//!
14//! A self-intersection is not automatically a dead end. Where two faces of one
15//! body cross along a curve and the material beyond that curve is a sliver,
16//! the answer is to SPLIT both faces along their carriers' exact intersection
17//! and drop the pieces that should not be there —
18//! [`resolve_face_crossing`]. Only when that cannot be bounded does the lane
19//! refuse.
20//!
21//! The drop is closed under one rule — a face left holding a boundary no other
22//! face answers for bordered the dropped material — and that rule has two
23//! answers. DROPPING the face is right when the unanswered boundary is the
24//! whole of what it contributed: a tail beside a tail. RE-TRIMMING it — taking
25//! the unanswered coedges off and re-linking what is left into closed loops —
26//! is right when the body keeps the face and only part of its boundary went,
27//! which is the only way a BREAKOUT closes. A bore carried out through a side
28//! wall leaves the mouth flat holding the stretch of a corner edge that now
29//! runs INSIDE the bore and the arc of its own mouth ring that now runs
30//! OUTSIDE the body; dropping the flat is absurd, and taking those two coedges
31//! off it merges its mouth loop into its outer loop, which is the topology a
32//! breakout has. The enumeration runs under the dropping closure first and
33//! reaches the re-trimming one only where that resolved nothing at all, so the
34//! second can turn a refusal into a repair and can never change a repair this
35//! module already made.
36//!
37//! A FOLD is different and is refused outright here. A face folded through
38//! itself is a TRIM that was never constructed: the answer is to carve the
39//! folded part of the trim away at the point the carrier is built
40//! (`offset/carve.rs`), not to repair the finished body. Refusing it by name
41//! is what sends the caller to the lane that can fix it.
42//!
43//! ## A hole that left its face
44//!
45//! The scan reads the body as a SURFACE: it convicts two faces that cross and
46//! one face that folds. Neither describes a face whose HOLE loop has wandered
47//! off the face it is a hole in — the trim is nonsense but nothing crosses,
48//! because the wall the hole opens into has gone with it. A direct edit that
49//! carries a bore clean off the body builds exactly that: `validate()` is an
50//! incidence test and passes, the scan finds no pair to convict, and the
51//! divergence-theorem volume of a cylinder is blind to where its axis sits, so
52//! the wrong body measures right to the last bit. [`refuse_holes_leaving_their_faces`]
53//! is the floor for it, and it runs LAST — after the repair, on whatever body
54//! the repair left — so a crossing the repair can take is still taken.
55//!
56//! ## And a hole that left its face ONTO A NEIGHBOUR
57//!
58//! That floor convicts two different things, and only one of them has no right
59//! answer. A bore dragged CLEAN OFF the body has none: the carrier misses the
60//! solid, and there is nothing to cut at any extent. A bore dragged so that its
61//! exit mouth crosses the edge between the face it opened through and the face
62//! next door has one, and it is the body the crossing repair already builds for
63//! a bore carried out through a side wall — the moved carrier and the
64//! neighbour split along their exact section, the overshoot dropped, and the
65//! faces left holding an unanswered boundary RE-TRIMMED, which merges the mouth
66//! into the outer loops of the two faces it now opens through.
67//!
68//! [`accept_or_break_out`] tries that before refusing, and the two cases are
69//! told apart by GEOMETRY rather than by a threshold: the convicted hole loop
70//! is traced in its own face's parameter plane and asked which of that face's
71//! outer coedges it crosses ([`breakout_pair`]). A hole that has broken out
72//! onto a neighbour crosses one, and the face across that edge is the one to
73//! re-trim against. A hole that has left the body altogether crosses none, and
74//! there is no second face for the repair to be about — so the runaway refuses
75//! before a single face is split, and it refuses with the floor's own words
76//! plus the repair's reason for declining.
77//!
78//! The extent is DERIVED and not invented, which is what makes this the same
79//! repair rather than a new operation with a new input. The tool is the moved
80//! carrier trimmed against the body, and where the carrier meets the body is
81//! what the imprint computes. Measured on the 2026-09-21 exit-hole report: the
82//! body this builds removes 4195.389267765 from the filled wedge, against
83//! 4195.389268713 for the same wedge with the INFINITE moved carrier
84//! subtracted through the boolean and 4195.389267095 for a slice integration
85//! that never enters the kernel — three roads, 1.6e-9 apart.
86//!
87//! ## The order
88//!
89//! Three separate slices built [`accept_sound`]'s sequence on 2026-09-21 and
90//! none of them saw the whole of it, so here it is end to end:
91//!
92//! 1. SCAN once. One tessellation, one BVH sweep; every verdict below is read
93//! off that single reading.
94//! 2. NOT FLAGGED — [`accept_or_break_out`]: the hole-containment floor runs on
95//! the body as built, and the body is returned when it says nothing. When it
96//! convicts, the BREAKOUT is tried before the refusal — the same split and
97//! re-trim step 4 runs, on a pair the floor names instead of one the scan
98//! confirmed — and only a breakout that cannot be built refuses. This is the
99//! ONLY path a bore carried clear of its faces reaches, because a bore that
100//! has left the solid crosses nothing.
101//! 3. FLAGGED — the body is dumped under `BREP_HEAL_DUMP`, and a FOLD refuses
102//! here and now: a fold is a trim that was never constructed, not material
103//! to drop.
104//! 4. A CROSSING goes to [`resolve_face_crossing`], which returns the body
105//! UNCHANGED when nothing is confirmed, refuses a fold and refuses more than
106//! one crossing pair, and otherwise imprints the two carriers' section onto
107//! both faces and enumerates every way of dropping the resulting pieces —
108//! closed under `Drop` first, and under `Retrim` only where that resolved
109//! nothing at all.
110//! 5. The repaired body is SCANNED AGAIN, and a repair that still
111//! self-intersects refuses, naming the original crossing.
112//! 6. [`accept_or_break_out`] AGAIN, on the repaired body, so a re-trim that
113//! left a hole off its face is broken out in turn or refused, rather than
114//! shipped.
115//!
116//! Steps 4 and 6 bound each other, and the boundary is not a gap. The crossing
117//! repair needs a section to split along, and a section exists exactly while
118//! the moved carrier still MEETS the body; the containment floor decides what
119//! is left. Measured on the 2026-09-21 runaway-bore report, the crossing
120//! regime ends and the carrier's clearance turns positive in the SAME step of
121//! a swept drag, and 13.90 mm past that the repair is handed a body with
122//! nothing to imprint and hands it straight back.
123//!
124//! What step 2's breakout adds is the OTHER side of that boundary: the window
125//! where the mouth has crossed its face's edge but the wall's overshoot is
126//! still a sliver the tessellation cannot resolve, so the scan confirms
127//! nothing and step 4 is never entered. On the reported document that window
128//! is `s = 0.99 … 1.03` of the drag, and the scan takes over at `s = 1.04`.
129//! The two routes reach the same [`split_and_drop`] and the repair is one
130//! repair.
131//!
132//! ## Cost
133//!
134//! One tessellation and one BVH sweep per accepted result, and only on the
135//! success path. Both verdicts come off that one scan. The containment floor
136//! traces the loops of PLANAR faces that have more than one, in their own
137//! parameters; a face with a single loop costs nothing.
138//!
139//! That note used to stop at that sentence, and stopping there was
140//! misleading: the floor's cost was never in how MANY faces qualify but in
141//! how LARGE one qualifying face's loops can be. A loop is traced at 32
142//! stations per KNOT SPAN, so a box's face gives four points and an imported
143//! 100-tooth gear's flat gives 1.1 MILLION; testing every hole point against
144//! every one of those is a PRODUCT, and three such faces out of 1004 cost 24
145//! s of a 30 s replay. The trace is unchanged — decimating it would weaken
146//! the predicate — and the product is what went instead: the outer loop is
147//! indexed once into bands of its own `v` ([`BoundaryBands`]) and each hole
148//! point reads only the band it falls in, for the same answer at `hole
149//! points × segments per band`.
150
151use crate::{KernelRefusal, KernelStage, OrRefuse, SoundnessDefect};
152use crate::topology::{BrepSolid, LoopRecord};
153use crate::{solid_self_intersections, SelfIntersectionOptions, SelfIntersectionReport};
154
155/// Scan a finished result and either accept it, repair it, or refuse it by
156/// name.
157///
158/// `entry` is the lane's own name, so the refusal reads as that lane's
159/// refusal rather than as a soundness module's.
160pub fn accept_sound(solid: BrepSolid, entry: &str) -> Result<BrepSolid, KernelRefusal> {
161 let report = scan(&solid, entry)?;
162 if !report.is_flagged() {
163 return accept_or_break_out(solid, entry);
164 }
165 dump_flagged(&solid, entry);
166 if let Some(refusal) = fold_refusal(&report, entry) {
167 return Err(refusal);
168 }
169 // Two faces crossing: try to split and trim before refusing.
170 let repaired = match resolve_face_crossing(&solid) {
171 Ok(repaired) => repaired,
172 // The crossing lane declined. Where the containment floor ALSO convicts
173 // a hole loop that has left its face, this body may be a BREAKOUT the
174 // other route can take — a mouth that has migrated onto a neighbouring
175 // face crosses that neighbour, so the scan flags it, and the pair the
176 // scan picked is not always the pair the re-trim needs.
177 //
178 // Guarded three ways, because a second chance is exactly how a body
179 // that should refuse gets talked into building. The floor's conviction
180 // is the ticket — a body that merely self-intersects never reaches
181 // here. The success path above is untouched, so a body that repairs
182 // today repairs identically. And the result goes through the same
183 // re-scan and the same floor below as any other repair.
184 Err(reason) if first_hole_breach(&solid).is_some() => {
185 match break_out_until_contained(solid.clone(), entry) {
186 Ok(repaired) => repaired,
187 Err(second) => {
188 return Err(crossing_refusal(
189 &report,
190 entry,
191 &format!(
192 "{reason}. The breakout route was offered it too and declined: \
193 {second}"
194 ),
195 ))
196 }
197 }
198 }
199 Err(reason) => return Err(crossing_refusal(&report, entry, &reason.message)),
200 };
201 let after = scan(&repaired, entry)?;
202 if after.is_flagged() {
203 return Err(crossing_refusal(
204 &report,
205 entry,
206 &format!(
207 "the repair ran and the result still self-intersects — {}",
208 after.summary().unwrap_or_else(|| "no summary".into())
209 ),
210 ));
211 }
212 accept_or_break_out(repaired, entry)
213}
214
215/// How many hole-containment breaches one acceptance may break out, before it
216/// stops and refuses.
217///
218/// A bore has TWO mouths, and a motion big enough to walk one onto the next
219/// face can walk the other too — so one is not enough, and the whole point of a
220/// bound is that a repair which does not actually reduce the breaches cannot
221/// spin. Four leaves room for a two-mouth migration and a re-trim that opens
222/// one more, and stops well short of a body being talked into building by
223/// attrition.
224const MAX_BREAKOUT_ROUNDS: usize = 4;
225
226/// The containment floor, with the BREAKOUT tried before the refusal.
227///
228/// A hole loop that has left its face is not one case but two, and they are
229/// told apart by where it went. A hole that has left ONTO ANOTHER FACE has a
230/// right answer the kernel can build — the moved carrier re-trimmed against the
231/// face it now opens through — and this builds it when the mouth STRADDLES its
232/// face's boundary, which is the reported case. A hole that has left the body
233/// has no such answer, and this refuses exactly as the floor always did, with
234/// the repair's own reason for declining carried after the floor's so the
235/// refusal says which of the two it was.
236///
237/// **A mouth that has migrated WHOLLY off its face is built by the CROSSING
238/// LANE, not by this route.** Until 2026-09-22 it was built by neither: the
239/// departed face kept a hole loop the imprint never cut and the drop
240/// enumeration had no move for retiring one, so every candidate face this
241/// route offered the carrier to declined. The drop closures have that move now
242/// — [`fragment_is_detached`] and [`holds_only_stale_loops`] — and a turned
243/// bore whose exit migrates onto the next face is repaired to its closed form
244/// through [`resolve_face_crossing`], which `accept_sound` reaches first.
245///
246/// What is left for this route to decide is the mouth that has arrived on
247/// NOTHING: a carrier clear of the body cuts no face of it, every candidate
248/// declines, and the refusal names each face it tried and the distance it
249/// tried it at. That is measured, not assumed, by
250/// `a_mouth_that_has_left_the_body_reaches_the_migration_route_and_is_refused_by_name`.
251fn accept_or_break_out(solid: BrepSolid, entry: &str) -> Result<BrepSolid, KernelRefusal> {
252 if refuse_holes_leaving_their_faces(&solid, entry).is_ok() {
253 return Ok(solid);
254 }
255 break_out_until_contained(solid, entry)
256}
257
258/// Break out every hole the containment floor convicts, one at a time, and hand
259/// back a body that BOTH scans clean and passes the floor.
260///
261/// The rounds are what a two-mouth migration needs: repairing one mouth leaves
262/// the other still off its face, and — while it is — the body legitimately
263/// still passes through itself, because the second mouth's wall is still
264/// standing where the material is not. So an intermediate round is allowed to
265/// be flagged; the END of it is not. Nothing is returned that would not have
266/// been accepted had it been built that way in the first place.
267fn break_out_until_contained(solid: BrepSolid, entry: &str) -> Result<BrepSolid, KernelRefusal> {
268 let mut current = solid;
269 for _ in 0..MAX_BREAKOUT_ROUNDS {
270 let Some(breach) = first_hole_breach(¤t) else {
271 // Contained. It still has to be SOUND to be returned.
272 let report = scan(¤t, entry)?;
273 if report.is_flagged() {
274 return Err(KernelRefusal::unsound(
275 KernelStage::Validate,
276 SoundnessDefect::Crossing,
277 crossing_faces(&report),
278 format!(
279 "{entry}: the breakout repair built a body and it SELF-INTERSECTS — {}",
280 report.summary().unwrap_or_else(|| "no summary".into()),
281 ),
282 ));
283 }
284 return Ok(current);
285 };
286 // The floor's refusal is the primary one; the breakout's decline is
287 // context appended to it, and the class stays the floor's.
288 let floor = hole_breach_refusal(¤t, &breach, entry);
289 current = repair_hole_breakout(¤t, &breach).map_err(|reason| {
290 floor.with_message(|floor| {
291 format!("{floor}. The breakout repair was offered this body and declined it: {reason}")
292 })
293 })?;
294 }
295 let breach = first_hole_breach(¤t).expect("the loop only falls through on a breach");
296 Err(
297 hole_breach_refusal(¤t, &breach, entry).with_message(|floor| {
298 format!(
299 "{floor}. {MAX_BREAKOUT_ROUNDS} breakouts were built and a hole is still off its face, so \
300 this stops rather than repairing a body into one nobody asked for"
301 )
302 }),
303 )
304}
305
306/// [`accept_sound`] for a lane whose INPUTS may themselves self-intersect.
307///
308/// A boolean of two bodies that already pass through themselves is entitled to
309/// return a result that does: the defect came in with the operand and refusing
310/// it here would blame the wrong operation. What must never pass is a crossing
311/// the operation ITSELF introduced, so the inputs are scanned only when the
312/// output is flagged — the common case pays for one scan, not three.
313pub fn accept_sound_against(
314 inputs: &[&BrepSolid],
315 solid: BrepSolid,
316 entry: &str,
317) -> Result<BrepSolid, KernelRefusal> {
318 let report = scan(&solid, entry)?;
319 if !report.is_flagged() {
320 return Ok(solid);
321 }
322 for input in inputs {
323 let before = scan(input, entry)?;
324 if before.is_flagged() {
325 // Said plainly rather than silently: the operand arrived broken.
326 return Ok(solid);
327 }
328 }
329 accept_sound(solid, entry)
330}
331
332/// [`accept_sound_against`] reading only the faces in `candidates`: the faces
333/// the operation built or changed, their neighbours, and every face whose box
334/// reaches the other operand. A face outside that set is an operand's own,
335/// untouched, and lies clear of everything the operation added, so a crossing
336/// on it was the operand's and passes through exactly as the full scan passes
337/// it (the inputs are scanned, flagged, and the result returned). An empty
338/// set is a result with nothing to scan. A flagged subset hands the whole
339/// result to the full acceptance, so every repair and refusal reads as
340/// before. (`BooleanOptions::untouched_passthrough`)
341pub fn accept_sound_against_scoped(
342 inputs: &[&BrepSolid],
343 solid: BrepSolid,
344 entry: &str,
345 candidates: &rustc_hash::FxHashSet<u64>,
346) -> Result<BrepSolid, KernelRefusal> {
347 if candidates.is_empty() {
348 return Ok(solid);
349 }
350 let faces: Vec<crate::topology::FaceRecord> = solid
351 .shells
352 .iter()
353 .flat_map(|shell| &shell.faces)
354 .filter(|face| candidates.contains(&face.id))
355 .cloned()
356 .collect();
357 if faces.len() == solid.shells.iter().map(|shell| shell.faces.len()).sum::<usize>() {
358 return accept_sound_against(inputs, solid, entry);
359 }
360 let edge_ids: rustc_hash::FxHashSet<u64> = faces
361 .iter()
362 .flat_map(|face| &face.loops)
363 .flat_map(|loop_record| &loop_record.coedges)
364 .map(|coedge| coedge.edge_id)
365 .collect();
366 let edges: Vec<crate::topology::EdgeRecord> =
367 solid.edges.iter().filter(|edge| edge_ids.contains(&edge.id)).cloned().collect();
368 let vertex_ids: rustc_hash::FxHashSet<u64> =
369 edges.iter().flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id]).collect();
370 let subset = BrepSolid {
371 mass_properties_cache: Default::default(),
372 id: solid.id,
373 vertices: solid.vertices.iter().filter(|vertex| vertex_ids.contains(&vertex.id)).cloned().collect(),
374 edges,
375 shells: vec![crate::topology::ShellRecord { id: 1, faces }],
376 genus: 0,
377 kind: crate::BodyKind::Solid,
378 };
379 // The bands come from the whole result, as the full scan derives them.
380 let options = SelfIntersectionOptions::for_solid(&solid);
381 let report = scan_with(&subset, options, entry)?;
382 if !report.is_flagged() {
383 return Ok(solid);
384 }
385 accept_sound_against(inputs, solid, entry)
386}
387
388/// The scan, with a read failure reported as a refusal rather than swallowed:
389/// a result that could not be scanned is not a result that scanned clean.
390fn scan(solid: &BrepSolid, entry: &str) -> Result<SelfIntersectionReport, KernelRefusal> {
391 scan_with(solid, SelfIntersectionOptions::for_solid(solid), entry)
392}
393
394fn scan_with(
395 solid: &BrepSolid,
396 options: SelfIntersectionOptions,
397 entry: &str,
398) -> Result<SelfIntersectionReport, KernelRefusal> {
399 solid_self_intersections(solid, options).map_err(|message| {
400 KernelRefusal::internal(
401 KernelStage::Validate,
402 "soundness_scan",
403 format!("{entry}: the soundness scan could not run: {message}"),
404 )
405 })
406}
407
408/// The faces the scan's confirmed crossings name, each once, in report order.
409fn crossing_faces(report: &SelfIntersectionReport) -> Vec<u64> {
410 let mut faces = Vec::new();
411 for crossing in &report.confirmed {
412 for face in [crossing.face_a, crossing.face_b] {
413 if !faces.contains(&face) {
414 faces.push(face);
415 }
416 }
417 }
418 faces
419}
420
421/// The refusal for a face folded through itself — named, with both feet, the
422/// point and the margins, and pointing at the lane that owns the fix.
423fn fold_refusal(report: &SelfIntersectionReport, entry: &str) -> Option<KernelRefusal> {
424 let fold = report.folds.first()?;
425 let faces = report.folds.iter().map(|fold| fold.face).collect();
426 let message = format!(
427 "{entry}: the result's face {}{} FOLDS THROUGH ITSELF in 3D — its surface carries \
428 ({:.6}, {:.6}) and ({:.6}, {:.6}), {:.3e} apart in its own domain and both inside \
429 its trim, to the same point ({:.4}, {:.4}, {:.4}) (residual {:.3e}, branches {:.1} \
430 degrees apart){}. The face is wrong in AREA where its volume is not, so nothing that \
431 measures volume notices and `validate()` cannot see it at all. A fold is a trim that \
432 was never constructed: the folded part of the parameter region has to be carved away \
433 where the carrier is built, not repaired out of the finished body.",
434 fold.face,
435 fold.face_name
436 .as_ref()
437 .map(|name| format!(" '{name}'"))
438 .unwrap_or_default(),
439 fold.uv_a[0],
440 fold.uv_a[1],
441 fold.uv_b[0],
442 fold.uv_b[1],
443 fold.uv_separation,
444 fold.point.x,
445 fold.point.y,
446 fold.point.z,
447 fold.residual,
448 fold.normal_angle.to_degrees(),
449 if report.folds.len() > 1 {
450 format!(" ({} folded faces in all)", report.folds.len())
451 } else {
452 String::new()
453 },
454 );
455 Some(KernelRefusal::unsound(
456 KernelStage::Validate,
457 SoundnessDefect::Fold,
458 faces,
459 message,
460 ))
461}
462
463/// The refusal for two faces crossing, naming every pair and why the repair
464/// did not take it.
465fn crossing_refusal(report: &SelfIntersectionReport, entry: &str, reason: &str) -> KernelRefusal {
466 let pairs = report
467 .confirmed
468 .iter()
469 .map(|crossing| {
470 format!(
471 "faces {} and {} near ({:.4}, {:.4}, {:.4}) (straddle {:.3e} against a band of {:.1e})",
472 crossing.face_a,
473 crossing.face_b,
474 crossing.point.x,
475 crossing.point.y,
476 crossing.point.z,
477 crossing.straddle,
478 report.options.straddle_band
479 )
480 })
481 .collect::<Vec<_>>()
482 .join("; ");
483 KernelRefusal::unsound(
484 KernelStage::Validate,
485 SoundnessDefect::Crossing,
486 crossing_faces(report),
487 format!(
488 "{entry}: the result SELF-INTERSECTS — {pairs}. The enclosed space can still measure \
489 right, so neither a volume oracle nor `validate()` would have caught this. The repair \
490 that splits both faces along their carriers' intersection and drops the enclosed \
491 material did not take it: {reason}"
492 ),
493 )
494}
495
496// ---------------------------------------------------------------------------
497// The hole-containment floor
498// ---------------------------------------------------------------------------
499
500/// Stations per curved pcurve span when a loop is traced in its face's own
501/// parameters. A straight pcurve contributes its start point only — the next
502/// coedge begins where it ends.
503const LOOP_SAMPLES_PER_SPAN: usize = 32;
504
505/// A loop of a face as a closed polyline in that face's own parameter plane,
506/// traced coedge by coedge along each pcurve (already oriented to its coedge).
507fn loop_parameter_polyline(loop_record: &LoopRecord) -> Result<Vec<(f64, f64)>, KernelRefusal> {
508 pcurve_polyline(loop_record.coedges.iter().map(|coedge| &coedge.pcurve))
509}
510
511/// The same walk for a FRAGMENT's loop, which carries the same pcurves under a
512/// different record. One sampler, so a loop traced during the repair and the
513/// same loop traced by the containment floor are the same polyline.
514fn fragment_loop_polyline(
515 fragment_loop: &crate::fragment::FragmentLoop,
516) -> Result<Vec<(f64, f64)>, KernelRefusal> {
517 pcurve_polyline(fragment_loop.coedges.iter().map(|coedge| &coedge.pcurve))
518}
519
520fn pcurve_polyline<'a>(
521 pcurves: impl Iterator<Item = &'a crate::NurbsCurve>,
522) -> Result<Vec<(f64, f64)>, KernelRefusal> {
523 let mut points = Vec::new();
524 for pcurve in pcurves {
525 let [d0, d1] = pcurve.domain().or_refuse(KernelStage::Validate, "domain")?;
526 let steps = if pcurve.degree <= 1 && pcurve.control_points.len() == 2 {
527 1
528 } else {
529 let mut spans = 0usize;
530 for window in pcurve.knots.windows(2) {
531 if window[1] > window[0] {
532 spans += 1;
533 }
534 }
535 spans.max(1) * LOOP_SAMPLES_PER_SPAN
536 };
537 for step in 0..steps {
538 let uv = pcurve.evaluate(d0 + (d1 - d0) * (step as f64 / steps as f64)).or_refuse(KernelStage::Validate, "evaluate")?;
539 points.push((uv.x, uv.y));
540 }
541 }
542 Ok(points)
543}
544
545fn polyline_area(points: &[(f64, f64)]) -> f64 {
546 let mut twice = 0.0;
547 for index in 0..points.len() {
548 let (x0, y0) = points[index];
549 let (x1, y1) = points[(index + 1) % points.len()];
550 twice += x0 * y1 - x1 * y0;
551 }
552 0.5 * twice
553}
554
555/// The outer loop's segments, bucketed by the band of `v` each one spans.
556///
557/// The floor asks a hole point two questions — even-odd parity against the
558/// outer loop, and whether the point sits within `tolerance` of it — and both
559/// read ONLY the segments whose own `v` range reaches the point's `v`. A
560/// segment that does not reach it has both endpoints on the same side of the
561/// horizontal ray, so it cannot toggle the parity; and every point of it is
562/// further away than `tolerance` in `v` alone, so it cannot be the witness
563/// that the point is on the boundary. Answering out of the band is therefore
564/// the SAME answer as walking the whole loop, not an approximation of it: the
565/// set of segments that can toggle is identical, parity is an XOR and does not
566/// care in what order they come, and the nearest segment — if there is one
567/// within `tolerance` at all — is among those searched.
568///
569/// What changes is the COST. The brute-force walk is `hole points × outer
570/// points`, which on an imported 100-tooth gear's flat is 1.1 million outer
571/// stations against 11 904 hole stations — 1.3e10 pairs on ONE face, 24 s of a
572/// 30 s replay. Through the bands it is `hole points × segments per band`,
573/// plus one linear pass to build.
574///
575/// Laid out CSR — one offsets array and one flat segment array — because a
576/// loop of a million points would otherwise want a million `Vec` headers.
577struct BoundaryBands<'a> {
578 points: &'a [(f64, f64)],
579 /// The bottom of the first band: the loop's lowest `v`.
580 v_lo: f64,
581 /// Bands per unit of `v`. Zero when the loop has no `v` extent to divide,
582 /// in which case there is one band and everything lands in it.
583 bands_per_v: f64,
584 /// `starts[band] .. starts[band + 1]` indexes `segments`.
585 starts: Vec<u32>,
586 /// Segment `i` is `points[i] -> points[(i + 1) % len]`, the closing
587 /// segment included.
588 segments: Vec<u32>,
589}
590
591impl<'a> BoundaryBands<'a> {
592 fn build(points: &'a [(f64, f64)]) -> Self {
593 let count = points.len();
594 debug_assert!(count < u32::MAX as usize, "segment indices are u32");
595 let (mut v_lo, mut v_hi) = (f64::INFINITY, f64::NEG_INFINITY);
596 let mut variation = 0.0;
597 for index in 0..count {
598 let (_, y0) = points[index];
599 let (_, y1) = points[(index + 1) % count];
600 v_lo = v_lo.min(y0);
601 v_hi = v_hi.max(y0);
602 variation += (y1 - y0).abs();
603 }
604 // One band per `variation / count` of `v`. That width is what makes the
605 // index linear in the loop whatever the loop's shape: a segment lands
606 // in `its own v extent / band width + 2` bands, and those sum to about
607 // `3 × count` however the vertical variation is distributed — a gear
608 // tooth's thousands of short segments and a box's four long ones alike.
609 let span = v_hi - v_lo;
610 let bands = if span.is_finite() && span > 0.0 && variation > 0.0 {
611 let wanted = span * count as f64 / variation;
612 if wanted.is_finite() && wanted >= 1.0 {
613 (wanted as usize).min(count.max(1))
614 } else {
615 1
616 }
617 } else {
618 1
619 };
620 let bands_per_v = if bands > 1 { bands as f64 / span } else { 0.0 };
621 let band_of = |v: f64| -> usize {
622 if bands_per_v == 0.0 {
623 return 0;
624 }
625 let raw = (v - v_lo) * bands_per_v;
626 // Written so a NaN takes the first branch rather than falling
627 // through to an `as usize` cast of it.
628 if !(raw > 0.0) {
629 0
630 } else if raw >= (bands - 1) as f64 {
631 bands - 1
632 } else {
633 raw as usize
634 }
635 };
636 let mut starts = vec![0u32; bands + 1];
637 for index in 0..count {
638 let (_, y0) = points[index];
639 let (_, y1) = points[(index + 1) % count];
640 let (lo, hi) = (band_of(y0.min(y1)), band_of(y0.max(y1)));
641 for start in starts[lo + 1..=hi + 1].iter_mut() {
642 *start += 1;
643 }
644 }
645 for band in 1..=bands {
646 starts[band] += starts[band - 1];
647 }
648 let mut cursor = starts.clone();
649 let mut segments = vec![0u32; starts[bands] as usize];
650 for index in 0..count {
651 let (_, y0) = points[index];
652 let (_, y1) = points[(index + 1) % count];
653 let (lo, hi) = (band_of(y0.min(y1)), band_of(y0.max(y1)));
654 for band in lo..=hi {
655 segments[cursor[band] as usize] = index as u32;
656 cursor[band] += 1;
657 }
658 }
659 Self {
660 points,
661 v_lo,
662 bands_per_v,
663 starts,
664 segments,
665 }
666 }
667
668 fn band_count(&self) -> usize {
669 self.starts.len() - 1
670 }
671
672 /// The band `v` falls in, clamped. A `v` outside the loop's own extent
673 /// clamps to an end band, whose segments then fail the tests on their own
674 /// terms — nothing straddles a ray the whole loop sits above or below.
675 fn band_of(&self, v: f64) -> usize {
676 if self.bands_per_v == 0.0 {
677 return 0;
678 }
679 let bands = self.band_count();
680 let raw = (v - self.v_lo) * self.bands_per_v;
681 if !(raw > 0.0) {
682 0
683 } else if raw >= (bands - 1) as f64 {
684 bands - 1
685 } else {
686 raw as usize
687 }
688 }
689
690 fn segment(&self, index: u32) -> ((f64, f64), (f64, f64)) {
691 let index = index as usize;
692 (
693 self.points[index],
694 self.points[(index + 1) % self.points.len()],
695 )
696 }
697
698 /// Even-odd parity: is `(u, v)` inside the closed loop?
699 fn contains(&self, point: (f64, f64)) -> bool {
700 let (u, v) = point;
701 let band = self.band_of(v);
702 let mut inside = false;
703 for &index in &self.segments[self.starts[band] as usize..self.starts[band + 1] as usize] {
704 let ((x0, y0), (x1, y1)) = self.segment(index);
705 if (y0 > v) != (y1 > v) && u < x0 + (v - y0) * (x1 - x0) / (y1 - y0) {
706 inside = !inside;
707 }
708 }
709 inside
710 }
711
712 /// Does `(u, v)` sit within `tolerance` of the loop — is it ON the
713 /// boundary rather than off the face?
714 ///
715 /// The bands searched are widened by one on each side of the `tolerance`
716 /// band, so the margin against a segment being excluded by a rounding of
717 /// its own `v` extent is a whole band rather than an ulp.
718 fn within(&self, point: (f64, f64), tolerance: f64) -> bool {
719 let (u, v) = point;
720 let lo = self.band_of(v - tolerance).saturating_sub(1);
721 let hi = (self.band_of(v + tolerance) + 1).min(self.band_count() - 1);
722 for band in lo..=hi {
723 for &index in &self.segments[self.starts[band] as usize..self.starts[band + 1] as usize]
724 {
725 let ((x0, y0), (x1, y1)) = self.segment(index);
726 let (dx, dy) = (x1 - x0, y1 - y0);
727 let length_squared = dx * dx + dy * dy;
728 let t = if length_squared > 0.0 {
729 (((u - x0) * dx + (v - y0) * dy) / length_squared).clamp(0.0, 1.0)
730 } else {
731 0.0
732 };
733 let (px, py) = (x0 + t * dx, y0 + t * dy);
734 if ((u - px).powi(2) + (v - py).powi(2)).sqrt() <= tolerance {
735 return true;
736 }
737 }
738 }
739 false
740 }
741
742 /// The point of the loop nearest to `(u, v)` in the parameter plane, and
743 /// how far it is.
744 ///
745 /// Searched outward from the point's own band a ring at a time, stopping
746 /// when the next ring's `v` alone is further than the best found: every
747 /// segment in band `b` has every point of it within that band's `v`
748 /// range, so a band `r` away from the query's cannot hold a closer point
749 /// than `(r - 1)` band heights. A query beyond the loop's `v` extent
750 /// clamps to an end band and the same bound holds, larger. Called only
751 /// for a station that has already FAILED parity and the parameter band,
752 /// so its cost is paid once per conviction, not once per station.
753 fn nearest(&self, point: (f64, f64)) -> ((f64, f64), f64) {
754 let (u, v) = point;
755 let bands = self.band_count();
756 let centre = self.band_of(v);
757 let band_height = if self.bands_per_v > 0.0 { 1.0 / self.bands_per_v } else { f64::INFINITY };
758 let mut best = f64::INFINITY;
759 let mut best_point = self.points[0];
760 let search = |band: usize, best: &mut f64, best_point: &mut (f64, f64)| {
761 for &index in &self.segments[self.starts[band] as usize..self.starts[band + 1] as usize] {
762 let ((x0, y0), (x1, y1)) = self.segment(index);
763 let (dx, dy) = (x1 - x0, y1 - y0);
764 let length_squared = dx * dx + dy * dy;
765 let t = if length_squared > 0.0 {
766 (((u - x0) * dx + (v - y0) * dy) / length_squared).clamp(0.0, 1.0)
767 } else {
768 0.0
769 };
770 let candidate = (x0 + t * dx, y0 + t * dy);
771 let distance = ((u - candidate.0).powi(2) + (v - candidate.1).powi(2)).sqrt();
772 if distance < *best {
773 *best = distance;
774 *best_point = candidate;
775 }
776 }
777 };
778 for ring in 0..bands {
779 if ring > 1 && (ring - 1) as f64 * band_height > best {
780 break;
781 }
782 let below = centre.checked_sub(ring);
783 let above = if centre + ring < bands { Some(centre + ring) } else { None };
784 if below.is_none() && above.is_none() {
785 break;
786 }
787 if let Some(band) = below {
788 search(band, &mut best, &mut best_point);
789 }
790 if let Some(band) = above {
791 if ring > 0 {
792 search(band, &mut best, &mut best_point);
793 }
794 }
795 }
796 (best_point, best)
797 }
798}
799
800/// THE HOLE-CONTAINMENT FLOOR: a face whose hole loop has left that face's
801/// own outer loop is refused, naming the hole — on EVERY carrier, planar or
802/// not.
803///
804/// A hole is a hole IN something. Carry a bore's mouth off the flat it opens
805/// through — a direct edit dragging the wall clear of the body — and what the
806/// fixed-topology road emits is the mouth loop hanging in the plane beside the
807/// face it is supposed to perforate, with the wall it bounds somewhere else
808/// entirely. Every other floor is blind to it: `validate()` tests INCIDENCE and
809/// the incidences are all intact; the scan needs two faces to CROSS and the
810/// runaway bore crosses nothing; and the divergence-theorem volume of a
811/// cylinder does not depend on where its axis sits, so the body can measure to
812/// the last bit of the right answer while being the wrong body. The correct
813/// result needs the hole to break through a neighbouring face — a topology
814/// change the direct edits do not make — so this refuses instead of shipping it.
815///
816/// Decided in the face's own parameter plane by parity against the outer loop
817/// (the one enclosing the largest parameter area). A point that fails parity
818/// but sits within `tolerance` of the outer loop is treated as ON it, so a
819/// mouth tangent to its face's own boundary is not convicted by sampling
820/// noise. The predicate is only as fine as the sampling: a curved hole poking
821/// out by less than its sampled chord sag can still pass.
822///
823/// On a PLANE the parameter plane is affine to space and the pcurves are exact
824/// images, so the band is the parameter band alone. On every other carrier the
825/// pcurves are FITTED, to [`crate::PCURVE_REFINEMENT_TOLERANCE`] in space, and
826/// a fit's noise can put a station of a legitimately tangent mouth a few
827/// parameter ulps outside its face. So a station that fails both tests on a
828/// non-affine face is convicted only if it is also further than
829/// [`CURVED_BREACH_ACQUITTAL`] IN SPACE from the nearest point of the outer
830/// loop — the same trace read back through the surface. That distance is the
831/// smallest curved breach the floor can see; it is a floor on the fit, not on
832/// the sampling. Until 2026-09-30 every non-planar face was skipped outright,
833/// so a mouth carried off a cylinder wall or a torus by a motion that pierced
834/// nothing the scan could resolve shipped as a wrong body.
835///
836/// A closed carrier is read in two shapes. Where NO loop winds the period
837/// (the nested case — a bored post's wall, whose outer loop holds the seam
838/// twice), the outer loop is the one enclosing the largest parameter area and
839/// the holes must lie inside it; a hole station is folded by whole periods
840/// into the outer loop's window only where that loop carries no seam coedge,
841/// so a hole traced across a seam that IS an edge of the outer loop (a hole
842/// with an edge of its own face running through it — the builders split the
843/// seam where a mouth reaches it) is a wrong body and is convicted. Where some
844/// loop DOES wind (the banded case — a refitted full cylinder is bounded by
845/// two rings and no seam, a dome cut off a sphere by one), the face has no
846/// outer loop and a hole must lie on every ring's material side, decided
847/// along the open direction with the ring's winding sign and the face's own
848/// orientation convention ([`banded_hole_breach`]). A band whose rings wind a
849/// carrier closed BOTH ways selects its intended region by the same orientation
850/// convention, using the next transverse boundary modulo that period. A loop
851/// winding both ways remains outside this arm. A breach the banded arm convicts is refused, not
852/// broken out: the breakout route reads a nested outer loop.
853///
854/// Both questions go through [`BoundaryBands`], which is an INDEX over the
855/// outer loop and not a coarser reading of it: the answers are the ones a walk
856/// of the whole traced polyline gives, off the whole traced polyline, at a
857/// cost that is no longer the product of the two loops' lengths.
858///
859/// This runs at the END of [`accept_sound`], on the body the repair left, so a
860/// crossing the repair can take is still taken — the reason an earlier
861/// primitive-level copy of this floor was removed.
862fn refuse_holes_leaving_their_faces(solid: &BrepSolid, entry: &str) -> Result<(), KernelRefusal> {
863 match first_hole_breach(solid) {
864 Some(breach) => Err(hole_breach_refusal(solid, &breach, entry)),
865 None => Ok(()),
866 }
867}
868
869/// A hole loop the floor has convicted: WHICH face, which loop of it, and the
870/// sample that fell outside, in that face's own parameters.
871///
872/// The floor's verdict is a yes or no; this is the same verdict with the
873/// witness kept, so a lane that can BUILD the right body has something to
874/// build it from. Nothing about the predicate changes by recording it.
875struct HoleBreach {
876 face: u64,
877 loop_index: usize,
878 loop_id: u64,
879 at: (f64, f64),
880 /// Convicted by the banded arm — the face has rings and no outer loop, so
881 /// the breakout route, which reads a nested outer loop, has nothing to
882 /// read and declines by name.
883 banded: bool,
884}
885
886/// THE FLOOR'S PREDICATE, verbatim — the first hole loop on any face that has
887/// left that face's own outer loop, or `None`.
888///
889/// This is the whole of the decision [`refuse_holes_leaving_their_faces`] used
890/// to make inline; the refusal is now written by [`hole_breach_refusal`] off
891/// what this returns. Same loops, same trace, same bands, same tolerance, same
892/// first-hit order.
893fn first_hole_breach(solid: &BrepSolid) -> Option<HoleBreach> {
894 for shell in &solid.shells {
895 for face in &shell.faces {
896 if face.loops.len() < 2 {
897 continue;
898 }
899 // Exact pcurves on an affine carrier; fitted ones everywhere else,
900 // which earn the acquittal in space.
901 let fitted = !face.surface.is_affine().unwrap_or(false);
902 let closed = face.surface.closed_directions().unwrap_or((false, false));
903 let period = (
904 face.surface.domain_u().map(|[a, b]| b - a).unwrap_or(0.0),
905 face.surface.domain_v().map(|[a, b]| b - a).unwrap_or(0.0),
906 );
907 let windings: Vec<(i32, i32)> = face
908 .loops
909 .iter()
910 .map(|record| loop_winding(record, closed, period))
911 .collect();
912 let polylines = face
913 .loops
914 .iter()
915 .map(loop_parameter_polyline)
916 .collect::<Result<Vec<_>, KernelRefusal>>()
917 // A loop this cannot be traced from is not a loop this can
918 // convict: the acceptance's own floors already ran.
919 .unwrap_or_default();
920 if polylines.len() != face.loops.len() || polylines.iter().any(Vec::is_empty) {
921 continue;
922 }
923 let breach = if windings.iter().all(|&winding| winding == (0, 0)) {
924 let spherical = matches!(face.surface.analytic(), Some(crate::AnalyticSurface::Sphere { .. }));
925 if spherical && !sphere_has_parameter_outer(solid, face, &polylines) {
926 spherical_hole_breach(solid, face)
927 } else {
928 nested_hole_breach(face, &polylines, fitted, closed, period)
929 }
930 } else {
931 banded_hole_breach(face, &polylines, &windings, fitted, closed, period)
932 };
933 if breach.is_some() {
934 return breach;
935 }
936 }
937 }
938 None
939}
940
941/// A bounded spherical patch can use the existing indexed nested floor.
942/// Its largest UV loop must be a real, correctly oriented material outer
943/// boundary, with the other real loops oriented as holes. A full-domain
944/// rectangle made only of seams and collapsed poles is not such a boundary.
945fn sphere_has_parameter_outer(
946 solid: &BrepSolid,
947 face: &crate::FaceRecord,
948 polylines: &[Vec<(f64, f64)>],
949) -> bool {
950 let areas: Vec<_> = polylines.iter().map(|points| polyline_area(points)).collect();
951 let Some(outer) = (0..areas.len()).max_by(|&a, &b| areas[a].abs().total_cmp(&areas[b].abs())) else {
952 return false;
953 };
954 let orientation = if face.same_sense { 1.0 } else { -1.0 };
955 if areas[outer] * orientation <= 0.0 { return false; }
956 let real = |index: usize| face.loops[index].coedges.iter().any(|coedge| {
957 face.loops.iter().flat_map(|rim| &rim.coedges)
958 .filter(|other| other.edge_id == coedge.edge_id).count() == 1
959 && !solid.edges.iter().any(|edge| edge.id == coedge.edge_id && edge.degenerate)
960 });
961 real(outer) && (0..areas.len()).all(|i| i == outer || !real(i) || areas[i] * orientation <= 0.0)
962}
963
964/// A sphere has no distinguished outer boundary: two disjoint negatively
965/// oriented trims can both be holes in the remaining sphere. Choosing the
966/// largest UV polygon as an outer loop falsely rejects that topology, and
967/// even mistakes an imported retraced pole cut for a runaway hole. Ask the
968/// same spherical material-side classifier used by containment and meshing:
969/// each real boundary must lie in the region left by the other loops.
970fn spherical_hole_breach(solid: &BrepSolid, face: &crate::FaceRecord) -> Option<HoleBreach> {
971 let mut uses = std::collections::HashMap::new();
972 for coedge in face.loops.iter().flat_map(|rim| &rim.coedges) {
973 *uses.entry(coedge.edge_id).or_insert(0usize) += 1;
974 }
975 for (index, rim) in face.loops.iter().enumerate() {
976 let mut other = face.clone();
977 other.loops.remove(index);
978 // A loop containing only domain seams and collapsed poles carries
979 // no material boundary. Keep mixed loops intact: the sampler reads
980 // each coedge's endpoint from the next coedge's start. Removing a
981 // paired seam joining two rims would bridge those rims with fake
982 // chords; the spherical classifier cancels the seam itself.
983 other.loops.retain(|boundary| {
984 boundary.coedges.iter().any(|coedge| {
985 uses[&coedge.edge_id] == 1
986 && !solid.edges.iter().any(|edge| {
987 edge.id == coedge.edge_id && edge.degenerate
988 })
989 })
990 });
991 if other.loops.is_empty() {
992 continue;
993 }
994 let [u0, u1] = face.surface.domain_u().ok()?;
995 for coedge in &rim.coedges {
996 // A seam traversed twice and a collapsed pole have no spherical
997 // area. Their UV trace is not a boundary of the retained region.
998 if uses[&coedge.edge_id] > 1
999 || solid
1000 .edges
1001 .iter()
1002 .any(|edge| edge.id == coedge.edge_id && edge.degenerate)
1003 {
1004 continue;
1005 }
1006 let boundary = LoopRecord {
1007 id: rim.id,
1008 coedges: vec![coedge.clone()],
1009 };
1010 let Ok(mut points) = loop_parameter_polyline(&boundary) else {
1011 continue;
1012 };
1013 // Straight UV edges contribute only their start in the ordinary
1014 // polygon sampler. Include an interior witness here: two holes
1015 // can share horizontal boundary levels while their vertical sides
1016 // run through the other hole's interior.
1017 if coedge.pcurve.degree == 1 && coedge.pcurve.control_points.len() == 2 {
1018 let [a, b] = coedge.pcurve.domain().ok()?;
1019 let midpoint = coedge.pcurve.evaluate((a + b) * 0.5).ok()?;
1020 points.push((midpoint.x, midpoint.y));
1021 }
1022 for (u, v) in points {
1023 if crate::parameter_point_in_face(
1024 &other,
1025 crate::Vec2 {
1026 x: u0 + (u - u0).rem_euclid(u1 - u0),
1027 y: v,
1028 },
1029 1e-6,
1030 )
1031 .ok()
1032 != Some(crate::PolygonClass::Outside)
1033 {
1034 continue;
1035 }
1036 return Some(HoleBreach {
1037 face: face.id,
1038 loop_index: index,
1039 loop_id: rim.id,
1040 at: (u, v),
1041 banded: true,
1042 });
1043 }
1044 }
1045 }
1046 None
1047}
1048
1049/// How many times a loop goes round each CLOSED direction of its carrier, read
1050/// off its coedges' pcurve ENDPOINTS rather than its traced stations: a ring
1051/// the refit writes as one straight pcurve across the whole period traces to a
1052/// single station, and the trace cannot see it go round. An open direction
1053/// winds nothing.
1054fn loop_winding(loop_record: &LoopRecord, closed: (bool, bool), period: (f64, f64)) -> (i32, i32) {
1055 let (mut du, mut dv) = (0.0, 0.0);
1056 for coedge in &loop_record.coedges {
1057 let Ok([d0, d1]) = coedge.pcurve.domain() else { return (0, 0) };
1058 let (Ok(start), Ok(end)) = (coedge.pcurve.evaluate(d0), coedge.pcurve.evaluate(d1)) else {
1059 return (0, 0);
1060 };
1061 du += end.x - start.x;
1062 dv += end.y - start.y;
1063 }
1064 let wind = |closed: bool, net: f64, period: f64| {
1065 if closed && period > 0.0 && net.is_finite() {
1066 (net / period).round() as i32
1067 } else {
1068 0
1069 }
1070 };
1071 (wind(closed.0, du, period.0), wind(closed.1, dv, period.1))
1072}
1073
1074/// Does this loop hold the same edge twice — a SEAM of a closed carrier, both
1075/// sides of it on this one face?
1076fn has_seam_pair(loop_record: &LoopRecord) -> bool {
1077 loop_record.coedges.iter().enumerate().any(|(index, coedge)| {
1078 loop_record.coedges[index + 1..]
1079 .iter()
1080 .any(|other| other.edge_id == coedge.edge_id)
1081 })
1082}
1083
1084/// THE NESTED CASE: no loop winds the carrier, so the loop enclosing the
1085/// largest parameter area is the face's outer boundary and every other loop is
1086/// a hole that must lie inside it. This is the floor exactly as the planar arm
1087/// always ran it, with two additions for a closed carrier: a hole station is
1088/// FOLDED by whole periods into the outer loop's own window where the outer
1089/// loop carries no seam coedge (a fitted pcurve unwraps across the seam on its
1090/// own terms, and a partial patch's outer loop and its hole need not have
1091/// unwrapped the same way), and never folded where it does — then the seam is
1092/// an edge of the outer loop and a hole across it is a wrong body.
1093fn nested_hole_breach(
1094 face: &crate::FaceRecord,
1095 polylines: &[Vec<(f64, f64)>],
1096 fitted: bool,
1097 closed: (bool, bool),
1098 period: (f64, f64),
1099) -> Option<HoleBreach> {
1100 let outer_index = (0..polylines.len())
1101 .max_by(|&a, &b| {
1102 polyline_area(&polylines[a])
1103 .abs()
1104 .total_cmp(&polyline_area(&polylines[b]).abs())
1105 })
1106 .unwrap_or(0);
1107 let outer = &polylines[outer_index];
1108 // Every hole point is asked the same two questions against this one
1109 // loop, so the loop is indexed ONCE and each question then reads the band
1110 // it falls in rather than the whole polyline. The answers are the ones
1111 // the whole-polyline walk gives; see [`BoundaryBands`].
1112 let bands = BoundaryBands::build(outer);
1113 let tolerance = boundary_tolerance(outer);
1114 let seamed = has_seam_pair(&face.loops[outer_index]);
1115 let (mut lo, mut hi) = ((f64::MAX, f64::MAX), (f64::MIN, f64::MIN));
1116 for &(u, v) in outer {
1117 lo = (lo.0.min(u), lo.1.min(v));
1118 hi = (hi.0.max(u), hi.1.max(v));
1119 }
1120 let centre = (0.5 * (lo.0 + hi.0), 0.5 * (lo.1 + hi.1));
1121 let fold_u = closed.0 && !seamed && period.0 > 0.0;
1122 let fold_v = closed.1 && !seamed && period.1 > 0.0;
1123 let fold = |(u, v): (f64, f64)| {
1124 (
1125 if fold_u { u + period.0 * ((centre.0 - u) / period.0).round() } else { u },
1126 if fold_v { v + period.1 * ((centre.1 - v) / period.1).round() } else { v },
1127 )
1128 };
1129 for (index, hole) in polylines.iter().enumerate() {
1130 if index == outer_index {
1131 continue;
1132 }
1133 let Some(at) = hole.iter().map(|&point| fold(point)).find(|&point| {
1134 !bands.contains(point)
1135 && !bands.within(point, tolerance)
1136 && !(fitted && acquitted_in_space(&face.surface, point, bands.nearest(point).0))
1137 }) else {
1138 continue;
1139 };
1140 return Some(HoleBreach {
1141 face: face.id,
1142 loop_index: index,
1143 loop_id: face.loops[index].id,
1144 at,
1145 banded: false,
1146 });
1147 }
1148 None
1149}
1150
1151/// A ring's segments in the parameter plane, one coedge after another with
1152/// every end point included, closed by the whole periods the ring winds — so
1153/// the segment that closes it is the short one back to the start's image, not
1154/// a jump across the domain.
1155struct Ring {
1156 segments: Vec<((f64, f64), (f64, f64))>,
1157 /// Is the face's material on the ring's POSITIVE side along the transverse
1158 /// direction?
1159 material_positive: bool,
1160 positive_winding: bool,
1161}
1162
1163/// THE BANDED CASE: some loop winds the carrier's closed direction, so the
1164/// face has no outer loop in the parameter plane — it is a band between RINGS
1165/// (a refitted full cylinder is bounded by two, a dome cut off a sphere by
1166/// one), and its holes are the loops that do not wind. A hole is in the face
1167/// when it is on every ring's MATERIAL side, decided by a ray along the open
1168/// direction: the ring's winding sign says which side its material is on once
1169/// the face's own orientation convention is read off a hole (a hole loop runs
1170/// with the material on its left when its parameter area is negative — the
1171/// mirror of the outer loop it is a hole in — and on its right otherwise).
1172/// Stations and rings are compared modulo the period, so a ring or a hole
1173/// unwrapped across the seam reads the same as one that was not.
1174///
1175/// Every winding loop must go round exactly once along the same closed axis.
1176/// If the transverse direction is also closed, oriented crossings select the
1177/// material region (including the complementary band); ring windings must
1178/// cancel. Loops winding both axes remain outside this arm.
1179fn banded_hole_breach(
1180 face: &crate::FaceRecord,
1181 polylines: &[Vec<(f64, f64)>],
1182 windings: &[(i32, i32)],
1183 fitted: bool,
1184 closed: (bool, bool),
1185 period: (f64, f64),
1186) -> Option<HoleBreach> {
1187 let rings_wind = |axis: usize| {
1188 windings
1189 .iter()
1190 .filter(|&&winding| winding != (0, 0))
1191 .all(|&(u, v)| {
1192 if axis == 0 {
1193 u.abs() == 1 && v == 0
1194 } else {
1195 v.abs() == 1 && u == 0
1196 }
1197 })
1198 };
1199 // `along` is the closed direction the rings wind; `open` names the
1200 // transverse axis (which can itself be periodic).
1201 let along = if rings_wind(0) && closed.0 && period.0 > 0.0 {
1202 0
1203 } else if rings_wind(1) && closed.1 && period.1 > 0.0 {
1204 1
1205 } else {
1206 return None;
1207 };
1208 let open = 1 - along;
1209 let axis = |point: (f64, f64), which: usize| if which == 0 { point.0 } else { point.1 };
1210 let period_along = axis(period, along);
1211 let transverse_period = if if open == 0 { closed.0 } else { closed.1 } {
1212 let p = axis(period, open);
1213 // Boundaries of a region on a torus must cancel in homology. A
1214 // nonseparating single ring does not define a material band.
1215 if p <= 0.0
1216 || windings
1217 .iter()
1218 .map(|&w| if along == 0 { w.0 } else { w.1 })
1219 .sum::<i32>()
1220 != 0
1221 {
1222 return None;
1223 }
1224 Some(p)
1225 } else {
1226 None
1227 };
1228 let domain_start = if along == 0 {
1229 face.surface.domain_u().ok()?[0]
1230 } else {
1231 face.surface.domain_v().ok()?[0]
1232 };
1233
1234 // The orientation convention, read off the first hole with an area.
1235 let left = polylines
1236 .iter()
1237 .zip(windings)
1238 .filter(|(_, &winding)| winding == (0, 0))
1239 .map(|(hole, _)| {
1240 // Area is translation invariant. Subtract a local origin before
1241 // multiplying: period translations must not cancel a small
1242 // hole's area to zero or invert its orientation convention.
1243 let origin = hole[0];
1244 let local: Vec<_> = hole.iter()
1245 .map(|&(u, v)| (u - origin.0, v - origin.1))
1246 .collect();
1247 polyline_area(&local)
1248 })
1249 .find(|area| *area != 0.0)?
1250 < 0.0;
1251
1252 let mut rings: Vec<Ring> = Vec::new();
1253 let mut extent = 0.0f64;
1254 for (index, record) in face.loops.iter().enumerate() {
1255 let winding = windings[index];
1256 if winding == (0, 0) {
1257 continue;
1258 }
1259 let mut points: Vec<(f64, f64)> = Vec::new();
1260 for coedge in &record.coedges {
1261 points.extend(coedge_parameter_polyline(coedge).ok()?);
1262 }
1263 let first = *points.first()?;
1264 points.push((
1265 first.0 + winding.0 as f64 * period.0,
1266 first.1 + winding.1 as f64 * period.1,
1267 ));
1268 for window in points.windows(2) {
1269 extent = extent
1270 .max(axis(window[0], open) - axis(window[1], open))
1271 .max(axis(window[1], open) - axis(window[0], open));
1272 }
1273 let segments = points
1274 .windows(2)
1275 .map(|window| (window[0], window[1]))
1276 .collect();
1277 // Material on the LEFT of a ring winding +u lies at +v; on the left of
1278 // one winding +v it lies at −u.
1279 let positive_winding = axis((winding.0 as f64, winding.1 as f64), along) > 0.0;
1280 let material_positive = if along == 0 {
1281 positive_winding == left
1282 } else {
1283 positive_winding != left
1284 };
1285 rings.push(Ring {
1286 segments,
1287 material_positive,
1288 positive_winding,
1289 });
1290 }
1291 let tolerance = (extent.max(period_along) * 1e-9).max(1e-12);
1292
1293 for (index, hole) in polylines.iter().enumerate() {
1294 if windings[index] != (0, 0) {
1295 continue;
1296 }
1297 for &station in hole {
1298 // Reduced into one period along the rings.
1299 let station = {
1300 let s = axis(station, along);
1301 let reduced = domain_start + (s - domain_start).rem_euclid(period_along);
1302 if along == 0 {
1303 (reduced, station.1)
1304 } else {
1305 (station.0, reduced)
1306 }
1307 };
1308 let held = if let Some(transverse) = transverse_period {
1309 periodic_band_holds(&rings, station, along, period_along, transverse)?
1310 } else {
1311 rings
1312 .iter()
1313 .all(|ring| ring_holds(ring, station, along, period_along))
1314 };
1315 if held {
1316 continue;
1317 }
1318 let (nearest, distance) =
1319 rings_nearest(&rings, station, along, period_along, transverse_period);
1320 if distance <= tolerance {
1321 continue;
1322 }
1323 if fitted && acquitted_in_space(&face.surface, station, nearest) {
1324 continue;
1325 }
1326 return Some(HoleBreach {
1327 face: face.id,
1328 loop_index: index,
1329 loop_id: face.loops[index].id,
1330 at: station,
1331 banded: true,
1332 });
1333 }
1334 }
1335 None
1336}
1337
1338/// Images relative to the segment midpoint nearest the query, rather than
1339/// the domain origin. Ordinary traced ring segments span at most a period
1340/// and a bit; two images either way cover those even after arbitrary whole
1341/// period translations. This is not a bound for multiply traversed segments.
1342const RING_IMAGES: [i32; 5] = [-2, -1, 0, 1, 2];
1343
1344/// Is `station` on this ring's material side? A ray from the station along
1345/// the OPEN direction, positive way, crosses the ring an odd number of times
1346/// exactly when the ring lies on the positive side of the station — and then
1347/// the station is on the ring's NEGATIVE side, which is the material side
1348/// exactly when the ring's material is not on its positive side.
1349fn ring_holds(ring: &Ring, station: (f64, f64), along: usize, period: f64) -> bool {
1350 let (s_along, s_open) = if along == 0 {
1351 (station.0, station.1)
1352 } else {
1353 (station.1, station.0)
1354 };
1355 let mut crossings = 0usize;
1356 for &(a, b) in &ring.segments {
1357 let (a_along, a_open) = if along == 0 { (a.0, a.1) } else { (a.1, a.0) };
1358 let (b_along, b_open) = if along == 0 { (b.0, b.1) } else { (b.1, b.0) };
1359 for image in RING_IMAGES {
1360 let shift =
1361 (((s_along - 0.5 * (a_along + b_along)) / period).round() + image as f64) * period;
1362 let (a_s, b_s) = (a_along + shift, b_along + shift);
1363 if (a_s > s_along) != (b_s > s_along) {
1364 let at = a_open + (s_along - a_s) * (b_open - a_open) / (b_s - a_s);
1365 if at > s_open {
1366 crossings += 1;
1367 }
1368 }
1369 }
1370 }
1371 let ring_is_positive = crossings % 2 == 1;
1372 ring_is_positive != ring.material_positive
1373}
1374
1375/// On a doubly periodic chart there is no outside at infinity. The first
1376/// transverse boundary ahead of a station decides its material side. Its
1377/// orientation comes from the same hole-area convention as the open band;
1378/// reversing all loops therefore preserves the region, while reversing just
1379/// the rings chooses its complement. Signed events cancel tangent contacts.
1380fn periodic_band_holds(
1381 rings: &[Ring],
1382 station: (f64, f64),
1383 along: usize,
1384 period: f64,
1385 transverse: f64,
1386) -> Option<bool> {
1387 let (s, t) = if along == 0 {
1388 station
1389 } else {
1390 (station.1, station.0)
1391 };
1392 let mut events = Vec::new();
1393 for ring in rings {
1394 for &(a, b) in &ring.segments {
1395 let (a_s, a_t) = if along == 0 { a } else { (a.1, a.0) };
1396 let (b_s, b_t) = if along == 0 { b } else { (b.1, b.0) };
1397 let centre = ((s - 0.5 * (a_s + b_s)) / period).round();
1398 for image in RING_IMAGES {
1399 let shift = (centre + image as f64) * period;
1400 let (a_image, b_image) = (a_s + shift, b_s + shift);
1401 if (a_image > s) != (b_image > s) {
1402 // Both segments incident to a tangent vertex must emit
1403 // the stored vertex value. Re-interpolating an endpoint
1404 // can move one event by ulps, splitting an exact signed
1405 // cancellation and falsely rejecting a remote station.
1406 // Compare in the same shifted chart as the crossing test;
1407 // no event-distance tolerance or physical band changes.
1408 let at = if s == a_image {
1409 a_t
1410 } else if s == b_image {
1411 b_t
1412 } else {
1413 a_t + (s - a_s - shift) * (b_t - a_t) / (b_s - a_s)
1414 };
1415 let positive = if (b_s > a_s) == ring.positive_winding {
1416 ring.material_positive
1417 } else {
1418 !ring.material_positive
1419 };
1420 events.push((
1421 (at - t).rem_euclid(transverse),
1422 if positive { 1i32 } else { -1 },
1423 ));
1424 }
1425 }
1426 }
1427 }
1428 events.sort_by(|a, b| a.0.total_cmp(&b.0));
1429 let mut i = 0;
1430 while i < events.len() {
1431 let distance = events[i].0;
1432 let mut sign = 0;
1433 while i < events.len() && events[i].0 == distance {
1434 sign += events[i].1;
1435 i += 1;
1436 }
1437 if sign != 0 {
1438 return Some(sign < 0);
1439 }
1440 }
1441 None
1442}
1443
1444/// The nearest point of any ring to `station`, over the rings' period images,
1445/// and its distance in the parameter plane.
1446fn rings_nearest(
1447 rings: &[Ring],
1448 station: (f64, f64),
1449 along: usize,
1450 period: f64,
1451 transverse: Option<f64>,
1452) -> ((f64, f64), f64) {
1453 let (u, v) = station;
1454 let mut best = (station, f64::INFINITY);
1455 for ring in rings {
1456 for &(a, b) in &ring.segments {
1457 for image in RING_IMAGES {
1458 let midpoint = (0.5 * (a.0 + b.0), 0.5 * (a.1 + b.1));
1459 let (s, m) = if along == 0 {
1460 (u, midpoint.0)
1461 } else {
1462 (v, midpoint.1)
1463 };
1464 let along_shift = (((s - m) / period).round() + image as f64) * period;
1465 for across_image in if transverse.is_some() {
1466 &RING_IMAGES[..]
1467 } else {
1468 &RING_IMAGES[2..3]
1469 } {
1470 let across_shift = transverse
1471 .map(|p| {
1472 let (s, m) = if along == 0 {
1473 (v, midpoint.1)
1474 } else {
1475 (u, midpoint.0)
1476 };
1477 (((s - m) / p).round() + *across_image as f64) * p
1478 })
1479 .unwrap_or(0.0);
1480 let shift = if along == 0 {
1481 (along_shift, across_shift)
1482 } else {
1483 (across_shift, along_shift)
1484 };
1485 let (x0, y0) = (a.0 + shift.0, a.1 + shift.1);
1486 let (x1, y1) = (b.0 + shift.0, b.1 + shift.1);
1487 let (dx, dy) = (x1 - x0, y1 - y0);
1488 let length_squared = dx * dx + dy * dy;
1489 let t = if length_squared > 0.0 {
1490 (((u - x0) * dx + (v - y0) * dy) / length_squared).clamp(0.0, 1.0)
1491 } else {
1492 0.0
1493 };
1494 let candidate = (x0 + t * dx, y0 + t * dy);
1495 let distance = ((u - candidate.0).powi(2) + (v - candidate.1).powi(2)).sqrt();
1496 if distance < best.1 {
1497 best = (candidate, distance);
1498 }
1499 }
1500 }
1501 }
1502 }
1503 best
1504}
1505
1506/// The "on the boundary" band, measured against the outer loop's own parameter
1507/// extent so it means the same thing on a 1 mm face and on a 1 m one.
1508fn boundary_tolerance(outer: &[(f64, f64)]) -> f64 {
1509 let (mut lo, mut hi) = ((f64::MAX, f64::MAX), (f64::MIN, f64::MIN));
1510 for &(u, v) in outer {
1511 lo = (lo.0.min(u), lo.1.min(v));
1512 hi = (hi.0.max(u), hi.1.max(v));
1513 }
1514 let extent = (hi.0 - lo.0).max(hi.1 - lo.1);
1515 (extent * 1e-9).max(1e-12)
1516}
1517
1518/// How far IN SPACE a station of a fitted hole loop may sit outside its face's
1519/// outer loop and still be read as ON it: ten times the floor the pcurve fit
1520/// refines to ([`crate::PCURVE_REFINEMENT_TOLERANCE`], 1e-7). A tangent mouth's
1521/// fit noise is under the floor; a breach the floor is for is a mouth carried
1522/// out by a motion, orders above it.
1523const CURVED_BREACH_ACQUITTAL: f64 = 10.0 * crate::PCURVE_REFINEMENT_TOLERANCE;
1524
1525/// Is a station that failed parity and the parameter band still within
1526/// [`CURVED_BREACH_ACQUITTAL`] of the face's boundary IN SPACE? Read through
1527/// the face's own surface at the station and at the boundary's nearest
1528/// parameter point (`nearest`), so a fitted pcurve's parameter noise is
1529/// judged by what it amounts to on the carrier. A surface that cannot be
1530/// evaluated acquits nothing.
1531fn acquitted_in_space(
1532 surface: &crate::NurbsSurface,
1533 point: (f64, f64),
1534 nearest: (f64, f64),
1535) -> bool {
1536 // EXTENDED, not clamped: a station past the end of an open direction is
1537 // read where the carrier's own ruled extension puts it, and one past a
1538 // seam where the period wraps it. `evaluate` clamps a parameter into the
1539 // domain, which would put every overshoot back ON the rim and acquit it.
1540 match (
1541 surface.evaluate_extended(point.0, point.1),
1542 surface.evaluate_extended(nearest.0, nearest.1),
1543 ) {
1544 (Ok(here), Ok(there)) => here.sub(there).length() <= CURVED_BREACH_ACQUITTAL,
1545 _ => false,
1546 }
1547}
1548
1549/// The floor's refusal, written off the breach it convicted.
1550fn hole_breach_refusal(solid: &BrepSolid, breach: &HoleBreach, entry: &str) -> KernelRefusal {
1551 let face = solid
1552 .shells
1553 .iter()
1554 .flat_map(|shell| &shell.faces)
1555 .find(|face| face.id == breach.face);
1556 let label = match face.and_then(|face| face.name.as_deref()) {
1557 Some(name) => format!("`{name}` (face {})", breach.face),
1558 None => format!("face {}", breach.face),
1559 };
1560 let (u, v) = breach.at;
1561 let where_in_space = face
1562 .and_then(|face| face.surface.evaluate_extended(u, v).ok())
1563 .map(|point| format!(" — ({:.4}, {:.4}, {:.4}) in space", point.x, point.y, point.z))
1564 .unwrap_or_default();
1565 KernelRefusal::unsound(
1566 KernelStage::Validate,
1567 SoundnessDefect::HoleBreach,
1568 vec![breach.face],
1569 format!(
1570 "{entry}: hole loop {} of {label} has LEFT that face — it runs outside the \
1571 face's own outer boundary, at ({u:.6}, {v:.6}) in the face's parameters{}. \
1572 A hole is a hole IN a face; this one bounds a wall that no longer opens \
1573 through it, so the right body needs the hole to break through a \
1574 neighbouring face, a topology change this operation does not make. \
1575 `validate()` cannot see it (every incidence is intact), the \
1576 self-intersection scan cannot (nothing crosses), and the volume need not \
1577 change at all — refusing rather than returning the wrong body",
1578 breach.loop_id, where_in_space,
1579 ),
1580 )
1581}
1582
1583// ---------------------------------------------------------------------------
1584// The breakout route: a hole that has left its face onto a NAMED neighbour
1585// ---------------------------------------------------------------------------
1586
1587/// The faces a breakout is between: the WALL the convicted hole loop bounds,
1588/// and the ONE neighbour of the breached face the hole has run out onto.
1589struct BreakoutPair {
1590 wall: u64,
1591 neighbour: u64,
1592}
1593
1594/// WHICH neighbour the runaway hole has gone onto — read in the breached
1595/// face's own parameter plane, where the question is a segment crossing and
1596/// not a guess.
1597///
1598/// The hole loop bounds a wall (for a bore, the bore's own cylindrical face):
1599/// exactly one other face answers for its edges, and that is the face whose
1600/// carrier has to be re-trimmed. The hole has left through some stretch of the
1601/// breached face's OUTER loop, and each outer coedge is shared with exactly one
1602/// neighbouring face, so the coedges the hole polyline crosses name the faces
1603/// it has gone out onto.
1604///
1605/// This refuses rather than choosing whenever the answer is not a single pair:
1606/// a hole bounding more than one face, a hole that crosses no outer coedge at
1607/// all (it has left the face's own parameter patch entirely — the runaway), and
1608/// a hole crossing into MORE THAN ONE neighbour, which is a breakout over a
1609/// corner and needs both re-trims resolved together rather than one at a time.
1610fn breakout_pair(solid: &BrepSolid, breach: &HoleBreach) -> Result<BreakoutPair, KernelRefusal> {
1611 let wall = breakout_wall(solid, breach)?;
1612 match breakout_neighbours_crossed(solid, breach, wall)?[..] {
1613 [neighbour] => Ok(BreakoutPair { wall, neighbour }),
1614 [] => Err(KernelRefusal::unsupported(KernelStage::Validate, NOT_A_STRADDLE_WHAT, NOT_A_STRADDLE)),
1615 ref many => Err(KernelRefusal::unsupported(KernelStage::Validate, "breakout.pair", format!(
1616 "the runaway hole crosses into {} neighbouring faces at once ({many:?}) — a \
1617 breakout over a corner, where the two re-trims bound each other and resolving one \
1618 at a time is a different problem from resolving both",
1619 many.len(),
1620 ))),
1621 }
1622}
1623
1624/// The marker [`breakout_pair`] returns when the hole does not STRADDLE its
1625/// face's boundary. It has either migrated wholly onto another face or left the
1626/// body; [`repair_hole_breakout`] tells those apart, by trying.
1627const NOT_A_STRADDLE: &str = "the hole does not straddle its own face's boundary";
1628
1629/// The slug [`breakout_pair`] mints [`NOT_A_STRADDLE`] under
1630/// (`UnsupportedGeometry`), shared with [`is_not_a_straddle`].
1631const NOT_A_STRADDLE_WHAT: &str = "breakout.not_a_straddle";
1632
1633/// Is this [`breakout_pair`]'s non-straddle answer? Read off the class and
1634/// slug, so the route's decision does not follow the sentence.
1635fn is_not_a_straddle(refusal: &KernelRefusal) -> bool {
1636 matches!(
1637 &refusal.class,
1638 crate::RefusalClass::UnsupportedGeometry { what } if what == NOT_A_STRADDLE_WHAT
1639 )
1640}
1641
1642/// The face the convicted hole loop BOUNDS — the moved carrier, for a bore the
1643/// bore's own wall. Exactly one other face may answer for its edges.
1644fn breakout_wall(solid: &BrepSolid, breach: &HoleBreach) -> Result<u64, KernelRefusal> {
1645 let face = face_by_id(solid, breach.face)?;
1646 let mut walls: Vec<u64> = Vec::new();
1647 for coedge in &face.loops[breach.loop_index].coedges {
1648 for other in solid.shells.iter().flat_map(|shell| &shell.faces) {
1649 if other.id == face.id {
1650 continue;
1651 }
1652 if other
1653 .loops
1654 .iter()
1655 .any(|wire| wire.coedges.iter().any(|use_| use_.edge_id == coedge.edge_id))
1656 && !walls.contains(&other.id)
1657 {
1658 walls.push(other.id);
1659 }
1660 }
1661 }
1662 match walls[..] {
1663 [wall] => Ok(wall),
1664 _ => Err(KernelRefusal::unsupported(KernelStage::Validate, "breakout.wall_count", format!(
1665 "the runaway hole loop bounds {} face(s) ({walls:?}); this route re-trims ONE moved \
1666 carrier against the body and cannot decide which of several is the one that moved",
1667 walls.len(),
1668 ))),
1669 }
1670}
1671
1672fn face_by_id<'a>(
1673 solid: &'a BrepSolid,
1674 id: u64,
1675) -> Result<&'a crate::FaceRecord, KernelRefusal> {
1676 solid
1677 .shells
1678 .iter()
1679 .flat_map(|shell| &shell.faces)
1680 .find(|face| face.id == id)
1681 .ok_or_else(|| {
1682 KernelRefusal::internal(KernelStage::Validate, "face_by_id", format!("face {id} is not on the body"))
1683 })
1684}
1685
1686/// The neighbours the hole has run out ONTO, read as segment crossings in the
1687/// breached face's own parameter plane.
1688///
1689/// This answers the STRADDLE: part of the mouth still inside the face it was
1690/// recorded on, part outside. It returns nothing for a mouth that has migrated
1691/// wholly onto another face — such a loop is entirely outside the outer loop
1692/// and crosses none of its coedges — and nothing for one that has left the body
1693/// altogether. Those two are told apart downstream, by geometry, in
1694/// [`repair_hole_breakout`].
1695fn breakout_neighbours_crossed(
1696 solid: &BrepSolid,
1697 breach: &HoleBreach,
1698 wall: u64,
1699) -> Result<Vec<u64>, KernelRefusal> {
1700 let face = face_by_id(solid, breach.face)?;
1701 let polylines = face
1702 .loops
1703 .iter()
1704 .map(loop_parameter_polyline)
1705 .collect::<Result<Vec<_>, KernelRefusal>>()
1706 .map_err(|error| {
1707 error.with_message(|error| format!("the breached face's loops could not be traced: {error}"))
1708 })?;
1709 let outer_index = (0..polylines.len())
1710 .max_by(|&a, &b| {
1711 polyline_area(&polylines[a])
1712 .abs()
1713 .total_cmp(&polyline_area(&polylines[b]).abs())
1714 })
1715 .unwrap_or(0);
1716 if outer_index == breach.loop_index {
1717 return Err(KernelRefusal::internal(KernelStage::Validate, "breakout.outer_loop", "the convicted loop is the face's own outer loop"));
1718 }
1719 let hole = &polylines[breach.loop_index];
1720
1721 let mut neighbours: Vec<u64> = Vec::new();
1722 for coedge in &face.loops[outer_index].coedges {
1723 let side = coedge_parameter_polyline(coedge)
1724 .map_err(|error| error.with_message(|error| format!("an outer coedge could not be traced: {error}")))?;
1725 if !polylines_cross(hole, &side) {
1726 continue;
1727 }
1728 for other in solid.shells.iter().flat_map(|shell| &shell.faces) {
1729 if other.id == face.id || other.id == wall {
1730 continue;
1731 }
1732 if other
1733 .loops
1734 .iter()
1735 .any(|wire| wire.coedges.iter().any(|use_| use_.edge_id == coedge.edge_id))
1736 && !neighbours.contains(&other.id)
1737 {
1738 neighbours.push(other.id);
1739 }
1740 }
1741 }
1742 Ok(neighbours)
1743}
1744
1745/// Every face across the breached face's OUTER boundary — the faces a mouth
1746/// leaving that face can have arrived on.
1747///
1748/// A migration is a mouth that has walked off one face and onto the next. The
1749/// next one is over an edge, so this is the candidate set, and it is small: the
1750/// outer loop's own neighbours, the wall excepted. A carrier that has reached
1751/// something further away than that is not a case this route claims, and it
1752/// says so rather than widening until it finds a face it can cut.
1753fn faces_over_the_outer_boundary(
1754 solid: &BrepSolid,
1755 breach: &HoleBreach,
1756 wall: u64,
1757) -> Result<Vec<u64>, KernelRefusal> {
1758 let face = face_by_id(solid, breach.face)?;
1759 let polylines = face
1760 .loops
1761 .iter()
1762 .map(loop_parameter_polyline)
1763 .collect::<Result<Vec<_>, KernelRefusal>>()
1764 .unwrap_or_default();
1765 let outer_index = (0..polylines.len())
1766 .max_by(|&a, &b| {
1767 polyline_area(&polylines[a])
1768 .abs()
1769 .total_cmp(&polyline_area(&polylines[b]).abs())
1770 })
1771 .unwrap_or(0);
1772 let mut over: Vec<u64> = Vec::new();
1773 for coedge in &face.loops[outer_index].coedges {
1774 for other in solid.shells.iter().flat_map(|shell| &shell.faces) {
1775 if other.id == face.id || other.id == wall {
1776 continue;
1777 }
1778 if other
1779 .loops
1780 .iter()
1781 .any(|wire| wire.coedges.iter().any(|use_| use_.edge_id == coedge.edge_id))
1782 && !over.contains(&other.id)
1783 {
1784 over.push(other.id);
1785 }
1786 }
1787 }
1788 Ok(over)
1789}
1790
1791/// One coedge as a polyline in its face's parameters, END POINT INCLUDED, so
1792/// the segments it yields are the whole of that coedge.
1793///
1794/// [`loop_parameter_polyline`] deliberately leaves each coedge's end point to
1795/// the next coedge's start; a single coedge asked about on its own has no next.
1796fn coedge_parameter_polyline(
1797 coedge: &crate::topology::CoedgeRecord,
1798) -> Result<Vec<(f64, f64)>, KernelRefusal> {
1799 let pcurve = &coedge.pcurve;
1800 let [d0, d1] = pcurve.domain().or_refuse(KernelStage::Validate, "domain")?;
1801 let steps = if pcurve.degree <= 1 && pcurve.control_points.len() == 2 {
1802 1
1803 } else {
1804 let mut spans = 0usize;
1805 for window in pcurve.knots.windows(2) {
1806 if window[1] > window[0] {
1807 spans += 1;
1808 }
1809 }
1810 spans.max(1) * LOOP_SAMPLES_PER_SPAN
1811 };
1812 let mut points = Vec::with_capacity(steps + 1);
1813 for step in 0..=steps {
1814 let uv = pcurve.evaluate(d0 + (d1 - d0) * (step as f64 / steps as f64)).or_refuse(KernelStage::Validate, "evaluate")?;
1815 points.push((uv.x, uv.y));
1816 }
1817 Ok(points)
1818}
1819
1820/// Does the CLOSED polyline `loop_points` cross the OPEN polyline `side`?
1821/// Proper segment intersection, in the face's own parameters.
1822fn polylines_cross(loop_points: &[(f64, f64)], side: &[(f64, f64)]) -> bool {
1823 for index in 0..loop_points.len() {
1824 let a = loop_points[index];
1825 let b = loop_points[(index + 1) % loop_points.len()];
1826 for window in side.windows(2) {
1827 if segments_cross(a, b, window[0], window[1]) {
1828 return true;
1829 }
1830 }
1831 }
1832 false
1833}
1834
1835fn segments_cross(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)) -> bool {
1836 let orient = |p: (f64, f64), q: (f64, f64), r: (f64, f64)| {
1837 (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0)
1838 };
1839 let (d1, d2) = (orient(c, d, a), orient(c, d, b));
1840 let (d3, d4) = (orient(a, b, c), orient(a, b, d));
1841 ((d1 > 0.0) != (d2 > 0.0)) && ((d3 > 0.0) != (d4 > 0.0))
1842}
1843
1844/// THE BREAKOUT, reached from the containment floor instead of from the scan.
1845///
1846/// The floor convicts a hole loop that has left the face it is recorded on,
1847/// planar or curved. Where that hole has left ONTO a named neighbour — the case the operator
1848/// reported, an exit hole dragged across the edge between two faces — the right
1849/// body is the one the self-crossing repair already builds for a bore carried
1850/// out through a side wall: the moved carrier and the neighbour split along
1851/// their exact section, the pieces that ended up on the wrong side dropped, and
1852/// the faces left holding an unanswered boundary RE-TRIMMED, which merges the
1853/// mouth into the outer loops of the faces it now opens through.
1854///
1855/// What makes it the SAME repair and not a second one is that the extent is
1856/// still derived and not invented: the tool is the moved carrier trimmed
1857/// against the body, and where it meets the body is what the imprint computes.
1858/// The route in is what changes — the scan needs a mesh crossing it can
1859/// confirm, and at the near edge of a breakout the crossing is a sliver the
1860/// tessellation does not resolve, so the floor sees it first.
1861///
1862/// The genuine runaway is excluded by GEOMETRY and not by a threshold. A bore
1863/// carried clean off the body crosses no boundary of the face it left, so
1864/// [`breakout_pair`] finds no neighbour and this refuses before a single face
1865/// is split; and even given a pair, the imprint of a carrier that misses the
1866/// body cuts neither trim and [`split_and_drop`] declines on its own terms.
1867fn repair_hole_breakout(solid: &BrepSolid, breach: &HoleBreach) -> Result<BrepSolid, KernelRefusal> {
1868 let origin = || RepairOrigin::Breakout {
1869 face: breach.face,
1870 loop_id: breach.loop_id,
1871 };
1872 if breach.banded {
1873 return Err(KernelRefusal::unsupported(
1874 KernelStage::Validate,
1875 "breakout.band_face",
1876 format!(
1877 "face {} is a band bounded by rings that wind its carrier's period, with no outer \
1878 loop in its parameter plane; this route re-trims a hole against the outer loop \
1879 it straddles and does not re-trim a band",
1880 breach.face,
1881 ),
1882 ));
1883 }
1884 // THE STRADDLE: part of the mouth still inside the face it was recorded on,
1885 // part outside. Which neighbour is read off the crossing, exactly.
1886 match breakout_pair(solid, breach) {
1887 Ok(BreakoutPair { wall, neighbour }) => {
1888 return split_and_drop(solid, wall, neighbour, origin())
1889 }
1890 // Every refusal but the non-straddle is this route's own, classed as it
1891 // always was here; the non-straddle is read off its slug, not its text.
1892 Err(reason) if !is_not_a_straddle(&reason) => {
1893 return Err(KernelRefusal::unsupported(
1894 KernelStage::Validate,
1895 "breakout.pair",
1896 reason.message,
1897 ))
1898 }
1899 Err(_) => {}
1900 }
1901
1902 // THE MIGRATION, or the RUNAWAY. The mouth is wholly outside the face it
1903 // was recorded on, so nothing in that face's own parameter plane can say
1904 // where it went — a mouth that has walked onto the next face and a mouth
1905 // that has left the solid look the same from there.
1906 //
1907 // They do not look the same to the IMPRINT. The question that separates
1908 // them is whether the moved carrier still meets a face of the body, and
1909 // `split_and_drop` answers exactly that: handed a face the carrier misses
1910 // it declines with "the carriers' intersection reaches ... neither", before
1911 // anything is dropped. So the candidates are tried, and the answer is
1912 // decided BY THE RESULT — the same principle the drop enumeration inside
1913 // already runs on, and a discriminator that cannot drift, because a carrier
1914 // clear of the body cuts nothing anywhere.
1915 //
1916 // More than one candidate building is not an answer: the mouth would be on
1917 // two faces at once with no reason to prefer either, and choosing would be
1918 // the guess this module exists not to make.
1919 let wall = breakout_wall(solid, breach).map_err(|refusal| {
1920 KernelRefusal::internal(KernelStage::Validate, "breakout.wall", refusal.message)
1921 })?;
1922 let mut candidates = faces_over_the_outer_boundary(solid, breach, wall)
1923 .map_err(|refusal| {
1924 KernelRefusal::internal(KernelStage::Validate, "breakout.candidates", refusal.message)
1925 })?;
1926 if candidates.is_empty() {
1927 return Err(KernelRefusal::unsupported(
1928 KernelStage::Validate,
1929 "breakout.runaway",
1930 RUNAWAY,
1931 ));
1932 }
1933
1934 // WHICH face the mouth moved onto, when its own face's parameter plane can
1935 // no longer say. The carrier is a whole surface and generally meets the
1936 // body in more than one place — a through bore leaves by one face and
1937 // enters by another, and both are faces this carrier can be re-trimmed
1938 // against — so "some face it cuts" is not the answer. The answer is the
1939 // face the MIGRATED MOUTH IS AT: the candidates are ordered by the least
1940 // 3D distance from the convicted hole loop to the face's own trim, and the
1941 // nearest one that the repair can actually build is taken.
1942 //
1943 // A TIE is refused rather than broken. Two faces the same distance from the
1944 // mouth means the mouth sits on the edge between them, which is a corner
1945 // breakout — the case whose two re-trims bound each other.
1946 let mouth = hole_loop_in_space(solid, breach).map_err(|refusal| {
1947 KernelRefusal::internal(KernelStage::Validate, "breakout.mouth", refusal.message)
1948 })?;
1949 let scale = crate::solid_scale(solid);
1950 let mut ranked: Vec<(f64, u64)> = Vec::new();
1951 for candidate in candidates.drain(..) {
1952 ranked.push((
1953 distance_to_face(solid, candidate, &mouth)
1954 .map_err(|refusal| {
1955 KernelRefusal::internal(KernelStage::Validate, "breakout.distance", refusal.message)
1956 })?,
1957 candidate,
1958 ));
1959 }
1960 ranked.sort_by(|a, b| a.0.total_cmp(&b.0));
1961 if ranked.len() > 1 && (ranked[1].0 - ranked[0].0).abs() <= scale * 1e-9 {
1962 return Err(KernelRefusal::ill_posed(
1963 KernelStage::Validate,
1964 "breakout.corner_tie",
1965 format!(
1966 "the migrated mouth is the same distance ({:.6}) from faces {} and {}, so the body \
1967 does not say which of them it moved onto — a breakout over the corner between \
1968 them, where the two re-trims bound each other",
1969 ranked[0].0, ranked[0].1, ranked[1].1,
1970 ),
1971 ));
1972 }
1973
1974 let carried = take_crossing_repairs();
1975 let mut declined: Vec<String> = Vec::new();
1976 for (distance, candidate) in ranked {
1977 match split_and_drop(solid, wall, candidate, origin()) {
1978 Ok(body) => {
1979 // The trial that built pushed a record; the ones that declined
1980 // pushed none. Keep that one and put the caller's ledger back.
1981 let mine = take_crossing_repairs();
1982 restore_repairs(carried.into_iter().chain(mine).collect());
1983 return Ok(body);
1984 }
1985 Err(reason) => {
1986 declined.push(format!("face {candidate} at {distance:.6}: {reason}"))
1987 }
1988 }
1989 }
1990 restore_repairs(carried);
1991 Err(KernelRefusal::unsupported(
1992 KernelStage::Validate,
1993 "breakout.runaway",
1994 format!(
1995 "{RUNAWAY} — the moved carrier was offered every face across that boundary, nearest \
1996 first, and cut none of them ({})",
1997 declined.join(" | ")
1998 ),
1999 ))
2000}
2001
2002/// The convicted hole loop as 3D points: its pcurves are in the breached face's
2003/// own parameters, so they are read back through that face's surface — the same
2004/// road the floor takes, and the reason the floor was never wrong about where a
2005/// loop is.
2006fn hole_loop_in_space(solid: &BrepSolid, breach: &HoleBreach) -> Result<Vec<crate::Vec3>, KernelRefusal> {
2007 let face = face_by_id(solid, breach.face)?;
2008 let uv = loop_parameter_polyline(&face.loops[breach.loop_index])?;
2009 uv.into_iter()
2010 .map(|(u, v)| face.surface.evaluate(u, v))
2011 .collect::<Result<Vec<_>, _>>()
2012 .map_err(|error| format!("the convicted hole loop could not be read in space: {error}"))
2013 .or_refuse(KernelStage::Validate, "evaluate")
2014}
2015
2016/// The least distance from a set of points to a face's own TRIM — sampled, in
2017/// the face's parameters, so a point beside a face's carrier but off the end of
2018/// the face does not read as being on it.
2019fn distance_to_face(
2020 solid: &BrepSolid,
2021 face_id: u64,
2022 points: &[crate::Vec3],
2023) -> Result<f64, KernelRefusal> {
2024 const STATIONS: usize = 32;
2025 let face = face_by_id(solid, face_id)?;
2026 let polylines = face
2027 .loops
2028 .iter()
2029 .map(loop_parameter_polyline)
2030 .collect::<Result<Vec<_>, KernelRefusal>>()?;
2031 let outer_index = (0..polylines.len())
2032 .max_by(|&a, &b| {
2033 polyline_area(&polylines[a])
2034 .abs()
2035 .total_cmp(&polyline_area(&polylines[b]).abs())
2036 })
2037 .unwrap_or(0);
2038 let bands = BoundaryBands::build(&polylines[outer_index]);
2039 let (mut lo, mut hi) = ((f64::MAX, f64::MAX), (f64::MIN, f64::MIN));
2040 for &(u, v) in &polylines[outer_index] {
2041 lo = (lo.0.min(u), lo.1.min(v));
2042 hi = (hi.0.max(u), hi.1.max(v));
2043 }
2044 let mut best = f64::INFINITY;
2045 for iu in 0..=STATIONS {
2046 for iv in 0..=STATIONS {
2047 let u = lo.0 + (hi.0 - lo.0) * iu as f64 / STATIONS as f64;
2048 let v = lo.1 + (hi.1 - lo.1) * iv as f64 / STATIONS as f64;
2049 if !bands.contains((u, v)) {
2050 continue;
2051 }
2052 if polylines
2053 .iter()
2054 .enumerate()
2055 .any(|(index, hole)| index != outer_index && inside_polyline((u, v), hole))
2056 {
2057 continue;
2058 }
2059 let Ok(point) = face.surface.evaluate(u, v) else {
2060 continue;
2061 };
2062 for probe in points {
2063 best = best.min(probe.sub(point).length());
2064 }
2065 }
2066 }
2067 Ok(best)
2068}
2069
2070/// Even-odd parity against a closed polyline, for the hole loops the band index
2071/// is not built over.
2072fn inside_polyline(point: (f64, f64), polygon: &[(f64, f64)]) -> bool {
2073 let (u, v) = point;
2074 let mut parity = false;
2075 for index in 0..polygon.len() {
2076 let (ax, ay) = polygon[index];
2077 let (bx, by) = polygon[(index + 1) % polygon.len()];
2078 if (ay > v) != (by > v) {
2079 let x = ax + (v - ay) / (by - ay) * (bx - ax);
2080 if x > u {
2081 parity = !parity;
2082 }
2083 }
2084 }
2085 parity
2086}
2087
2088/// The sentence a hole that has left the BODY earns, as opposed to one that has
2089/// moved onto another face. It is the one thing standing between the operator
2090/// and a silently ruined model, so it is a constant and not a phrasing.
2091const RUNAWAY: &str = "the runaway hole has left the face it was recorded on without arriving \
2092 on another: the moved carrier meets no face of the body, so there is nothing for it to be \
2093 re-trimmed against and no cut to perform at any extent";
2094
2095/// [`resolve_face_crossing`] for a pair this module has named itself rather
2096/// than read off the scan.
2097///
2098/// The scan is one way to find two faces of one body that have to be split
2099/// along their section, and the containment floor is another. Both hand the
2100/// same pair to the same repair.
2101pub fn resolve_face_crossing_between(
2102 solid: &BrepSolid,
2103 face_a: u64,
2104 face_b: u64,
2105) -> Result<BrepSolid, KernelRefusal> {
2106 let report = scan(solid, "resolve_face_crossing_between")?;
2107 split_and_drop(solid, face_a, face_b, RepairOrigin::Scan(&report))
2108}
2109
2110// ---------------------------------------------------------------------------
2111// The repair
2112// ---------------------------------------------------------------------------
2113
2114/// WHAT FOUND the pair this repair is about to split.
2115///
2116/// Two floors reach the same repair. The scan finds two faces whose meshes
2117/// cross and can say where and by how much; the hole-containment floor finds a
2118/// hole loop that has left its face and can say which loop and which face. The
2119/// repair does the same work either way, and the ledger says which asked for
2120/// it rather than printing a crossing point that was never measured.
2121enum RepairOrigin<'a> {
2122 /// The self-intersection scan confirmed a crossing pair.
2123 Scan(&'a SelfIntersectionReport),
2124 /// The containment floor convicted a hole loop that has broken out onto a
2125 /// neighbouring face.
2126 Breakout { face: u64, loop_id: u64 },
2127}
2128
2129/// The operand tag this repair works under. The solid is its own and only
2130/// operand: what is being resolved is a body crossing ITSELF, so there is no
2131/// second operand to be the other side of.
2132const SELF_OPERAND: u8 = 0;
2133
2134/// How many pieces the crossing may cut the two faces into before this repair
2135/// declines. Every way of dropping some of them is REBUILT and re-checked, so
2136/// the enumeration has to stay small enough to run in full: at eight pieces
2137/// there are at most 225 candidate drops, nearly all of which the re-trim
2138/// closure ends before anything is assembled. The variable-radius fillet's
2139/// crossing cuts four pieces and the two-bore fixture's cuts six.
2140const MAXIMUM_CROSSING_PIECES: usize = 8;
2141
2142/// Split two faces of ONE body that cross each other along their carriers'
2143/// exact intersection, drop the material the crossing encloses, and re-sew.
2144///
2145/// This is the bounded half of the self-intersection answer. Where two walls of
2146/// one body overshoot past each other instead of meeting at an edge, the
2147/// crossing curve IS the edge they should have met at: imprinting it onto both
2148/// faces cuts each into the part that belongs and the tail that overshot, and
2149/// dropping both tails leaves a body sewn along that curve.
2150///
2151/// **What it refuses, and why each refusal is a bound rather than a shrug:**
2152///
2153/// * A FOLD inside one face. There is no second face to imprint against, and
2154/// the answer is a trim that was never constructed rather than material to
2155/// drop — `offset/carve.rs` owns it.
2156/// * More than one crossing pair. Each repair changes the faces the next one
2157/// would be measured against, so resolving them together is a different
2158/// problem from resolving one.
2159/// * A crossing whose carriers' intersection does not cut BOTH trims. Then the
2160/// mesh crossing is not a curve the faces share and there is nothing to split
2161/// along.
2162/// * More pieces than [`MAXIMUM_CROSSING_PIECES`], because every candidate drop
2163/// is rebuilt in full and the enumeration has to run to the end rather than
2164/// be sampled.
2165/// * Any split whose reassembly does not come back SOUND. Every way of dropping
2166/// some pieces of each face — keeping at least one of each — is enumerated,
2167/// closed under the re-trim, rebuilt and re-checked in full (`validate()`
2168/// clean, connected, and scanning clean), so the piece choice is decided by
2169/// the result rather than guessed from a normal. If none verifies, the solid
2170/// is returned untouched and the caller refuses: a repair that cannot prove
2171/// its own answer must not ship one.
2172/// * A split that MORE THAN ONE drop resolves. Two sound answers is not an
2173/// answer, and choosing between them would be the guess this repair exists
2174/// not to make.
2175///
2176/// **The re-trim is the other half of the split.** Dropping a tail also removes
2177/// that tail's boundary uses on the NEIGHBOURING faces it bordered, and a body
2178/// cannot close over an edge only one face answers for. So the drop is closed
2179/// under "a face left holding an unmatched boundary goes too" — which, since
2180/// the imprint already cut those neighbours where the crossing curve met their
2181/// edges, drops the part of each that the crossing cut off. The closure is
2182/// bounded by the faces the imprint TOUCHED: reaching any other face ends that
2183/// candidate. Nothing here edits a loop by hand; every piece it chooses between
2184/// came out of the imprint/fragment machinery.
2185///
2186/// **And the drop RETIRES what the old configuration left.** The touched bound
2187/// is about material the crossing encloses, and it does not reach the third
2188/// thing a moved carrier leaves on a body: a piece, or a whole loop, that the
2189/// NEW configuration has no room for — a bore's exit ring still recorded on the
2190/// top face after the exit has migrated onto the side. Neither is material this
2191/// drop ran past; both are held by nothing the drop keeps, and keeping either
2192/// cannot close. [`fragment_is_detached`] retires the one the fragmenter
2193/// returns as its own island and [`holds_only_stale_loops`] the one it returns
2194/// as a hole loop that has left its face, and the ledger says a repair retired
2195/// something rather than leaving it to be read off the cell counts.
2196///
2197/// **A census has two directions and only one was enforced.** An edge used ONCE
2198/// is a face left holding an unanswered boundary, above. An edge used THREE
2199/// times is three faces along one curve, which is not a body either, and the
2200/// assembly does not refuse it — it puts the third face on a shell of its own
2201/// and every floor in this repository then passes the result.
2202/// [`over_used_edge`] refuses that candidate where the third use still exists,
2203/// which is here. It is what stopped the turned-bore migration returning a body
2204/// 23% out on area.
2205pub fn resolve_face_crossing(solid: &BrepSolid) -> Result<BrepSolid, KernelRefusal> {
2206 let options = SelfIntersectionOptions::for_solid(solid);
2207 let report = solid_self_intersections(solid, options).map_err(|message| {
2208 KernelRefusal::internal(
2209 KernelStage::Validate,
2210 "soundness_scan",
2211 format!("the soundness scan could not run: {message}"),
2212 )
2213 })?;
2214 if let Some(fold) = report.folds.first() {
2215 return Err(KernelRefusal::unsound(
2216 KernelStage::Validate,
2217 SoundnessDefect::Fold,
2218 report.folds.iter().map(|fold| fold.face).collect(),
2219 format!(
2220 "face {} folds through ITSELF; a fold is a trim that was never constructed and \
2221 belongs to the carrier carve, not to this repair",
2222 fold.face
2223 ),
2224 ));
2225 }
2226 if report.confirmed.is_empty() {
2227 return Ok(solid.clone());
2228 }
2229 if report.confirmed.len() > 1 {
2230 return Err(KernelRefusal::unsupported(
2231 KernelStage::Validate,
2232 "crossing.multiple_pairs",
2233 format!(
2234 "{} crossing face pairs; each repair changes the faces the next would be measured \
2235 against, so this repair takes one pair at a time",
2236 report.confirmed.len()
2237 ),
2238 ));
2239 }
2240 let crossing = &report.confirmed[0];
2241 split_and_drop(solid, crossing.face_a, crossing.face_b, RepairOrigin::Scan(&report))
2242}
2243
2244/// One crossing pair, split and resolved. Separated from the scan so the
2245/// refusals above read as one list.
2246fn split_and_drop(
2247 solid: &BrepSolid,
2248 face_a: u64,
2249 face_b: u64,
2250 origin: RepairOrigin<'_>,
2251) -> Result<BrepSolid, KernelRefusal> {
2252 use crate::fragment::fragment_solid;
2253 use crate::imprint::{FaceKey, ImprintOptions};
2254 use crate::{apply_edge_splits, build_imprints};
2255
2256 let scale = crate::solid_scale(solid);
2257 let tolerance = (scale * 1e-7).max(1e-9);
2258 if trace_on() {
2259 eprintln!("HEAL split_and_drop: faces {face_a} | {face_b}, scale {scale:.6}");
2260 trace_solid("entry solid", solid);
2261 }
2262
2263 // Each face as its own one-face open body, which is what the imprint
2264 // driver pairs: it walks operand A's faces against operand B's, and a
2265 // solid paired with itself would pair every face with itself first.
2266 let open_a = one_face_body(solid, face_a)?;
2267 let open_b = one_face_body(solid, face_b)?;
2268 let mut request = ImprintOptions {
2269 tolerance,
2270 // The GLOBAL interpolation lane: the section the repair cuts along is
2271 // the faithful one. On the only row this repair takes
2272 // (`FilletFailureWith2RasiusValues`) the global lane's section lies
2273 // 1.6e-6 from its two carriers where the local lane's lies 7.8e-4, and
2274 // it moves every face the section bounds onto its closed form (`Pin_S`
2275 // 1.6e-4 -> 4.4e-7). The local lane was asked for until
2276 // `process_curve` split an edge a section ENDS on whatever the angle:
2277 // that section is tangent to the bore's mouth ring at both ends, the
2278 // global fit reproduces the tangency, and the old gates left the ring
2279 // whole, so the repair succeeded only on the local fit's 1.4-degree
2280 // end-tangent error. `BREP_HEAL_FIT_LANE` re-measures it.
2281 local_fit: false,
2282 // The station budget is the imprint's own (the ladder climbs it to
2283 // twice that). `96` was `offset_shell`'s: every section this repair
2284 // reaches is a sparse march (68 points on the fillet row, 7 and 8 on
2285 // `BadBoolean`) that saturates either budget, bit-identically.
2286 ..ImprintOptions::default()
2287 };
2288 measured_fit_request(&mut request);
2289 let mut imprint = build_imprints(&open_a, &open_b, &request).map_err(|error| {
2290 error.with_message(|error| format!("the two carriers could not be imprinted: {error}"))
2291 })?;
2292
2293 // Both faces belong to ONE body, so both sides of the imprint are rewritten
2294 // onto that body's operand. The face ids are the solid's own and cannot
2295 // collide, so this is a relabel and not a merge.
2296 for face in &mut imprint.by_face {
2297 face.operand = SELF_OPERAND;
2298 }
2299 for split in &mut imprint.edge_splits {
2300 split.operand = SELF_OPERAND;
2301 }
2302 for piece in &mut imprint.pieces {
2303 for support in &mut piece.support_faces {
2304 *support = FaceKey {
2305 operand: SELF_OPERAND,
2306 face_id: support.face_id,
2307 };
2308 }
2309 for pcurve in &mut piece.pcurves {
2310 pcurve.operand = SELF_OPERAND;
2311 }
2312 }
2313 for barrier in &mut imprint.barrier_edges {
2314 barrier.0 = SELF_OPERAND;
2315 }
2316
2317 if trace_on() {
2318 trace_imprint(&imprint);
2319 }
2320
2321 let cut_a = imprint
2322 .by_face
2323 .iter()
2324 .any(|face| face.face_id == face_a && !face.piece_ids.is_empty());
2325 let cut_b = imprint
2326 .by_face
2327 .iter()
2328 .any(|face| face.face_id == face_b && !face.piece_ids.is_empty());
2329 if !cut_a || !cut_b {
2330 return Err(KernelRefusal::unsupported(
2331 KernelStage::Fragment,
2332 "crossing.no_shared_curve",
2333 format!(
2334 "the carriers' intersection reaches {} of the two trims, so there is no shared \
2335 curve to split along",
2336 match (cut_a, cut_b) {
2337 (true, false) => format!("only face {face_a}"),
2338 (false, true) => format!("only face {face_b}"),
2339 _ => "neither".into(),
2340 }
2341 ),
2342 ));
2343 }
2344
2345 let split = apply_edge_splits(solid, SELF_OPERAND, &imprint).map_err(|error| {
2346 error.with_message(|error| format!("the imprinted edge splits could not be applied: {error}"))
2347 })?;
2348 let fragments = fragment_solid(&split, SELF_OPERAND, &imprint).map_err(|error| {
2349 error.with_message(|error| format!("the split faces could not be fragmented: {error}"))
2350 })?;
2351
2352 if trace_on() {
2353 trace_solid("split solid", &split);
2354 trace_fragments(&fragments);
2355 }
2356
2357 // The faces the imprint TOUCHED: the two that cross, plus every face whose
2358 // own loop carries an edge the imprint split. Those are exactly the faces
2359 // the dropped material can border — the crossing curve ends ON a boundary
2360 // edge of each crossing face, and that edge is shared with a neighbour — so
2361 // they are the only faces this repair may re-trim. Anything beyond them is
2362 // material this repair has not been shown to own, and reaching one ends the
2363 // candidate rather than widening the drop.
2364 let touched = touched_faces(solid, &imprint, face_a, face_b);
2365 // A POLE edge is legitimately used once — it is a collapsed boundary, not a
2366 // shared one — so it never means the face beside it bordered dropped
2367 // material. Left in the census it would drop the fragment holding it, and
2368 // the cascade would refuse a body the repair could have resolved.
2369 let degenerate: rustc_hash::FxHashSet<EdgeKey> = split
2370 .edges
2371 .iter()
2372 .filter(|edge| edge.degenerate)
2373 .map(|edge| EdgeKey::Boundary(SELF_OPERAND, edge.id))
2374 .collect();
2375
2376 let pieces_of = |face: u64| -> Vec<usize> {
2377 fragments
2378 .iter()
2379 .enumerate()
2380 .filter_map(|(index, fragment)| (fragment.source_face_id == face).then_some(index))
2381 .collect()
2382 };
2383 let pieces_a = pieces_of(face_a);
2384 let pieces_b = pieces_of(face_b);
2385 // How many pieces the section has to cut, and it is not the same question
2386 // on both roads.
2387 //
2388 // From the SCAN, two faces overshoot PAST each other and the crossing curve
2389 // is the edge they should have met at, so it runs across both trims and
2390 // each has a tail: both sides must come out in two or more pieces, and one
2391 // piece is a section that grazed a trim rather than cut it.
2392 //
2393 // From the containment floor, the shape is different and the difference is
2394 // the whole of a COMPLETED MIGRATION. When a bore's mouth has walked
2395 // entirely onto the next face, the moved carrier meets that face in a
2396 // CLOSED loop inside its trim — the new mouth — which cuts nothing off it;
2397 // the face comes back as one piece carrying a new hole, and the only tail
2398 // is the wall's. Demanding two pieces of both would refuse exactly the case
2399 // the operator asked for, so this road asks for two pieces on ONE side and
2400 // at least one on the other. Nothing about the enumeration changes: a face
2401 // with one piece has one drop set, the empty one.
2402 let (need_a, need_b) = match &origin {
2403 RepairOrigin::Scan(_) => (2, 2),
2404 RepairOrigin::Breakout { .. } => {
2405 if pieces_a.len().max(pieces_b.len()) >= 2 {
2406 (1, 1)
2407 } else {
2408 (2, 2)
2409 }
2410 }
2411 };
2412 if pieces_a.len() < need_a || pieces_b.len() < need_b {
2413 return Err(KernelRefusal::unsupported(
2414 KernelStage::Fragment,
2415 "crossing.one_sided_cut",
2416 format!(
2417 "the crossing cut face {face_a} into {} piece(s) and face {face_b} into {} — this \
2418 repair resolves the case where the curve cuts BOTH trims, so one part of each is \
2419 the tail that overshot",
2420 pieces_a.len(),
2421 pieces_b.len()
2422 ),
2423 ));
2424 }
2425 if pieces_a.len() + pieces_b.len() > MAXIMUM_CROSSING_PIECES {
2426 return Err(KernelRefusal::non_convergence(
2427 KernelStage::Fragment,
2428 "crossing.pieces",
2429 format!(
2430 "the crossing cut face {face_a} into {} piece(s) and face {face_b} into {}, which is \
2431 {} pieces against this repair's budget of {MAXIMUM_CROSSING_PIECES} — every way to \
2432 drop some of them is rebuilt and re-checked, so the enumeration is bounded rather \
2433 than sampled",
2434 pieces_a.len(),
2435 pieces_b.len(),
2436 pieces_a.len() + pieces_b.len(),
2437 ),
2438 ));
2439 }
2440
2441 // WHICH pieces are the tails is decided by the RESULT, not by a normal.
2442 // Every way to drop some pieces of each crossing face (keeping at least one
2443 // of each) is closed under the re-trim below, rebuilt in full and
2444 // re-checked; the repair is the answer only when exactly ONE of them
2445 // verifies.
2446 //
2447 // The enumeration runs under TWO closures, and the second only where the
2448 // first found nothing at all. [`DropClosure::Drop`] drops a neighbour the
2449 // drop leaves holding an unmatched boundary — the rule this repair has
2450 // always used, and the one every body it already resolves is decided by.
2451 // [`DropClosure::Retrim`] RE-TRIMS that neighbour instead. Ordering them
2452 // this way makes the second closure strictly additive: a body the first
2453 // resolves never reaches it, so no answer this repair already returns can
2454 // change. The price is the enumeration twice over on a body that refuses.
2455 let plan = DropPlan {
2456 split: &split,
2457 fragments: &fragments,
2458 imprint: &imprint,
2459 touched: &touched,
2460 degenerate: °enerate,
2461 pieces_a: &pieces_a,
2462 pieces_b: &pieces_b,
2463 face_a,
2464 face_b,
2465 scale,
2466 };
2467 let (mut verified, refusals) = plan.enumerate(DropClosure::Drop);
2468 if verified.is_empty() {
2469 verified = plan.enumerate(DropClosure::Retrim).0;
2470 }
2471
2472 if verified.len() > 1 {
2473 return Err(KernelRefusal::ill_posed(
2474 KernelStage::Select,
2475 "crossing.ambiguous_drop",
2476 format!(
2477 "{} different drops of the crossing pieces all rebuilt into a sound body, so the \
2478 result does not decide which material the crossing encloses; this repair answers \
2479 only where one does",
2480 verified.len()
2481 ),
2482 ));
2483 }
2484 let Some(Candidate {
2485 dropped,
2486 retired,
2487 retrimmed,
2488 solid: repaired,
2489 }) = verified.pop()
2490 else {
2491 // The first few reasons, not all of them: eight pieces is up to 225
2492 // candidates and a refusal nobody can read is a refusal nobody uses.
2493 let listed = refusals
2494 .iter()
2495 .take(4)
2496 .cloned()
2497 .collect::<Vec<_>>()
2498 .join(" | ");
2499 // The candidate reasons (an over-used edge, a closure that consumed
2500 // every face, a rebuild that declined) are CONTEXT inside this one
2501 // refusal, whose class is minted here: the body still crosses itself.
2502 return Err(KernelRefusal::unsound(
2503 KernelStage::Sew,
2504 SoundnessDefect::Crossing,
2505 vec![face_a, face_b],
2506 format!(
2507 "none of the {} ways of dropping the crossing pieces rebuilt into a sound body \
2508 ({listed}{}); the worst crossing was {:.3e} past its band, so the overshoot is \
2509 real and it is the SPLIT that did not resolve it",
2510 refusals.len(),
2511 if refusals.len() > 4 {
2512 format!(" | and {} more", refusals.len() - 4)
2513 } else {
2514 String::new()
2515 },
2516 match &origin {
2517 RepairOrigin::Scan(report) => report
2518 .confirmed
2519 .first()
2520 .map(|crossing| crossing.straddle)
2521 .unwrap_or_default(),
2522 RepairOrigin::Breakout { .. } => 0.0,
2523 }
2524 ),
2525 ));
2526 };
2527
2528 record_repair(
2529 &fragments,
2530 &dropped,
2531 &retired,
2532 &retrimmed,
2533 face_a,
2534 face_b,
2535 &origin,
2536 );
2537 Ok(repaired)
2538}
2539
2540/// One candidate answer: the fragments the drop removed, the ones it RETIRED
2541/// as detached, the faces it RE-TRIMMED rather than removed, and the body
2542/// those choices rebuilt into.
2543struct Candidate {
2544 dropped: Vec<usize>,
2545 retired: Vec<u64>,
2546 retrimmed: Vec<u64>,
2547 solid: BrepSolid,
2548}
2549
2550/// How a candidate drop closes over a neighbour it leaves holding a boundary
2551/// no other face answers for.
2552#[derive(Clone, Copy, Eq, PartialEq)]
2553enum DropClosure {
2554 /// DROP that neighbour too, iterated to a fixed point — [`close_drop_set`].
2555 Drop,
2556 /// RE-TRIM it — [`retrim_drop_set`]: take the unmatched coedges off it and
2557 /// re-link what remains into closed loops on the same carrier, with the
2558 /// same pcurves. The bound is the same one (a face the imprint never cut
2559 /// still ends the candidate); what changes is that a face it DID cut is
2560 /// re-trimmed rather than consumed.
2561 Retrim,
2562}
2563
2564/// One crossing pair's split and everything a candidate drop is measured
2565/// against. Bundled so the enumeration can run once per closure.
2566struct DropPlan<'a> {
2567 split: &'a BrepSolid,
2568 fragments: &'a [crate::fragment::FaceFragmentRecord],
2569 imprint: &'a crate::imprint::ImprintResultRecord,
2570 touched: &'a rustc_hash::FxHashSet<u64>,
2571 degenerate: &'a rustc_hash::FxHashSet<EdgeKey>,
2572 pieces_a: &'a [usize],
2573 pieces_b: &'a [usize],
2574 face_a: u64,
2575 face_b: u64,
2576 scale: f64,
2577}
2578
2579impl DropPlan<'_> {
2580 /// Every way of dropping some pieces of each crossing face, closed under
2581 /// `closure`, rebuilt in full and re-checked. The candidates that verified
2582 /// come back first, the reasons the rest did not second.
2583 fn enumerate(&self, closure: DropClosure) -> (Vec<Candidate>, Vec<String>) {
2584 let (face_a, face_b) = (self.face_a, self.face_b);
2585 let mut refusals: Vec<String> = Vec::new();
2586 let mut verified: Vec<Candidate> = Vec::new();
2587 let mut seen: Vec<(Vec<usize>, Vec<Vec<usize>>)> = Vec::new();
2588 for drop_a in proper_subsets(self.pieces_a) {
2589 for drop_b in proper_subsets(self.pieces_b) {
2590 let mut dropped: Vec<usize> =
2591 drop_a.iter().chain(drop_b.iter()).copied().collect();
2592 if dropped.is_empty() {
2593 continue;
2594 }
2595 let label = format!(
2596 "dropping piece(s) {:?} of face {face_a} and {:?} of face {face_b}",
2597 drop_a
2598 .iter()
2599 .map(|index| self
2600 .pieces_a
2601 .iter()
2602 .position(|piece| piece == index)
2603 .unwrap_or(0))
2604 .collect::<Vec<_>>(),
2605 drop_b
2606 .iter()
2607 .map(|index| self
2608 .pieces_b
2609 .iter()
2610 .position(|piece| piece == index)
2611 .unwrap_or(0))
2612 .collect::<Vec<_>>(),
2613 );
2614 // The re-trim: a face that bordered dropped material is left
2615 // with a boundary use no other face answers, and a body cannot
2616 // close over one. Closing the drop under that rule is what
2617 // turns a two-face split into a re-trim of every face the
2618 // dropped material bordered.
2619 // The dropping closure leaves every fragment as it was, so it
2620 // borrows the list rather than copying it per candidate.
2621 let mut retired: Vec<u64> = Vec::new();
2622 let closed = match closure {
2623 DropClosure::Drop => close_drop_set(
2624 self.fragments,
2625 &mut dropped,
2626 &mut retired,
2627 self.touched,
2628 self.degenerate,
2629 )
2630 .map(|()| (None, Vec::new())),
2631 DropClosure::Retrim => retrim_drop_set(
2632 self.fragments,
2633 &mut dropped,
2634 &mut retired,
2635 self.touched,
2636 self.degenerate,
2637 )
2638 .map(|(working, retrimmed)| (Some(working), retrimmed)),
2639 };
2640 let (working, retrimmed) = match closed {
2641 Ok(closed) => closed,
2642 Err(reason) => {
2643 if trace_on() {
2644 println!(" candidate died at closure {label}: {reason}");
2645 }
2646 refusals.push(format!("{label}: {reason}"));
2647 continue;
2648 }
2649 };
2650 let working: &[crate::fragment::FaceFragmentRecord] =
2651 working.as_deref().unwrap_or(self.fragments);
2652 dropped.sort_unstable();
2653 // Two seeds can close onto the same answer. The signature is
2654 // the drop AND what the re-trim left of each kept fragment, so
2655 // a second closure that re-trimmed differently is a different
2656 // candidate rather than a hidden duplicate.
2657 let signature = (
2658 dropped.clone(),
2659 working
2660 .iter()
2661 .enumerate()
2662 .filter(|(index, _)| !dropped.contains(index))
2663 .map(|(_, fragment)| {
2664 fragment
2665 .loops
2666 .iter()
2667 .map(|fragment_loop| fragment_loop.coedges.len())
2668 .collect()
2669 })
2670 .collect(),
2671 );
2672 if seen.contains(&signature) {
2673 continue;
2674 }
2675 seen.push(signature);
2676 if dropped.len() == working.len() {
2677 refusals.push(format!("{label}: the re-trim consumed every face"));
2678 continue;
2679 }
2680 // The census's OTHER direction — see [`over_used_edge`].
2681 if let Some((key, count)) = over_used_edge(working, &dropped, self.degenerate) {
2682 refusals.push(format!(
2683 "{label}: {} is held {count} times by the pieces this drop keeps, and a \
2684 closed body holds every edge exactly twice",
2685 match key {
2686 EdgeKey::Boundary(_, edge) => format!("edge {edge}"),
2687 EdgeKey::Imprint(piece) => format!("imprint piece {piece}"),
2688 }
2689 ));
2690 if trace_on() {
2691 println!(
2692 " candidate refused {label}: {key:?} held {count} times"
2693 );
2694 }
2695 continue;
2696 }
2697 let selected: Vec<_> = working
2698 .iter()
2699 .enumerate()
2700 .filter(|(index, _)| !dropped.contains(index))
2701 .map(|(_, fragment)| fragment.clone())
2702 .collect();
2703 match rebuild(self.split, selected, self.imprint, self.scale) {
2704 // `BREP_HEAL_TRACE=1` prints every candidate the
2705 // enumeration considered, whether it verified, and what
2706 // volume it produced. This is the instrument that found the
2707 // 2026-09-22 stale-fragment defect: the `touched` bound
2708 // kills most candidates before `verified.len() == 1` picks
2709 // a survivor, and without the list there is no way to see
2710 // that the survivor was not the right one.
2711 Ok(solid) => {
2712 if trace_on() {
2713 println!(
2714 " CANDIDATE VERIFIED {label} volume {:.6} dropped {:?}",
2715 crate::solid_mass_properties(&solid)
2716 .map(|mass| mass.volume)
2717 .unwrap_or(f64::NAN),
2718 dropped
2719 );
2720 }
2721 verified.push(Candidate {
2722 dropped,
2723 retired,
2724 retrimmed,
2725 solid,
2726 })
2727 }
2728 Err(reason) => {
2729 if trace_on() {
2730 println!(" candidate refused {label}: {reason}");
2731 }
2732 refusals.push(format!("{label}: {reason}"))
2733 }
2734 }
2735 }
2736 }
2737 (verified, refusals)
2738 }
2739}
2740
2741/// The faces this repair may re-trim: the two that cross, and every face whose
2742/// own loops use an edge the imprint split.
2743fn touched_faces(
2744 solid: &BrepSolid,
2745 imprint: &crate::imprint::ImprintResultRecord,
2746 face_a: u64,
2747 face_b: u64,
2748) -> rustc_hash::FxHashSet<u64> {
2749 use rustc_hash::FxHashSet as HashSet;
2750 let split_edges: HashSet<u64> = imprint
2751 .edge_splits
2752 .iter()
2753 .filter(|split| split.operand == SELF_OPERAND)
2754 .map(|split| split.edge_id)
2755 .collect();
2756 let mut touched: HashSet<u64> = [face_a, face_b].into_iter().collect();
2757 for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
2758 let uses_split = face
2759 .loops
2760 .iter()
2761 .flat_map(|loop_record| &loop_record.coedges)
2762 .any(|coedge| split_edges.contains(&coedge.edge_id));
2763 if uses_split {
2764 touched.insert(face.id);
2765 }
2766 }
2767 touched
2768}
2769
2770/// Close a drop set under the rule a closed body cannot break: every edge is
2771/// used exactly twice. Dropping a tail takes its uses with it, so any face left
2772/// holding a boundary no other face answers bordered the dropped material and
2773/// has to be re-trimmed — which, since the imprint already cut it, means
2774/// dropping the part of it that the crossing cut off. Iterated to a fixed
2775/// point, and bounded by the touched set: reaching a face the imprint never cut
2776/// means the drop has left the material the crossing encloses, and the
2777/// candidate is refused rather than grown.
2778///
2779/// The one thing that bound does not own is a fragment joined to NOTHING the
2780/// drop keeps — see [`fragment_is_detached`]. That is not material the drop ran
2781/// past; it is what the old configuration left, and it is retired wherever it
2782/// came from.
2783fn close_drop_set(
2784 fragments: &[crate::fragment::FaceFragmentRecord],
2785 dropped: &mut Vec<usize>,
2786 retired: &mut Vec<u64>,
2787 touched: &rustc_hash::FxHashSet<u64>,
2788 degenerate: &rustc_hash::FxHashSet<EdgeKey>,
2789) -> Result<(), KernelRefusal> {
2790 use rustc_hash::FxHashMap as HashMap;
2791 loop {
2792 let mut uses: HashMap<EdgeKey, usize> = HashMap::default();
2793 for (index, fragment) in fragments.iter().enumerate() {
2794 if dropped.contains(&index) {
2795 continue;
2796 }
2797 for key in fragment
2798 .loops
2799 .iter()
2800 .flat_map(|fragment_loop| &fragment_loop.coedges)
2801 .filter_map(|coedge| edge_key(&coedge.source))
2802 {
2803 *uses.entry(key).or_default() += 1;
2804 }
2805 }
2806 let mut grew = false;
2807 for (index, fragment) in fragments.iter().enumerate() {
2808 if dropped.contains(&index) {
2809 continue;
2810 }
2811 // RETIRED, not re-trimmed: a fragment holding no edge that any
2812 // other kept fragment holds is attached to nothing this drop keeps.
2813 // The [`touched`] bound does not apply to it, because that bound
2814 // asks whether the drop has run past the material the crossing
2815 // encloses — and a piece joined to the body by nothing is not
2816 // material this drop reached, it is material the OLD configuration
2817 // left behind. Retiring it cannot cascade: the only uses it removes
2818 // are uses nothing else holds. See [`fragment_is_detached`].
2819 if fragment_is_detached(fragment, &uses, degenerate) {
2820 dropped.push(index);
2821 retired.push(fragment.source_face_id);
2822 grew = true;
2823 continue;
2824 }
2825 let unmatched = fragment
2826 .loops
2827 .iter()
2828 .flat_map(|fragment_loop| &fragment_loop.coedges)
2829 .filter_map(|coedge| edge_key(&coedge.source))
2830 .filter(|key| !degenerate.contains(key))
2831 .any(|key| uses.get(&key) == Some(&1));
2832 if !unmatched {
2833 continue;
2834 }
2835 if !touched.contains(&fragment.source_face_id) {
2836 return Err(KernelRefusal::internal(KernelStage::Validate, "retrim.uncut_face", format!(
2837 "the re-trim reached face {}, which the imprint never cut — the drop has run \
2838 past the material the crossing encloses",
2839 fragment.source_face_id
2840 )));
2841 }
2842 dropped.push(index);
2843 grew = true;
2844 }
2845 if !grew {
2846 return Ok(());
2847 }
2848 }
2849}
2850
2851/// [`close_drop_set`]'s other closure: a face left holding a boundary no other
2852/// face answers for is RE-TRIMMED rather than dropped.
2853///
2854/// Dropping it is the right answer when the unanswered boundary is the whole
2855/// of what that face contributed to the dropped material — a tail beside a
2856/// tail. It is the WRONG answer when the face is one the body keeps and only
2857/// part of its boundary went: a bore carried out through a side wall leaves
2858/// the mouth flat holding the stretch of its own corner edge that now runs
2859/// inside the bore, and the arc of the mouth ring that now runs outside the
2860/// body, and neither is material to drop — they are two coedges to take OFF a
2861/// face that otherwise stays exactly as it is. Taking them off merges that
2862/// face's mouth loop into its outer one, which is precisely the topology a
2863/// breakout has and a drop-only closure cannot reach.
2864///
2865/// The bound is [`close_drop_set`]'s: a face the imprint never cut still ends
2866/// the candidate rather than widening the re-trim, with the two exceptions that
2867/// are not re-trims at all — a fragment attached to nothing
2868/// ([`fragment_is_detached`]) and a whole loop that has left its face
2869/// ([`holds_only_stale_loops`]), both of which are retired. What comes back
2870/// is the fragment list to rebuild from — the untouched fragments as they were,
2871/// the re-trimmed ones with their loops re-linked — and the ids of the faces
2872/// that were re-trimmed, for the ledger. A fragment left with no coedge at all
2873/// joins `dropped`.
2874fn retrim_drop_set(
2875 fragments: &[crate::fragment::FaceFragmentRecord],
2876 dropped: &mut Vec<usize>,
2877 retired: &mut Vec<u64>,
2878 touched: &rustc_hash::FxHashSet<u64>,
2879 degenerate: &rustc_hash::FxHashSet<EdgeKey>,
2880) -> Result<(Vec<crate::fragment::FaceFragmentRecord>, Vec<u64>), KernelRefusal> {
2881 use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
2882
2883 let mut working = fragments.to_vec();
2884 let mut retrimmed: Vec<u64> = Vec::new();
2885 loop {
2886 let mut uses: HashMap<EdgeKey, usize> = HashMap::default();
2887 for (index, fragment) in working.iter().enumerate() {
2888 if dropped.contains(&index) {
2889 continue;
2890 }
2891 for key in fragment
2892 .loops
2893 .iter()
2894 .flat_map(|fragment_loop| &fragment_loop.coedges)
2895 .filter_map(|coedge| edge_key(&coedge.source))
2896 {
2897 *uses.entry(key).or_default() += 1;
2898 }
2899 }
2900 // RETIRED FIRST, and before the early return below, because a
2901 // detached fragment does not have to hold an UNMATCHED edge to be
2902 // detached — one whose loop walks its own seam twice holds that seam
2903 // twice itself, and the census reads two. See [`fragment_is_detached`].
2904 let mut grew = false;
2905 for index in 0..working.len() {
2906 if dropped.contains(&index) {
2907 continue;
2908 }
2909 if fragment_is_detached(&working[index], &uses, degenerate) {
2910 dropped.push(index);
2911 retired.push(working[index].source_face_id);
2912 grew = true;
2913 }
2914 }
2915 if grew {
2916 // The census is stale the moment anything is retired.
2917 continue;
2918 }
2919 // A POLE edge is legitimately used once, as in [`close_drop_set`].
2920 let unmatched: HashSet<EdgeKey> = uses
2921 .iter()
2922 .filter(|(key, count)| **count == 1 && !degenerate.contains(key))
2923 .map(|(key, _)| *key)
2924 .collect();
2925 if unmatched.is_empty() {
2926 return Ok((working, retrimmed));
2927 }
2928 for index in 0..working.len() {
2929 if dropped.contains(&index) {
2930 continue;
2931 }
2932 let holds = working[index]
2933 .loops
2934 .iter()
2935 .flat_map(|fragment_loop| &fragment_loop.coedges)
2936 .filter_map(|coedge| edge_key(&coedge.source))
2937 .any(|key| unmatched.contains(&key));
2938 if !holds {
2939 continue;
2940 }
2941 // A STALE LOOP is the other way the old configuration leaves
2942 // something behind, and the [`touched`] bound does not own it —
2943 // see [`holds_only_stale_loops`].
2944 let stale = holds_only_stale_loops(&working[index], &unmatched, degenerate);
2945 if !touched.contains(&working[index].source_face_id) && !stale {
2946 return Err(KernelRefusal::internal(KernelStage::Validate, "retrim.uncut_face", format!(
2947 "the re-trim reached face {}, which the imprint never cut — the drop has run \
2948 past the material the crossing encloses",
2949 working[index].source_face_id
2950 )));
2951 }
2952 let face = working[index].source_face_id;
2953 match retrim_fragment(&working[index], &unmatched)? {
2954 Some(next) => {
2955 working[index] = next;
2956 if stale {
2957 retired.push(face);
2958 } else if !retrimmed.contains(&face) {
2959 retrimmed.push(face);
2960 }
2961 }
2962 None => dropped.push(index),
2963 }
2964 grew = true;
2965 }
2966 if !grew {
2967 return Ok((working, retrimmed));
2968 }
2969 }
2970}
2971
2972/// Re-trim ONE fragment: take off every coedge in `remove` and re-link what is
2973/// left into closed loops.
2974///
2975/// The link is made in the face's OWN parameter domain, which is where a trim
2976/// loop is closed and the only frame that tells the two sides of a seam apart —
2977/// a cylinder's seam is one curve in 3D and two coedges a period apart here.
2978/// Nothing is fitted or re-parameterised: each surviving coedge keeps its
2979/// source, its sense and its pcurve, and only the ORDER and the grouping into
2980/// loops are rebuilt. A chain that does not close is a re-trim this repair
2981/// cannot prove, and it refuses rather than emitting an open face.
2982///
2983/// The loops come back in the order of their first surviving coedge, so a face
2984/// whose outer loop has just absorbed an inner one is still outer-loop-first.
2985/// `Ok(None)` when nothing at all is left to keep.
2986fn retrim_fragment(
2987 fragment: &crate::fragment::FaceFragmentRecord,
2988 remove: &rustc_hash::FxHashSet<EdgeKey>,
2989) -> Result<Option<crate::fragment::FaceFragmentRecord>, KernelRefusal> {
2990 use crate::fragment::{FaceFragmentRecord, FragmentCoedge, FragmentLoop};
2991
2992 let kept: Vec<FragmentCoedge> = fragment
2993 .loops
2994 .iter()
2995 .flat_map(|fragment_loop| &fragment_loop.coedges)
2996 .filter(|coedge| !edge_key(&coedge.source).is_some_and(|key| remove.contains(&key)))
2997 .cloned()
2998 .collect();
2999 if kept.is_empty() {
3000 return Ok(None);
3001 }
3002 let ends = kept
3003 .iter()
3004 .map(|coedge| {
3005 let [t0, t1] = coedge.pcurve.domain()?;
3006 Ok((coedge.pcurve.evaluate(t0)?, coedge.pcurve.evaluate(t1)?))
3007 })
3008 .collect::<Result<Vec<_>, String>>().or_refuse(KernelStage::Validate, "collect")?;
3009 // The domain's own diagonal sets the band a junction is matched in: a
3010 // plane's parameters are millimetres and a cylinder's are radians against
3011 // millimetres, so one fixed number would mean two different things on the
3012 // two carriers. The ends being matched were cut from ONE curve by the
3013 // imprint's own split, so they agree to the fit's precision and the band
3014 // only has to be small against the face.
3015 let [u0, u1] = fragment.surface.domain_u().or_refuse(KernelStage::Validate, "domain_u")?;
3016 let [v0, v1] = fragment.surface.domain_v().or_refuse(KernelStage::Validate, "domain_v")?;
3017 let tolerance = 1e-4 * (u1 - u0).hypot(v1 - v0);
3018 let mut used = vec![false; kept.len()];
3019 let mut loops: Vec<FragmentLoop> = Vec::new();
3020 for seed in 0..kept.len() {
3021 if used[seed] {
3022 continue;
3023 }
3024 used[seed] = true;
3025 let mut chain = vec![seed];
3026 let start = ends[seed].0;
3027 let mut cursor = ends[seed].1;
3028 while cursor.sub(start).length() > tolerance {
3029 let next = (0..kept.len())
3030 .filter(|index| !used[*index])
3031 .map(|index| (index, ends[index].0.sub(cursor).length()))
3032 .filter(|(_, gap)| *gap <= tolerance)
3033 .min_by(|left, right| left.1.total_cmp(&right.1))
3034 .map(|(index, _)| index);
3035 let Some(next) = next else {
3036 return Err(KernelRefusal::internal(KernelStage::Validate, "retrim.open_chain", format!(
3037 "face {}'s re-trim left a chain open at ({:.6}, {:.6}) in its own parameter \
3038 domain — what the drop took off it does not close into a loop",
3039 fragment.source_face_id, cursor.x, cursor.y
3040 )));
3041 };
3042 used[next] = true;
3043 chain.push(next);
3044 cursor = ends[next].1;
3045 }
3046 loops.push(FragmentLoop {
3047 coedges: chain.into_iter().map(|index| kept[index].clone()).collect(),
3048 });
3049 }
3050 Ok(Some(FaceFragmentRecord {
3051 loops,
3052 ..fragment.clone()
3053 }))
3054}
3055
3056/// Is this fragment attached to NOTHING else the drop keeps?
3057///
3058/// The census `uses` counts every non-dropped fragment's edge keys, this one's
3059/// included. So a fragment is detached exactly when, for every non-degenerate
3060/// key it holds, the census count equals ITS OWN count of that key: nobody else
3061/// is holding any edge of it.
3062///
3063/// It is stated that way rather than as "every key it holds is used once"
3064/// because a fragment may legitimately hold one key TWICE on its own — a
3065/// cylinder band walks its seam up one side and down the other — and such a
3066/// fragment is still detached when the seam's only two uses are both its.
3067///
3068/// **What a detached fragment IS.** It is a piece of the old configuration that
3069/// the new one has no room for: on the turned-bore migration, the top face's
3070/// hole loop is the exit ring the bore no longer opens through, sitting out at
3071/// x = 44.6 where the top face's own square ends at 20. The fragmenter returns
3072/// it as its own island because the ring is disjoint from the square, and the
3073/// only other face holding that ring is the wall's overshoot tail. Drop the
3074/// tail and the island is joined to nothing.
3075///
3076/// **Why retiring it is bounded.** Keeping it can never produce a closed body:
3077/// every edge it holds is answered for only by itself, so the assembly is open
3078/// there however the rest of the drop goes. And retiring it removes only uses
3079/// nothing else holds, so it cannot leave a third face unmatched — it does not
3080/// cascade, and no candidate the enumeration would otherwise verify is lost.
3081fn fragment_is_detached(
3082 fragment: &crate::fragment::FaceFragmentRecord,
3083 uses: &rustc_hash::FxHashMap<EdgeKey, usize>,
3084 degenerate: &rustc_hash::FxHashSet<EdgeKey>,
3085) -> bool {
3086 use rustc_hash::FxHashMap as HashMap;
3087 let mut own: HashMap<EdgeKey, usize> = HashMap::default();
3088 for key in fragment
3089 .loops
3090 .iter()
3091 .flat_map(|fragment_loop| &fragment_loop.coedges)
3092 .filter_map(|coedge| edge_key(&coedge.source))
3093 .filter(|key| !degenerate.contains(key))
3094 {
3095 *own.entry(key).or_default() += 1;
3096 }
3097 // A fragment carrying no identified edge at all is NOT called detached:
3098 // its boundary is all DERIVED, which `edge_key` deliberately does not
3099 // count, and the rebuild's own closure is what answers for those.
3100 !own.is_empty()
3101 && own
3102 .iter()
3103 .all(|(key, count)| uses.get(key).copied().unwrap_or(0) == *count)
3104}
3105
3106/// The OTHER way the old configuration leaves something behind: a WHOLE LOOP
3107/// of a face that nothing else answers for AND THAT HAS LEFT THAT FACE.
3108///
3109/// [`fragment_is_detached`] catches the leftover when the fragmenter returns it
3110/// as its own island — which it does when the stale ring is far enough from the
3111/// face's own trim to be plainly disjoint. When it is nearer, the same ring
3112/// comes back as a HOLE LOOP of the face it left: the turned bore's exit ring
3113/// at 40 degrees sits at `x = 22.9 - 30.7` against a top face that ends at 20,
3114/// and the top face is ONE fragment carrying it. There is no island to retire,
3115/// and taking the loop off is a RE-TRIM of a face the imprint never cut.
3116///
3117/// That is the move the earlier records named as missing — *"the departed face
3118/// keeps a hole loop the imprint never cut, and the drop enumeration has no
3119/// move for retiring one"*. This is the move, and it is bounded FOUR ways:
3120///
3121/// * **Unanswered for.** Every non-degenerate edge of the loop is used once,
3122/// by this fragment alone, so no other face the drop keeps is standing on it
3123/// and removing it takes nothing from anyone. A loop only PART of which is
3124/// unanswered for is a genuine re-trim of a neighbour that bordered the
3125/// dropped material, and the [`touched`] bound still owns that case.
3126/// * **It has LEFT the face**, by the containment floor's own test: every
3127/// sample of it lies outside the face's outer loop in that face's own
3128/// parameter plane. This is the condition that matters, and it was added
3129/// after measuring what happens without it. Unanswered-for alone is
3130/// SYMMETRIC — at 40 degrees it licensed retiring the bore's ENTRY mouth
3131/// just as readily as its departed exit ring, so the enumeration verified
3132/// two bodies and refused the pair as ambiguous. A loop still inside its own
3133/// face is trimming material and is not a leftover, whoever is holding it.
3134/// * **A loop survives.** This never empties a face; a fragment with nothing
3135/// left at all is the detached case above.
3136/// * **A PLANE only**, as the containment floor restricts itself, because on a
3137/// periodic surface "outside the outer loop" in the parameter plane is a
3138/// statement about the seam rather than about the trim.
3139///
3140/// And the body still has to rebuild, `validate()`, connect, scan clean and
3141/// pass the containment floor afterwards, exactly as before.
3142fn holds_only_stale_loops(
3143 fragment: &crate::fragment::FaceFragmentRecord,
3144 unmatched: &rustc_hash::FxHashSet<EdgeKey>,
3145 degenerate: &rustc_hash::FxHashSet<EdgeKey>,
3146) -> bool {
3147 if !matches!(
3148 fragment.surface.analytic(),
3149 Some(crate::AnalyticSurface::Plane { .. })
3150 ) {
3151 return false;
3152 }
3153 let mut stale: Vec<usize> = Vec::new();
3154 let mut surviving = 0usize;
3155 for (index, fragment_loop) in fragment.loops.iter().enumerate() {
3156 let keys: Vec<EdgeKey> = fragment_loop
3157 .coedges
3158 .iter()
3159 .filter_map(|coedge| edge_key(&coedge.source))
3160 .filter(|key| !degenerate.contains(key))
3161 .collect();
3162 if !keys.is_empty() && keys.iter().all(|key| unmatched.contains(key)) {
3163 stale.push(index);
3164 continue;
3165 }
3166 if keys.iter().any(|key| unmatched.contains(key)) {
3167 return false;
3168 }
3169 surviving += 1;
3170 }
3171 if stale.is_empty() || surviving == 0 {
3172 return false;
3173 }
3174 let Ok(polylines) = fragment
3175 .loops
3176 .iter()
3177 .map(fragment_loop_polyline)
3178 .collect::<Result<Vec<_>, KernelRefusal>>()
3179 else {
3180 return false;
3181 };
3182 if polylines.iter().any(Vec::is_empty) {
3183 return false;
3184 }
3185 let outer_index = (0..polylines.len())
3186 .max_by(|&a, &b| {
3187 polyline_area(&polylines[a])
3188 .abs()
3189 .total_cmp(&polyline_area(&polylines[b]).abs())
3190 })
3191 .unwrap_or(0);
3192 // The face's own outer loop being the stale one is not a leftover to
3193 // retire; it is the whole face going, which is the detached case.
3194 if stale.contains(&outer_index) {
3195 return false;
3196 }
3197 let outer = &polylines[outer_index];
3198 let bands = BoundaryBands::build(outer);
3199 let tolerance = boundary_tolerance(outer);
3200 stale.iter().all(|index| {
3201 polylines[*index]
3202 .iter()
3203 .all(|&point| !bands.contains(point) && !bands.within(point, tolerance))
3204 })
3205}
3206
3207/// The other half of the census rule, and the one that had no enforcement.
3208///
3209/// A closed body uses every non-degenerate edge EXACTLY TWICE. The drop
3210/// closures above are the "fewer than twice" half — a face left holding a
3211/// boundary nobody answers is dropped or re-trimmed. Nothing checked the other
3212/// direction, and the direction matters here: an imprint piece is cut into BOTH
3213/// crossing faces, so before any drop it is held four times, once by each of
3214/// the four pieces. A drop that takes one piece away leaves it held three
3215/// times — three faces along one curve, which is not a body.
3216///
3217/// That is the candidate the turned-bore migration used to return: dropping the
3218/// exit mouth's disc alone leaves the wall's kept part, the wall's overshoot
3219/// tail and the mouth flat's remainder all on the mouth ring. Refusing it is
3220/// what leaves the one drop that is right.
3221///
3222/// **And nothing downstream catches it**, which is why the check belongs here
3223/// and not there. `assemble_open_fragments` resolves the third use by putting
3224/// the overshoot on a shell of its OWN: the body comes back with every edge
3225/// used exactly twice, `validate()` clean, `solid_connectivity` calling it
3226/// connected (the sheets share edges with the main shell), the scan clean and
3227/// the containment floor acquitting it — a three-shell solid whose first shell
3228/// is the right answer and whose other two are sheets stapled to it. Measured
3229/// on the turned bore at 60 degrees: shell 1 is the 7 correct faces at
3230/// 7673.516114379 against a closed form of 7673.516114438, shell 2 is the
3231/// overshoot alone at -536.326313 and shell 3 the departed ring at -6.7e-14.
3232///
3233/// Returns the offending key and its count, smallest key first so the refusal
3234/// reads the same on every run.
3235fn over_used_edge(
3236 fragments: &[crate::fragment::FaceFragmentRecord],
3237 dropped: &[usize],
3238 degenerate: &rustc_hash::FxHashSet<EdgeKey>,
3239) -> Option<(EdgeKey, usize)> {
3240 use rustc_hash::FxHashMap as HashMap;
3241 let mut uses: HashMap<EdgeKey, usize> = HashMap::default();
3242 for (index, fragment) in fragments.iter().enumerate() {
3243 if dropped.contains(&index) {
3244 continue;
3245 }
3246 for key in fragment
3247 .loops
3248 .iter()
3249 .flat_map(|fragment_loop| &fragment_loop.coedges)
3250 .filter_map(|coedge| edge_key(&coedge.source))
3251 .filter(|key| !degenerate.contains(key))
3252 {
3253 *uses.entry(key).or_default() += 1;
3254 }
3255 }
3256 let mut over: Vec<(EdgeKey, usize)> = uses
3257 .into_iter()
3258 .filter(|(_, count)| *count > 2)
3259 .collect();
3260 over.sort_unstable();
3261 over.into_iter().next()
3262}
3263
3264/// The identity of a fragment coedge's edge, for the use census. A DERIVED
3265/// coedge carries a freshly minted curve rather than an identity, so it is not
3266/// counted — the rebuild's own closure check is what answers for those.
3267fn edge_key(source: &crate::fragment::FragmentEdgeSource) -> Option<EdgeKey> {
3268 use crate::fragment::FragmentEdgeSource;
3269 match source {
3270 FragmentEdgeSource::Boundary { operand, edge_id }
3271 | FragmentEdgeSource::SharedBoundary { operand, edge_id } => {
3272 Some(EdgeKey::Boundary(*operand, *edge_id))
3273 }
3274 FragmentEdgeSource::Imprint { piece_id } => Some(EdgeKey::Imprint(*piece_id)),
3275 FragmentEdgeSource::Derived { .. } => None,
3276 }
3277}
3278
3279#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
3280enum EdgeKey {
3281 Boundary(u8, u64),
3282 Imprint(u64),
3283}
3284
3285/// Every subset of `pieces` that leaves at least one behind, the empty one
3286/// included — the ways one face's pieces can be dropped.
3287fn proper_subsets(pieces: &[usize]) -> Vec<Vec<usize>> {
3288 let mut subsets = Vec::new();
3289 for mask in 0..(1u32 << pieces.len()) {
3290 if mask == (1u32 << pieces.len()) - 1 {
3291 // Dropping every piece of a crossing face is not a re-trim of it.
3292 continue;
3293 }
3294 subsets.push(
3295 pieces
3296 .iter()
3297 .enumerate()
3298 .filter(|(bit, _)| mask & (1 << bit) != 0)
3299 .map(|(_, piece)| *piece)
3300 .collect(),
3301 );
3302 }
3303 subsets
3304}
3305
3306/// Reassemble one choice of kept fragments and prove it sound, or say why not.
3307fn rebuild(
3308 split: &BrepSolid,
3309 selected: Vec<crate::fragment::FaceFragmentRecord>,
3310 imprint: &crate::imprint::ImprintResultRecord,
3311 scale: f64,
3312) -> Result<BrepSolid, KernelRefusal> {
3313 use crate::boolean::{assemble_open_fragments, finalize_assembled_solid};
3314 use rustc_hash::FxHashMap as HashMap;
3315
3316 let mut sources: HashMap<u8, &BrepSolid> = HashMap::default();
3317 sources.insert(SELF_OPERAND, split);
3318 let assembly_tolerance = 2e-3f64.max(scale * 5e-5);
3319 let solid = assemble_open_fragments(selected, &sources, imprint, assembly_tolerance)
3320 .map_err(|error| error.with_message(|error| format!("the kept pieces did not assemble: {error}")))?;
3321 let solid = finalize_assembled_solid(solid, assembly_tolerance)
3322 .map_err(|error| error.with_message(|error| format!("the assembly did not finalize: {error}")))?;
3323
3324 let issues = solid.validate();
3325 if !issues.is_empty() {
3326 return Err(KernelRefusal::internal(KernelStage::Validate, "rebuild.validate", format!(
3327 "the rebuilt body fails validate(): {}",
3328 issues[0].message
3329 )));
3330 }
3331 if crate::solid_connectivity(&solid).is_disconnected() {
3332 return Err(KernelRefusal::internal(KernelStage::Validate, "rebuild.connected", "the rebuilt body is not connected"));
3333 }
3334 let options = SelfIntersectionOptions::for_solid(&solid);
3335 let report = solid_self_intersections(&solid, options)
3336 .map_err(|message| format!("the rebuilt body could not be scanned: {message}"))
3337 .or_refuse(KernelStage::Validate, "solid_self_intersections")?;
3338 if report.is_flagged() {
3339 return Err(KernelRefusal::internal(KernelStage::Validate, "rebuild.self_intersects", format!(
3340 "the rebuilt body still self-intersects: {}",
3341 report.summary().unwrap_or_else(|| "no summary".into())
3342 )));
3343 }
3344 Ok(solid)
3345}
3346
3347/// One face of a body as its own open body, carrying the edges and vertices
3348/// that face uses and nothing else.
3349fn one_face_body(solid: &BrepSolid, face_id: u64) -> Result<BrepSolid, KernelRefusal> {
3350 use crate::topology::ShellRecord;
3351 use rustc_hash::FxHashSet as HashSet;
3352
3353 let face = solid
3354 .shells
3355 .iter()
3356 .flat_map(|shell| &shell.faces)
3357 .find(|face| face.id == face_id)
3358 .ok_or_else(|| {
3359 // The class its one caller gave it before this helper was typed.
3360 KernelRefusal::internal(KernelStage::Fragment, "one_face_body", format!("face {face_id} is not in this body"))
3361 })?
3362 .clone();
3363 let used: HashSet<u64> = face
3364 .loops
3365 .iter()
3366 .flat_map(|loop_record| &loop_record.coedges)
3367 .map(|coedge| coedge.edge_id)
3368 .collect();
3369 let edges: Vec<_> = solid
3370 .edges
3371 .iter()
3372 .filter(|edge| used.contains(&edge.id))
3373 .cloned()
3374 .collect();
3375 let vertices: HashSet<u64> = edges
3376 .iter()
3377 .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
3378 .collect();
3379 Ok(BrepSolid {
3380 mass_properties_cache: Default::default(),
3381 id: solid.id,
3382 genus: 0,
3383 kind: crate::BodyKind::Solid,
3384 vertices: solid
3385 .vertices
3386 .iter()
3387 .filter(|vertex| vertices.contains(&vertex.id))
3388 .cloned()
3389 .collect(),
3390 edges,
3391 shells: vec![ShellRecord {
3392 id: 1,
3393 faces: vec![face],
3394 }],
3395 })
3396}
3397
3398// ---------------------------------------------------------------------------
3399// The ledger: a repaired result is reported as repaired
3400// ---------------------------------------------------------------------------
3401
3402/// One self-crossing this module REPAIRED rather than refused.
3403///
3404/// A repair changes the answer a lane returns, so it is never silent: the
3405/// feature that ran it carries this in its own result
3406/// (`FeatureResult::notes`), and the case gate records it per case. The
3407/// ledger is the carrier between the two — [`accept_sound`] is called from
3408/// lanes with no diagnostics record to write into, and threading one through
3409/// every lane exit would be a wider change than the repair itself. Same shape
3410/// as the boolean's `CONFORMANCE_REPAIRS` counter and the pcurve fit ledger.
3411#[derive(Clone, Debug)]
3412pub struct CrossingRepairRecord {
3413 /// The two faces that crossed.
3414 pub faces: (u64, u64),
3415 /// The crossing point the scan confirmed.
3416 pub point: crate::Vec3,
3417 /// How far past its band the crossing straddled.
3418 pub straddle: f64,
3419 /// Source faces whose pieces were dropped, with how many pieces of each.
3420 pub dropped: Vec<(u64, usize)>,
3421 /// Source faces the repair RETIRED a leftover from, with how many: a
3422 /// piece the drop left joined to nothing, or a whole loop of a face that
3423 /// nothing else answers for. Either way it is what the OLD configuration
3424 /// left on the body, and the ledger is the only place that says a repair
3425 /// took one off — "a piece was dropped" and "a leftover was retired" are
3426 /// different events.
3427 pub retired: Vec<(u64, usize)>,
3428 /// The faces the drop RE-TRIMMED: neighbours of the dropped material,
3429 /// which are neither of the crossing pair.
3430 pub retrimmed: Vec<u64>,
3431 /// Set when the repair was reached from the HOLE-CONTAINMENT FLOOR rather
3432 /// than from the scan: the face whose hole loop had left it, and that
3433 /// loop. `point` and `straddle` are then unmeasured and read zero, because
3434 /// no crossing was confirmed — the floor convicts a containment, not a
3435 /// crossing.
3436 pub breakout: Option<(u64, u64)>,
3437}
3438
3439impl std::fmt::Display for CrossingRepairRecord {
3440 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3441 if let Some((face, loop_id)) = self.breakout {
3442 return write!(
3443 formatter,
3444 "repaired a BREAKOUT: hole loop {loop_id} of face {face} had left that face \
3445 onto face {}, so face {} — the wall it bounds — and face {} were split along \
3446 their carriers' intersection; dropped {} and re-trimmed {}{}",
3447 self.faces.1,
3448 self.faces.0,
3449 self.faces.1,
3450 self.pieces_dropped(),
3451 self.faces_retrimmed(),
3452 self.pieces_retired(),
3453 );
3454 }
3455 write!(
3456 formatter,
3457 "repaired a self-crossing: faces {} and {} crossed near ({:.4}, {:.4}, {:.4}) \
3458 (straddle {:.3e}); split along their carriers' intersection, dropped {} \
3459 and re-trimmed {}{}",
3460 self.faces.0,
3461 self.faces.1,
3462 self.point.x,
3463 self.point.y,
3464 self.point.z,
3465 self.straddle,
3466 self.pieces_dropped(),
3467 self.faces_retrimmed(),
3468 self.pieces_retired(),
3469 )
3470 }
3471}
3472
3473impl CrossingRepairRecord {
3474 fn pieces_dropped(&self) -> String {
3475 self.dropped
3476 .iter()
3477 .map(|(face, count)| format!("{count} piece(s) of face {face}"))
3478 .collect::<Vec<_>>()
3479 .join(", ")
3480 }
3481
3482 fn faces_retrimmed(&self) -> String {
3483 if self.retrimmed.is_empty() {
3484 "no neighbouring face".to_string()
3485 } else {
3486 format!(
3487 "face(s) {}",
3488 self.retrimmed
3489 .iter()
3490 .map(|face| face.to_string())
3491 .collect::<Vec<_>>()
3492 .join(", ")
3493 )
3494 }
3495 }
3496
3497 /// The clause that says the OLD configuration's leftovers were taken off
3498 /// the body. Empty when nothing was retired, so every message this ledger
3499 /// already printed is unchanged; a repair that retires something says so
3500 /// and names the face, which is the only way the case gate's result line
3501 /// tells a repaired migration from the one it used to return.
3502 fn pieces_retired(&self) -> String {
3503 if self.retired.is_empty() {
3504 return String::new();
3505 }
3506 format!(
3507 ", and RETIRED {} the old configuration left, held by nothing else this repair keeps",
3508 self.retired
3509 .iter()
3510 .map(|(face, count)| format!("{count} leftover(s) of face {face}"))
3511 .collect::<Vec<_>>()
3512 .join(", ")
3513 )
3514 }
3515}
3516
3517thread_local! {
3518 static CROSSING_REPAIRS: std::cell::RefCell<Vec<CrossingRepairRecord>> =
3519 const { std::cell::RefCell::new(Vec::new()) };
3520}
3521
3522/// Take every repair made on this thread since the last call, leaving the
3523/// ledger empty. The history loop drains it after each feature so the repair
3524/// is reported against the feature that made it.
3525pub fn take_crossing_repairs() -> Vec<CrossingRepairRecord> {
3526 CROSSING_REPAIRS.with(|repairs| std::mem::take(&mut *repairs.borrow_mut()))
3527}
3528
3529/// Put a ledger back, replacing whatever is there.
3530///
3531/// The migration route TRIES candidate faces, and a trial that builds records
3532/// itself on the way past. Only the candidate whose body is returned may keep
3533/// its record, and a caller's own entries must survive the trials, so the
3534/// ledger is taken before and written back after. Nothing else may use this:
3535/// a repair reports itself once, where it happened.
3536fn restore_repairs(records: Vec<CrossingRepairRecord>) {
3537 CROSSING_REPAIRS.with(|repairs| *repairs.borrow_mut() = records);
3538}
3539
3540fn record_repair(
3541 fragments: &[crate::fragment::FaceFragmentRecord],
3542 dropped: &[usize],
3543 retired: &[u64],
3544 retrimmed_faces: &[u64],
3545 face_a: u64,
3546 face_b: u64,
3547 origin: &RepairOrigin<'_>,
3548) {
3549 let tally = |faces: &[u64]| {
3550 let mut by_face: Vec<(u64, usize)> = Vec::new();
3551 for face in faces {
3552 match by_face.iter_mut().find(|(existing, _)| existing == face) {
3553 Some((_, count)) => *count += 1,
3554 None => by_face.push((*face, 1)),
3555 }
3556 }
3557 by_face.sort_unstable();
3558 by_face
3559 };
3560 let by_face = tally(
3561 &dropped
3562 .iter()
3563 .map(|index| fragments[*index].source_face_id)
3564 .collect::<Vec<_>>(),
3565 );
3566 let retired = tally(retired);
3567 // A face that lost ONLY a detached piece was not re-trimmed — nothing of
3568 // it was re-cut, a leftover of the old configuration was taken off the
3569 // body — so it is reported under `retired` and not counted here twice.
3570 let mut retrimmed: Vec<u64> = by_face
3571 .iter()
3572 .filter(|(face, count)| {
3573 retired
3574 .iter()
3575 .find(|(retired_face, _)| retired_face == face)
3576 .map(|(_, retired_count)| retired_count < count)
3577 .unwrap_or(true)
3578 })
3579 .map(|(face, _)| *face)
3580 .chain(retrimmed_faces.iter().copied())
3581 .filter(|face| *face != face_a && *face != face_b)
3582 .collect();
3583 retrimmed.sort_unstable();
3584 retrimmed.dedup();
3585 let crossing = match origin {
3586 RepairOrigin::Scan(report) => report.confirmed.first(),
3587 RepairOrigin::Breakout { .. } => None,
3588 };
3589 let record = CrossingRepairRecord {
3590 faces: (face_a, face_b),
3591 point: crossing.map(|crossing| crossing.point).unwrap_or_default(),
3592 straddle: crossing.map(|crossing| crossing.straddle).unwrap_or_default(),
3593 dropped: by_face,
3594 retired,
3595 retrimmed,
3596 breakout: match origin {
3597 RepairOrigin::Scan(_) => None,
3598 RepairOrigin::Breakout { face, loop_id } => Some((*face, *loop_id)),
3599 },
3600 };
3601 if trace_on() {
3602 eprintln!("HEAL {record}");
3603 }
3604 CROSSING_REPAIRS.with(|repairs| repairs.borrow_mut().push(record));
3605}
3606
3607/// Write the flagged body out before the repair touches it, when
3608/// `BREP_HEAL_DUMP` names a directory. A crossing is measured off the body that
3609/// carries it — per-face areas, a mesh-arbitrated genus, the volume the repair
3610/// changes — and replaying a whole document to reach it again costs more than
3611/// the file does. Diagnostics only: the lane still repairs or refuses exactly
3612/// as it would have.
3613fn dump_flagged(solid: &BrepSolid, entry: &str) {
3614 let Ok(directory) = std::env::var("BREP_HEAL_DUMP") else {
3615 return;
3616 };
3617 if directory.is_empty() {
3618 return;
3619 }
3620 let name: String = entry
3621 .chars()
3622 .map(|character| if character.is_alphanumeric() { character } else { '_' })
3623 .collect();
3624 let path = std::path::Path::new(&directory).join(format!("flagged-{name}.json"));
3625 match serde_json::to_string(solid) {
3626 Ok(text) => {
3627 if let Err(error) = std::fs::write(&path, text) {
3628 eprintln!("HEAL dump {}: {error}", path.display());
3629 } else {
3630 eprintln!("HEAL dumped the flagged body to {}", path.display());
3631 }
3632 }
3633 Err(error) => eprintln!("HEAL dump: {error}"),
3634 }
3635}
3636
3637// ---------------------------------------------------------------------------
3638// Trace instrument (BREP_HEAL_TRACE=1)
3639// ---------------------------------------------------------------------------
3640
3641fn trace_on() -> bool {
3642 std::env::var("BREP_HEAL_TRACE").is_ok_and(|value| !value.is_empty() && value != "0")
3643}
3644
3645/// Measurement hatch for the section fit the repair's imprint asks for:
3646/// `BREP_HEAL_FIT_LANE=local|global` picks the lane its ladder starts in and
3647/// `BREP_HEAL_FIT_POINTS=<n>` its station budget. Both unset is the request as
3648/// built, so every lane and budget can be read off ONE binary against the
3649/// shipped one (`crossing_repair_probe body` with `BREP_DEBUG_POLYLINE_FIT=1`).
3650fn measured_fit_request(request: &mut crate::imprint::ImprintOptions) {
3651 match std::env::var("BREP_HEAL_FIT_LANE").as_deref() {
3652 Ok("local") => request.local_fit = true,
3653 Ok("global") => request.local_fit = false,
3654 _ => {}
3655 }
3656 if let Some(points) = std::env::var("BREP_HEAL_FIT_POINTS")
3657 .ok()
3658 .and_then(|value| value.parse::<usize>().ok())
3659 .filter(|points| *points >= 2)
3660 {
3661 request.maximum_fit_points = points;
3662 }
3663 if trace_on() {
3664 eprintln!(
3665 "HEAL imprint request: {} lane, {} stations, tolerance {:.3e}",
3666 if request.local_fit { "local" } else { "global" },
3667 request.maximum_fit_points,
3668 request.tolerance
3669 );
3670 }
3671}
3672
3673fn trace_solid(label: &str, solid: &BrepSolid) {
3674 use rustc_hash::FxHashMap as HashMap;
3675 let point: HashMap<u64, crate::Vec3> = solid
3676 .vertices
3677 .iter()
3678 .map(|vertex| (vertex.id, vertex.point))
3679 .collect();
3680 eprintln!(
3681 "HEAL {label}: {}F {}E {}V shells={}",
3682 solid.shells.iter().map(|shell| shell.faces.len()).sum::<usize>(),
3683 solid.edges.len(),
3684 solid.vertices.len(),
3685 solid.shells.len()
3686 );
3687 let mut uses: HashMap<u64, usize> = HashMap::default();
3688 for shell in &solid.shells {
3689 for face in &shell.faces {
3690 for loop_record in &face.loops {
3691 for coedge in &loop_record.coedges {
3692 *uses.entry(coedge.edge_id).or_default() += 1;
3693 }
3694 }
3695 }
3696 }
3697 for edge in &solid.edges {
3698 let start = point.get(&edge.start_vertex_id).copied().unwrap_or_default();
3699 let end = point.get(&edge.end_vertex_id).copied().unwrap_or_default();
3700 eprintln!(
3701 "HEAL edge {:>3} uses={} v{}->v{} ({:.4},{:.4},{:.4})->({:.4},{:.4},{:.4}) deg={} '{}'",
3702 edge.id,
3703 uses.get(&edge.id).copied().unwrap_or(0),
3704 edge.start_vertex_id,
3705 edge.end_vertex_id,
3706 start.x, start.y, start.z, end.x, end.y, end.z,
3707 edge.degenerate,
3708 edge.name.as_deref().unwrap_or("")
3709 );
3710 }
3711 for shell in &solid.shells {
3712 for face in &shell.faces {
3713 eprintln!(
3714 "HEAL face {:>3} loops={} '{}'",
3715 face.id,
3716 face.loops.len(),
3717 face.name.as_deref().unwrap_or("")
3718 );
3719 for (index, loop_record) in face.loops.iter().enumerate() {
3720 let text = loop_record
3721 .coedges
3722 .iter()
3723 .map(|coedge| format!("{}{}", if coedge.forward { '+' } else { '-' }, coedge.edge_id))
3724 .collect::<Vec<_>>()
3725 .join(" ");
3726 eprintln!("HEAL loop {index}: {text}");
3727 }
3728 }
3729 }
3730}
3731
3732fn trace_imprint(imprint: &crate::imprint::ImprintResultRecord) {
3733 eprintln!(
3734 "HEAL imprint: {} piece(s) {} edge_split(s) by_face={:?}",
3735 imprint.pieces.len(),
3736 imprint.edge_splits.len(),
3737 imprint
3738 .by_face
3739 .iter()
3740 .map(|face| (face.operand, face.face_id, face.piece_ids.clone()))
3741 .collect::<Vec<_>>()
3742 );
3743 for piece in &imprint.pieces {
3744 let start = piece.curve.evaluate(piece.t0).unwrap_or_default();
3745 let end = piece.curve.evaluate(piece.t1).unwrap_or_default();
3746 let middle = piece.curve.evaluate((piece.t0 + piece.t1) / 2.0).unwrap_or_default();
3747 eprintln!(
3748 "HEAL piece {} v{}->v{} ({:.4},{:.4},{:.4}) .. ({:.4},{:.4},{:.4}) .. ({:.4},{:.4},{:.4}) supports={:?} shared={:?}",
3749 piece.id,
3750 piece.start_vertex_id,
3751 piece.end_vertex_id,
3752 start.x, start.y, start.z,
3753 middle.x, middle.y, middle.z,
3754 end.x, end.y, end.z,
3755 piece.support_faces.iter().map(|key| (key.operand, key.face_id)).collect::<Vec<_>>(),
3756 piece.shared_edge,
3757 );
3758 }
3759 for split in &imprint.edge_splits {
3760 eprintln!(
3761 "HEAL edge_split operand={} edge={} at {:?}",
3762 split.operand, split.edge_id, split.parameters
3763 );
3764 }
3765 for vertex in &imprint.vertices {
3766 eprintln!(
3767 "HEAL imprint vertex {} ({:.4},{:.4},{:.4})",
3768 vertex.id, vertex.point.x, vertex.point.y, vertex.point.z
3769 );
3770 }
3771}
3772
3773fn trace_fragments(fragments: &[crate::fragment::FaceFragmentRecord]) {
3774 use crate::fragment::FragmentEdgeSource;
3775 eprintln!("HEAL fragments: {}", fragments.len());
3776 for (index, fragment) in fragments.iter().enumerate() {
3777 eprintln!(
3778 "HEAL fragment {index} face {} loops={} test=({:.4},{:.4},{:.4})",
3779 fragment.source_face_id,
3780 fragment.loops.len(),
3781 fragment.test_point.x,
3782 fragment.test_point.y,
3783 fragment.test_point.z
3784 );
3785 for (loop_index, fragment_loop) in fragment.loops.iter().enumerate() {
3786 let text = fragment_loop
3787 .coedges
3788 .iter()
3789 .map(|coedge| {
3790 let tag = match &coedge.source {
3791 FragmentEdgeSource::Boundary { operand, edge_id } => format!("b{operand}:{edge_id}"),
3792 FragmentEdgeSource::SharedBoundary { operand, edge_id } => format!("s{operand}:{edge_id}"),
3793 FragmentEdgeSource::Imprint { piece_id } => format!("p{piece_id}"),
3794 FragmentEdgeSource::Derived { start, end, .. } => format!(
3795 "d({:.3},{:.3},{:.3})->({:.3},{:.3},{:.3})",
3796 start.x, start.y, start.z, end.x, end.y, end.z
3797 ),
3798 };
3799 format!("{}{tag}", if coedge.forward { '+' } else { '-' })
3800 })
3801 .collect::<Vec<_>>()
3802 .join(" ");
3803 eprintln!("HEAL loop {loop_index}: {text}");
3804 }
3805 }
3806}
3807
3808