1use crate::arrangement::Vec2;
2use crate::classification::{classify_point, parameter_point_in_face, PolygonClass};
3use crate::fragment::{FaceFragmentRecord, FragmentEdgeSource};
4use crate::imprint::{
5 EdgeSplitRecord, FaceImprints, FaceKey, ImprintOptions, ImprintPieceRecord,
6 ImprintResultRecord, ImprintVertex,
7};
8use crate::tolerance::{assembler_weld, commit_weld};
9use crate::topology::{
10 adaptive_coedge_error, BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord,
11 ShellRecord, VertexRecord,
12};
13use crate::{
14 KernelRefusal, OrRefuse, RefusalClass,
15 apply_edge_splits, apply_edge_splits_with_map, build_imprints, build_pcurve_on_surface,
16 build_pcurve_on_surface_range, classify_surface_pair,
17 fragment_solid, interpolate_curve, merge_curve_continuation_edges,
18 merge_same_surface_faces_excluding, merge_same_surface_faces_scoped,
19 project_point_to_curve, project_point_to_surface, solid_signed_volume, solid_signed_volume_for_sign, AffineTransform,
20 DiagnosticSeverity, KernelDiagnostics, KernelOutcome, KernelStage, KernelTolerances, NurbsCurve,
21 PointClass, SolidClassifier, SurfacePairRelation, Vec3,
22};
23use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
24use serde::{Deserialize, Serialize};
25use web_time::Instant;
26
27thread_local! {
28 pub(crate) static CONFORMANCE_REPAIRS: std::cell::Cell<u64> =
34 const { std::cell::Cell::new(0) };
35}
36
37#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
38#[serde(rename_all = "lowercase")]
39pub enum BooleanOperation {
40 Union,
41 Intersect,
42 Subtract,
43}
44
45#[derive(Clone, Debug, Deserialize)]
46pub struct BooleanOptions {
47 #[serde(default = "default_tolerance")]
48 pub tolerance: f64,
49 #[serde(default)]
52 pub tolerances: Option<KernelTolerances>,
53 #[serde(default)]
54 pub imprint: ImprintOptions,
55 #[serde(default = "default_true")]
58 pub merge_coplanar_faces: bool,
59 #[serde(default)]
69 pub keep_unmerged_name_substrs: Vec<String>,
70 #[serde(default = "default_true")]
74 pub untouched_passthrough: bool,
75 #[serde(default = "default_true")]
79 pub disjoint_fast_path: bool,
80 #[serde(default)]
88 pub touched_assembly: bool,
89 #[serde(default = "default_true")]
97 pub sector_classification: bool,
98}
99
100fn touched_assembly(options: &BooleanOptions) -> bool {
102 match std::env::var("BREP_TOUCHED_ASSEMBLY").as_deref() {
103 Ok("0") => false,
104 Ok(_) => true,
105 Err(_) => options.touched_assembly,
106 }
107}
108
109fn disjoint_fast_path(options: &BooleanOptions) -> bool {
111 match std::env::var("BREP_DISJOINT_FAST_PATH").as_deref() {
112 Ok("0") => false,
113 Ok(_) => true,
114 Err(_) => options.disjoint_fast_path,
115 }
116}
117
118fn untouched_passthrough(options: &BooleanOptions) -> bool {
120 match std::env::var("BREP_UNTOUCHED_PASSTHROUGH").as_deref() {
121 Ok("0") => false,
122 Ok(_) => true,
123 Err(_) => options.untouched_passthrough,
124 }
125}
126
127fn default_tolerance() -> f64 {
128 1e-7
129}
130
131fn default_true() -> bool {
132 true
133}
134
135impl Default for BooleanOptions {
136 fn default() -> Self {
137 Self {
138 tolerance: default_tolerance(),
139 tolerances: None,
140 imprint: ImprintOptions::default(),
141 merge_coplanar_faces: true,
142 keep_unmerged_name_substrs: Vec::new(),
143 untouched_passthrough: true,
144 disjoint_fast_path: true,
145 touched_assembly: false,
146 sector_classification: true,
147 }
148 }
149}
150
151mod select;
152mod sector;
153mod assemble;
154mod operand_state;
155mod rim;
156mod coincident;
157mod touch;
158
159use rim::*;
160use select::*;
161use coincident::*;
162use touch::TouchCensus;
163pub(crate) use assemble::{
164 assemble_fragments, assemble_open_fragments, commit_nearby_edge_endpoints,
165 edge_interior_lies_on, finalize_assembled_solid,
166};
167use assemble::{assemble_fragments_scoped, AssemblyScope};
168#[allow(unused_imports)]
170pub(crate) use assemble::apply_assembly_heal_chain;
171
172pub fn boolean_operation(
173 first: &BrepSolid,
174 second: &BrepSolid,
175 operation: BooleanOperation,
176 options: &BooleanOptions,
177) -> Result<BrepSolid, KernelRefusal> {
178 match boolean_operation_with_diagnostics(first, second, operation, options) {
179 Ok(outcome) => Ok(outcome.value),
180 Err(error) => {
181 if std::env::var("BREP_NO_PERTURB").as_deref() == Ok("1")
189 || !error.class.perturbation_eligible()
190 {
191 return Err(error);
192 }
193 if std::env::var("BREP_OPEN_OPERAND_GATE").as_deref() != Ok("0") {
215 if let Some((operand, open)) = [first, second]
216 .iter()
217 .enumerate()
218 .find_map(|(operand, solid)| {
219 let open = one_use_edges(solid);
220 (open > 0).then_some((operand, open))
221 })
222 {
223 return Err(error.with_message(|message| format!(
224 "{message}; operand {operand} is not a closed shell: {open} of its edges bound \
225 only one face, so no boolean of it encloses a solid and no perturbation \
226 can close it"
227 )));
228 }
229 }
230 if let Some((operand, face_id, deviation, weld)) =
231 planar_operand_off_its_plane(first, second, options)
232 {
233 return Err(error.with_message(|message| format!(
234 "{message}; operand {operand} face {face_id} is a plane whose straight \
235 boundary sits {deviation:.3e} off it (assembly weld {weld:.1e}), which \
236 no perturbation can reconcile — the body's planes must contain their \
237 boundary before it can take part in an exact boolean"
238 )));
239 }
240 if matches!(operation, BooleanOperation::Subtract)
255 && std::env::var("BREP_PINCH_GATE").as_deref() != Ok("0")
256 && subtract_pinches_at_internal_tangency(first, second, options.tolerance)
257 .unwrap_or(false)
258 {
259 return Err(error.with_message(|message| format!(
260 "{message}; internal tangency: the operands touch tangentially with \
261 co-directed normals, so the exact difference pinches to zero thickness \
262 (non-manifold, unrepresentable in a boundary model) — refusing rather \
263 than returning a perturbed sliver"
264 )));
265 }
266 if matches!(error.class, RefusalClass::TangentNodeSingularity)
282 && std::env::var("BREP_TANGENT_NODE_GATE").as_deref() != Ok("0")
283 {
284 return Err(error);
285 }
286 match perturbation_retry(first, second, operation, options) {
287 Ok(solid) => Ok(solid),
288 Err(refused) if refused.is_empty() => Err(error),
289 Err(refused) => {
290 Err(error.with_message(|message| format!("{message}; {}", refused.join("; "))))
291 }
292 }
293 }
294 }
295}
296
297const PINCH_PROBE_STEPS: usize = 8;
302
303const PINCH_ANGULAR_TOLERANCE: f64 = 1e-4;
306
307const PINCH_SEPARATION_STEP: f64 = 1e-2;
311
312fn subtract_pinches_at_internal_tangency(
334 first: &BrepSolid,
335 second: &BrepSolid,
336 tolerance: f64,
337) -> Result<bool, KernelRefusal> {
338 let contact = (tolerance * 100.0).max(1e-12);
341 let probes_a = first
342 .shells
343 .iter()
344 .flat_map(|shell| &shell.faces)
345 .map(PinchProbe::of)
346 .collect::<Result<Vec<_>, _>>()?;
347 let probes_b = second
348 .shells
349 .iter()
350 .flat_map(|shell| &shell.faces)
351 .map(PinchProbe::of)
352 .collect::<Result<Vec<_>, _>>()?;
353 for probe_a in &probes_a {
354 for probe_b in &probes_b {
355 let pad = 1e-2 * probe_a.extent().max(probe_b.extent());
362 if probe_a.separation(probe_b) > contact + pad {
363 continue;
364 }
365 if classify_surface_pair(
372 &probe_a.face.surface,
373 &probe_b.face.surface,
374 tolerance,
375 PINCH_ANGULAR_TOLERANCE,
376 ).or_refuse(KernelStage::Validate, "csg.boolean.mod")?
377 .relation
378 == SurfacePairRelation::Cosurface
379 {
380 continue;
381 }
382 if faces_touch_with_codirected_normals(probe_a, probe_b, contact)? {
383 return Ok(true);
384 }
385 }
386 }
387 Ok(false)
388}
389
390struct PinchProbe<'a> {
393 face: &'a FaceRecord,
394 samples: Vec<(f64, f64, Vec3)>,
396 minimum: [f64; 3],
397 maximum: [f64; 3],
398}
399
400impl<'a> PinchProbe<'a> {
401 fn of(face: &'a FaceRecord) -> Result<Self, KernelRefusal> {
402 let [u0, u1] = face.surface.domain_u().or_refuse(KernelStage::Validate, "domain_u")?;
403 let [v0, v1] = face.surface.domain_v().or_refuse(KernelStage::Validate, "domain_v")?;
404 let steps = PINCH_PROBE_STEPS as f64;
405 let mut samples = Vec::with_capacity((PINCH_PROBE_STEPS + 1).pow(2));
406 let mut minimum = [f64::INFINITY; 3];
407 let mut maximum = [f64::NEG_INFINITY; 3];
408 for i in 0..=PINCH_PROBE_STEPS {
409 let u = u0 + (u1 - u0) * i as f64 / steps;
410 for j in 0..=PINCH_PROBE_STEPS {
411 let v = v0 + (v1 - v0) * j as f64 / steps;
412 let Ok(point) = face.surface.evaluate(u, v) else {
413 continue;
414 };
415 for (axis, value) in [point.x, point.y, point.z].into_iter().enumerate() {
416 minimum[axis] = minimum[axis].min(value);
417 maximum[axis] = maximum[axis].max(value);
418 }
419 samples.push((u, v, point));
420 }
421 }
422 Ok(Self {
423 face,
424 samples,
425 minimum,
426 maximum,
427 })
428 }
429
430 fn extent(&self) -> f64 {
432 (0..3)
433 .map(|axis| self.maximum[axis] - self.minimum[axis])
434 .fold(0.0f64, f64::max)
435 }
436
437 fn separation(&self, other: &Self) -> f64 {
439 let mut gap: f64 = 0.0;
440 for axis in 0..3 {
441 gap = gap.max(self.minimum[axis] - other.maximum[axis]);
442 gap = gap.max(other.minimum[axis] - self.maximum[axis]);
443 }
444 gap
445 }
446}
447
448fn faces_touch_with_codirected_normals(
453 probe_a: &PinchProbe<'_>,
454 probe_b: &PinchProbe<'_>,
455 contact: f64,
456) -> Result<bool, KernelRefusal> {
457 for (probe, other) in [(probe_a, probe_b), (probe_b, probe_a)] {
458 let source = probe.face;
459 let target = other.face;
460 for &(u, v, point) in &probe.samples {
461 let projection = project_point_to_surface(&target.surface, point).or_refuse(KernelStage::Validate, "project_point_to_surface")?;
462 if projection.distance > contact {
463 continue;
464 }
465 let (Ok(source_normal), Ok(target_normal)) = (
468 source.surface.normal(u, v),
469 target.surface.normal(projection.u, projection.v),
470 ) else {
471 continue;
472 };
473 let source_outward = outward_normal(source_normal, source.same_sense);
474 let target_outward = outward_normal(target_normal, target.same_sense);
475 if source_outward.cross(target_outward).length() > PINCH_ANGULAR_TOLERANCE
476 || source_outward.dot(target_outward) <= 0.0
477 {
478 continue;
479 }
480 if parameter_point_in_face(source, Vec2 { x: u, y: v }, 1e-6).or_refuse(KernelStage::Validate, "parameter_point_in_face")?
481 == PolygonClass::Outside
482 || parameter_point_in_face(
483 target,
484 Vec2 {
485 x: projection.u,
486 y: projection.v,
487 },
488 1e-6,
489 ).or_refuse(KernelStage::Validate, "csg.boolean.mod")? == PolygonClass::Outside
490 {
491 continue;
492 }
493 if contact_separates_locally(source, target, u, v, contact)? {
494 return Ok(true);
495 }
496 }
497 }
498 Ok(false)
499}
500
501fn outward_normal(normal: Vec3, same_sense: bool) -> Vec3 {
502 if same_sense {
503 normal
504 } else {
505 normal.scale(-1.0)
506 }
507}
508
509fn contact_separates_locally(
515 source: &FaceRecord,
516 target: &FaceRecord,
517 u: f64,
518 v: f64,
519 contact: f64,
520) -> Result<bool, KernelRefusal> {
521 let [u0, u1] = source.surface.domain_u().or_refuse(KernelStage::Validate, "domain_u")?;
522 let [v0, v1] = source.surface.domain_v().or_refuse(KernelStage::Validate, "domain_v")?;
523 let step_u = (u1 - u0) * PINCH_SEPARATION_STEP;
524 let step_v = (v1 - v0) * PINCH_SEPARATION_STEP;
525 for (probe_u, probe_v) in [
526 ((u + step_u).min(u1), v),
527 ((u - step_u).max(u0), v),
528 (u, (v + step_v).min(v1)),
529 (u, (v - step_v).max(v0)),
530 ] {
531 if (probe_u - u).abs() < f64::EPSILON && (probe_v - v).abs() < f64::EPSILON {
532 continue;
533 }
534 let Ok(point) = source.surface.evaluate(probe_u, probe_v) else {
535 continue;
536 };
537 if project_point_to_surface(&target.surface, point).or_refuse(KernelStage::Validate, "project_point_to_surface")?.distance > contact * 10.0 {
540 return Ok(true);
541 }
542 }
543 Ok(false)
544}
545
546fn perturbation_retry(
600 first: &BrepSolid,
601 second: &BrepSolid,
602 operation: BooleanOperation,
603 options: &BooleanOptions,
604) -> Result<BrepSolid, Vec<String>> {
605 const DIRECTIONS: [[f64; 3]; 4] = [
616 [0.4034, 0.7973, 0.4491],
617 [0.7973, 0.4491, 0.4034],
618 [0.4491, 0.4034, 0.7973],
619 [0.5774, -0.5774, 0.5774],
620 ];
621 const FRACTIONS: [f64; 5] = PERTURBATION_FRACTIONS;
627
628 let debug = std::env::var("BREP_DEBUG_BOOL").is_ok();
629 let scale = crate::tolerance::solid_scale(first)
630 .max(crate::tolerance::solid_scale(second))
631 .max(1.0);
632
633 let _caller = crate::mass_caller("boolean.perturbation_rescue");
637 let va = solid_signed_volume(first).ok().map(f64::abs);
638 let vb = solid_signed_volume(second).ok().map(f64::abs);
639 let hold_identity = std::env::var("BREP_PERTURB_IDENTITY").as_deref() != Ok("0");
640 let hold_mirror = hold_identity && std::env::var("BREP_PERTURB_MIRROR").as_deref() != Ok("0");
643 let early_exit = std::env::var("BREP_MIRROR_EARLY_EXIT").as_deref() != Ok("0");
646 let mut trends = [MirrorTrend::default(); 4];
647 let mut identity: Option<Option<InclusionExclusion>> = None;
649 let mut refused = Vec::new();
650 let ladder = std::env::var("BREP_DEBUG_PERTURB_LADDER").is_ok();
654 let ladder_start = web_time::Instant::now();
655 if ladder {
656 let measured = inclusion_exclusion_volume(first, second, operation, options, va, vb, false);
657 match &measured {
658 Some(identity) => eprintln!(
659 "[ladder] identity {} = {:.9} from the exact lane's {} ({:.9}); V(first) {:?} V(second) {:?}",
660 identity.formula, identity.expected, identity.reading, identity.read, va, vb
661 ),
662 None => eprintln!("[ladder] no identity: no exact-lane boolean of these operands clears in either order"),
663 }
664 identity = Some(measured);
665 }
666 let rung_miss = |identity: &Option<Option<InclusionExclusion>>, candidate: &BrepSolid| -> String {
667 let volume = body_volume(candidate);
668 match (identity, volume) {
669 (Some(Some(identity)), Some(volume)) => format!(
670 "volume {volume:.9} miss {:+.3e} bar {:.3e}",
671 volume - identity.expected,
672 crate::VOLUME_DRIFT_ABS.max(crate::VOLUME_DRIFT_REL * identity.expected.abs())
673 ),
674 (_, Some(volume)) => format!("volume {volume:.9}"),
675 _ => "no volume".to_string(),
676 }
677 };
678
679 for &fraction in &FRACTIONS {
680 let magnitude = scale * fraction;
681 for (direction, dir) in DIRECTIONS.iter().enumerate() {
682 if trends[direction].closed {
683 if ladder {
684 eprintln!("[ladder] dir={dir:?} fraction={fraction:.1e} offset {magnitude:.3e}: not tried, its direction is closed");
685 }
686 continue;
687 }
688 let offset = [dir[0] * magnitude, dir[1] * magnitude, dir[2] * magnitude];
689 let translate = match AffineTransform::new([
690 1.0, 0.0, 0.0, offset[0], 0.0, 1.0, 0.0, offset[1], 0.0, 0.0, 1.0, offset[2], 0.0, 0.0, 0.0, 1.0,
694 ]) {
695 Ok(transform) => transform,
696 Err(_) => continue,
697 };
698 let moved = match crate::transform_brep(second, translate, false) {
699 Ok(solid) => solid,
700 Err(_) => continue,
701 };
702 let rung = format!("dir={dir:?} fraction={fraction:.1e} offset {magnitude:.3e}");
703 let rung_start = web_time::Instant::now();
704 let candidate =
705 match boolean_operation_with_diagnostics(first, &moved, operation, options) {
706 Ok(outcome) => outcome.value,
707 Err(error) => {
708 if ladder {
709 eprintln!("[ladder] {rung}: exact lane refused {:?}", error.class);
710 }
711 continue;
712 }
713 };
714 let built = rung_start.elapsed();
715 if candidate.shells.is_empty() {
717 if ladder {
718 eprintln!("[ladder] {rung}: empty");
719 }
720 continue;
721 }
722 if let (Some(va), Some(vb)) = (va, vb) {
727 if let Ok(vr) = solid_signed_volume(&candidate) {
728 if !volume_within_csg_bounds(operation, va, vb, vr.abs()) {
729 if ladder {
730 eprintln!("[ladder] {rung}: outside the CSG bound, {}", rung_miss(&identity, &candidate));
731 }
732 continue;
733 }
734 }
735 }
736 let bounded = rung_start.elapsed();
741 if hold_identity {
743 let identity = identity.get_or_insert_with(|| {
744 let identity =
745 inclusion_exclusion_volume(first, second, operation, options, va, vb, false);
746 if debug && identity.is_none() {
747 eprintln!(
748 "[perturb] no exact-lane boolean of these operands clears in either \
749 order; no identity to hold"
750 );
751 }
752 identity
753 });
754 if let Some(identity) = identity {
755 let expected = identity.expected;
756 let bar = crate::VOLUME_DRIFT_ABS.max(crate::VOLUME_DRIFT_REL * expected.abs());
757 let volume = body_volume(&candidate);
758 let miss = volume.map(|volume| (volume - expected).abs());
759 if !miss.is_some_and(|miss| miss <= bar) {
760 let sentence = format!(
761 "perturbation rung dir={dir:?} fraction={fraction:.1e} \
762 (offset {magnitude:.3e}) refused: its body reads {} against the \
763 inclusion–exclusion identity {} = {expected:.9} (the exact lane's \
764 {} reads {:.9}), a miss of {} beyond the volume drift bar {bar:.3e}",
765 volume.map_or("no volume".to_string(), |volume| format!("{volume:.9}")),
766 identity.formula,
767 identity.reading,
768 identity.read,
769 miss.map_or("unmeasured".to_string(), |miss| format!("{miss:.3e}")),
770 );
771 if debug {
772 eprintln!("[perturb] {sentence}");
773 }
774 if ladder {
775 eprintln!(
776 "[ladder] {rung}: refused by the identity, volume {} miss {} bar {bar:.3e}",
777 volume.map_or("none".to_string(), |volume| format!("{volume:.9}")),
778 volume.map_or("unmeasured".to_string(), |volume| format!("{:+.3e}", volume - expected)),
779 );
780 }
781 refused.push(sentence);
782 continue;
783 }
784 } else if hold_mirror {
785 let mirror_start = web_time::Instant::now();
788 let mirrored = AffineTransform::new([
789 1.0, 0.0, 0.0, -offset[0], 0.0, 1.0, 0.0, -offset[1], 0.0, 0.0, 1.0, -offset[2], 0.0, 0.0, 0.0, 1.0,
793 ])
794 .ok()
795 .and_then(|transform| crate::transform_brep(second, transform, false).ok());
796 let mirror = mirrored.as_ref().and_then(|mirrored| {
797 inclusion_exclusion_volume(first, mirrored, operation, options, va, vb, true)
798 });
799 let mirror_time = mirror_start.elapsed();
800 let volume = body_volume(&candidate);
801 let mut measured = None;
802 let verdict = match (&mirror, volume) {
803 (Some(mirror), Some(volume)) => {
804 let bar = crate::VOLUME_DRIFT_ABS.max(crate::VOLUME_DRIFT_REL * mirror.expected.abs());
805 let miss = 0.5 * (volume - mirror.expected).abs();
806 measured = Some((0.5 * (volume - mirror.expected), bar));
807 if ladder {
808 eprintln!(
809 "[ladder] {rung}: mirror at −offset reads {} = {:.9} (the mirrored {} {:.9}); body {volume:.9}, first-order translation error {:+.3e} bar {bar:.3e}",
810 mirror.formula, mirror.expected, mirror.reading, mirror.read, 0.5 * (volume - mirror.expected)
811 );
812 }
813 (miss > bar).then(|| format!(
814 "perturbation rung dir={dir:?} fraction={fraction:.1e} \
815 (offset {magnitude:.3e}) refused: no exact-lane boolean of the \
816 operands clears, so the rung is held to its mirror: its body reads \
817 {volume:.9} with the second operand moved +offset and {} = {:.9} \
818 with it moved −offset (the mirrored {} reads {:.9}), a first-order \
819 translation error of {miss:.3e} beyond the volume drift bar {bar:.3e}",
820 mirror.formula, mirror.expected, mirror.reading, mirror.read
821 ))
822 }
823 _ => Some(format!(
824 "perturbation rung dir={dir:?} fraction={fraction:.1e} \
825 (offset {magnitude:.3e}) refused: no exact-lane boolean of the operands \
826 clears, and none clears with the second operand moved −offset either, \
827 so nothing checks the rung's body{}",
828 volume.map_or(String::new(), |volume| format!(" ({volume:.9})"))
829 )),
830 };
831 match verdict {
832 None => trends[direction].cleared(),
833 Some(sentence) => {
834 if debug {
835 eprintln!("[perturb] {sentence}");
836 }
837 if ladder {
838 eprintln!(
839 "[ladder] {rung}: refused by its mirror (rung {:.0} ms: built {:.0}, bound {:.0}, mirror {:.0} ms; ladder {:.1} s)",
840 rung_start.elapsed().as_secs_f64() * 1e3,
841 built.as_secs_f64() * 1e3,
842 (bounded - built).as_secs_f64() * 1e3,
843 mirror_time.as_secs_f64() * 1e3,
844 ladder_start.elapsed().as_secs_f64()
845 );
846 }
847 refused.push(sentence);
848 let closure = measured.and_then(|(error, bar)| {
851 let earlier = trends[direction].refused(magnitude, error, early_exit)?;
852 Some((earlier, error, bar))
853 });
854 if let Some(((earlier_offset, earlier_error), error, bar)) = closure {
855 let closed = format!(
856 "perturbation direction dir={dir:?} closed after offset {magnitude:.3e}: its \
857 rungs' first-order translation errors read {earlier_error:+.3e} at offset \
858 {earlier_offset:.3e} and {error:+.3e} at {magnitude:.3e}, one sign and \
859 growing, so no larger offset along it comes back to the volume drift bar \
860 {bar:.3e}"
861 );
862 if debug {
863 eprintln!("[perturb] {closed}");
864 }
865 if ladder {
866 eprintln!("[ladder] {closed}");
867 }
868 refused.push(closed);
869 }
870 continue;
871 }
872 }
873 }
874 }
875 match crate::oracle::boolean_semantic_disagreement(
879 first, second, operation, &candidate, 400,
880 ) {
881 Ok(report) if report.considered >= 30 && !report.is_flagged() => {
882 if ladder {
883 eprintln!(
884 "[ladder] {rung}: accepted (oracle {} considered, rate {:.4}), {}",
885 report.considered, report.disagreement_rate, rung_miss(&identity, &candidate)
886 );
887 }
888 if debug {
889 eprintln!(
890 "[perturb] rescued {operation:?}: dir={dir:?} fraction={fraction:.1e} \
891 magnitude={magnitude:.3e} offset={offset:?} \
892 (oracle considered={} rate={:.4})",
893 report.considered, report.disagreement_rate
894 );
895 }
896 return Ok(candidate);
897 }
898 report => {
899 if ladder {
900 let verdict = match &report {
901 Ok(report) => format!("oracle {} considered, rate {:.4}", report.considered, report.disagreement_rate),
902 Err(error) => format!("oracle error {error:?}"),
903 };
904 eprintln!("[ladder] {rung}: refused by the oracle ({verdict}), {}", rung_miss(&identity, &candidate));
905 }
906 continue;
907 }
908 }
909 }
910 }
911 if debug {
912 eprintln!("[perturb] ladder exhausted for {operation:?}; no rung produced a clean result");
913 }
914 Err(refused)
915}
916
917const PERTURBATION_FRACTIONS: [f64; 5] = [3e-5, 1e-4, 3e-4, 1e-3, 3e-3];
920
921#[derive(Clone, Copy, Default)]
945struct MirrorTrend {
946 last: Option<(f64, f64)>,
948 closed: bool,
949}
950
951impl MirrorTrend {
952 fn refused(&mut self, offset: f64, error: f64, close: bool) -> Option<(f64, f64)> {
957 let earlier = self
958 .last
959 .filter(|&(_, earlier)| earlier.signum() == error.signum() && error.abs() >= earlier.abs())
960 .filter(|_| close);
961 self.last = Some((offset, error));
962 self.closed |= earlier.is_some();
963 earlier
964 }
965
966 fn cleared(&mut self) {
968 self.last = None;
969 }
970}
971
972fn one_use_edges(solid: &BrepSolid) -> usize {
974 let mut uses: HashMap<u64, usize> = HashMap::default();
975 for coedge in solid
976 .shells
977 .iter()
978 .flat_map(|shell| &shell.faces)
979 .flat_map(|face| &face.loops)
980 .flat_map(|loop_record| &loop_record.coedges)
981 {
982 *uses.entry(coedge.edge_id).or_default() += 1;
983 }
984 solid
985 .edges
986 .iter()
987 .filter(|edge| !edge.degenerate && uses.get(&edge.id) == Some(&1))
988 .count()
989}
990
991fn body_volume(solid: &BrepSolid) -> Option<f64> {
993 if solid.shells.is_empty() {
994 return Some(0.0);
995 }
996 solid_signed_volume(solid).ok().map(f64::abs)
997}
998
999struct InclusionExclusion {
1002 formula: &'static str,
1005 reading: &'static str,
1007 read: f64,
1009 expected: f64,
1011}
1012
1013fn inclusion_exclusion_volume(
1024 first: &BrepSolid,
1025 second: &BrepSolid,
1026 operation: BooleanOperation,
1027 options: &BooleanOptions,
1028 va: Option<f64>,
1029 vb: Option<f64>,
1030 own_first: bool,
1031) -> Option<InclusionExclusion> {
1032 use BooleanOperation::{Intersect, Subtract, Union};
1033 let (va, vb) = (va?, vb?);
1034 let own = match operation {
1035 Intersect => (Intersect, false, "first ∩ second"),
1036 Union => (Union, false, "first ∪ second"),
1037 Subtract => (Subtract, false, "first − second"),
1038 };
1039 let readings = [
1040 (Intersect, false, "first ∩ second"),
1041 (Intersect, true, "second ∩ first"),
1042 (Union, false, "first ∪ second"),
1043 (Union, true, "second ∪ first"),
1044 (Subtract, false, "first − second"),
1045 (Subtract, true, "second − first"),
1046 ];
1047 let own_reading = own_first.then_some(own);
1048 let leading = usize::from(own_first);
1049 for (index, (other, swapped, reading)) in own_reading.into_iter().chain(readings).enumerate() {
1050 if index >= leading && !swapped && std::mem::discriminant(&other) == std::mem::discriminant(&operation) {
1051 continue;
1052 }
1053 let (x, y) = if swapped { (second, first) } else { (first, second) };
1054 let Some(read) = boolean_operation_with_diagnostics(x, y, other, options)
1055 .ok()
1056 .and_then(|outcome| body_volume(&outcome.value))
1057 else {
1058 continue;
1059 };
1060 let intersection = match (other, swapped) {
1061 (Intersect, _) => read,
1062 (Union, _) => va + vb - read,
1063 (Subtract, false) => va - read,
1064 (Subtract, true) => vb - read,
1065 };
1066 let (formula, expected) = match operation {
1067 Intersect => ("V(first ∩ second)", intersection),
1068 Union => ("V(first) + V(second) − V(first ∩ second)", va + vb - intersection),
1069 Subtract => ("V(first) − V(first ∩ second)", va - intersection),
1070 };
1071 return Some(InclusionExclusion { formula, reading, read, expected });
1072 }
1073 None
1074}
1075
1076fn planar_operand_off_its_plane(
1085 first: &BrepSolid,
1086 second: &BrepSolid,
1087 options: &BooleanOptions,
1088) -> Option<(u8, u64, f64, f64)> {
1089 let policy = options
1090 .tolerances
1091 .clone()
1092 .unwrap_or_else(|| KernelTolerances::for_pair(first, second, options.tolerance));
1093 let weld = crate::tolerance::assembler_weld(policy.model);
1094 let mut worst: Option<(u8, u64, f64)> = None;
1095 for (operand, solid) in [(0u8, first), (1u8, second)] {
1096 let vertex_point = |id: u64| solid.vertices.iter().find(|v| v.id == id).map(|v| v.point);
1097 for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
1098 let Some(crate::AnalyticSurface::Plane {
1099 origin,
1100 u_dir,
1101 v_dir,
1102 ..
1103 }) = face.surface.analytic()
1104 else {
1105 continue;
1106 };
1107 let [loop_record] = face.loops.as_slice() else {
1108 continue;
1109 };
1110 let Ok(normal) = u_dir.cross(*v_dir).normalized() else {
1111 continue;
1112 };
1113 let mut off_plane = 0.0f64;
1114 let mut straight = true;
1115 for coedge in &loop_record.coedges {
1116 let Some(edge) = solid.edges.iter().find(|edge| edge.id == coedge.edge_id) else {
1117 straight = false;
1118 break;
1119 };
1120 let (Some(start), Some(end)) =
1121 (vertex_point(edge.start_vertex_id), vertex_point(edge.end_vertex_id))
1122 else {
1123 straight = false;
1124 break;
1125 };
1126 let chord = end.sub(start);
1127 let length = chord.length();
1128 if edge.degenerate || length <= weld {
1129 straight = false;
1130 break;
1131 }
1132 let direction = chord.scale(1.0 / length);
1133 for fraction in [0.25, 0.5, 0.75] {
1134 let Ok(point) = edge.curve.evaluate(edge.t0 + (edge.t1 - edge.t0) * fraction) else {
1135 straight = false;
1136 break;
1137 };
1138 let delta = point.sub(start);
1139 let along = delta.dot(direction);
1140 if delta.sub(direction.scale(along)).length() > weld
1141 || along < -weld
1142 || along > length + weld
1143 {
1144 straight = false;
1145 break;
1146 }
1147 }
1148 if !straight {
1149 break;
1150 }
1151 off_plane = off_plane.max(start.sub(*origin).dot(normal).abs());
1152 }
1153 if straight && off_plane > weld && worst.is_none_or(|(_, _, gap)| off_plane > gap) {
1154 worst = Some((operand, face.id, off_plane));
1155 }
1156 }
1157 }
1158 worst.map(|(operand, face_id, gap)| (operand, face_id, gap, weld))
1159}
1160
1161fn volume_within_csg_bounds(operation: BooleanOperation, va: f64, vb: f64, vr: f64) -> bool {
1164 let slack = 1e-2 * (va + vb).max(1.0);
1165 match operation {
1166 BooleanOperation::Union => vr >= va.max(vb) - slack && vr <= va + vb + slack,
1168 BooleanOperation::Subtract => vr <= va + slack && vr >= -slack,
1170 BooleanOperation::Intersect => vr <= va.min(vb) + slack && vr >= -slack,
1172 }
1173}
1174
1175fn reverse_fragment(fragment: &mut FaceFragmentRecord) -> Result<(), KernelRefusal> {
1202 fragment.same_sense = !fragment.same_sense;
1203 for loop_record in &mut fragment.loops {
1204 loop_record.coedges.reverse();
1205 for coedge in &mut loop_record.coedges {
1206 coedge.forward = !coedge.forward;
1207 coedge.pcurve = coedge.pcurve.reversed().or_refuse(KernelStage::Validate, "reversed")?;
1208 }
1209 }
1210 Ok(())
1211}
1212
1213fn build_nary_imprint(
1220 operands: &[BrepSolid],
1221 options: &ImprintOptions,
1222) -> Result<ImprintResultRecord, KernelRefusal> {
1223 let mut section_evidence = false;
1224 let mut vertices: Vec<ImprintVertex> = Vec::new();
1225 let mut pieces: Vec<ImprintPieceRecord> = Vec::new();
1226 let mut by_face: HashMap<(u8, u64), Vec<u64>> = HashMap::default();
1227 let mut edge_splits: HashMap<(u8, u64), Vec<f64>> = HashMap::default();
1228 let mut next_vertex_id: u64 = 1;
1229 let mut next_piece_id: u64 = 1;
1230
1231 for i in 0..operands.len() {
1232 for j in (i + 1)..operands.len() {
1233 let pair = build_imprints(&operands[i], &operands[j], options)?;
1234 let remap = |operand: u8| -> u8 {
1235 if operand == 0 {
1236 i as u8
1237 } else {
1238 j as u8
1239 }
1240 };
1241
1242 section_evidence = section_evidence || pair.section_evidence;
1243 let mut vertex_map: HashMap<u64, u64> = HashMap::default();
1244 for vertex in &pair.vertices {
1245 let global = next_vertex_id;
1246 next_vertex_id += 1;
1247 vertex_map.insert(vertex.id, global);
1248 vertices.push(ImprintVertex {
1249 id: global,
1250 point: vertex.point,
1251 });
1252 }
1253
1254 let mut piece_map: HashMap<u64, u64> = HashMap::default();
1255 for piece in &pair.pieces {
1256 let global = next_piece_id;
1257 next_piece_id += 1;
1258 piece_map.insert(piece.id, global);
1259 let mut mapped = piece.clone();
1260 mapped.id = global;
1261 mapped.start_vertex_id = *vertex_map
1262 .get(&piece.start_vertex_id)
1263 .ok_or_else(|| KernelRefusal::internal(KernelStage::Intersect, "nary.imprint", "n-ary imprint: piece references unknown vertex"))?;
1264 mapped.end_vertex_id = *vertex_map
1265 .get(&piece.end_vertex_id)
1266 .ok_or_else(|| KernelRefusal::internal(KernelStage::Intersect, "nary.imprint", "n-ary imprint: piece references unknown vertex"))?;
1267 for pcurve in &mut mapped.pcurves {
1268 pcurve.operand = remap(pcurve.operand);
1269 }
1270 mapped.support_faces = [
1271 FaceKey {
1272 operand: remap(piece.support_faces[0].operand),
1273 face_id: piece.support_faces[0].face_id,
1274 },
1275 FaceKey {
1276 operand: remap(piece.support_faces[1].operand),
1277 face_id: piece.support_faces[1].face_id,
1278 },
1279 ];
1280 pieces.push(mapped);
1281 }
1282
1283 for entry in &pair.by_face {
1284 let list = by_face
1285 .entry((remap(entry.operand), entry.face_id))
1286 .or_default();
1287 for piece_id in &entry.piece_ids {
1288 let global = *piece_map.get(piece_id).ok_or_else(|| {
1289 KernelRefusal::internal(KernelStage::Intersect, "nary.imprint", "n-ary imprint: by_face references unknown piece")
1290 })?;
1291 list.push(global);
1292 }
1293 }
1294
1295 for split in &pair.edge_splits {
1296 edge_splits
1297 .entry((remap(split.operand), split.edge_id))
1298 .or_default()
1299 .extend(split.parameters.iter().copied());
1300 }
1301 }
1302 }
1303
1304 Ok(ImprintResultRecord {
1305 vertices,
1306 pieces,
1307 tangent_nodes: Vec::new(),
1308 barrier_edges: Vec::new(),
1309 section_evidence,
1310 cosurface_pairs: Vec::new(),
1311 tangent_sections: Vec::new(),
1312 by_face: by_face
1313 .into_iter()
1314 .map(|((operand, face_id), piece_ids)| FaceImprints {
1315 operand,
1316 face_id,
1317 piece_ids,
1318 })
1319 .collect(),
1320 edge_splits: edge_splits
1321 .into_iter()
1322 .map(|((operand, edge_id), parameters)| EdgeSplitRecord {
1323 operand,
1324 edge_id,
1325 parameters,
1326 })
1327 .collect(),
1328 })
1329}
1330
1331pub fn boolean_operation_nary(
1342 operands: &[BrepSolid],
1343 operation: BooleanOperation,
1344) -> Result<BrepSolid, KernelRefusal> {
1345 if operands.is_empty() {
1346 return Err(KernelRefusal::input(KernelStage::Collect, "operands", "boolean_operation_nary: no operands provided"));
1347 }
1348 if operands.len() == 1 {
1349 return Ok(operands[0].clone());
1350 }
1351 if operands.len() > 255 {
1352 return Err(KernelRefusal::input(KernelStage::Collect, "operands", "boolean_operation_nary: at most 255 operands supported"));
1353 }
1354 let debug = std::env::var("BREP_DEBUG_BOOL").is_ok();
1355 let options = BooleanOptions::default();
1356
1357 let mut policy = KernelTolerances::for_solid(&operands[0], options.tolerance);
1361 for operand in &operands[1..] {
1362 let candidate = KernelTolerances::for_solid(operand, options.tolerance);
1363 if candidate.sew_search > policy.sew_search {
1364 policy = candidate;
1365 }
1366 }
1367 policy.check().or_input(KernelStage::Collect, "tolerances")?;
1368 let tolerance = policy.model;
1369
1370 let mut healed = operands.to_vec();
1373 for operand in &mut healed {
1374 crate::heal::heal_operands(operand, &policy)?;
1375 crate::heal::refit_inherited_trims(operand, &policy)?;
1378 normalize_operand_band_seams(operand)?;
1381 }
1382
1383 let mut imprint_options = options.imprint.clone();
1385 imprint_options.tolerance = tolerance;
1386 let imprint = build_nary_imprint(&healed, &imprint_options)?;
1387
1388 let mut split = Vec::with_capacity(healed.len());
1390 for (index, operand) in healed.iter().enumerate() {
1391 split.push(apply_edge_splits(operand, index as u8, &imprint)?);
1392 }
1393 let mut fragments = Vec::with_capacity(split.len());
1394 for (index, solid) in split.iter().enumerate() {
1395 fragments.push(fragment_solid(solid, index as u8, &imprint)?);
1396 }
1397
1398 let selected = select_fragments_nary(&fragments, &healed, operation, tolerance, debug)?;
1400
1401 let solids: HashMap<u8, &BrepSolid> = split
1403 .iter()
1404 .enumerate()
1405 .map(|(index, solid)| (index as u8, solid))
1406 .collect();
1407 let solid = assemble_fragments(selected, &solids, &imprint, tolerance)?;
1408 let solid = if options.merge_coplanar_faces
1409 && std::env::var("BREP_COPLANAR_MERGE").as_deref() != Ok("0")
1410 {
1411 let merged = merge_same_surface_faces_excluding(
1412 &solid,
1413 tolerance,
1414 &options.keep_unmerged_name_substrs,
1415 )?;
1416 merge_curve_continuation_edges(&merged, tolerance).map_err(|refusal| KernelRefusal { stage: KernelStage::Validate, ..refusal })?
1417 } else {
1418 solid
1419 };
1420 let validation = solid.validate_detailed(&policy);
1421 if !validation.issues.is_empty() {
1422 return Err(KernelRefusal::new(
1423 RefusalClass::InvalidResultTopology { issues: validation.issues.len() as u32 },
1424 KernelStage::Validate,
1425 format!(
1426 "boolean_operation_nary: invalid result: {:?}",
1427 validation.issues
1428 )));
1429 }
1430 if debug {
1431 if let Ok(report) =
1432 crate::oracle::boolean_semantic_disagreement_nary(&healed, operation, &solid, 300)
1433 {
1434 if report.is_flagged() {
1435 eprintln!(
1436 "[oracle] n-ary semantic disagreement rate {:.4} ({} of {} decidable points); sample {:?}",
1437 report.disagreement_rate,
1438 report.disagreements.len(),
1439 report.considered,
1440 report.sample_disagreement()
1441 );
1442 }
1443 }
1444 }
1445 Ok(solid)
1446}
1447
1448fn attribute_to_tangent_node(error: KernelRefusal, nodes: &[Vec3]) -> KernelRefusal {
1473 let Some(node) = nodes.first() else {
1474 return error;
1475 };
1476 if !error.class.perturbation_eligible()
1477 || matches!(error.class, RefusalClass::TangentNodeSingularity)
1478 {
1479 return error;
1480 }
1481 KernelRefusal::new(
1482 RefusalClass::TangentNodeSingularity,
1483 error.stage,
1484 format!(
1485 "boolean: unsupported singular/tangent-node surface intersection \
1486 at the tangent node at ({:.6},{:.6},{:.6}): the section through it did not \
1487 assemble into a closed shell",
1488 node.x, node.y, node.z
1489 ),
1490 )
1491}
1492
1493fn attribute_to_tangent_section(
1515 error: KernelRefusal,
1516 sections: &[crate::imprint::TangentSectionRecord],
1517 origins: &[FaceKey],
1518) -> KernelRefusal {
1519 if sections.is_empty()
1520 || !error.class.perturbation_eligible()
1521 || matches!(error.class, RefusalClass::TangentNodeSingularity)
1522 {
1523 return error;
1524 }
1525 let named = refusal_result_faces(&error.message)
1526 .into_iter()
1527 .filter_map(|id| origins.get(id.checked_sub(1)? as usize).copied())
1528 .collect::<Vec<FaceKey>>();
1529 let Some(section) = sections
1530 .iter()
1531 .find(|section| named.iter().any(|face| section.faces.contains(face)))
1532 else {
1533 return error;
1534 };
1535 let at = |point: Vec3| {
1538 let shown = |value: f64| if value.abs() < 5e-7 { 0.0 } else { value };
1539 format!("({:.6},{:.6},{:.6})", shown(point.x), shown(point.y), shown(point.z))
1540 };
1541 let shape = if section.planar {
1542 format!(
1543 "touch along a tangent curve from {} to {} (length {:.6}, {} stations{}) which the \
1544 imprint does not build yet",
1545 at(section.start),
1546 at(section.end),
1547 section.length,
1548 section.stations,
1549 if section.closed { ", closed" } else { "" }
1550 )
1551 } else {
1552 format!(
1553 "touch along a curve through {} with neither carrier planar, a curved/curved \
1554 tangent contact the lane does not trace yet",
1555 at(section.start)
1556 )
1557 };
1558 let involved = named
1559 .iter()
1560 .filter(|face| section.faces.contains(face))
1561 .map(|face| format!("{}/{}", face.operand, face.face_id))
1562 .collect::<Vec<_>>()
1563 .join(", ");
1564 KernelRefusal::new(
1565 RefusalClass::TangentNodeSingularity,
1566 error.stage,
1567 format!(
1568 "boolean: unsupported tangent-curve surface intersection between faces {}/{} and {}/{}: \
1569 the carriers {shape}; the refusal names face(s) {involved} the contact involves; {}",
1570 section.first.operand, section.first.face_id, section.second.operand, section.second.face_id,
1571 error.message
1572 ),
1573 )
1574}
1575
1576fn refusal_result_faces(message: &str) -> Vec<u64> {
1581 let mut ids = Vec::new();
1582 let digits_after = |text: &str| -> Option<u64> {
1583 let end = text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len());
1584 text[..end].parse().ok()
1585 };
1586 for (index, _) in message.match_indices("face ") {
1587 if let Some(id) = digits_after(&message[index + "face ".len()..]) {
1588 ids.push(id);
1589 }
1590 }
1591 if let Some(start) = message.find("on faces [") {
1592 let rest = &message[start + "on faces [".len()..];
1593 let list = &rest[..rest.find(']').unwrap_or(rest.len())];
1594 for entry in list.split(", ") {
1595 let entry = entry.trim_start();
1596 if let Some(id) = digits_after(entry) {
1597 if entry[id.to_string().len()..].starts_with(" '") {
1598 ids.push(id);
1599 }
1600 }
1601 }
1602 }
1603 ids.sort_unstable();
1604 ids.dedup();
1605 ids
1606}
1607
1608pub fn boolean_operation_with_diagnostics(
1609 first: &BrepSolid,
1610 second: &BrepSolid,
1611 operation: BooleanOperation,
1612 options: &BooleanOptions,
1613) -> Result<KernelOutcome<BrepSolid>, KernelRefusal> {
1614 let closed_inputs = matches!(std::env::var("BREP_CLOSED_OPERAND_INPUT").as_deref(), Err(_) | Ok("1") | Ok("on"));
1615 boolean_operation_with_diagnostics_impl(first, second, operation, options, closed_inputs)
1616}
1617
1618fn boolean_operation_with_diagnostics_impl(
1619 first: &BrepSolid,
1620 second: &BrepSolid,
1621 operation: BooleanOperation,
1622 options: &BooleanOptions,
1623 closed_inputs: bool,
1624) -> Result<KernelOutcome<BrepSolid>, KernelRefusal> {
1625 if matches!(operation, BooleanOperation::Union | BooleanOperation::Subtract) {
1634 if let Some(operand) = [first, second]
1635 .iter()
1636 .position(|solid| solid.kind == crate::BodyKind::Sheet)
1637 {
1638 return Err(KernelRefusal::input(
1639 KernelStage::Collect,
1640 "sheet_operand",
1641 format!(
1642 "boolean: operand {operand} is a Sheet, which encloses no solid; union and \
1643 subtract of a sheet are refused (slice and trim are not built)"
1644 ),
1645 ));
1646 }
1647 }
1648 let mut outcome = boolean_operation_with_diagnostics_core(first, second, operation, options)?;
1654 let closure_started = closed_inputs.then(Instant::now);
1655 if closed_inputs {
1656 for (operand, solid) in [first, second].into_iter().enumerate() {
1657 let open = one_use_edges(solid);
1658 if open > 0 {
1659 return Err(KernelRefusal::input(KernelStage::Collect, "operand_closure", format!(
1660 "boolean: operand {operand} is not a closed shell: {open} of its edges bound only one face, so its inside is undefined"
1661 )));
1662 }
1663 }
1664 }
1665 if let Some(started) = closure_started {
1666 let ms = started.elapsed().as_secs_f64() * 1000.0;
1667 profile_stage("operand_closure_ms", ms);
1668 outcome.diagnostics.measure_max("timing.operand_closure_ms", ms);
1669 }
1670 Ok(outcome)
1671}
1672
1673fn boolean_operation_with_diagnostics_core(
1674 first: &BrepSolid, second: &BrepSolid, operation: BooleanOperation, options: &BooleanOptions,
1675) -> Result<KernelOutcome<BrepSolid>, KernelRefusal> {
1676
1677 if let Some(offset) = crate::recentre::control_offset_at_entry() {
1680 return boolean_operation_with_diagnostics_at_offset(first, second, operation, options, offset);
1681 }
1682 let mut bounds = operand_bounds(first);
1685 bounds.include(operand_bounds(second));
1686 if let Some(offset) = crate::recentre::guard_offset(bounds) {
1687 crate::recentre::profile_fired("boolean", offset, bounds);
1688 let mut outcome = boolean_translated(first, second, operation, options, offset, crate::recentre::EntryKind::Guard)?;
1689 outcome.diagnostics.count_n("recentre.fired", 1);
1690 outcome.diagnostics.measure_max("recentre.distance", offset.length());
1691 return Ok(outcome);
1692 }
1693 let mut tangent_nodes = Vec::new();
1694 let (mut outcome, soundness_candidates) =
1695 boolean_pipeline(first, second, operation, options, &mut tangent_nodes)
1696 .map_err(|error| attribute_to_tangent_node(error, &tangent_nodes))?;
1697 let stage_started = Instant::now();
1710 let accepted = match &soundness_candidates {
1711 Some(candidates) => crate::accept_sound_against_scoped(&[first, second], outcome.value, "boolean", candidates),
1714 None => crate::accept_sound_against(&[first, second], outcome.value, "boolean"),
1715 };
1716 let stage_ms = stage_started.elapsed().as_secs_f64() * 1_000.0;
1717 profile_stage("soundness_ms", stage_ms);
1718 outcome.diagnostics.measure_max("timing.soundness_ms", stage_ms);
1719 outcome.value = accepted?;
1720 Ok(outcome)
1721}
1722
1723pub fn boolean_operation_with_diagnostics_at_offset(
1727 first: &BrepSolid,
1728 second: &BrepSolid,
1729 operation: BooleanOperation,
1730 options: &BooleanOptions,
1731 offset: Vec3,
1732) -> Result<KernelOutcome<BrepSolid>, KernelRefusal> {
1733 boolean_translated(first, second, operation, options, offset, crate::recentre::EntryKind::Control)
1734}
1735
1736fn boolean_translated(
1738 first: &BrepSolid,
1739 second: &BrepSolid,
1740 operation: BooleanOperation,
1741 options: &BooleanOptions,
1742 offset: Vec3,
1743 kind: crate::recentre::EntryKind,
1744) -> Result<KernelOutcome<BrepSolid>, KernelRefusal> {
1745 let _entry = crate::recentre::TranslatedEntry::open(kind);
1746 let translate = |solid: &BrepSolid, offset: Vec3| {
1747 crate::recentre::translate_solid(solid, offset).map_err(|message| {
1748 KernelRefusal::internal(
1749 KernelStage::Collect,
1750 "translation_control",
1751 format!("boolean: the translation control could not move an operand: {message}"),
1752 )
1753 })
1754 };
1755 let moved_first = translate(first, offset)?;
1756 let moved_second = translate(second, offset)?;
1757 let outcome = boolean_operation_with_diagnostics(&moved_first, &moved_second, operation, options)?;
1758 let value = translate(&outcome.value, offset.scale(-1.0))?;
1759 Ok(KernelOutcome { value, diagnostics: outcome.diagnostics })
1760}
1761
1762fn profile_stage(code: &str, ms: f64) {
1766 if std::env::var("BREP_PROFILE").is_ok() {
1767 eprintln!("boolean.{code}={ms:.2}");
1768 }
1769}
1770
1771struct BooleanStageDump {
1776 directory: std::path::PathBuf,
1777 serial: usize,
1778}
1779
1780impl BooleanStageDump {
1781 fn open() -> Option<Self> {
1782 static SERIAL: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1783 let directory = std::env::var("BREP_BOOLEAN_DUMP").ok().filter(|value| !value.is_empty())?;
1784 let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1785 Some(Self { directory: directory.into(), serial })
1786 }
1787
1788 fn write<T: Serialize + ?Sized>(&self, stage: &str, value: &T) {
1789 let path = self.directory.join(format!("{:03}-{stage}.json", self.serial));
1790 match serde_json::to_string(value) {
1791 Ok(text) => {
1792 if let Err(error) = std::fs::write(&path, text) {
1793 eprintln!("BREP_BOOLEAN_DUMP {}: {error}", path.display());
1794 }
1795 }
1796 Err(error) => eprintln!("BREP_BOOLEAN_DUMP {stage}: {error}"),
1797 }
1798 }
1799}
1800
1801fn boolean_policy(
1803 first: &BrepSolid,
1804 second: &BrepSolid,
1805 options: &BooleanOptions,
1806) -> Result<KernelTolerances, KernelRefusal> {
1807 let policy = options
1808 .tolerances
1809 .unwrap_or_else(|| KernelTolerances::for_pair(first, second, options.tolerance));
1810 policy.check().or_input(KernelStage::Collect, "tolerances")?;
1811 Ok(policy)
1812}
1813
1814fn prepared_operands(
1817 first: &BrepSolid,
1818 second: &BrepSolid,
1819 policy: &KernelTolerances,
1820) -> Result<(BrepSolid, BrepSolid), KernelRefusal> {
1821 let stage_started = Instant::now();
1830 let mut first_owned = first.clone();
1831 let mut second_owned = second.clone();
1832 crate::heal::heal_operands(&mut first_owned, policy)?;
1833 crate::heal::heal_operands(&mut second_owned, policy)?;
1834 profile_stage("heal.operands_ms", stage_started.elapsed().as_secs_f64() * 1_000.0);
1835 let refit_started = Instant::now();
1839 crate::heal::refit_inherited_trims(&mut first_owned, policy)?;
1840 crate::heal::refit_inherited_trims(&mut second_owned, policy)?;
1841 profile_stage("heal.refit_ms", refit_started.elapsed().as_secs_f64() * 1_000.0);
1842 if std::env::var("BREP_PROFILE").is_ok() {
1843 let (hits, misses) = crate::reading_memo::counts();
1844 eprintln!("boolean.reading_memo hits={hits} misses={misses}");
1845 }
1846 profile_stage("heal_ms", stage_started.elapsed().as_secs_f64() * 1_000.0);
1847 normalize_operand_band_seams(&mut first_owned)?;
1852 normalize_operand_band_seams(&mut second_owned)?;
1853 Ok((first_owned, second_owned))
1854}
1855
1856fn boolean_imprint_options(options: &BooleanOptions, tolerance: f64) -> ImprintOptions {
1857 let mut imprint_options = options.imprint.clone();
1858 imprint_options.tolerance = tolerance;
1859 imprint_options
1860}
1861
1862pub fn boolean_split_operands(
1872 first: &BrepSolid,
1873 second: &BrepSolid,
1874 options: &BooleanOptions,
1875) -> Result<(BrepSolid, BrepSolid), KernelRefusal> {
1876 let policy = boolean_policy(first, second, options)?;
1877 let (first, second) = prepared_operands(first, second, &policy)?;
1878 let imprint = build_imprints(&first, &second, &boolean_imprint_options(options, policy.model))?;
1879 let (split_first, _) = apply_edge_splits_with_map(&first, 0, &imprint)?;
1880 let (split_second, _) = apply_edge_splits_with_map(&second, 1, &imprint)?;
1881 Ok((split_first, split_second))
1882}
1883
1884fn operand_bounds(solid: &BrepSolid) -> crate::Aabb {
1888 let mut bounds = crate::Aabb::from_points(solid.vertices.iter().map(|vertex| vertex.point));
1889 for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
1890 match crate::Aabb::from_surface_controls(&face.surface) {
1891 Ok(face_bounds) => bounds.include(face_bounds),
1892 Err(_) => {
1893 return crate::Aabb {
1894 minimum: Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY),
1895 maximum: Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY),
1896 }
1897 }
1898 }
1899 }
1900 bounds
1901}
1902
1903fn soundness_candidates(
1908 solid: &BrepSolid,
1909 scope: &AssemblyScope,
1910 bounds: [crate::Aabb; 2],
1911 band: f64,
1912) -> HashSet<u64> {
1913 let mut candidates = HashSet::default();
1914 for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
1915 if scope.examined.contains(&face.id) {
1916 candidates.insert(face.id);
1917 continue;
1918 }
1919 let Some(&operand) = scope.operand_of.get(&face.id) else {
1920 candidates.insert(face.id);
1921 continue;
1922 };
1923 let other = bounds[usize::from(operand == 0)];
1924 match crate::Aabb::from_surface_controls(&face.surface) {
1925 Ok(face_bounds) if !face_bounds.intersects(other, band) => {}
1926 _ => {
1927 candidates.insert(face.id);
1928 }
1929 }
1930 }
1931 candidates
1932}
1933
1934fn empty_solid() -> BrepSolid {
1936 BrepSolid {
1937 mass_properties_cache: Default::default(),
1938 id: 0,
1939 vertices: Vec::new(),
1940 edges: Vec::new(),
1941 shells: Vec::new(),
1942 genus: 0,
1943 kind: crate::BodyKind::Solid,
1944 }
1945}
1946
1947fn concatenate_disjoint(first: &BrepSolid, second: &BrepSolid) -> BrepSolid {
1953 let mut vertices = Vec::new();
1954 let mut edges = Vec::new();
1955 let mut shells = Vec::new();
1956 let mut used_face_names = HashSet::<String>::default();
1957 let (mut next_face_id, mut next_loop_id, mut next_coedge_id) = (1u64, 1u64, 1u64);
1958 for operand in [first, second] {
1959 let vertex_base = vertices.len() as u64;
1960 let edge_base = edges.len() as u64;
1961 let mut vertex_ids: HashMap<u64, u64> = HashMap::default();
1962 for (index, vertex) in operand.vertices.iter().enumerate() {
1963 let id = vertex_base + index as u64 + 1;
1964 vertex_ids.insert(vertex.id, id);
1965 vertices.push(VertexRecord { id, point: vertex.point });
1966 }
1967 let mut edge_ids: HashMap<u64, u64> = HashMap::default();
1968 for (index, edge) in operand.edges.iter().enumerate() {
1969 let id = edge_base + index as u64 + 1;
1970 edge_ids.insert(edge.id, id);
1971 edges.push(EdgeRecord {
1972 id,
1973 curve: edge.curve.clone(),
1974 t0: edge.t0,
1975 t1: edge.t1,
1976 start_vertex_id: vertex_ids.get(&edge.start_vertex_id).copied().unwrap_or(edge.start_vertex_id),
1977 end_vertex_id: vertex_ids.get(&edge.end_vertex_id).copied().unwrap_or(edge.end_vertex_id),
1978 degenerate: edge.degenerate,
1979 name: edge.name.clone(),
1980 });
1981 }
1982 for shell in &operand.shells {
1983 let mut faces = Vec::with_capacity(shell.faces.len());
1984 for face in &shell.faces {
1985 let loops = face
1986 .loops
1987 .iter()
1988 .map(|loop_record| {
1989 let coedges = loop_record
1990 .coedges
1991 .iter()
1992 .map(|coedge| {
1993 let id = next_coedge_id;
1994 next_coedge_id += 1;
1995 CoedgeRecord {
1996 id,
1997 edge_id: edge_ids.get(&coedge.edge_id).copied().unwrap_or(coedge.edge_id),
1998 forward: coedge.forward,
1999 pcurve: coedge.pcurve.clone(),
2000 }
2001 })
2002 .collect();
2003 let id = next_loop_id;
2004 next_loop_id += 1;
2005 LoopRecord { id, coedges }
2006 })
2007 .collect();
2008 let name = face.name.clone().map(|base| {
2009 if used_face_names.insert(base.clone()) {
2010 base
2011 } else {
2012 let mut suffix = 1usize;
2013 loop {
2014 let candidate = format!("{base}_{suffix}");
2015 if used_face_names.insert(candidate.clone()) {
2016 break candidate;
2017 }
2018 suffix += 1;
2019 }
2020 }
2021 });
2022 faces.push(FaceRecord {
2023 id: next_face_id,
2024 surface: face.surface.clone(),
2025 same_sense: face.same_sense,
2026 loops,
2027 name,
2028 });
2029 next_face_id += 1;
2030 }
2031 shells.push(ShellRecord { id: shells.len() as u64 + 1, faces });
2032 }
2033 }
2034 BrepSolid {
2035 mass_properties_cache: Default::default(),
2036 id: 1,
2037 vertices,
2038 edges,
2039 shells,
2040 genus: first.genus + second.genus,
2041 kind: crate::BodyKind::Solid,
2042 }
2043}
2044
2045fn boolean_pipeline(
2048 first: &BrepSolid,
2049 second: &BrepSolid,
2050 operation: BooleanOperation,
2051 options: &BooleanOptions,
2052 tangent_nodes: &mut Vec<Vec3>,
2053) -> Result<(KernelOutcome<BrepSolid>, Option<HashSet<u64>>), KernelRefusal> {
2054 let replaced_operands = operand_state::replace_operands(first, second, operation, options);
2055 let (first, second) = match &replaced_operands {
2056 Some((first, second)) => (first, second),
2057 None => (first, second),
2058 };
2059 let policy = boolean_policy(first, second, options)?;
2060 let tolerance = policy.model;
2061 if disjoint_fast_path(options)
2073 && !operand_bounds(first).intersects(operand_bounds(second), policy.sew_search)
2074 {
2075 let operation_started = Instant::now();
2076 let mut diagnostics = KernelDiagnostics::default();
2077 diagnostics.count_n(
2078 "collect.faces",
2079 first.shells.iter().chain(&second.shells).map(|shell| shell.faces.len() as u64).sum(),
2080 );
2081 diagnostics.count("boolean.disjoint_fast_path");
2082 diagnostics.measure_max("tolerance.model", policy.model);
2083 diagnostics.measure_max("tolerance.sew_search", policy.sew_search);
2084 let value = match operation {
2085 BooleanOperation::Union => concatenate_disjoint(first, second),
2086 BooleanOperation::Subtract => first.clone(),
2087 BooleanOperation::Intersect => {
2088 diagnostics.event(
2089 DiagnosticSeverity::Info,
2090 KernelStage::Select,
2091 "boolean.empty_result",
2092 format!("{operation:?} of witnessed-non-overlapping operands is empty"),
2093 );
2094 empty_solid()
2095 }
2096 };
2097 let stage_ms = operation_started.elapsed().as_secs_f64() * 1_000.0;
2098 profile_stage("disjoint_fast_path_ms", stage_ms);
2099 diagnostics.measure_max("timing.total_ms", stage_ms);
2100 return Ok((KernelOutcome { value, diagnostics }, Some(HashSet::default())));
2101 }
2102 let heal_started = Instant::now();
2103 let (first_owned, second_owned) = prepared_operands(first, second, &policy)?;
2104 let heal_ms = heal_started.elapsed().as_secs_f64() * 1_000.0;
2105 let first = &first_owned;
2106 let second = &second_owned;
2107
2108 let mut diagnostics = KernelDiagnostics::default();
2109 diagnostics.measure_max("timing.heal_ms", heal_ms);
2112 let fit_scope = crate::PcurveFitScope::open();
2117 let polyline_scope = crate::PolylineFitScope::open();
2121 let operation_started = Instant::now();
2122 diagnostics.count_n(
2123 "collect.faces",
2124 first
2125 .shells
2126 .iter()
2127 .chain(&second.shells)
2128 .map(|shell| shell.faces.len() as u64)
2129 .sum(),
2130 );
2131 diagnostics.measure_max("tolerance.model", policy.model);
2132 diagnostics.measure_max("tolerance.sew_search", policy.sew_search);
2133
2134 let imprint_options = boolean_imprint_options(options, tolerance);
2135 let stage_started = Instant::now();
2136 let imprint = build_imprints(first, second, &imprint_options)?;
2137 tangent_nodes.extend_from_slice(&imprint.tangent_nodes);
2138 if std::env::var("BREP_DEBUG_BOOL").is_ok() {
2139 for piece in &imprint.pieces {
2140 let start = piece.curve.evaluate(piece.t0);
2141 let end = piece.curve.evaluate(piece.t1);
2142 eprintln!(
2143 "piece {} supports={:?} t=[{:.6},{:.6}] start={:?} end={:?}",
2144 piece.id, piece.support_faces, piece.t0, piece.t1, start, end
2145 );
2146 }
2147 for entry in &imprint.by_face {
2148 eprintln!(
2149 "by_face operand={} face={} pieces={:?}",
2150 entry.operand, entry.face_id, entry.piece_ids
2151 );
2152 }
2153 for split in &imprint.edge_splits {
2154 eprintln!(
2155 "edge_split operand={} edge={} params={:?}",
2156 split.operand, split.edge_id, split.parameters
2157 );
2158 }
2159 }
2160 let stage_ms = stage_started.elapsed().as_secs_f64() * 1_000.0;
2161 profile_stage("imprint_ms", stage_ms);
2162 diagnostics.measure_max("timing.imprint_ms", stage_ms);
2163 diagnostics.count_n("intersect.pieces", imprint.pieces.len() as u64);
2164 diagnostics.count_n("intersect.vertices", imprint.vertices.len() as u64);
2165 diagnostics.count_n("intersect.edge_splits", imprint.edge_splits.len() as u64);
2166 let (march_swap_rescues, clip_swap_rescues) = crate::imprint::take_rescue_firings();
2167 diagnostics.count_n("intersect.march_swap_rescue", march_swap_rescues);
2168 diagnostics.count_n("intersect.clip_swap_rescue", clip_swap_rescues);
2169 let touch = TouchCensus::build([first, second], &imprint);
2170 touch.report(&mut diagnostics);
2171 let stage_started = Instant::now();
2172 let (split_first, split_map_first) = apply_edge_splits_with_map(first, 0, &imprint)?;
2173 let (split_second, split_map_second) = apply_edge_splits_with_map(second, 1, &imprint)?;
2174 let stage_ms = stage_started.elapsed().as_secs_f64() * 1_000.0;
2175 profile_stage("edge_split_ms", stage_ms);
2176 diagnostics.measure_max("timing.edge_split_ms", stage_ms);
2177 let stage_started = Instant::now();
2178 crate::classification::classify_profile_begin();
2179 let fragments_a = fragment_solid(&split_first, 0, &imprint)?;
2180 let fragments_b = fragment_solid(&split_second, 1, &imprint)?;
2181 crate::classification::classify_profile_report("fragment");
2182 let stage_ms = stage_started.elapsed().as_secs_f64() * 1_000.0;
2183 profile_stage("fragment_ms", stage_ms);
2184 diagnostics.measure_max("timing.fragment_ms", stage_ms);
2185 diagnostics.count_n(
2186 "fragment.candidates",
2187 (fragments_a.len() + fragments_b.len()) as u64,
2188 );
2189 touch.report_fragments(&[&fragments_a, &fragments_b], &mut diagnostics);
2190 let a_surface_samples: Vec<Vec3> = fragments_a
2195 .iter()
2196 .flat_map(|fragment| {
2197 std::iter::once(fragment.test_point).chain(fragment.extra_test_points.iter().copied())
2198 })
2199 .collect();
2200 let b_surface_samples: Vec<Vec3> = fragments_b
2201 .iter()
2202 .flat_map(|fragment| {
2203 std::iter::once(fragment.test_point).chain(fragment.extra_test_points.iter().copied())
2204 })
2205 .collect();
2206 let stage_started = Instant::now();
2207 let mut barrier_edges: HashSet<(u8, u64)> = imprint
2208 .pieces
2209 .iter()
2210 .filter_map(|piece| piece.shared_edge.map(|(operand, edge_id, _)| (operand, edge_id)))
2211 .collect();
2212 barrier_edges.extend(imprint.barrier_edges.iter().copied());
2213 for (operand, split_map) in [(0u8, &split_map_first), (1u8, &split_map_second)] {
2219 let minted: Vec<(u8, u64)> = barrier_edges
2220 .iter()
2221 .filter(|(barrier_operand, _)| *barrier_operand == operand)
2222 .filter_map(|(_, edge_id)| split_map.get(edge_id))
2223 .flat_map(|sub_ids| sub_ids.iter().map(|sub_id| (operand, *sub_id)))
2224 .collect();
2225 barrier_edges.extend(minted);
2226 }
2227 crate::classification::classify_profile_begin();
2228 let coincident = coincident_pairs(
2229 &fragments_a,
2230 &fragments_b,
2231 first,
2232 second,
2233 &imprint.cosurface_pairs,
2234 tolerance,
2235 )?;
2236 let sector_mode = sector::sector_mode(options);
2237 let mut select_census = sector::SelectCensus::default();
2238 let mut selected = select_fragments(
2239 fragments_a,
2240 fragments_b,
2241 first,
2242 second,
2243 operation,
2244 tolerance,
2245 &barrier_edges,
2246 &imprint,
2247 &coincident,
2248 sector_mode,
2249 &mut select_census,
2250 )?;
2251 let resewn = resew_coincident_rims(
2252 &mut selected,
2253 [&split_first, &split_second],
2254 [first, second],
2255 &imprint.cosurface_pairs,
2256 operation,
2257 tolerance,
2258 )?;
2259 diagnostics.count_n("select.coincident_pairs", coincident.len() as u64);
2260 diagnostics.count_n("select.rims_resewn", resewn as u64);
2261 crate::classification::classify_profile_report("select");
2262 let stage_ms = stage_started.elapsed().as_secs_f64() * 1_000.0;
2263 profile_stage("select_ms", stage_ms);
2264 diagnostics.measure_max("timing.select_ms", stage_ms);
2265 diagnostics.count_n("select.fragments", selected.len() as u64);
2266 diagnostics.count_n("select.sector_eligible", select_census.eligible);
2268 diagnostics.count_n("select.sector_primary", select_census.sector_primary);
2269 diagnostics.count_n("select.sector_fallback", select_census.sector_fallback);
2270 diagnostics.count_n("select.sector_disagree", select_census.disagree);
2271 diagnostics.count_n("select.ray_casts", select_census.ray_casts);
2272 diagnostics.count_n("select.on_votes", select_census.on_votes);
2273 diagnostics.count_n("select.overturns", select_census.overturns);
2274 if sector_mode != sector::SectorMode::Off || std::env::var_os("BREP_SECTOR_CENSUS").is_some() {
2275 sector::sector_census_line(|| {
2276 format!(
2277 "selection mode={sector_mode:?} select_ms={stage_ms:.3} selected={} eligible={} primary={} fallback={} disagree={} ray_casts={} on_votes={} overturns={}",
2278 selected.len(),
2279 select_census.eligible,
2280 select_census.sector_primary,
2281 select_census.sector_fallback,
2282 select_census.disagree,
2283 select_census.ray_casts,
2284 select_census.on_votes,
2285 select_census.overturns
2286 )
2287 });
2288 }
2289 if selected.is_empty()
2311 && !imprint.section_evidence
2312 && std::env::var("BREP_EMPTY_BOOLEAN").as_deref() != Ok("0")
2313 {
2314 let strictly_inside = |samples: &[Vec3], other: &BrepSolid| -> Result<bool, KernelRefusal> {
2315 for &sample in samples {
2316 if classify_point(sample, other, tolerance).or_refuse(KernelStage::Validate, "classify_point")?.class == PointClass::In {
2317 return Ok(true);
2318 }
2319 }
2320 Ok(false)
2321 };
2322 let outside_witness = |samples: &[Vec3], other: &BrepSolid| -> Result<bool, KernelRefusal> {
2323 for &sample in samples {
2324 if classify_point(sample, other, tolerance).or_refuse(KernelStage::Validate, "classify_point")?.class == PointClass::Out {
2325 return Ok(true);
2326 }
2327 }
2328 Ok(false)
2329 };
2330 let legitimate = match operation {
2331 BooleanOperation::Intersect => {
2332 !strictly_inside(&a_surface_samples, second)?
2333 && !strictly_inside(&b_surface_samples, first)?
2334 }
2335 BooleanOperation::Subtract => !outside_witness(&a_surface_samples, second)?,
2336 BooleanOperation::Union => false,
2337 };
2338 if legitimate {
2339 diagnostics.event(
2340 DiagnosticSeverity::Info,
2341 KernelStage::Select,
2342 "boolean.empty_result",
2343 format!("{operation:?} of witnessed-non-overlapping operands is empty"),
2344 );
2345 fit_scope.close().report_into(&mut diagnostics);
2346 polyline_scope.close().report_into(&mut diagnostics);
2347 diagnostics.measure_max(
2348 "timing.total_ms",
2349 operation_started.elapsed().as_secs_f64() * 1_000.0,
2350 );
2351 return Ok((
2352 KernelOutcome {
2353 value: empty_solid(),
2354 diagnostics,
2355 },
2356 None,
2357 ));
2358 }
2359 }
2360 let solids = [(0, &split_first), (1, &split_second)]
2361 .into_iter()
2362 .collect::<HashMap<_, _>>();
2363 let dump = BooleanStageDump::open();
2364 if let Some(dump) = &dump {
2365 dump.write("first", &split_first);
2366 dump.write("second", &split_second);
2367 dump.write("selected", &selected);
2368 }
2369 let stage_started = Instant::now();
2370 let assembled_origins: Vec<FaceKey> = if imprint.tangent_sections.is_empty() {
2374 Vec::new()
2375 } else {
2376 selected
2377 .iter()
2378 .map(|fragment| FaceKey { operand: fragment.operand, face_id: fragment.source_face_id })
2379 .collect()
2380 };
2381 let attribute = |error: KernelRefusal| {
2382 attribute_to_tangent_section(error, &imprint.tangent_sections, &assembled_origins)
2383 };
2384 let (solid, scope) = if untouched_passthrough(options) {
2385 assemble_fragments_scoped(selected, &solids, &imprint, tolerance, &touch, touched_assembly(options))
2386 .map_err(attribute)?
2387 } else {
2388 (assemble_fragments(selected, &solids, &imprint, tolerance).map_err(attribute)?, None)
2389 };
2390 if let Some(scope) = &scope {
2391 diagnostics.count_n("passthrough.faces_examined", scope.examined.len() as u64);
2392 }
2393 if let Some(dump) = &dump {
2394 dump.write("assembled", &solid);
2395 }
2396 let stage_ms = stage_started.elapsed().as_secs_f64() * 1_000.0;
2397 profile_stage("assemble_ms", stage_ms);
2398 diagnostics.measure_max("timing.assemble_ms", stage_ms);
2399 let stage_started = Instant::now();
2400 if std::env::var("BREP_DEBUG_PREMERGE_VALIDATE").is_ok() {
2401 let issues = solid.validate();
2402 eprintln!(
2403 "pre-merge validation: {} issue(s){}",
2404 issues.len(),
2405 issues
2406 .first()
2407 .map(|issue| format!(" — first: {}", issue.message))
2408 .unwrap_or_default()
2409 );
2410 }
2411 let solid = if options.merge_coplanar_faces
2412 && std::env::var("BREP_COPLANAR_MERGE").as_deref() != Ok("0")
2413 {
2414 let merged = match &scope {
2415 Some(scope) => merge_same_surface_faces_scoped(
2419 &solid,
2420 tolerance,
2421 &options.keep_unmerged_name_substrs,
2422 &scope.examined,
2423 )?,
2424 None => merge_same_surface_faces_excluding(
2425 &solid,
2426 tolerance,
2427 &options.keep_unmerged_name_substrs,
2428 )?,
2429 };
2430 merge_curve_continuation_edges(&merged, tolerance).map_err(|refusal| KernelRefusal { stage: KernelStage::Validate, ..refusal })?
2435 } else {
2436 solid
2437 };
2438 if let Some(dump) = &dump {
2439 dump.write("merged", &solid);
2440 }
2441 let stage_ms = stage_started.elapsed().as_secs_f64() * 1_000.0;
2442 profile_stage("face_merge_ms", stage_ms);
2443 diagnostics.measure_max("timing.face_merge_ms", stage_ms);
2444 let validation = solid.validate_detailed_scoped(&policy, scope.as_ref().map(|scope| &scope.examined));
2445 diagnostics.count_n("validate.issues", validation.issues.len() as u64);
2446 diagnostics.count_n(
2447 "validate.wire_warnings",
2448 validation.wire_warnings.len() as u64,
2449 );
2450 diagnostics.measure_max("validate.max_pcurve_error", validation.max_pcurve_error);
2451 for warning in &validation.wire_warnings {
2452 diagnostics.event(
2453 DiagnosticSeverity::Warning,
2454 KernelStage::Validate,
2455 "validate.uv_wire",
2456 warning.message.clone(),
2457 );
2458 }
2459 for issue in &validation.issues {
2460 diagnostics.event(
2461 DiagnosticSeverity::Error,
2462 KernelStage::Validate,
2463 "validate.brep",
2464 issue.message.clone(),
2465 );
2466 }
2467 fit_scope.close().report_into(&mut diagnostics);
2468 polyline_scope.close().report_into(&mut diagnostics);
2469 diagnostics.measure_max(
2470 "timing.total_ms",
2471 operation_started.elapsed().as_secs_f64() * 1_000.0,
2472 );
2473 if !validation.issues.is_empty() {
2474 return Err(KernelRefusal::new(
2475 RefusalClass::InvalidResultTopology { issues: validation.issues.len() as u32 },
2476 KernelStage::Validate,
2477 format!(
2478 "boolean_operation: invalid result: {:?}",
2479 validation.issues
2480 )));
2481 }
2482 if std::env::var("BREP_DEBUG_BOOL").is_ok() {
2488 if let Ok(report) =
2489 crate::oracle::boolean_semantic_disagreement(first, second, operation, &solid, 200)
2490 {
2491 if report.is_flagged() {
2492 eprintln!(
2493 "[oracle] semantic disagreement rate {:.4} ({} of {} decidable points); sample {:?}",
2494 report.disagreement_rate,
2495 report.disagreements.len(),
2496 report.considered,
2497 report.sample_disagreement()
2498 );
2499 }
2500 }
2501 let fusable_band = crate::tolerance::assembler_weld(policy.model);
2502 let fusables = crate::oracle::boolean_residual_fusables(&solid, fusable_band);
2503 if !fusables.is_empty() {
2504 eprintln!(
2505 "[oracle] {} residual fusable(s) after weld; sample {:?}",
2506 fusables.len(),
2507 fusables.first()
2508 );
2509 }
2510 }
2511 let candidates = scope.as_ref().map(|scope| {
2512 let candidates = soundness_candidates(
2513 &solid,
2514 scope,
2515 [operand_bounds(first), operand_bounds(second)],
2516 policy.sew_search,
2517 );
2518 diagnostics.count_n("passthrough.soundness_candidates", candidates.len() as u64);
2519 candidates
2520 });
2521 Ok((
2522 KernelOutcome {
2523 value: solid,
2524 diagnostics,
2525 },
2526 candidates,
2527 ))
2528}
2529