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/// The scan, with a read failure reported as a refusal rather than swallowed:
333/// a result that could not be scanned is not a result that scanned clean.
334fn scan(solid: &BrepSolid, entry: &str) -> Result<SelfIntersectionReport, KernelRefusal> {
335 let options = SelfIntersectionOptions::for_solid(solid);
336 solid_self_intersections(solid, options).map_err(|message| {
337 KernelRefusal::internal(
338 KernelStage::Validate,
339 "soundness_scan",
340 format!("{entry}: the soundness scan could not run: {message}"),
341 )
342 })
343}
344
345/// The faces the scan's confirmed crossings name, each once, in report order.
346fn crossing_faces(report: &SelfIntersectionReport) -> Vec<u64> {
347 let mut faces = Vec::new();
348 for crossing in &report.confirmed {
349 for face in [crossing.face_a, crossing.face_b] {
350 if !faces.contains(&face) {
351 faces.push(face);
352 }
353 }
354 }
355 faces
356}
357
358/// The refusal for a face folded through itself — named, with both feet, the
359/// point and the margins, and pointing at the lane that owns the fix.
360fn fold_refusal(report: &SelfIntersectionReport, entry: &str) -> Option<KernelRefusal> {
361 let fold = report.folds.first()?;
362 let faces = report.folds.iter().map(|fold| fold.face).collect();
363 let message = format!(
364 "{entry}: the result's face {}{} FOLDS THROUGH ITSELF in 3D — its surface carries \
365 ({:.6}, {:.6}) and ({:.6}, {:.6}), {:.3e} apart in its own domain and both inside \
366 its trim, to the same point ({:.4}, {:.4}, {:.4}) (residual {:.3e}, branches {:.1} \
367 degrees apart){}. The face is wrong in AREA where its volume is not, so nothing that \
368 measures volume notices and `validate()` cannot see it at all. A fold is a trim that \
369 was never constructed: the folded part of the parameter region has to be carved away \
370 where the carrier is built, not repaired out of the finished body.",
371 fold.face,
372 fold.face_name
373 .as_ref()
374 .map(|name| format!(" '{name}'"))
375 .unwrap_or_default(),
376 fold.uv_a[0],
377 fold.uv_a[1],
378 fold.uv_b[0],
379 fold.uv_b[1],
380 fold.uv_separation,
381 fold.point.x,
382 fold.point.y,
383 fold.point.z,
384 fold.residual,
385 fold.normal_angle.to_degrees(),
386 if report.folds.len() > 1 {
387 format!(" ({} folded faces in all)", report.folds.len())
388 } else {
389 String::new()
390 },
391 );
392 Some(KernelRefusal::unsound(
393 KernelStage::Validate,
394 SoundnessDefect::Fold,
395 faces,
396 message,
397 ))
398}
399
400/// The refusal for two faces crossing, naming every pair and why the repair
401/// did not take it.
402fn crossing_refusal(report: &SelfIntersectionReport, entry: &str, reason: &str) -> KernelRefusal {
403 let pairs = report
404 .confirmed
405 .iter()
406 .map(|crossing| {
407 format!(
408 "faces {} and {} near ({:.4}, {:.4}, {:.4}) (straddle {:.3e} against a band of {:.1e})",
409 crossing.face_a,
410 crossing.face_b,
411 crossing.point.x,
412 crossing.point.y,
413 crossing.point.z,
414 crossing.straddle,
415 report.options.straddle_band
416 )
417 })
418 .collect::<Vec<_>>()
419 .join("; ");
420 KernelRefusal::unsound(
421 KernelStage::Validate,
422 SoundnessDefect::Crossing,
423 crossing_faces(report),
424 format!(
425 "{entry}: the result SELF-INTERSECTS — {pairs}. The enclosed space can still measure \
426 right, so neither a volume oracle nor `validate()` would have caught this. The repair \
427 that splits both faces along their carriers' intersection and drops the enclosed \
428 material did not take it: {reason}"
429 ),
430 )
431}
432
433// ---------------------------------------------------------------------------
434// The hole-containment floor
435// ---------------------------------------------------------------------------
436
437/// Stations per curved pcurve span when a loop is traced in its face's own
438/// parameters. A straight pcurve contributes its start point only — the next
439/// coedge begins where it ends.
440const LOOP_SAMPLES_PER_SPAN: usize = 32;
441
442/// A loop of a face as a closed polyline in that face's own parameter plane,
443/// traced coedge by coedge along each pcurve (already oriented to its coedge).
444fn loop_parameter_polyline(loop_record: &LoopRecord) -> Result<Vec<(f64, f64)>, KernelRefusal> {
445 pcurve_polyline(loop_record.coedges.iter().map(|coedge| &coedge.pcurve))
446}
447
448/// The same walk for a FRAGMENT's loop, which carries the same pcurves under a
449/// different record. One sampler, so a loop traced during the repair and the
450/// same loop traced by the containment floor are the same polyline.
451fn fragment_loop_polyline(
452 fragment_loop: &crate::fragment::FragmentLoop,
453) -> Result<Vec<(f64, f64)>, KernelRefusal> {
454 pcurve_polyline(fragment_loop.coedges.iter().map(|coedge| &coedge.pcurve))
455}
456
457fn pcurve_polyline<'a>(
458 pcurves: impl Iterator<Item = &'a crate::NurbsCurve>,
459) -> Result<Vec<(f64, f64)>, KernelRefusal> {
460 let mut points = Vec::new();
461 for pcurve in pcurves {
462 let [d0, d1] = pcurve.domain().or_refuse(KernelStage::Validate, "domain")?;
463 let steps = if pcurve.degree <= 1 && pcurve.control_points.len() == 2 {
464 1
465 } else {
466 let mut spans = 0usize;
467 for window in pcurve.knots.windows(2) {
468 if window[1] > window[0] {
469 spans += 1;
470 }
471 }
472 spans.max(1) * LOOP_SAMPLES_PER_SPAN
473 };
474 for step in 0..steps {
475 let uv = pcurve.evaluate(d0 + (d1 - d0) * (step as f64 / steps as f64)).or_refuse(KernelStage::Validate, "evaluate")?;
476 points.push((uv.x, uv.y));
477 }
478 }
479 Ok(points)
480}
481
482fn polyline_area(points: &[(f64, f64)]) -> f64 {
483 let mut twice = 0.0;
484 for index in 0..points.len() {
485 let (x0, y0) = points[index];
486 let (x1, y1) = points[(index + 1) % points.len()];
487 twice += x0 * y1 - x1 * y0;
488 }
489 0.5 * twice
490}
491
492/// The outer loop's segments, bucketed by the band of `v` each one spans.
493///
494/// The floor asks a hole point two questions — even-odd parity against the
495/// outer loop, and whether the point sits within `tolerance` of it — and both
496/// read ONLY the segments whose own `v` range reaches the point's `v`. A
497/// segment that does not reach it has both endpoints on the same side of the
498/// horizontal ray, so it cannot toggle the parity; and every point of it is
499/// further away than `tolerance` in `v` alone, so it cannot be the witness
500/// that the point is on the boundary. Answering out of the band is therefore
501/// the SAME answer as walking the whole loop, not an approximation of it: the
502/// set of segments that can toggle is identical, parity is an XOR and does not
503/// care in what order they come, and the nearest segment — if there is one
504/// within `tolerance` at all — is among those searched.
505///
506/// What changes is the COST. The brute-force walk is `hole points × outer
507/// points`, which on an imported 100-tooth gear's flat is 1.1 million outer
508/// stations against 11 904 hole stations — 1.3e10 pairs on ONE face, 24 s of a
509/// 30 s replay. Through the bands it is `hole points × segments per band`,
510/// plus one linear pass to build.
511///
512/// Laid out CSR — one offsets array and one flat segment array — because a
513/// loop of a million points would otherwise want a million `Vec` headers.
514struct BoundaryBands<'a> {
515 points: &'a [(f64, f64)],
516 /// The bottom of the first band: the loop's lowest `v`.
517 v_lo: f64,
518 /// Bands per unit of `v`. Zero when the loop has no `v` extent to divide,
519 /// in which case there is one band and everything lands in it.
520 bands_per_v: f64,
521 /// `starts[band] .. starts[band + 1]` indexes `segments`.
522 starts: Vec<u32>,
523 /// Segment `i` is `points[i] -> points[(i + 1) % len]`, the closing
524 /// segment included.
525 segments: Vec<u32>,
526}
527
528impl<'a> BoundaryBands<'a> {
529 fn build(points: &'a [(f64, f64)]) -> Self {
530 let count = points.len();
531 debug_assert!(count < u32::MAX as usize, "segment indices are u32");
532 let (mut v_lo, mut v_hi) = (f64::INFINITY, f64::NEG_INFINITY);
533 let mut variation = 0.0;
534 for index in 0..count {
535 let (_, y0) = points[index];
536 let (_, y1) = points[(index + 1) % count];
537 v_lo = v_lo.min(y0);
538 v_hi = v_hi.max(y0);
539 variation += (y1 - y0).abs();
540 }
541 // One band per `variation / count` of `v`. That width is what makes the
542 // index linear in the loop whatever the loop's shape: a segment lands
543 // in `its own v extent / band width + 2` bands, and those sum to about
544 // `3 × count` however the vertical variation is distributed — a gear
545 // tooth's thousands of short segments and a box's four long ones alike.
546 let span = v_hi - v_lo;
547 let bands = if span.is_finite() && span > 0.0 && variation > 0.0 {
548 let wanted = span * count as f64 / variation;
549 if wanted.is_finite() && wanted >= 1.0 {
550 (wanted as usize).min(count.max(1))
551 } else {
552 1
553 }
554 } else {
555 1
556 };
557 let bands_per_v = if bands > 1 { bands as f64 / span } else { 0.0 };
558 let band_of = |v: f64| -> usize {
559 if bands_per_v == 0.0 {
560 return 0;
561 }
562 let raw = (v - v_lo) * bands_per_v;
563 // Written so a NaN takes the first branch rather than falling
564 // through to an `as usize` cast of it.
565 if !(raw > 0.0) {
566 0
567 } else if raw >= (bands - 1) as f64 {
568 bands - 1
569 } else {
570 raw as usize
571 }
572 };
573 let mut starts = vec![0u32; bands + 1];
574 for index in 0..count {
575 let (_, y0) = points[index];
576 let (_, y1) = points[(index + 1) % count];
577 let (lo, hi) = (band_of(y0.min(y1)), band_of(y0.max(y1)));
578 for start in starts[lo + 1..=hi + 1].iter_mut() {
579 *start += 1;
580 }
581 }
582 for band in 1..=bands {
583 starts[band] += starts[band - 1];
584 }
585 let mut cursor = starts.clone();
586 let mut segments = vec![0u32; starts[bands] as usize];
587 for index in 0..count {
588 let (_, y0) = points[index];
589 let (_, y1) = points[(index + 1) % count];
590 let (lo, hi) = (band_of(y0.min(y1)), band_of(y0.max(y1)));
591 for band in lo..=hi {
592 segments[cursor[band] as usize] = index as u32;
593 cursor[band] += 1;
594 }
595 }
596 Self {
597 points,
598 v_lo,
599 bands_per_v,
600 starts,
601 segments,
602 }
603 }
604
605 fn band_count(&self) -> usize {
606 self.starts.len() - 1
607 }
608
609 /// The band `v` falls in, clamped. A `v` outside the loop's own extent
610 /// clamps to an end band, whose segments then fail the tests on their own
611 /// terms — nothing straddles a ray the whole loop sits above or below.
612 fn band_of(&self, v: f64) -> usize {
613 if self.bands_per_v == 0.0 {
614 return 0;
615 }
616 let bands = self.band_count();
617 let raw = (v - self.v_lo) * self.bands_per_v;
618 if !(raw > 0.0) {
619 0
620 } else if raw >= (bands - 1) as f64 {
621 bands - 1
622 } else {
623 raw as usize
624 }
625 }
626
627 fn segment(&self, index: u32) -> ((f64, f64), (f64, f64)) {
628 let index = index as usize;
629 (
630 self.points[index],
631 self.points[(index + 1) % self.points.len()],
632 )
633 }
634
635 /// Even-odd parity: is `(u, v)` inside the closed loop?
636 fn contains(&self, point: (f64, f64)) -> bool {
637 let (u, v) = point;
638 let band = self.band_of(v);
639 let mut inside = false;
640 for &index in &self.segments[self.starts[band] as usize..self.starts[band + 1] as usize] {
641 let ((x0, y0), (x1, y1)) = self.segment(index);
642 if (y0 > v) != (y1 > v) && u < x0 + (v - y0) * (x1 - x0) / (y1 - y0) {
643 inside = !inside;
644 }
645 }
646 inside
647 }
648
649 /// Does `(u, v)` sit within `tolerance` of the loop — is it ON the
650 /// boundary rather than off the face?
651 ///
652 /// The bands searched are widened by one on each side of the `tolerance`
653 /// band, so the margin against a segment being excluded by a rounding of
654 /// its own `v` extent is a whole band rather than an ulp.
655 fn within(&self, point: (f64, f64), tolerance: f64) -> bool {
656 let (u, v) = point;
657 let lo = self.band_of(v - tolerance).saturating_sub(1);
658 let hi = (self.band_of(v + tolerance) + 1).min(self.band_count() - 1);
659 for band in lo..=hi {
660 for &index in &self.segments[self.starts[band] as usize..self.starts[band + 1] as usize]
661 {
662 let ((x0, y0), (x1, y1)) = self.segment(index);
663 let (dx, dy) = (x1 - x0, y1 - y0);
664 let length_squared = dx * dx + dy * dy;
665 let t = if length_squared > 0.0 {
666 (((u - x0) * dx + (v - y0) * dy) / length_squared).clamp(0.0, 1.0)
667 } else {
668 0.0
669 };
670 let (px, py) = (x0 + t * dx, y0 + t * dy);
671 if ((u - px).powi(2) + (v - py).powi(2)).sqrt() <= tolerance {
672 return true;
673 }
674 }
675 }
676 false
677 }
678}
679
680/// THE HOLE-CONTAINMENT FLOOR: a PLANAR face whose hole loop has left that
681/// face's own outer loop is refused, naming the hole.
682///
683/// A hole is a hole IN something. Carry a bore's mouth off the flat it opens
684/// through — a direct edit dragging the wall clear of the body — and what the
685/// fixed-topology road emits is the mouth loop hanging in the plane beside the
686/// face it is supposed to perforate, with the wall it bounds somewhere else
687/// entirely. Every other floor is blind to it: `validate()` tests INCIDENCE and
688/// the incidences are all intact; the scan needs two faces to CROSS and the
689/// runaway bore crosses nothing; and the divergence-theorem volume of a
690/// cylinder does not depend on where its axis sits, so the body can measure to
691/// the last bit of the right answer while being the wrong body. The correct
692/// result needs the hole to break through a neighbouring face — a topology
693/// change the direct edits do not make — so this refuses instead of shipping it.
694///
695/// Decided in the face's own parameter plane, which is affine to 3D for a
696/// plane, by parity against the outer loop (the one enclosing the largest
697/// parameter area). A point that fails parity but sits within `tolerance` of
698/// the outer loop is treated as ON it, so a mouth tangent to its face's own
699/// boundary is not convicted by sampling noise. The predicate is only as fine
700/// as the sampling: a curved hole poking out by less than its sampled chord sag
701/// can still pass.
702///
703/// Both questions go through [`BoundaryBands`], which is an INDEX over the
704/// outer loop and not a coarser reading of it: the answers are the ones a walk
705/// of the whole traced polyline gives, off the whole traced polyline, at a
706/// cost that is no longer the product of the two loops' lengths.
707///
708/// This runs at the END of [`accept_sound`], on the body the repair left, so a
709/// crossing the repair can take is still taken — the reason an earlier
710/// primitive-level copy of this floor was removed.
711fn refuse_holes_leaving_their_faces(solid: &BrepSolid, entry: &str) -> Result<(), KernelRefusal> {
712 match first_hole_breach(solid) {
713 Some(breach) => Err(hole_breach_refusal(solid, &breach, entry)),
714 None => Ok(()),
715 }
716}
717
718/// A hole loop the floor has convicted: WHICH face, which loop of it, and the
719/// sample that fell outside, in that face's own parameters.
720///
721/// The floor's verdict is a yes or no; this is the same verdict with the
722/// witness kept, so a lane that can BUILD the right body has something to
723/// build it from. Nothing about the predicate changes by recording it.
724struct HoleBreach {
725 face: u64,
726 loop_index: usize,
727 loop_id: u64,
728 at: (f64, f64),
729}
730
731/// THE FLOOR'S PREDICATE, verbatim — the first hole loop on a planar face that
732/// has left that face's own outer loop, or `None`.
733///
734/// This is the whole of the decision [`refuse_holes_leaving_their_faces`] used
735/// to make inline; the refusal is now written by [`hole_breach_refusal`] off
736/// what this returns. Same loops, same trace, same bands, same tolerance, same
737/// first-hit order.
738fn first_hole_breach(solid: &BrepSolid) -> Option<HoleBreach> {
739 for shell in &solid.shells {
740 for face in &shell.faces {
741 if face.loops.len() < 2 {
742 continue;
743 }
744 if !matches!(
745 face.surface.analytic(),
746 Some(crate::AnalyticSurface::Plane { .. })
747 ) {
748 continue;
749 }
750 let polylines = face
751 .loops
752 .iter()
753 .map(loop_parameter_polyline)
754 .collect::<Result<Vec<_>, KernelRefusal>>()
755 // A loop this cannot be traced from is not a loop this can
756 // convict: the acceptance's own floors already ran.
757 .unwrap_or_default();
758 if polylines.len() != face.loops.len() || polylines.iter().any(Vec::is_empty) {
759 continue;
760 }
761 let outer_index = (0..polylines.len())
762 .max_by(|&a, &b| {
763 polyline_area(&polylines[a])
764 .abs()
765 .total_cmp(&polyline_area(&polylines[b]).abs())
766 })
767 .unwrap_or(0);
768 let outer = &polylines[outer_index];
769 // Every hole point is asked the same two questions against this
770 // one loop, so the loop is indexed ONCE and each question then
771 // reads the band it falls in rather than the whole polyline. The
772 // answers are the ones the whole-polyline walk gives; see
773 // [`BoundaryBands`].
774 let bands = BoundaryBands::build(outer);
775 let tolerance = boundary_tolerance(outer);
776 for (index, hole) in polylines.iter().enumerate() {
777 if index == outer_index {
778 continue;
779 }
780 let Some(&at) = hole
781 .iter()
782 .find(|&&point| !bands.contains(point) && !bands.within(point, tolerance))
783 else {
784 continue;
785 };
786 return Some(HoleBreach {
787 face: face.id,
788 loop_index: index,
789 loop_id: face.loops[index].id,
790 at,
791 });
792 }
793 }
794 }
795 None
796}
797
798/// The "on the boundary" band, measured against the outer loop's own parameter
799/// extent so it means the same thing on a 1 mm face and on a 1 m one.
800fn boundary_tolerance(outer: &[(f64, f64)]) -> f64 {
801 let (mut lo, mut hi) = ((f64::MAX, f64::MAX), (f64::MIN, f64::MIN));
802 for &(u, v) in outer {
803 lo = (lo.0.min(u), lo.1.min(v));
804 hi = (hi.0.max(u), hi.1.max(v));
805 }
806 let extent = (hi.0 - lo.0).max(hi.1 - lo.1);
807 (extent * 1e-9).max(1e-12)
808}
809
810/// The floor's refusal, written off the breach it convicted.
811fn hole_breach_refusal(solid: &BrepSolid, breach: &HoleBreach, entry: &str) -> KernelRefusal {
812 let face = solid
813 .shells
814 .iter()
815 .flat_map(|shell| &shell.faces)
816 .find(|face| face.id == breach.face);
817 let label = match face.and_then(|face| face.name.as_deref()) {
818 Some(name) => format!("`{name}` (face {})", breach.face),
819 None => format!("face {}", breach.face),
820 };
821 let (u, v) = breach.at;
822 let where_in_space = face
823 .and_then(|face| face.surface.evaluate(u, v).ok())
824 .map(|point| format!(" — ({:.4}, {:.4}, {:.4}) in space", point.x, point.y, point.z))
825 .unwrap_or_default();
826 KernelRefusal::unsound(
827 KernelStage::Validate,
828 SoundnessDefect::HoleBreach,
829 vec![breach.face],
830 format!(
831 "{entry}: hole loop {} of {label} has LEFT that face — it runs outside the \
832 face's own outer boundary, at ({u:.6}, {v:.6}) in the face's parameters{}. \
833 A hole is a hole IN a face; this one bounds a wall that no longer opens \
834 through it, so the right body needs the hole to break through a \
835 neighbouring face, a topology change this operation does not make. \
836 `validate()` cannot see it (every incidence is intact), the \
837 self-intersection scan cannot (nothing crosses), and the volume need not \
838 change at all — refusing rather than returning the wrong body",
839 breach.loop_id, where_in_space,
840 ),
841 )
842}
843
844// ---------------------------------------------------------------------------
845// The breakout route: a hole that has left its face onto a NAMED neighbour
846// ---------------------------------------------------------------------------
847
848/// The faces a breakout is between: the WALL the convicted hole loop bounds,
849/// and the ONE neighbour of the breached face the hole has run out onto.
850struct BreakoutPair {
851 wall: u64,
852 neighbour: u64,
853}
854
855/// WHICH neighbour the runaway hole has gone onto — read in the breached
856/// face's own parameter plane, where the question is a segment crossing and
857/// not a guess.
858///
859/// The hole loop bounds a wall (for a bore, the bore's own cylindrical face):
860/// exactly one other face answers for its edges, and that is the face whose
861/// carrier has to be re-trimmed. The hole has left through some stretch of the
862/// breached face's OUTER loop, and each outer coedge is shared with exactly one
863/// neighbouring face, so the coedges the hole polyline crosses name the faces
864/// it has gone out onto.
865///
866/// This refuses rather than choosing whenever the answer is not a single pair:
867/// a hole bounding more than one face, a hole that crosses no outer coedge at
868/// all (it has left the face's own parameter patch entirely — the runaway), and
869/// a hole crossing into MORE THAN ONE neighbour, which is a breakout over a
870/// corner and needs both re-trims resolved together rather than one at a time.
871fn breakout_pair(solid: &BrepSolid, breach: &HoleBreach) -> Result<BreakoutPair, KernelRefusal> {
872 let wall = breakout_wall(solid, breach)?;
873 match breakout_neighbours_crossed(solid, breach, wall)?[..] {
874 [neighbour] => Ok(BreakoutPair { wall, neighbour }),
875 [] => Err(KernelRefusal::unsupported(KernelStage::Validate, NOT_A_STRADDLE_WHAT, NOT_A_STRADDLE)),
876 ref many => Err(KernelRefusal::unsupported(KernelStage::Validate, "breakout.pair", format!(
877 "the runaway hole crosses into {} neighbouring faces at once ({many:?}) — a \
878 breakout over a corner, where the two re-trims bound each other and resolving one \
879 at a time is a different problem from resolving both",
880 many.len(),
881 ))),
882 }
883}
884
885/// The marker [`breakout_pair`] returns when the hole does not STRADDLE its
886/// face's boundary. It has either migrated wholly onto another face or left the
887/// body; [`repair_hole_breakout`] tells those apart, by trying.
888const NOT_A_STRADDLE: &str = "the hole does not straddle its own face's boundary";
889
890/// The slug [`breakout_pair`] mints [`NOT_A_STRADDLE`] under
891/// (`UnsupportedGeometry`), shared with [`is_not_a_straddle`].
892const NOT_A_STRADDLE_WHAT: &str = "breakout.not_a_straddle";
893
894/// Is this [`breakout_pair`]'s non-straddle answer? Read off the class and
895/// slug, so the route's decision does not follow the sentence.
896fn is_not_a_straddle(refusal: &KernelRefusal) -> bool {
897 matches!(
898 &refusal.class,
899 crate::RefusalClass::UnsupportedGeometry { what } if what == NOT_A_STRADDLE_WHAT
900 )
901}
902
903/// The face the convicted hole loop BOUNDS — the moved carrier, for a bore the
904/// bore's own wall. Exactly one other face may answer for its edges.
905fn breakout_wall(solid: &BrepSolid, breach: &HoleBreach) -> Result<u64, KernelRefusal> {
906 let face = face_by_id(solid, breach.face)?;
907 let mut walls: Vec<u64> = Vec::new();
908 for coedge in &face.loops[breach.loop_index].coedges {
909 for other in solid.shells.iter().flat_map(|shell| &shell.faces) {
910 if other.id == face.id {
911 continue;
912 }
913 if other
914 .loops
915 .iter()
916 .any(|wire| wire.coedges.iter().any(|use_| use_.edge_id == coedge.edge_id))
917 && !walls.contains(&other.id)
918 {
919 walls.push(other.id);
920 }
921 }
922 }
923 match walls[..] {
924 [wall] => Ok(wall),
925 _ => Err(KernelRefusal::unsupported(KernelStage::Validate, "breakout.wall_count", format!(
926 "the runaway hole loop bounds {} face(s) ({walls:?}); this route re-trims ONE moved \
927 carrier against the body and cannot decide which of several is the one that moved",
928 walls.len(),
929 ))),
930 }
931}
932
933fn face_by_id<'a>(
934 solid: &'a BrepSolid,
935 id: u64,
936) -> Result<&'a crate::FaceRecord, KernelRefusal> {
937 solid
938 .shells
939 .iter()
940 .flat_map(|shell| &shell.faces)
941 .find(|face| face.id == id)
942 .ok_or_else(|| {
943 KernelRefusal::internal(KernelStage::Validate, "face_by_id", format!("face {id} is not on the body"))
944 })
945}
946
947/// The neighbours the hole has run out ONTO, read as segment crossings in the
948/// breached face's own parameter plane.
949///
950/// This answers the STRADDLE: part of the mouth still inside the face it was
951/// recorded on, part outside. It returns nothing for a mouth that has migrated
952/// wholly onto another face — such a loop is entirely outside the outer loop
953/// and crosses none of its coedges — and nothing for one that has left the body
954/// altogether. Those two are told apart downstream, by geometry, in
955/// [`repair_hole_breakout`].
956fn breakout_neighbours_crossed(
957 solid: &BrepSolid,
958 breach: &HoleBreach,
959 wall: u64,
960) -> Result<Vec<u64>, KernelRefusal> {
961 let face = face_by_id(solid, breach.face)?;
962 let polylines = face
963 .loops
964 .iter()
965 .map(loop_parameter_polyline)
966 .collect::<Result<Vec<_>, KernelRefusal>>()
967 .map_err(|error| {
968 error.with_message(|error| format!("the breached face's loops could not be traced: {error}"))
969 })?;
970 let outer_index = (0..polylines.len())
971 .max_by(|&a, &b| {
972 polyline_area(&polylines[a])
973 .abs()
974 .total_cmp(&polyline_area(&polylines[b]).abs())
975 })
976 .unwrap_or(0);
977 if outer_index == breach.loop_index {
978 return Err(KernelRefusal::internal(KernelStage::Validate, "breakout.outer_loop", "the convicted loop is the face's own outer loop"));
979 }
980 let hole = &polylines[breach.loop_index];
981
982 let mut neighbours: Vec<u64> = Vec::new();
983 for coedge in &face.loops[outer_index].coedges {
984 let side = coedge_parameter_polyline(coedge)
985 .map_err(|error| error.with_message(|error| format!("an outer coedge could not be traced: {error}")))?;
986 if !polylines_cross(hole, &side) {
987 continue;
988 }
989 for other in solid.shells.iter().flat_map(|shell| &shell.faces) {
990 if other.id == face.id || other.id == wall {
991 continue;
992 }
993 if other
994 .loops
995 .iter()
996 .any(|wire| wire.coedges.iter().any(|use_| use_.edge_id == coedge.edge_id))
997 && !neighbours.contains(&other.id)
998 {
999 neighbours.push(other.id);
1000 }
1001 }
1002 }
1003 Ok(neighbours)
1004}
1005
1006/// Every face across the breached face's OUTER boundary — the faces a mouth
1007/// leaving that face can have arrived on.
1008///
1009/// A migration is a mouth that has walked off one face and onto the next. The
1010/// next one is over an edge, so this is the candidate set, and it is small: the
1011/// outer loop's own neighbours, the wall excepted. A carrier that has reached
1012/// something further away than that is not a case this route claims, and it
1013/// says so rather than widening until it finds a face it can cut.
1014fn faces_over_the_outer_boundary(
1015 solid: &BrepSolid,
1016 breach: &HoleBreach,
1017 wall: u64,
1018) -> Result<Vec<u64>, KernelRefusal> {
1019 let face = face_by_id(solid, breach.face)?;
1020 let polylines = face
1021 .loops
1022 .iter()
1023 .map(loop_parameter_polyline)
1024 .collect::<Result<Vec<_>, KernelRefusal>>()
1025 .unwrap_or_default();
1026 let outer_index = (0..polylines.len())
1027 .max_by(|&a, &b| {
1028 polyline_area(&polylines[a])
1029 .abs()
1030 .total_cmp(&polyline_area(&polylines[b]).abs())
1031 })
1032 .unwrap_or(0);
1033 let mut over: Vec<u64> = Vec::new();
1034 for coedge in &face.loops[outer_index].coedges {
1035 for other in solid.shells.iter().flat_map(|shell| &shell.faces) {
1036 if other.id == face.id || other.id == wall {
1037 continue;
1038 }
1039 if other
1040 .loops
1041 .iter()
1042 .any(|wire| wire.coedges.iter().any(|use_| use_.edge_id == coedge.edge_id))
1043 && !over.contains(&other.id)
1044 {
1045 over.push(other.id);
1046 }
1047 }
1048 }
1049 Ok(over)
1050}
1051
1052/// One coedge as a polyline in its face's parameters, END POINT INCLUDED, so
1053/// the segments it yields are the whole of that coedge.
1054///
1055/// [`loop_parameter_polyline`] deliberately leaves each coedge's end point to
1056/// the next coedge's start; a single coedge asked about on its own has no next.
1057fn coedge_parameter_polyline(
1058 coedge: &crate::topology::CoedgeRecord,
1059) -> Result<Vec<(f64, f64)>, KernelRefusal> {
1060 let pcurve = &coedge.pcurve;
1061 let [d0, d1] = pcurve.domain().or_refuse(KernelStage::Validate, "domain")?;
1062 let steps = if pcurve.degree <= 1 && pcurve.control_points.len() == 2 {
1063 1
1064 } else {
1065 let mut spans = 0usize;
1066 for window in pcurve.knots.windows(2) {
1067 if window[1] > window[0] {
1068 spans += 1;
1069 }
1070 }
1071 spans.max(1) * LOOP_SAMPLES_PER_SPAN
1072 };
1073 let mut points = Vec::with_capacity(steps + 1);
1074 for step in 0..=steps {
1075 let uv = pcurve.evaluate(d0 + (d1 - d0) * (step as f64 / steps as f64)).or_refuse(KernelStage::Validate, "evaluate")?;
1076 points.push((uv.x, uv.y));
1077 }
1078 Ok(points)
1079}
1080
1081/// Does the CLOSED polyline `loop_points` cross the OPEN polyline `side`?
1082/// Proper segment intersection, in the face's own parameters.
1083fn polylines_cross(loop_points: &[(f64, f64)], side: &[(f64, f64)]) -> bool {
1084 for index in 0..loop_points.len() {
1085 let a = loop_points[index];
1086 let b = loop_points[(index + 1) % loop_points.len()];
1087 for window in side.windows(2) {
1088 if segments_cross(a, b, window[0], window[1]) {
1089 return true;
1090 }
1091 }
1092 }
1093 false
1094}
1095
1096fn segments_cross(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)) -> bool {
1097 let orient = |p: (f64, f64), q: (f64, f64), r: (f64, f64)| {
1098 (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0)
1099 };
1100 let (d1, d2) = (orient(c, d, a), orient(c, d, b));
1101 let (d3, d4) = (orient(a, b, c), orient(a, b, d));
1102 ((d1 > 0.0) != (d2 > 0.0)) && ((d3 > 0.0) != (d4 > 0.0))
1103}
1104
1105/// THE BREAKOUT, reached from the containment floor instead of from the scan.
1106///
1107/// The floor convicts a hole loop that has left the planar face it is recorded
1108/// on. Where that hole has left ONTO a named neighbour — the case the operator
1109/// reported, an exit hole dragged across the edge between two faces — the right
1110/// body is the one the self-crossing repair already builds for a bore carried
1111/// out through a side wall: the moved carrier and the neighbour split along
1112/// their exact section, the pieces that ended up on the wrong side dropped, and
1113/// the faces left holding an unanswered boundary RE-TRIMMED, which merges the
1114/// mouth into the outer loops of the faces it now opens through.
1115///
1116/// What makes it the SAME repair and not a second one is that the extent is
1117/// still derived and not invented: the tool is the moved carrier trimmed
1118/// against the body, and where it meets the body is what the imprint computes.
1119/// The route in is what changes — the scan needs a mesh crossing it can
1120/// confirm, and at the near edge of a breakout the crossing is a sliver the
1121/// tessellation does not resolve, so the floor sees it first.
1122///
1123/// The genuine runaway is excluded by GEOMETRY and not by a threshold. A bore
1124/// carried clean off the body crosses no boundary of the face it left, so
1125/// [`breakout_pair`] finds no neighbour and this refuses before a single face
1126/// is split; and even given a pair, the imprint of a carrier that misses the
1127/// body cuts neither trim and [`split_and_drop`] declines on its own terms.
1128fn repair_hole_breakout(solid: &BrepSolid, breach: &HoleBreach) -> Result<BrepSolid, KernelRefusal> {
1129 let origin = || RepairOrigin::Breakout {
1130 face: breach.face,
1131 loop_id: breach.loop_id,
1132 };
1133 // THE STRADDLE: part of the mouth still inside the face it was recorded on,
1134 // part outside. Which neighbour is read off the crossing, exactly.
1135 match breakout_pair(solid, breach) {
1136 Ok(BreakoutPair { wall, neighbour }) => {
1137 return split_and_drop(solid, wall, neighbour, origin())
1138 }
1139 // Every refusal but the non-straddle is this route's own, classed as it
1140 // always was here; the non-straddle is read off its slug, not its text.
1141 Err(reason) if !is_not_a_straddle(&reason) => {
1142 return Err(KernelRefusal::unsupported(
1143 KernelStage::Validate,
1144 "breakout.pair",
1145 reason.message,
1146 ))
1147 }
1148 Err(_) => {}
1149 }
1150
1151 // THE MIGRATION, or the RUNAWAY. The mouth is wholly outside the face it
1152 // was recorded on, so nothing in that face's own parameter plane can say
1153 // where it went — a mouth that has walked onto the next face and a mouth
1154 // that has left the solid look the same from there.
1155 //
1156 // They do not look the same to the IMPRINT. The question that separates
1157 // them is whether the moved carrier still meets a face of the body, and
1158 // `split_and_drop` answers exactly that: handed a face the carrier misses
1159 // it declines with "the carriers' intersection reaches ... neither", before
1160 // anything is dropped. So the candidates are tried, and the answer is
1161 // decided BY THE RESULT — the same principle the drop enumeration inside
1162 // already runs on, and a discriminator that cannot drift, because a carrier
1163 // clear of the body cuts nothing anywhere.
1164 //
1165 // More than one candidate building is not an answer: the mouth would be on
1166 // two faces at once with no reason to prefer either, and choosing would be
1167 // the guess this module exists not to make.
1168 let wall = breakout_wall(solid, breach).map_err(|refusal| {
1169 KernelRefusal::internal(KernelStage::Validate, "breakout.wall", refusal.message)
1170 })?;
1171 let mut candidates = faces_over_the_outer_boundary(solid, breach, wall)
1172 .map_err(|refusal| {
1173 KernelRefusal::internal(KernelStage::Validate, "breakout.candidates", refusal.message)
1174 })?;
1175 if candidates.is_empty() {
1176 return Err(KernelRefusal::unsupported(
1177 KernelStage::Validate,
1178 "breakout.runaway",
1179 RUNAWAY,
1180 ));
1181 }
1182
1183 // WHICH face the mouth moved onto, when its own face's parameter plane can
1184 // no longer say. The carrier is a whole surface and generally meets the
1185 // body in more than one place — a through bore leaves by one face and
1186 // enters by another, and both are faces this carrier can be re-trimmed
1187 // against — so "some face it cuts" is not the answer. The answer is the
1188 // face the MIGRATED MOUTH IS AT: the candidates are ordered by the least
1189 // 3D distance from the convicted hole loop to the face's own trim, and the
1190 // nearest one that the repair can actually build is taken.
1191 //
1192 // A TIE is refused rather than broken. Two faces the same distance from the
1193 // mouth means the mouth sits on the edge between them, which is a corner
1194 // breakout — the case whose two re-trims bound each other.
1195 let mouth = hole_loop_in_space(solid, breach).map_err(|refusal| {
1196 KernelRefusal::internal(KernelStage::Validate, "breakout.mouth", refusal.message)
1197 })?;
1198 let scale = crate::solid_scale(solid);
1199 let mut ranked: Vec<(f64, u64)> = Vec::new();
1200 for candidate in candidates.drain(..) {
1201 ranked.push((
1202 distance_to_face(solid, candidate, &mouth)
1203 .map_err(|refusal| {
1204 KernelRefusal::internal(KernelStage::Validate, "breakout.distance", refusal.message)
1205 })?,
1206 candidate,
1207 ));
1208 }
1209 ranked.sort_by(|a, b| a.0.total_cmp(&b.0));
1210 if ranked.len() > 1 && (ranked[1].0 - ranked[0].0).abs() <= scale * 1e-9 {
1211 return Err(KernelRefusal::ill_posed(
1212 KernelStage::Validate,
1213 "breakout.corner_tie",
1214 format!(
1215 "the migrated mouth is the same distance ({:.6}) from faces {} and {}, so the body \
1216 does not say which of them it moved onto — a breakout over the corner between \
1217 them, where the two re-trims bound each other",
1218 ranked[0].0, ranked[0].1, ranked[1].1,
1219 ),
1220 ));
1221 }
1222
1223 let carried = take_crossing_repairs();
1224 let mut declined: Vec<String> = Vec::new();
1225 for (distance, candidate) in ranked {
1226 match split_and_drop(solid, wall, candidate, origin()) {
1227 Ok(body) => {
1228 // The trial that built pushed a record; the ones that declined
1229 // pushed none. Keep that one and put the caller's ledger back.
1230 let mine = take_crossing_repairs();
1231 restore_repairs(carried.into_iter().chain(mine).collect());
1232 return Ok(body);
1233 }
1234 Err(reason) => {
1235 declined.push(format!("face {candidate} at {distance:.6}: {reason}"))
1236 }
1237 }
1238 }
1239 restore_repairs(carried);
1240 Err(KernelRefusal::unsupported(
1241 KernelStage::Validate,
1242 "breakout.runaway",
1243 format!(
1244 "{RUNAWAY} — the moved carrier was offered every face across that boundary, nearest \
1245 first, and cut none of them ({})",
1246 declined.join(" | ")
1247 ),
1248 ))
1249}
1250
1251/// The convicted hole loop as 3D points: its pcurves are in the breached face's
1252/// own parameters, so they are read back through that face's surface — the same
1253/// road the floor takes, and the reason the floor was never wrong about where a
1254/// loop is.
1255fn hole_loop_in_space(solid: &BrepSolid, breach: &HoleBreach) -> Result<Vec<crate::Vec3>, KernelRefusal> {
1256 let face = face_by_id(solid, breach.face)?;
1257 let uv = loop_parameter_polyline(&face.loops[breach.loop_index])?;
1258 uv.into_iter()
1259 .map(|(u, v)| face.surface.evaluate(u, v))
1260 .collect::<Result<Vec<_>, _>>()
1261 .map_err(|error| format!("the convicted hole loop could not be read in space: {error}"))
1262 .or_refuse(KernelStage::Validate, "evaluate")
1263}
1264
1265/// The least distance from a set of points to a face's own TRIM — sampled, in
1266/// the face's parameters, so a point beside a face's carrier but off the end of
1267/// the face does not read as being on it.
1268fn distance_to_face(
1269 solid: &BrepSolid,
1270 face_id: u64,
1271 points: &[crate::Vec3],
1272) -> Result<f64, KernelRefusal> {
1273 const STATIONS: usize = 32;
1274 let face = face_by_id(solid, face_id)?;
1275 let polylines = face
1276 .loops
1277 .iter()
1278 .map(loop_parameter_polyline)
1279 .collect::<Result<Vec<_>, KernelRefusal>>()?;
1280 let outer_index = (0..polylines.len())
1281 .max_by(|&a, &b| {
1282 polyline_area(&polylines[a])
1283 .abs()
1284 .total_cmp(&polyline_area(&polylines[b]).abs())
1285 })
1286 .unwrap_or(0);
1287 let bands = BoundaryBands::build(&polylines[outer_index]);
1288 let (mut lo, mut hi) = ((f64::MAX, f64::MAX), (f64::MIN, f64::MIN));
1289 for &(u, v) in &polylines[outer_index] {
1290 lo = (lo.0.min(u), lo.1.min(v));
1291 hi = (hi.0.max(u), hi.1.max(v));
1292 }
1293 let mut best = f64::INFINITY;
1294 for iu in 0..=STATIONS {
1295 for iv in 0..=STATIONS {
1296 let u = lo.0 + (hi.0 - lo.0) * iu as f64 / STATIONS as f64;
1297 let v = lo.1 + (hi.1 - lo.1) * iv as f64 / STATIONS as f64;
1298 if !bands.contains((u, v)) {
1299 continue;
1300 }
1301 if polylines
1302 .iter()
1303 .enumerate()
1304 .any(|(index, hole)| index != outer_index && inside_polyline((u, v), hole))
1305 {
1306 continue;
1307 }
1308 let Ok(point) = face.surface.evaluate(u, v) else {
1309 continue;
1310 };
1311 for probe in points {
1312 best = best.min(probe.sub(point).length());
1313 }
1314 }
1315 }
1316 Ok(best)
1317}
1318
1319/// Even-odd parity against a closed polyline, for the hole loops the band index
1320/// is not built over.
1321fn inside_polyline(point: (f64, f64), polygon: &[(f64, f64)]) -> bool {
1322 let (u, v) = point;
1323 let mut parity = false;
1324 for index in 0..polygon.len() {
1325 let (ax, ay) = polygon[index];
1326 let (bx, by) = polygon[(index + 1) % polygon.len()];
1327 if (ay > v) != (by > v) {
1328 let x = ax + (v - ay) / (by - ay) * (bx - ax);
1329 if x > u {
1330 parity = !parity;
1331 }
1332 }
1333 }
1334 parity
1335}
1336
1337/// The sentence a hole that has left the BODY earns, as opposed to one that has
1338/// moved onto another face. It is the one thing standing between the operator
1339/// and a silently ruined model, so it is a constant and not a phrasing.
1340const RUNAWAY: &str = "the runaway hole has left the face it was recorded on without arriving \
1341 on another: the moved carrier meets no face of the body, so there is nothing for it to be \
1342 re-trimmed against and no cut to perform at any extent";
1343
1344/// [`resolve_face_crossing`] for a pair this module has named itself rather
1345/// than read off the scan.
1346///
1347/// The scan is one way to find two faces of one body that have to be split
1348/// along their section, and the containment floor is another. Both hand the
1349/// same pair to the same repair.
1350pub fn resolve_face_crossing_between(
1351 solid: &BrepSolid,
1352 face_a: u64,
1353 face_b: u64,
1354) -> Result<BrepSolid, KernelRefusal> {
1355 let report = scan(solid, "resolve_face_crossing_between")?;
1356 split_and_drop(solid, face_a, face_b, RepairOrigin::Scan(&report))
1357}
1358
1359// ---------------------------------------------------------------------------
1360// The repair
1361// ---------------------------------------------------------------------------
1362
1363/// WHAT FOUND the pair this repair is about to split.
1364///
1365/// Two floors reach the same repair. The scan finds two faces whose meshes
1366/// cross and can say where and by how much; the hole-containment floor finds a
1367/// hole loop that has left its face and can say which loop and which face. The
1368/// repair does the same work either way, and the ledger says which asked for
1369/// it rather than printing a crossing point that was never measured.
1370enum RepairOrigin<'a> {
1371 /// The self-intersection scan confirmed a crossing pair.
1372 Scan(&'a SelfIntersectionReport),
1373 /// The containment floor convicted a hole loop that has broken out onto a
1374 /// neighbouring face.
1375 Breakout { face: u64, loop_id: u64 },
1376}
1377
1378/// The operand tag this repair works under. The solid is its own and only
1379/// operand: what is being resolved is a body crossing ITSELF, so there is no
1380/// second operand to be the other side of.
1381const SELF_OPERAND: u8 = 0;
1382
1383/// How many pieces the crossing may cut the two faces into before this repair
1384/// declines. Every way of dropping some of them is REBUILT and re-checked, so
1385/// the enumeration has to stay small enough to run in full: at eight pieces
1386/// there are at most 225 candidate drops, nearly all of which the re-trim
1387/// closure ends before anything is assembled. The variable-radius fillet's
1388/// crossing cuts four pieces and the two-bore fixture's cuts six.
1389const MAXIMUM_CROSSING_PIECES: usize = 8;
1390
1391/// Split two faces of ONE body that cross each other along their carriers'
1392/// exact intersection, drop the material the crossing encloses, and re-sew.
1393///
1394/// This is the bounded half of the self-intersection answer. Where two walls of
1395/// one body overshoot past each other instead of meeting at an edge, the
1396/// crossing curve IS the edge they should have met at: imprinting it onto both
1397/// faces cuts each into the part that belongs and the tail that overshot, and
1398/// dropping both tails leaves a body sewn along that curve.
1399///
1400/// **What it refuses, and why each refusal is a bound rather than a shrug:**
1401///
1402/// * A FOLD inside one face. There is no second face to imprint against, and
1403/// the answer is a trim that was never constructed rather than material to
1404/// drop — `offset/carve.rs` owns it.
1405/// * More than one crossing pair. Each repair changes the faces the next one
1406/// would be measured against, so resolving them together is a different
1407/// problem from resolving one.
1408/// * A crossing whose carriers' intersection does not cut BOTH trims. Then the
1409/// mesh crossing is not a curve the faces share and there is nothing to split
1410/// along.
1411/// * More pieces than [`MAXIMUM_CROSSING_PIECES`], because every candidate drop
1412/// is rebuilt in full and the enumeration has to run to the end rather than
1413/// be sampled.
1414/// * Any split whose reassembly does not come back SOUND. Every way of dropping
1415/// some pieces of each face — keeping at least one of each — is enumerated,
1416/// closed under the re-trim, rebuilt and re-checked in full (`validate()`
1417/// clean, connected, and scanning clean), so the piece choice is decided by
1418/// the result rather than guessed from a normal. If none verifies, the solid
1419/// is returned untouched and the caller refuses: a repair that cannot prove
1420/// its own answer must not ship one.
1421/// * A split that MORE THAN ONE drop resolves. Two sound answers is not an
1422/// answer, and choosing between them would be the guess this repair exists
1423/// not to make.
1424///
1425/// **The re-trim is the other half of the split.** Dropping a tail also removes
1426/// that tail's boundary uses on the NEIGHBOURING faces it bordered, and a body
1427/// cannot close over an edge only one face answers for. So the drop is closed
1428/// under "a face left holding an unmatched boundary goes too" — which, since
1429/// the imprint already cut those neighbours where the crossing curve met their
1430/// edges, drops the part of each that the crossing cut off. The closure is
1431/// bounded by the faces the imprint TOUCHED: reaching any other face ends that
1432/// candidate. Nothing here edits a loop by hand; every piece it chooses between
1433/// came out of the imprint/fragment machinery.
1434///
1435/// **And the drop RETIRES what the old configuration left.** The touched bound
1436/// is about material the crossing encloses, and it does not reach the third
1437/// thing a moved carrier leaves on a body: a piece, or a whole loop, that the
1438/// NEW configuration has no room for — a bore's exit ring still recorded on the
1439/// top face after the exit has migrated onto the side. Neither is material this
1440/// drop ran past; both are held by nothing the drop keeps, and keeping either
1441/// cannot close. [`fragment_is_detached`] retires the one the fragmenter
1442/// returns as its own island and [`holds_only_stale_loops`] the one it returns
1443/// as a hole loop that has left its face, and the ledger says a repair retired
1444/// something rather than leaving it to be read off the cell counts.
1445///
1446/// **A census has two directions and only one was enforced.** An edge used ONCE
1447/// is a face left holding an unanswered boundary, above. An edge used THREE
1448/// times is three faces along one curve, which is not a body either, and the
1449/// assembly does not refuse it — it puts the third face on a shell of its own
1450/// and every floor in this repository then passes the result.
1451/// [`over_used_edge`] refuses that candidate where the third use still exists,
1452/// which is here. It is what stopped the turned-bore migration returning a body
1453/// 23% out on area.
1454pub fn resolve_face_crossing(solid: &BrepSolid) -> Result<BrepSolid, KernelRefusal> {
1455 let options = SelfIntersectionOptions::for_solid(solid);
1456 let report = solid_self_intersections(solid, options).map_err(|message| {
1457 KernelRefusal::internal(
1458 KernelStage::Validate,
1459 "soundness_scan",
1460 format!("the soundness scan could not run: {message}"),
1461 )
1462 })?;
1463 if let Some(fold) = report.folds.first() {
1464 return Err(KernelRefusal::unsound(
1465 KernelStage::Validate,
1466 SoundnessDefect::Fold,
1467 report.folds.iter().map(|fold| fold.face).collect(),
1468 format!(
1469 "face {} folds through ITSELF; a fold is a trim that was never constructed and \
1470 belongs to the carrier carve, not to this repair",
1471 fold.face
1472 ),
1473 ));
1474 }
1475 if report.confirmed.is_empty() {
1476 return Ok(solid.clone());
1477 }
1478 if report.confirmed.len() > 1 {
1479 return Err(KernelRefusal::unsupported(
1480 KernelStage::Validate,
1481 "crossing.multiple_pairs",
1482 format!(
1483 "{} crossing face pairs; each repair changes the faces the next would be measured \
1484 against, so this repair takes one pair at a time",
1485 report.confirmed.len()
1486 ),
1487 ));
1488 }
1489 let crossing = &report.confirmed[0];
1490 split_and_drop(solid, crossing.face_a, crossing.face_b, RepairOrigin::Scan(&report))
1491}
1492
1493/// One crossing pair, split and resolved. Separated from the scan so the
1494/// refusals above read as one list.
1495fn split_and_drop(
1496 solid: &BrepSolid,
1497 face_a: u64,
1498 face_b: u64,
1499 origin: RepairOrigin<'_>,
1500) -> Result<BrepSolid, KernelRefusal> {
1501 use crate::fragment::fragment_solid;
1502 use crate::imprint::{FaceKey, ImprintOptions};
1503 use crate::{apply_edge_splits, build_imprints};
1504
1505 let scale = crate::solid_scale(solid);
1506 let tolerance = (scale * 1e-7).max(1e-9);
1507 if trace_on() {
1508 eprintln!("HEAL split_and_drop: faces {face_a} | {face_b}, scale {scale:.6}");
1509 trace_solid("entry solid", solid);
1510 }
1511
1512 // Each face as its own one-face open body, which is what the imprint
1513 // driver pairs: it walks operand A's faces against operand B's, and a
1514 // solid paired with itself would pair every face with itself first.
1515 let open_a = one_face_body(solid, face_a)?;
1516 let open_b = one_face_body(solid, face_b)?;
1517 let mut request = ImprintOptions {
1518 tolerance,
1519 // The GLOBAL interpolation lane: the section the repair cuts along is
1520 // the faithful one. On the only row this repair takes
1521 // (`FilletFailureWith2RasiusValues`) the global lane's section lies
1522 // 1.6e-6 from its two carriers where the local lane's lies 7.8e-4, and
1523 // it moves every face the section bounds onto its closed form (`Pin_S`
1524 // 1.6e-4 -> 4.4e-7). The local lane was asked for until
1525 // `process_curve` split an edge a section ENDS on whatever the angle:
1526 // that section is tangent to the bore's mouth ring at both ends, the
1527 // global fit reproduces the tangency, and the old gates left the ring
1528 // whole, so the repair succeeded only on the local fit's 1.4-degree
1529 // end-tangent error. `BREP_HEAL_FIT_LANE` re-measures it.
1530 local_fit: false,
1531 // The station budget is the imprint's own (the ladder climbs it to
1532 // twice that). `96` was `offset_shell`'s: every section this repair
1533 // reaches is a sparse march (68 points on the fillet row, 7 and 8 on
1534 // `BadBoolean`) that saturates either budget, bit-identically.
1535 ..ImprintOptions::default()
1536 };
1537 measured_fit_request(&mut request);
1538 let mut imprint = build_imprints(&open_a, &open_b, &request).map_err(|error| {
1539 error.with_message(|error| format!("the two carriers could not be imprinted: {error}"))
1540 })?;
1541
1542 // Both faces belong to ONE body, so both sides of the imprint are rewritten
1543 // onto that body's operand. The face ids are the solid's own and cannot
1544 // collide, so this is a relabel and not a merge.
1545 for face in &mut imprint.by_face {
1546 face.operand = SELF_OPERAND;
1547 }
1548 for split in &mut imprint.edge_splits {
1549 split.operand = SELF_OPERAND;
1550 }
1551 for piece in &mut imprint.pieces {
1552 for support in &mut piece.support_faces {
1553 *support = FaceKey {
1554 operand: SELF_OPERAND,
1555 face_id: support.face_id,
1556 };
1557 }
1558 for pcurve in &mut piece.pcurves {
1559 pcurve.operand = SELF_OPERAND;
1560 }
1561 }
1562 for barrier in &mut imprint.barrier_edges {
1563 barrier.0 = SELF_OPERAND;
1564 }
1565
1566 if trace_on() {
1567 trace_imprint(&imprint);
1568 }
1569
1570 let cut_a = imprint
1571 .by_face
1572 .iter()
1573 .any(|face| face.face_id == face_a && !face.piece_ids.is_empty());
1574 let cut_b = imprint
1575 .by_face
1576 .iter()
1577 .any(|face| face.face_id == face_b && !face.piece_ids.is_empty());
1578 if !cut_a || !cut_b {
1579 return Err(KernelRefusal::unsupported(
1580 KernelStage::Fragment,
1581 "crossing.no_shared_curve",
1582 format!(
1583 "the carriers' intersection reaches {} of the two trims, so there is no shared \
1584 curve to split along",
1585 match (cut_a, cut_b) {
1586 (true, false) => format!("only face {face_a}"),
1587 (false, true) => format!("only face {face_b}"),
1588 _ => "neither".into(),
1589 }
1590 ),
1591 ));
1592 }
1593
1594 let split = apply_edge_splits(solid, SELF_OPERAND, &imprint).map_err(|error| {
1595 error.with_message(|error| format!("the imprinted edge splits could not be applied: {error}"))
1596 })?;
1597 let fragments = fragment_solid(&split, SELF_OPERAND, &imprint).map_err(|error| {
1598 error.with_message(|error| format!("the split faces could not be fragmented: {error}"))
1599 })?;
1600
1601 if trace_on() {
1602 trace_solid("split solid", &split);
1603 trace_fragments(&fragments);
1604 }
1605
1606 // The faces the imprint TOUCHED: the two that cross, plus every face whose
1607 // own loop carries an edge the imprint split. Those are exactly the faces
1608 // the dropped material can border — the crossing curve ends ON a boundary
1609 // edge of each crossing face, and that edge is shared with a neighbour — so
1610 // they are the only faces this repair may re-trim. Anything beyond them is
1611 // material this repair has not been shown to own, and reaching one ends the
1612 // candidate rather than widening the drop.
1613 let touched = touched_faces(solid, &imprint, face_a, face_b);
1614 // A POLE edge is legitimately used once — it is a collapsed boundary, not a
1615 // shared one — so it never means the face beside it bordered dropped
1616 // material. Left in the census it would drop the fragment holding it, and
1617 // the cascade would refuse a body the repair could have resolved.
1618 let degenerate: rustc_hash::FxHashSet<EdgeKey> = split
1619 .edges
1620 .iter()
1621 .filter(|edge| edge.degenerate)
1622 .map(|edge| EdgeKey::Boundary(SELF_OPERAND, edge.id))
1623 .collect();
1624
1625 let pieces_of = |face: u64| -> Vec<usize> {
1626 fragments
1627 .iter()
1628 .enumerate()
1629 .filter_map(|(index, fragment)| (fragment.source_face_id == face).then_some(index))
1630 .collect()
1631 };
1632 let pieces_a = pieces_of(face_a);
1633 let pieces_b = pieces_of(face_b);
1634 // How many pieces the section has to cut, and it is not the same question
1635 // on both roads.
1636 //
1637 // From the SCAN, two faces overshoot PAST each other and the crossing curve
1638 // is the edge they should have met at, so it runs across both trims and
1639 // each has a tail: both sides must come out in two or more pieces, and one
1640 // piece is a section that grazed a trim rather than cut it.
1641 //
1642 // From the containment floor, the shape is different and the difference is
1643 // the whole of a COMPLETED MIGRATION. When a bore's mouth has walked
1644 // entirely onto the next face, the moved carrier meets that face in a
1645 // CLOSED loop inside its trim — the new mouth — which cuts nothing off it;
1646 // the face comes back as one piece carrying a new hole, and the only tail
1647 // is the wall's. Demanding two pieces of both would refuse exactly the case
1648 // the operator asked for, so this road asks for two pieces on ONE side and
1649 // at least one on the other. Nothing about the enumeration changes: a face
1650 // with one piece has one drop set, the empty one.
1651 let (need_a, need_b) = match &origin {
1652 RepairOrigin::Scan(_) => (2, 2),
1653 RepairOrigin::Breakout { .. } => {
1654 if pieces_a.len().max(pieces_b.len()) >= 2 {
1655 (1, 1)
1656 } else {
1657 (2, 2)
1658 }
1659 }
1660 };
1661 if pieces_a.len() < need_a || pieces_b.len() < need_b {
1662 return Err(KernelRefusal::unsupported(
1663 KernelStage::Fragment,
1664 "crossing.one_sided_cut",
1665 format!(
1666 "the crossing cut face {face_a} into {} piece(s) and face {face_b} into {} — this \
1667 repair resolves the case where the curve cuts BOTH trims, so one part of each is \
1668 the tail that overshot",
1669 pieces_a.len(),
1670 pieces_b.len()
1671 ),
1672 ));
1673 }
1674 if pieces_a.len() + pieces_b.len() > MAXIMUM_CROSSING_PIECES {
1675 return Err(KernelRefusal::non_convergence(
1676 KernelStage::Fragment,
1677 "crossing.pieces",
1678 format!(
1679 "the crossing cut face {face_a} into {} piece(s) and face {face_b} into {}, which is \
1680 {} pieces against this repair's budget of {MAXIMUM_CROSSING_PIECES} — every way to \
1681 drop some of them is rebuilt and re-checked, so the enumeration is bounded rather \
1682 than sampled",
1683 pieces_a.len(),
1684 pieces_b.len(),
1685 pieces_a.len() + pieces_b.len(),
1686 ),
1687 ));
1688 }
1689
1690 // WHICH pieces are the tails is decided by the RESULT, not by a normal.
1691 // Every way to drop some pieces of each crossing face (keeping at least one
1692 // of each) is closed under the re-trim below, rebuilt in full and
1693 // re-checked; the repair is the answer only when exactly ONE of them
1694 // verifies.
1695 //
1696 // The enumeration runs under TWO closures, and the second only where the
1697 // first found nothing at all. [`DropClosure::Drop`] drops a neighbour the
1698 // drop leaves holding an unmatched boundary — the rule this repair has
1699 // always used, and the one every body it already resolves is decided by.
1700 // [`DropClosure::Retrim`] RE-TRIMS that neighbour instead. Ordering them
1701 // this way makes the second closure strictly additive: a body the first
1702 // resolves never reaches it, so no answer this repair already returns can
1703 // change. The price is the enumeration twice over on a body that refuses.
1704 let plan = DropPlan {
1705 split: &split,
1706 fragments: &fragments,
1707 imprint: &imprint,
1708 touched: &touched,
1709 degenerate: °enerate,
1710 pieces_a: &pieces_a,
1711 pieces_b: &pieces_b,
1712 face_a,
1713 face_b,
1714 scale,
1715 };
1716 let (mut verified, refusals) = plan.enumerate(DropClosure::Drop);
1717 if verified.is_empty() {
1718 verified = plan.enumerate(DropClosure::Retrim).0;
1719 }
1720
1721 if verified.len() > 1 {
1722 return Err(KernelRefusal::ill_posed(
1723 KernelStage::Select,
1724 "crossing.ambiguous_drop",
1725 format!(
1726 "{} different drops of the crossing pieces all rebuilt into a sound body, so the \
1727 result does not decide which material the crossing encloses; this repair answers \
1728 only where one does",
1729 verified.len()
1730 ),
1731 ));
1732 }
1733 let Some(Candidate {
1734 dropped,
1735 retired,
1736 retrimmed,
1737 solid: repaired,
1738 }) = verified.pop()
1739 else {
1740 // The first few reasons, not all of them: eight pieces is up to 225
1741 // candidates and a refusal nobody can read is a refusal nobody uses.
1742 let listed = refusals
1743 .iter()
1744 .take(4)
1745 .cloned()
1746 .collect::<Vec<_>>()
1747 .join(" | ");
1748 // The candidate reasons (an over-used edge, a closure that consumed
1749 // every face, a rebuild that declined) are CONTEXT inside this one
1750 // refusal, whose class is minted here: the body still crosses itself.
1751 return Err(KernelRefusal::unsound(
1752 KernelStage::Sew,
1753 SoundnessDefect::Crossing,
1754 vec![face_a, face_b],
1755 format!(
1756 "none of the {} ways of dropping the crossing pieces rebuilt into a sound body \
1757 ({listed}{}); the worst crossing was {:.3e} past its band, so the overshoot is \
1758 real and it is the SPLIT that did not resolve it",
1759 refusals.len(),
1760 if refusals.len() > 4 {
1761 format!(" | and {} more", refusals.len() - 4)
1762 } else {
1763 String::new()
1764 },
1765 match &origin {
1766 RepairOrigin::Scan(report) => report
1767 .confirmed
1768 .first()
1769 .map(|crossing| crossing.straddle)
1770 .unwrap_or_default(),
1771 RepairOrigin::Breakout { .. } => 0.0,
1772 }
1773 ),
1774 ));
1775 };
1776
1777 record_repair(
1778 &fragments,
1779 &dropped,
1780 &retired,
1781 &retrimmed,
1782 face_a,
1783 face_b,
1784 &origin,
1785 );
1786 Ok(repaired)
1787}
1788
1789/// One candidate answer: the fragments the drop removed, the ones it RETIRED
1790/// as detached, the faces it RE-TRIMMED rather than removed, and the body
1791/// those choices rebuilt into.
1792struct Candidate {
1793 dropped: Vec<usize>,
1794 retired: Vec<u64>,
1795 retrimmed: Vec<u64>,
1796 solid: BrepSolid,
1797}
1798
1799/// How a candidate drop closes over a neighbour it leaves holding a boundary
1800/// no other face answers for.
1801#[derive(Clone, Copy, Eq, PartialEq)]
1802enum DropClosure {
1803 /// DROP that neighbour too, iterated to a fixed point — [`close_drop_set`].
1804 Drop,
1805 /// RE-TRIM it — [`retrim_drop_set`]: take the unmatched coedges off it and
1806 /// re-link what remains into closed loops on the same carrier, with the
1807 /// same pcurves. The bound is the same one (a face the imprint never cut
1808 /// still ends the candidate); what changes is that a face it DID cut is
1809 /// re-trimmed rather than consumed.
1810 Retrim,
1811}
1812
1813/// One crossing pair's split and everything a candidate drop is measured
1814/// against. Bundled so the enumeration can run once per closure.
1815struct DropPlan<'a> {
1816 split: &'a BrepSolid,
1817 fragments: &'a [crate::fragment::FaceFragmentRecord],
1818 imprint: &'a crate::imprint::ImprintResultRecord,
1819 touched: &'a rustc_hash::FxHashSet<u64>,
1820 degenerate: &'a rustc_hash::FxHashSet<EdgeKey>,
1821 pieces_a: &'a [usize],
1822 pieces_b: &'a [usize],
1823 face_a: u64,
1824 face_b: u64,
1825 scale: f64,
1826}
1827
1828impl DropPlan<'_> {
1829 /// Every way of dropping some pieces of each crossing face, closed under
1830 /// `closure`, rebuilt in full and re-checked. The candidates that verified
1831 /// come back first, the reasons the rest did not second.
1832 fn enumerate(&self, closure: DropClosure) -> (Vec<Candidate>, Vec<String>) {
1833 let (face_a, face_b) = (self.face_a, self.face_b);
1834 let mut refusals: Vec<String> = Vec::new();
1835 let mut verified: Vec<Candidate> = Vec::new();
1836 let mut seen: Vec<(Vec<usize>, Vec<Vec<usize>>)> = Vec::new();
1837 for drop_a in proper_subsets(self.pieces_a) {
1838 for drop_b in proper_subsets(self.pieces_b) {
1839 let mut dropped: Vec<usize> =
1840 drop_a.iter().chain(drop_b.iter()).copied().collect();
1841 if dropped.is_empty() {
1842 continue;
1843 }
1844 let label = format!(
1845 "dropping piece(s) {:?} of face {face_a} and {:?} of face {face_b}",
1846 drop_a
1847 .iter()
1848 .map(|index| self
1849 .pieces_a
1850 .iter()
1851 .position(|piece| piece == index)
1852 .unwrap_or(0))
1853 .collect::<Vec<_>>(),
1854 drop_b
1855 .iter()
1856 .map(|index| self
1857 .pieces_b
1858 .iter()
1859 .position(|piece| piece == index)
1860 .unwrap_or(0))
1861 .collect::<Vec<_>>(),
1862 );
1863 // The re-trim: a face that bordered dropped material is left
1864 // with a boundary use no other face answers, and a body cannot
1865 // close over one. Closing the drop under that rule is what
1866 // turns a two-face split into a re-trim of every face the
1867 // dropped material bordered.
1868 // The dropping closure leaves every fragment as it was, so it
1869 // borrows the list rather than copying it per candidate.
1870 let mut retired: Vec<u64> = Vec::new();
1871 let closed = match closure {
1872 DropClosure::Drop => close_drop_set(
1873 self.fragments,
1874 &mut dropped,
1875 &mut retired,
1876 self.touched,
1877 self.degenerate,
1878 )
1879 .map(|()| (None, Vec::new())),
1880 DropClosure::Retrim => retrim_drop_set(
1881 self.fragments,
1882 &mut dropped,
1883 &mut retired,
1884 self.touched,
1885 self.degenerate,
1886 )
1887 .map(|(working, retrimmed)| (Some(working), retrimmed)),
1888 };
1889 let (working, retrimmed) = match closed {
1890 Ok(closed) => closed,
1891 Err(reason) => {
1892 if trace_on() {
1893 println!(" candidate died at closure {label}: {reason}");
1894 }
1895 refusals.push(format!("{label}: {reason}"));
1896 continue;
1897 }
1898 };
1899 let working: &[crate::fragment::FaceFragmentRecord] =
1900 working.as_deref().unwrap_or(self.fragments);
1901 dropped.sort_unstable();
1902 // Two seeds can close onto the same answer. The signature is
1903 // the drop AND what the re-trim left of each kept fragment, so
1904 // a second closure that re-trimmed differently is a different
1905 // candidate rather than a hidden duplicate.
1906 let signature = (
1907 dropped.clone(),
1908 working
1909 .iter()
1910 .enumerate()
1911 .filter(|(index, _)| !dropped.contains(index))
1912 .map(|(_, fragment)| {
1913 fragment
1914 .loops
1915 .iter()
1916 .map(|fragment_loop| fragment_loop.coedges.len())
1917 .collect()
1918 })
1919 .collect(),
1920 );
1921 if seen.contains(&signature) {
1922 continue;
1923 }
1924 seen.push(signature);
1925 if dropped.len() == working.len() {
1926 refusals.push(format!("{label}: the re-trim consumed every face"));
1927 continue;
1928 }
1929 // The census's OTHER direction — see [`over_used_edge`].
1930 if let Some((key, count)) = over_used_edge(working, &dropped, self.degenerate) {
1931 refusals.push(format!(
1932 "{label}: {} is held {count} times by the pieces this drop keeps, and a \
1933 closed body holds every edge exactly twice",
1934 match key {
1935 EdgeKey::Boundary(_, edge) => format!("edge {edge}"),
1936 EdgeKey::Imprint(piece) => format!("imprint piece {piece}"),
1937 }
1938 ));
1939 if trace_on() {
1940 println!(
1941 " candidate refused {label}: {key:?} held {count} times"
1942 );
1943 }
1944 continue;
1945 }
1946 let selected: Vec<_> = working
1947 .iter()
1948 .enumerate()
1949 .filter(|(index, _)| !dropped.contains(index))
1950 .map(|(_, fragment)| fragment.clone())
1951 .collect();
1952 match rebuild(self.split, selected, self.imprint, self.scale) {
1953 // `BREP_HEAL_TRACE=1` prints every candidate the
1954 // enumeration considered, whether it verified, and what
1955 // volume it produced. This is the instrument that found the
1956 // 2026-09-22 stale-fragment defect: the `touched` bound
1957 // kills most candidates before `verified.len() == 1` picks
1958 // a survivor, and without the list there is no way to see
1959 // that the survivor was not the right one.
1960 Ok(solid) => {
1961 if trace_on() {
1962 println!(
1963 " CANDIDATE VERIFIED {label} volume {:.6} dropped {:?}",
1964 crate::solid_mass_properties(&solid)
1965 .map(|mass| mass.volume)
1966 .unwrap_or(f64::NAN),
1967 dropped
1968 );
1969 }
1970 verified.push(Candidate {
1971 dropped,
1972 retired,
1973 retrimmed,
1974 solid,
1975 })
1976 }
1977 Err(reason) => {
1978 if trace_on() {
1979 println!(" candidate refused {label}: {reason}");
1980 }
1981 refusals.push(format!("{label}: {reason}"))
1982 }
1983 }
1984 }
1985 }
1986 (verified, refusals)
1987 }
1988}
1989
1990/// The faces this repair may re-trim: the two that cross, and every face whose
1991/// own loops use an edge the imprint split.
1992fn touched_faces(
1993 solid: &BrepSolid,
1994 imprint: &crate::imprint::ImprintResultRecord,
1995 face_a: u64,
1996 face_b: u64,
1997) -> rustc_hash::FxHashSet<u64> {
1998 use rustc_hash::FxHashSet as HashSet;
1999 let split_edges: HashSet<u64> = imprint
2000 .edge_splits
2001 .iter()
2002 .filter(|split| split.operand == SELF_OPERAND)
2003 .map(|split| split.edge_id)
2004 .collect();
2005 let mut touched: HashSet<u64> = [face_a, face_b].into_iter().collect();
2006 for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
2007 let uses_split = face
2008 .loops
2009 .iter()
2010 .flat_map(|loop_record| &loop_record.coedges)
2011 .any(|coedge| split_edges.contains(&coedge.edge_id));
2012 if uses_split {
2013 touched.insert(face.id);
2014 }
2015 }
2016 touched
2017}
2018
2019/// Close a drop set under the rule a closed body cannot break: every edge is
2020/// used exactly twice. Dropping a tail takes its uses with it, so any face left
2021/// holding a boundary no other face answers bordered the dropped material and
2022/// has to be re-trimmed — which, since the imprint already cut it, means
2023/// dropping the part of it that the crossing cut off. Iterated to a fixed
2024/// point, and bounded by the touched set: reaching a face the imprint never cut
2025/// means the drop has left the material the crossing encloses, and the
2026/// candidate is refused rather than grown.
2027///
2028/// The one thing that bound does not own is a fragment joined to NOTHING the
2029/// drop keeps — see [`fragment_is_detached`]. That is not material the drop ran
2030/// past; it is what the old configuration left, and it is retired wherever it
2031/// came from.
2032fn close_drop_set(
2033 fragments: &[crate::fragment::FaceFragmentRecord],
2034 dropped: &mut Vec<usize>,
2035 retired: &mut Vec<u64>,
2036 touched: &rustc_hash::FxHashSet<u64>,
2037 degenerate: &rustc_hash::FxHashSet<EdgeKey>,
2038) -> Result<(), KernelRefusal> {
2039 use rustc_hash::FxHashMap as HashMap;
2040 loop {
2041 let mut uses: HashMap<EdgeKey, usize> = HashMap::default();
2042 for (index, fragment) in fragments.iter().enumerate() {
2043 if dropped.contains(&index) {
2044 continue;
2045 }
2046 for key in fragment
2047 .loops
2048 .iter()
2049 .flat_map(|fragment_loop| &fragment_loop.coedges)
2050 .filter_map(|coedge| edge_key(&coedge.source))
2051 {
2052 *uses.entry(key).or_default() += 1;
2053 }
2054 }
2055 let mut grew = false;
2056 for (index, fragment) in fragments.iter().enumerate() {
2057 if dropped.contains(&index) {
2058 continue;
2059 }
2060 // RETIRED, not re-trimmed: a fragment holding no edge that any
2061 // other kept fragment holds is attached to nothing this drop keeps.
2062 // The [`touched`] bound does not apply to it, because that bound
2063 // asks whether the drop has run past the material the crossing
2064 // encloses — and a piece joined to the body by nothing is not
2065 // material this drop reached, it is material the OLD configuration
2066 // left behind. Retiring it cannot cascade: the only uses it removes
2067 // are uses nothing else holds. See [`fragment_is_detached`].
2068 if fragment_is_detached(fragment, &uses, degenerate) {
2069 dropped.push(index);
2070 retired.push(fragment.source_face_id);
2071 grew = true;
2072 continue;
2073 }
2074 let unmatched = fragment
2075 .loops
2076 .iter()
2077 .flat_map(|fragment_loop| &fragment_loop.coedges)
2078 .filter_map(|coedge| edge_key(&coedge.source))
2079 .filter(|key| !degenerate.contains(key))
2080 .any(|key| uses.get(&key) == Some(&1));
2081 if !unmatched {
2082 continue;
2083 }
2084 if !touched.contains(&fragment.source_face_id) {
2085 return Err(KernelRefusal::internal(KernelStage::Validate, "retrim.uncut_face", format!(
2086 "the re-trim reached face {}, which the imprint never cut — the drop has run \
2087 past the material the crossing encloses",
2088 fragment.source_face_id
2089 )));
2090 }
2091 dropped.push(index);
2092 grew = true;
2093 }
2094 if !grew {
2095 return Ok(());
2096 }
2097 }
2098}
2099
2100/// [`close_drop_set`]'s other closure: a face left holding a boundary no other
2101/// face answers for is RE-TRIMMED rather than dropped.
2102///
2103/// Dropping it is the right answer when the unanswered boundary is the whole
2104/// of what that face contributed to the dropped material — a tail beside a
2105/// tail. It is the WRONG answer when the face is one the body keeps and only
2106/// part of its boundary went: a bore carried out through a side wall leaves
2107/// the mouth flat holding the stretch of its own corner edge that now runs
2108/// inside the bore, and the arc of the mouth ring that now runs outside the
2109/// body, and neither is material to drop — they are two coedges to take OFF a
2110/// face that otherwise stays exactly as it is. Taking them off merges that
2111/// face's mouth loop into its outer one, which is precisely the topology a
2112/// breakout has and a drop-only closure cannot reach.
2113///
2114/// The bound is [`close_drop_set`]'s: a face the imprint never cut still ends
2115/// the candidate rather than widening the re-trim, with the two exceptions that
2116/// are not re-trims at all — a fragment attached to nothing
2117/// ([`fragment_is_detached`]) and a whole loop that has left its face
2118/// ([`holds_only_stale_loops`]), both of which are retired. What comes back
2119/// is the fragment list to rebuild from — the untouched fragments as they were,
2120/// the re-trimmed ones with their loops re-linked — and the ids of the faces
2121/// that were re-trimmed, for the ledger. A fragment left with no coedge at all
2122/// joins `dropped`.
2123fn retrim_drop_set(
2124 fragments: &[crate::fragment::FaceFragmentRecord],
2125 dropped: &mut Vec<usize>,
2126 retired: &mut Vec<u64>,
2127 touched: &rustc_hash::FxHashSet<u64>,
2128 degenerate: &rustc_hash::FxHashSet<EdgeKey>,
2129) -> Result<(Vec<crate::fragment::FaceFragmentRecord>, Vec<u64>), KernelRefusal> {
2130 use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
2131
2132 let mut working = fragments.to_vec();
2133 let mut retrimmed: Vec<u64> = Vec::new();
2134 loop {
2135 let mut uses: HashMap<EdgeKey, usize> = HashMap::default();
2136 for (index, fragment) in working.iter().enumerate() {
2137 if dropped.contains(&index) {
2138 continue;
2139 }
2140 for key in fragment
2141 .loops
2142 .iter()
2143 .flat_map(|fragment_loop| &fragment_loop.coedges)
2144 .filter_map(|coedge| edge_key(&coedge.source))
2145 {
2146 *uses.entry(key).or_default() += 1;
2147 }
2148 }
2149 // RETIRED FIRST, and before the early return below, because a
2150 // detached fragment does not have to hold an UNMATCHED edge to be
2151 // detached — one whose loop walks its own seam twice holds that seam
2152 // twice itself, and the census reads two. See [`fragment_is_detached`].
2153 let mut grew = false;
2154 for index in 0..working.len() {
2155 if dropped.contains(&index) {
2156 continue;
2157 }
2158 if fragment_is_detached(&working[index], &uses, degenerate) {
2159 dropped.push(index);
2160 retired.push(working[index].source_face_id);
2161 grew = true;
2162 }
2163 }
2164 if grew {
2165 // The census is stale the moment anything is retired.
2166 continue;
2167 }
2168 // A POLE edge is legitimately used once, as in [`close_drop_set`].
2169 let unmatched: HashSet<EdgeKey> = uses
2170 .iter()
2171 .filter(|(key, count)| **count == 1 && !degenerate.contains(key))
2172 .map(|(key, _)| *key)
2173 .collect();
2174 if unmatched.is_empty() {
2175 return Ok((working, retrimmed));
2176 }
2177 for index in 0..working.len() {
2178 if dropped.contains(&index) {
2179 continue;
2180 }
2181 let holds = working[index]
2182 .loops
2183 .iter()
2184 .flat_map(|fragment_loop| &fragment_loop.coedges)
2185 .filter_map(|coedge| edge_key(&coedge.source))
2186 .any(|key| unmatched.contains(&key));
2187 if !holds {
2188 continue;
2189 }
2190 // A STALE LOOP is the other way the old configuration leaves
2191 // something behind, and the [`touched`] bound does not own it —
2192 // see [`holds_only_stale_loops`].
2193 let stale = holds_only_stale_loops(&working[index], &unmatched, degenerate);
2194 if !touched.contains(&working[index].source_face_id) && !stale {
2195 return Err(KernelRefusal::internal(KernelStage::Validate, "retrim.uncut_face", format!(
2196 "the re-trim reached face {}, which the imprint never cut — the drop has run \
2197 past the material the crossing encloses",
2198 working[index].source_face_id
2199 )));
2200 }
2201 let face = working[index].source_face_id;
2202 match retrim_fragment(&working[index], &unmatched)? {
2203 Some(next) => {
2204 working[index] = next;
2205 if stale {
2206 retired.push(face);
2207 } else if !retrimmed.contains(&face) {
2208 retrimmed.push(face);
2209 }
2210 }
2211 None => dropped.push(index),
2212 }
2213 grew = true;
2214 }
2215 if !grew {
2216 return Ok((working, retrimmed));
2217 }
2218 }
2219}
2220
2221/// Re-trim ONE fragment: take off every coedge in `remove` and re-link what is
2222/// left into closed loops.
2223///
2224/// The link is made in the face's OWN parameter domain, which is where a trim
2225/// loop is closed and the only frame that tells the two sides of a seam apart —
2226/// a cylinder's seam is one curve in 3D and two coedges a period apart here.
2227/// Nothing is fitted or re-parameterised: each surviving coedge keeps its
2228/// source, its sense and its pcurve, and only the ORDER and the grouping into
2229/// loops are rebuilt. A chain that does not close is a re-trim this repair
2230/// cannot prove, and it refuses rather than emitting an open face.
2231///
2232/// The loops come back in the order of their first surviving coedge, so a face
2233/// whose outer loop has just absorbed an inner one is still outer-loop-first.
2234/// `Ok(None)` when nothing at all is left to keep.
2235fn retrim_fragment(
2236 fragment: &crate::fragment::FaceFragmentRecord,
2237 remove: &rustc_hash::FxHashSet<EdgeKey>,
2238) -> Result<Option<crate::fragment::FaceFragmentRecord>, KernelRefusal> {
2239 use crate::fragment::{FaceFragmentRecord, FragmentCoedge, FragmentLoop};
2240
2241 let kept: Vec<FragmentCoedge> = fragment
2242 .loops
2243 .iter()
2244 .flat_map(|fragment_loop| &fragment_loop.coedges)
2245 .filter(|coedge| !edge_key(&coedge.source).is_some_and(|key| remove.contains(&key)))
2246 .cloned()
2247 .collect();
2248 if kept.is_empty() {
2249 return Ok(None);
2250 }
2251 let ends = kept
2252 .iter()
2253 .map(|coedge| {
2254 let [t0, t1] = coedge.pcurve.domain()?;
2255 Ok((coedge.pcurve.evaluate(t0)?, coedge.pcurve.evaluate(t1)?))
2256 })
2257 .collect::<Result<Vec<_>, String>>().or_refuse(KernelStage::Validate, "collect")?;
2258 // The domain's own diagonal sets the band a junction is matched in: a
2259 // plane's parameters are millimetres and a cylinder's are radians against
2260 // millimetres, so one fixed number would mean two different things on the
2261 // two carriers. The ends being matched were cut from ONE curve by the
2262 // imprint's own split, so they agree to the fit's precision and the band
2263 // only has to be small against the face.
2264 let [u0, u1] = fragment.surface.domain_u().or_refuse(KernelStage::Validate, "domain_u")?;
2265 let [v0, v1] = fragment.surface.domain_v().or_refuse(KernelStage::Validate, "domain_v")?;
2266 let tolerance = 1e-4 * (u1 - u0).hypot(v1 - v0);
2267 let mut used = vec![false; kept.len()];
2268 let mut loops: Vec<FragmentLoop> = Vec::new();
2269 for seed in 0..kept.len() {
2270 if used[seed] {
2271 continue;
2272 }
2273 used[seed] = true;
2274 let mut chain = vec![seed];
2275 let start = ends[seed].0;
2276 let mut cursor = ends[seed].1;
2277 while cursor.sub(start).length() > tolerance {
2278 let next = (0..kept.len())
2279 .filter(|index| !used[*index])
2280 .map(|index| (index, ends[index].0.sub(cursor).length()))
2281 .filter(|(_, gap)| *gap <= tolerance)
2282 .min_by(|left, right| left.1.total_cmp(&right.1))
2283 .map(|(index, _)| index);
2284 let Some(next) = next else {
2285 return Err(KernelRefusal::internal(KernelStage::Validate, "retrim.open_chain", format!(
2286 "face {}'s re-trim left a chain open at ({:.6}, {:.6}) in its own parameter \
2287 domain — what the drop took off it does not close into a loop",
2288 fragment.source_face_id, cursor.x, cursor.y
2289 )));
2290 };
2291 used[next] = true;
2292 chain.push(next);
2293 cursor = ends[next].1;
2294 }
2295 loops.push(FragmentLoop {
2296 coedges: chain.into_iter().map(|index| kept[index].clone()).collect(),
2297 });
2298 }
2299 Ok(Some(FaceFragmentRecord {
2300 loops,
2301 ..fragment.clone()
2302 }))
2303}
2304
2305/// Is this fragment attached to NOTHING else the drop keeps?
2306///
2307/// The census `uses` counts every non-dropped fragment's edge keys, this one's
2308/// included. So a fragment is detached exactly when, for every non-degenerate
2309/// key it holds, the census count equals ITS OWN count of that key: nobody else
2310/// is holding any edge of it.
2311///
2312/// It is stated that way rather than as "every key it holds is used once"
2313/// because a fragment may legitimately hold one key TWICE on its own — a
2314/// cylinder band walks its seam up one side and down the other — and such a
2315/// fragment is still detached when the seam's only two uses are both its.
2316///
2317/// **What a detached fragment IS.** It is a piece of the old configuration that
2318/// the new one has no room for: on the turned-bore migration, the top face's
2319/// hole loop is the exit ring the bore no longer opens through, sitting out at
2320/// x = 44.6 where the top face's own square ends at 20. The fragmenter returns
2321/// it as its own island because the ring is disjoint from the square, and the
2322/// only other face holding that ring is the wall's overshoot tail. Drop the
2323/// tail and the island is joined to nothing.
2324///
2325/// **Why retiring it is bounded.** Keeping it can never produce a closed body:
2326/// every edge it holds is answered for only by itself, so the assembly is open
2327/// there however the rest of the drop goes. And retiring it removes only uses
2328/// nothing else holds, so it cannot leave a third face unmatched — it does not
2329/// cascade, and no candidate the enumeration would otherwise verify is lost.
2330fn fragment_is_detached(
2331 fragment: &crate::fragment::FaceFragmentRecord,
2332 uses: &rustc_hash::FxHashMap<EdgeKey, usize>,
2333 degenerate: &rustc_hash::FxHashSet<EdgeKey>,
2334) -> bool {
2335 use rustc_hash::FxHashMap as HashMap;
2336 let mut own: HashMap<EdgeKey, usize> = HashMap::default();
2337 for key in fragment
2338 .loops
2339 .iter()
2340 .flat_map(|fragment_loop| &fragment_loop.coedges)
2341 .filter_map(|coedge| edge_key(&coedge.source))
2342 .filter(|key| !degenerate.contains(key))
2343 {
2344 *own.entry(key).or_default() += 1;
2345 }
2346 // A fragment carrying no identified edge at all is NOT called detached:
2347 // its boundary is all DERIVED, which `edge_key` deliberately does not
2348 // count, and the rebuild's own closure is what answers for those.
2349 !own.is_empty()
2350 && own
2351 .iter()
2352 .all(|(key, count)| uses.get(key).copied().unwrap_or(0) == *count)
2353}
2354
2355/// The OTHER way the old configuration leaves something behind: a WHOLE LOOP
2356/// of a face that nothing else answers for AND THAT HAS LEFT THAT FACE.
2357///
2358/// [`fragment_is_detached`] catches the leftover when the fragmenter returns it
2359/// as its own island — which it does when the stale ring is far enough from the
2360/// face's own trim to be plainly disjoint. When it is nearer, the same ring
2361/// comes back as a HOLE LOOP of the face it left: the turned bore's exit ring
2362/// at 40 degrees sits at `x = 22.9 - 30.7` against a top face that ends at 20,
2363/// and the top face is ONE fragment carrying it. There is no island to retire,
2364/// and taking the loop off is a RE-TRIM of a face the imprint never cut.
2365///
2366/// That is the move the earlier records named as missing — *"the departed face
2367/// keeps a hole loop the imprint never cut, and the drop enumeration has no
2368/// move for retiring one"*. This is the move, and it is bounded FOUR ways:
2369///
2370/// * **Unanswered for.** Every non-degenerate edge of the loop is used once,
2371/// by this fragment alone, so no other face the drop keeps is standing on it
2372/// and removing it takes nothing from anyone. A loop only PART of which is
2373/// unanswered for is a genuine re-trim of a neighbour that bordered the
2374/// dropped material, and the [`touched`] bound still owns that case.
2375/// * **It has LEFT the face**, by the containment floor's own test: every
2376/// sample of it lies outside the face's outer loop in that face's own
2377/// parameter plane. This is the condition that matters, and it was added
2378/// after measuring what happens without it. Unanswered-for alone is
2379/// SYMMETRIC — at 40 degrees it licensed retiring the bore's ENTRY mouth
2380/// just as readily as its departed exit ring, so the enumeration verified
2381/// two bodies and refused the pair as ambiguous. A loop still inside its own
2382/// face is trimming material and is not a leftover, whoever is holding it.
2383/// * **A loop survives.** This never empties a face; a fragment with nothing
2384/// left at all is the detached case above.
2385/// * **A PLANE only**, as the containment floor restricts itself, because on a
2386/// periodic surface "outside the outer loop" in the parameter plane is a
2387/// statement about the seam rather than about the trim.
2388///
2389/// And the body still has to rebuild, `validate()`, connect, scan clean and
2390/// pass the containment floor afterwards, exactly as before.
2391fn holds_only_stale_loops(
2392 fragment: &crate::fragment::FaceFragmentRecord,
2393 unmatched: &rustc_hash::FxHashSet<EdgeKey>,
2394 degenerate: &rustc_hash::FxHashSet<EdgeKey>,
2395) -> bool {
2396 if !matches!(
2397 fragment.surface.analytic(),
2398 Some(crate::AnalyticSurface::Plane { .. })
2399 ) {
2400 return false;
2401 }
2402 let mut stale: Vec<usize> = Vec::new();
2403 let mut surviving = 0usize;
2404 for (index, fragment_loop) in fragment.loops.iter().enumerate() {
2405 let keys: Vec<EdgeKey> = fragment_loop
2406 .coedges
2407 .iter()
2408 .filter_map(|coedge| edge_key(&coedge.source))
2409 .filter(|key| !degenerate.contains(key))
2410 .collect();
2411 if !keys.is_empty() && keys.iter().all(|key| unmatched.contains(key)) {
2412 stale.push(index);
2413 continue;
2414 }
2415 if keys.iter().any(|key| unmatched.contains(key)) {
2416 return false;
2417 }
2418 surviving += 1;
2419 }
2420 if stale.is_empty() || surviving == 0 {
2421 return false;
2422 }
2423 let Ok(polylines) = fragment
2424 .loops
2425 .iter()
2426 .map(fragment_loop_polyline)
2427 .collect::<Result<Vec<_>, KernelRefusal>>()
2428 else {
2429 return false;
2430 };
2431 if polylines.iter().any(Vec::is_empty) {
2432 return false;
2433 }
2434 let outer_index = (0..polylines.len())
2435 .max_by(|&a, &b| {
2436 polyline_area(&polylines[a])
2437 .abs()
2438 .total_cmp(&polyline_area(&polylines[b]).abs())
2439 })
2440 .unwrap_or(0);
2441 // The face's own outer loop being the stale one is not a leftover to
2442 // retire; it is the whole face going, which is the detached case.
2443 if stale.contains(&outer_index) {
2444 return false;
2445 }
2446 let outer = &polylines[outer_index];
2447 let bands = BoundaryBands::build(outer);
2448 let tolerance = boundary_tolerance(outer);
2449 stale.iter().all(|index| {
2450 polylines[*index]
2451 .iter()
2452 .all(|&point| !bands.contains(point) && !bands.within(point, tolerance))
2453 })
2454}
2455
2456/// The other half of the census rule, and the one that had no enforcement.
2457///
2458/// A closed body uses every non-degenerate edge EXACTLY TWICE. The drop
2459/// closures above are the "fewer than twice" half — a face left holding a
2460/// boundary nobody answers is dropped or re-trimmed. Nothing checked the other
2461/// direction, and the direction matters here: an imprint piece is cut into BOTH
2462/// crossing faces, so before any drop it is held four times, once by each of
2463/// the four pieces. A drop that takes one piece away leaves it held three
2464/// times — three faces along one curve, which is not a body.
2465///
2466/// That is the candidate the turned-bore migration used to return: dropping the
2467/// exit mouth's disc alone leaves the wall's kept part, the wall's overshoot
2468/// tail and the mouth flat's remainder all on the mouth ring. Refusing it is
2469/// what leaves the one drop that is right.
2470///
2471/// **And nothing downstream catches it**, which is why the check belongs here
2472/// and not there. `assemble_open_fragments` resolves the third use by putting
2473/// the overshoot on a shell of its OWN: the body comes back with every edge
2474/// used exactly twice, `validate()` clean, `solid_connectivity` calling it
2475/// connected (the sheets share edges with the main shell), the scan clean and
2476/// the containment floor acquitting it — a three-shell solid whose first shell
2477/// is the right answer and whose other two are sheets stapled to it. Measured
2478/// on the turned bore at 60 degrees: shell 1 is the 7 correct faces at
2479/// 7673.516114379 against a closed form of 7673.516114438, shell 2 is the
2480/// overshoot alone at -536.326313 and shell 3 the departed ring at -6.7e-14.
2481///
2482/// Returns the offending key and its count, smallest key first so the refusal
2483/// reads the same on every run.
2484fn over_used_edge(
2485 fragments: &[crate::fragment::FaceFragmentRecord],
2486 dropped: &[usize],
2487 degenerate: &rustc_hash::FxHashSet<EdgeKey>,
2488) -> Option<(EdgeKey, usize)> {
2489 use rustc_hash::FxHashMap as HashMap;
2490 let mut uses: HashMap<EdgeKey, usize> = HashMap::default();
2491 for (index, fragment) in fragments.iter().enumerate() {
2492 if dropped.contains(&index) {
2493 continue;
2494 }
2495 for key in fragment
2496 .loops
2497 .iter()
2498 .flat_map(|fragment_loop| &fragment_loop.coedges)
2499 .filter_map(|coedge| edge_key(&coedge.source))
2500 .filter(|key| !degenerate.contains(key))
2501 {
2502 *uses.entry(key).or_default() += 1;
2503 }
2504 }
2505 let mut over: Vec<(EdgeKey, usize)> = uses
2506 .into_iter()
2507 .filter(|(_, count)| *count > 2)
2508 .collect();
2509 over.sort_unstable();
2510 over.into_iter().next()
2511}
2512
2513/// The identity of a fragment coedge's edge, for the use census. A DERIVED
2514/// coedge carries a freshly minted curve rather than an identity, so it is not
2515/// counted — the rebuild's own closure check is what answers for those.
2516fn edge_key(source: &crate::fragment::FragmentEdgeSource) -> Option<EdgeKey> {
2517 use crate::fragment::FragmentEdgeSource;
2518 match source {
2519 FragmentEdgeSource::Boundary { operand, edge_id }
2520 | FragmentEdgeSource::SharedBoundary { operand, edge_id } => {
2521 Some(EdgeKey::Boundary(*operand, *edge_id))
2522 }
2523 FragmentEdgeSource::Imprint { piece_id } => Some(EdgeKey::Imprint(*piece_id)),
2524 FragmentEdgeSource::Derived { .. } => None,
2525 }
2526}
2527
2528#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
2529enum EdgeKey {
2530 Boundary(u8, u64),
2531 Imprint(u64),
2532}
2533
2534/// Every subset of `pieces` that leaves at least one behind, the empty one
2535/// included — the ways one face's pieces can be dropped.
2536fn proper_subsets(pieces: &[usize]) -> Vec<Vec<usize>> {
2537 let mut subsets = Vec::new();
2538 for mask in 0..(1u32 << pieces.len()) {
2539 if mask == (1u32 << pieces.len()) - 1 {
2540 // Dropping every piece of a crossing face is not a re-trim of it.
2541 continue;
2542 }
2543 subsets.push(
2544 pieces
2545 .iter()
2546 .enumerate()
2547 .filter(|(bit, _)| mask & (1 << bit) != 0)
2548 .map(|(_, piece)| *piece)
2549 .collect(),
2550 );
2551 }
2552 subsets
2553}
2554
2555/// Reassemble one choice of kept fragments and prove it sound, or say why not.
2556fn rebuild(
2557 split: &BrepSolid,
2558 selected: Vec<crate::fragment::FaceFragmentRecord>,
2559 imprint: &crate::imprint::ImprintResultRecord,
2560 scale: f64,
2561) -> Result<BrepSolid, KernelRefusal> {
2562 use crate::boolean::{assemble_open_fragments, finalize_assembled_solid};
2563 use rustc_hash::FxHashMap as HashMap;
2564
2565 let mut sources: HashMap<u8, &BrepSolid> = HashMap::default();
2566 sources.insert(SELF_OPERAND, split);
2567 let assembly_tolerance = 2e-3f64.max(scale * 5e-5);
2568 let solid = assemble_open_fragments(selected, &sources, imprint, assembly_tolerance)
2569 .map_err(|error| error.with_message(|error| format!("the kept pieces did not assemble: {error}")))?;
2570 let solid = finalize_assembled_solid(solid, assembly_tolerance)
2571 .map_err(|error| error.with_message(|error| format!("the assembly did not finalize: {error}")))?;
2572
2573 let issues = solid.validate();
2574 if !issues.is_empty() {
2575 return Err(KernelRefusal::internal(KernelStage::Validate, "rebuild.validate", format!(
2576 "the rebuilt body fails validate(): {}",
2577 issues[0].message
2578 )));
2579 }
2580 if crate::solid_connectivity(&solid).is_disconnected() {
2581 return Err(KernelRefusal::internal(KernelStage::Validate, "rebuild.connected", "the rebuilt body is not connected"));
2582 }
2583 let options = SelfIntersectionOptions::for_solid(&solid);
2584 let report = solid_self_intersections(&solid, options)
2585 .map_err(|message| format!("the rebuilt body could not be scanned: {message}"))
2586 .or_refuse(KernelStage::Validate, "solid_self_intersections")?;
2587 if report.is_flagged() {
2588 return Err(KernelRefusal::internal(KernelStage::Validate, "rebuild.self_intersects", format!(
2589 "the rebuilt body still self-intersects: {}",
2590 report.summary().unwrap_or_else(|| "no summary".into())
2591 )));
2592 }
2593 Ok(solid)
2594}
2595
2596/// One face of a body as its own open body, carrying the edges and vertices
2597/// that face uses and nothing else.
2598fn one_face_body(solid: &BrepSolid, face_id: u64) -> Result<BrepSolid, KernelRefusal> {
2599 use crate::topology::ShellRecord;
2600 use rustc_hash::FxHashSet as HashSet;
2601
2602 let face = solid
2603 .shells
2604 .iter()
2605 .flat_map(|shell| &shell.faces)
2606 .find(|face| face.id == face_id)
2607 .ok_or_else(|| {
2608 // The class its one caller gave it before this helper was typed.
2609 KernelRefusal::internal(KernelStage::Fragment, "one_face_body", format!("face {face_id} is not in this body"))
2610 })?
2611 .clone();
2612 let used: HashSet<u64> = face
2613 .loops
2614 .iter()
2615 .flat_map(|loop_record| &loop_record.coedges)
2616 .map(|coedge| coedge.edge_id)
2617 .collect();
2618 let edges: Vec<_> = solid
2619 .edges
2620 .iter()
2621 .filter(|edge| used.contains(&edge.id))
2622 .cloned()
2623 .collect();
2624 let vertices: HashSet<u64> = edges
2625 .iter()
2626 .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
2627 .collect();
2628 Ok(BrepSolid {
2629 mass_properties_cache: Default::default(),
2630 id: solid.id,
2631 genus: 0,
2632 vertices: solid
2633 .vertices
2634 .iter()
2635 .filter(|vertex| vertices.contains(&vertex.id))
2636 .cloned()
2637 .collect(),
2638 edges,
2639 shells: vec![ShellRecord {
2640 id: 1,
2641 faces: vec![face],
2642 }],
2643 })
2644}
2645
2646// ---------------------------------------------------------------------------
2647// The ledger: a repaired result is reported as repaired
2648// ---------------------------------------------------------------------------
2649
2650/// One self-crossing this module REPAIRED rather than refused.
2651///
2652/// A repair changes the answer a lane returns, so it is never silent: the
2653/// feature that ran it carries this in its own result
2654/// (`FeatureResult::notes`), and the case gate records it per case. The
2655/// ledger is the carrier between the two — [`accept_sound`] is called from
2656/// lanes with no diagnostics record to write into, and threading one through
2657/// every lane exit would be a wider change than the repair itself. Same shape
2658/// as the boolean's `CONFORMANCE_REPAIRS` counter and the pcurve fit ledger.
2659#[derive(Clone, Debug)]
2660pub struct CrossingRepairRecord {
2661 /// The two faces that crossed.
2662 pub faces: (u64, u64),
2663 /// The crossing point the scan confirmed.
2664 pub point: crate::Vec3,
2665 /// How far past its band the crossing straddled.
2666 pub straddle: f64,
2667 /// Source faces whose pieces were dropped, with how many pieces of each.
2668 pub dropped: Vec<(u64, usize)>,
2669 /// Source faces the repair RETIRED a leftover from, with how many: a
2670 /// piece the drop left joined to nothing, or a whole loop of a face that
2671 /// nothing else answers for. Either way it is what the OLD configuration
2672 /// left on the body, and the ledger is the only place that says a repair
2673 /// took one off — "a piece was dropped" and "a leftover was retired" are
2674 /// different events.
2675 pub retired: Vec<(u64, usize)>,
2676 /// The faces the drop RE-TRIMMED: neighbours of the dropped material,
2677 /// which are neither of the crossing pair.
2678 pub retrimmed: Vec<u64>,
2679 /// Set when the repair was reached from the HOLE-CONTAINMENT FLOOR rather
2680 /// than from the scan: the face whose hole loop had left it, and that
2681 /// loop. `point` and `straddle` are then unmeasured and read zero, because
2682 /// no crossing was confirmed — the floor convicts a containment, not a
2683 /// crossing.
2684 pub breakout: Option<(u64, u64)>,
2685}
2686
2687impl std::fmt::Display for CrossingRepairRecord {
2688 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2689 if let Some((face, loop_id)) = self.breakout {
2690 return write!(
2691 formatter,
2692 "repaired a BREAKOUT: hole loop {loop_id} of face {face} had left that face \
2693 onto face {}, so face {} — the wall it bounds — and face {} were split along \
2694 their carriers' intersection; dropped {} and re-trimmed {}{}",
2695 self.faces.1,
2696 self.faces.0,
2697 self.faces.1,
2698 self.pieces_dropped(),
2699 self.faces_retrimmed(),
2700 self.pieces_retired(),
2701 );
2702 }
2703 write!(
2704 formatter,
2705 "repaired a self-crossing: faces {} and {} crossed near ({:.4}, {:.4}, {:.4}) \
2706 (straddle {:.3e}); split along their carriers' intersection, dropped {} \
2707 and re-trimmed {}{}",
2708 self.faces.0,
2709 self.faces.1,
2710 self.point.x,
2711 self.point.y,
2712 self.point.z,
2713 self.straddle,
2714 self.pieces_dropped(),
2715 self.faces_retrimmed(),
2716 self.pieces_retired(),
2717 )
2718 }
2719}
2720
2721impl CrossingRepairRecord {
2722 fn pieces_dropped(&self) -> String {
2723 self.dropped
2724 .iter()
2725 .map(|(face, count)| format!("{count} piece(s) of face {face}"))
2726 .collect::<Vec<_>>()
2727 .join(", ")
2728 }
2729
2730 fn faces_retrimmed(&self) -> String {
2731 if self.retrimmed.is_empty() {
2732 "no neighbouring face".to_string()
2733 } else {
2734 format!(
2735 "face(s) {}",
2736 self.retrimmed
2737 .iter()
2738 .map(|face| face.to_string())
2739 .collect::<Vec<_>>()
2740 .join(", ")
2741 )
2742 }
2743 }
2744
2745 /// The clause that says the OLD configuration's leftovers were taken off
2746 /// the body. Empty when nothing was retired, so every message this ledger
2747 /// already printed is unchanged; a repair that retires something says so
2748 /// and names the face, which is the only way the case gate's result line
2749 /// tells a repaired migration from the one it used to return.
2750 fn pieces_retired(&self) -> String {
2751 if self.retired.is_empty() {
2752 return String::new();
2753 }
2754 format!(
2755 ", and RETIRED {} the old configuration left, held by nothing else this repair keeps",
2756 self.retired
2757 .iter()
2758 .map(|(face, count)| format!("{count} leftover(s) of face {face}"))
2759 .collect::<Vec<_>>()
2760 .join(", ")
2761 )
2762 }
2763}
2764
2765thread_local! {
2766 static CROSSING_REPAIRS: std::cell::RefCell<Vec<CrossingRepairRecord>> =
2767 const { std::cell::RefCell::new(Vec::new()) };
2768}
2769
2770/// Take every repair made on this thread since the last call, leaving the
2771/// ledger empty. The history loop drains it after each feature so the repair
2772/// is reported against the feature that made it.
2773pub fn take_crossing_repairs() -> Vec<CrossingRepairRecord> {
2774 CROSSING_REPAIRS.with(|repairs| std::mem::take(&mut *repairs.borrow_mut()))
2775}
2776
2777/// Put a ledger back, replacing whatever is there.
2778///
2779/// The migration route TRIES candidate faces, and a trial that builds records
2780/// itself on the way past. Only the candidate whose body is returned may keep
2781/// its record, and a caller's own entries must survive the trials, so the
2782/// ledger is taken before and written back after. Nothing else may use this:
2783/// a repair reports itself once, where it happened.
2784fn restore_repairs(records: Vec<CrossingRepairRecord>) {
2785 CROSSING_REPAIRS.with(|repairs| *repairs.borrow_mut() = records);
2786}
2787
2788fn record_repair(
2789 fragments: &[crate::fragment::FaceFragmentRecord],
2790 dropped: &[usize],
2791 retired: &[u64],
2792 retrimmed_faces: &[u64],
2793 face_a: u64,
2794 face_b: u64,
2795 origin: &RepairOrigin<'_>,
2796) {
2797 let tally = |faces: &[u64]| {
2798 let mut by_face: Vec<(u64, usize)> = Vec::new();
2799 for face in faces {
2800 match by_face.iter_mut().find(|(existing, _)| existing == face) {
2801 Some((_, count)) => *count += 1,
2802 None => by_face.push((*face, 1)),
2803 }
2804 }
2805 by_face.sort_unstable();
2806 by_face
2807 };
2808 let by_face = tally(
2809 &dropped
2810 .iter()
2811 .map(|index| fragments[*index].source_face_id)
2812 .collect::<Vec<_>>(),
2813 );
2814 let retired = tally(retired);
2815 // A face that lost ONLY a detached piece was not re-trimmed — nothing of
2816 // it was re-cut, a leftover of the old configuration was taken off the
2817 // body — so it is reported under `retired` and not counted here twice.
2818 let mut retrimmed: Vec<u64> = by_face
2819 .iter()
2820 .filter(|(face, count)| {
2821 retired
2822 .iter()
2823 .find(|(retired_face, _)| retired_face == face)
2824 .map(|(_, retired_count)| retired_count < count)
2825 .unwrap_or(true)
2826 })
2827 .map(|(face, _)| *face)
2828 .chain(retrimmed_faces.iter().copied())
2829 .filter(|face| *face != face_a && *face != face_b)
2830 .collect();
2831 retrimmed.sort_unstable();
2832 retrimmed.dedup();
2833 let crossing = match origin {
2834 RepairOrigin::Scan(report) => report.confirmed.first(),
2835 RepairOrigin::Breakout { .. } => None,
2836 };
2837 let record = CrossingRepairRecord {
2838 faces: (face_a, face_b),
2839 point: crossing.map(|crossing| crossing.point).unwrap_or_default(),
2840 straddle: crossing.map(|crossing| crossing.straddle).unwrap_or_default(),
2841 dropped: by_face,
2842 retired,
2843 retrimmed,
2844 breakout: match origin {
2845 RepairOrigin::Scan(_) => None,
2846 RepairOrigin::Breakout { face, loop_id } => Some((*face, *loop_id)),
2847 },
2848 };
2849 if trace_on() {
2850 eprintln!("HEAL {record}");
2851 }
2852 CROSSING_REPAIRS.with(|repairs| repairs.borrow_mut().push(record));
2853}
2854
2855/// Write the flagged body out before the repair touches it, when
2856/// `BREP_HEAL_DUMP` names a directory. A crossing is measured off the body that
2857/// carries it — per-face areas, a mesh-arbitrated genus, the volume the repair
2858/// changes — and replaying a whole document to reach it again costs more than
2859/// the file does. Diagnostics only: the lane still repairs or refuses exactly
2860/// as it would have.
2861fn dump_flagged(solid: &BrepSolid, entry: &str) {
2862 let Ok(directory) = std::env::var("BREP_HEAL_DUMP") else {
2863 return;
2864 };
2865 if directory.is_empty() {
2866 return;
2867 }
2868 let name: String = entry
2869 .chars()
2870 .map(|character| if character.is_alphanumeric() { character } else { '_' })
2871 .collect();
2872 let path = std::path::Path::new(&directory).join(format!("flagged-{name}.json"));
2873 match serde_json::to_string(solid) {
2874 Ok(text) => {
2875 if let Err(error) = std::fs::write(&path, text) {
2876 eprintln!("HEAL dump {}: {error}", path.display());
2877 } else {
2878 eprintln!("HEAL dumped the flagged body to {}", path.display());
2879 }
2880 }
2881 Err(error) => eprintln!("HEAL dump: {error}"),
2882 }
2883}
2884
2885// ---------------------------------------------------------------------------
2886// Trace instrument (BREP_HEAL_TRACE=1)
2887// ---------------------------------------------------------------------------
2888
2889fn trace_on() -> bool {
2890 std::env::var("BREP_HEAL_TRACE").is_ok_and(|value| !value.is_empty() && value != "0")
2891}
2892
2893/// Measurement hatch for the section fit the repair's imprint asks for:
2894/// `BREP_HEAL_FIT_LANE=local|global` picks the lane its ladder starts in and
2895/// `BREP_HEAL_FIT_POINTS=<n>` its station budget. Both unset is the request as
2896/// built, so every lane and budget can be read off ONE binary against the
2897/// shipped one (`crossing_repair_probe body` with `BREP_DEBUG_POLYLINE_FIT=1`).
2898fn measured_fit_request(request: &mut crate::imprint::ImprintOptions) {
2899 match std::env::var("BREP_HEAL_FIT_LANE").as_deref() {
2900 Ok("local") => request.local_fit = true,
2901 Ok("global") => request.local_fit = false,
2902 _ => {}
2903 }
2904 if let Some(points) = std::env::var("BREP_HEAL_FIT_POINTS")
2905 .ok()
2906 .and_then(|value| value.parse::<usize>().ok())
2907 .filter(|points| *points >= 2)
2908 {
2909 request.maximum_fit_points = points;
2910 }
2911 if trace_on() {
2912 eprintln!(
2913 "HEAL imprint request: {} lane, {} stations, tolerance {:.3e}",
2914 if request.local_fit { "local" } else { "global" },
2915 request.maximum_fit_points,
2916 request.tolerance
2917 );
2918 }
2919}
2920
2921fn trace_solid(label: &str, solid: &BrepSolid) {
2922 use rustc_hash::FxHashMap as HashMap;
2923 let point: HashMap<u64, crate::Vec3> = solid
2924 .vertices
2925 .iter()
2926 .map(|vertex| (vertex.id, vertex.point))
2927 .collect();
2928 eprintln!(
2929 "HEAL {label}: {}F {}E {}V shells={}",
2930 solid.shells.iter().map(|shell| shell.faces.len()).sum::<usize>(),
2931 solid.edges.len(),
2932 solid.vertices.len(),
2933 solid.shells.len()
2934 );
2935 let mut uses: HashMap<u64, usize> = HashMap::default();
2936 for shell in &solid.shells {
2937 for face in &shell.faces {
2938 for loop_record in &face.loops {
2939 for coedge in &loop_record.coedges {
2940 *uses.entry(coedge.edge_id).or_default() += 1;
2941 }
2942 }
2943 }
2944 }
2945 for edge in &solid.edges {
2946 let start = point.get(&edge.start_vertex_id).copied().unwrap_or_default();
2947 let end = point.get(&edge.end_vertex_id).copied().unwrap_or_default();
2948 eprintln!(
2949 "HEAL edge {:>3} uses={} v{}->v{} ({:.4},{:.4},{:.4})->({:.4},{:.4},{:.4}) deg={} '{}'",
2950 edge.id,
2951 uses.get(&edge.id).copied().unwrap_or(0),
2952 edge.start_vertex_id,
2953 edge.end_vertex_id,
2954 start.x, start.y, start.z, end.x, end.y, end.z,
2955 edge.degenerate,
2956 edge.name.as_deref().unwrap_or("")
2957 );
2958 }
2959 for shell in &solid.shells {
2960 for face in &shell.faces {
2961 eprintln!(
2962 "HEAL face {:>3} loops={} '{}'",
2963 face.id,
2964 face.loops.len(),
2965 face.name.as_deref().unwrap_or("")
2966 );
2967 for (index, loop_record) in face.loops.iter().enumerate() {
2968 let text = loop_record
2969 .coedges
2970 .iter()
2971 .map(|coedge| format!("{}{}", if coedge.forward { '+' } else { '-' }, coedge.edge_id))
2972 .collect::<Vec<_>>()
2973 .join(" ");
2974 eprintln!("HEAL loop {index}: {text}");
2975 }
2976 }
2977 }
2978}
2979
2980fn trace_imprint(imprint: &crate::imprint::ImprintResultRecord) {
2981 eprintln!(
2982 "HEAL imprint: {} piece(s) {} edge_split(s) by_face={:?}",
2983 imprint.pieces.len(),
2984 imprint.edge_splits.len(),
2985 imprint
2986 .by_face
2987 .iter()
2988 .map(|face| (face.operand, face.face_id, face.piece_ids.clone()))
2989 .collect::<Vec<_>>()
2990 );
2991 for piece in &imprint.pieces {
2992 let start = piece.curve.evaluate(piece.t0).unwrap_or_default();
2993 let end = piece.curve.evaluate(piece.t1).unwrap_or_default();
2994 let middle = piece.curve.evaluate((piece.t0 + piece.t1) / 2.0).unwrap_or_default();
2995 eprintln!(
2996 "HEAL piece {} v{}->v{} ({:.4},{:.4},{:.4}) .. ({:.4},{:.4},{:.4}) .. ({:.4},{:.4},{:.4}) supports={:?} shared={:?}",
2997 piece.id,
2998 piece.start_vertex_id,
2999 piece.end_vertex_id,
3000 start.x, start.y, start.z,
3001 middle.x, middle.y, middle.z,
3002 end.x, end.y, end.z,
3003 piece.support_faces.iter().map(|key| (key.operand, key.face_id)).collect::<Vec<_>>(),
3004 piece.shared_edge,
3005 );
3006 }
3007 for split in &imprint.edge_splits {
3008 eprintln!(
3009 "HEAL edge_split operand={} edge={} at {:?}",
3010 split.operand, split.edge_id, split.parameters
3011 );
3012 }
3013 for vertex in &imprint.vertices {
3014 eprintln!(
3015 "HEAL imprint vertex {} ({:.4},{:.4},{:.4})",
3016 vertex.id, vertex.point.x, vertex.point.y, vertex.point.z
3017 );
3018 }
3019}
3020
3021fn trace_fragments(fragments: &[crate::fragment::FaceFragmentRecord]) {
3022 use crate::fragment::FragmentEdgeSource;
3023 eprintln!("HEAL fragments: {}", fragments.len());
3024 for (index, fragment) in fragments.iter().enumerate() {
3025 eprintln!(
3026 "HEAL fragment {index} face {} loops={} test=({:.4},{:.4},{:.4})",
3027 fragment.source_face_id,
3028 fragment.loops.len(),
3029 fragment.test_point.x,
3030 fragment.test_point.y,
3031 fragment.test_point.z
3032 );
3033 for (loop_index, fragment_loop) in fragment.loops.iter().enumerate() {
3034 let text = fragment_loop
3035 .coedges
3036 .iter()
3037 .map(|coedge| {
3038 let tag = match &coedge.source {
3039 FragmentEdgeSource::Boundary { operand, edge_id } => format!("b{operand}:{edge_id}"),
3040 FragmentEdgeSource::SharedBoundary { operand, edge_id } => format!("s{operand}:{edge_id}"),
3041 FragmentEdgeSource::Imprint { piece_id } => format!("p{piece_id}"),
3042 FragmentEdgeSource::Derived { start, end, .. } => format!(
3043 "d({:.3},{:.3},{:.3})->({:.3},{:.3},{:.3})",
3044 start.x, start.y, start.z, end.x, end.y, end.z
3045 ),
3046 };
3047 format!("{}{tag}", if coedge.forward { '+' } else { '-' })
3048 })
3049 .collect::<Vec<_>>()
3050 .join(" ");
3051 eprintln!("HEAL loop {loop_index}: {text}");
3052 }
3053 }
3054}
3055
3056