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