1mod cavities;
30mod colors;
31mod degenerate_coverage;
32mod feature_fragments;
33mod primitive_gate;
34pub mod t_junctions;
35
36use crate::{
37 recognize_surfaces_with_candidates, reconstruct_surface, AnalyticSurface, ComponentCandidate,
38 ConeSurface, CylinderSurface, Mesh, PlaneSurface, SphereSurface, TorusSurface,
39 MeshAnalysisOptions, PhaseTimings, RecognitionOptions, SamplingMode, SurfaceFitResult,
40 SurfaceHint, SurfaceRegion, SurfaceType, Vec3,
41};
42use brep_kernel::{
43 audit_step_manifold, export_step, import_step, make_cone_brep, make_cylinder_brep,
44 make_sphere_brep, make_torus_brep, merge_same_surface_faces, mesh_regions_to_brep,
45 mesh_to_faceted_brep, repair_triangle_soup, segment_mesh_faces, MeshSegmentation,
46 RegionCarrier, SegmentOptions,
47 UNASSIGNED_REGION,
48};
49use serde::{Deserialize, Serialize};
50use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
51use std::fmt::{Display, Formatter};
52use web_time::Instant;
53
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
57pub enum ConversionPolicy {
58 #[default]
60 AllowFacetedFallback,
61 RequireFullyAnalytic,
63}
64
65#[derive(Clone, Debug, Deserialize, Serialize)]
67#[serde(default)]
68pub struct StlConversionOptions {
69 pub recognition: RecognitionOptions,
73 pub policy: ConversionPolicy,
75 pub try_kernel_analytic_rebuild: bool,
78 pub try_hybrid_rebuild: bool,
84 pub weld_tolerance: f64,
88 pub coordinate_precision_tolerance: f64,
95 pub kernel_deflection_angle_degrees: f64,
97 pub kernel_fit_tolerance: f64,
99 pub kernel_normal_tolerance_degrees: f64,
101}
102
103impl Default for StlConversionOptions {
104 fn default() -> Self {
105 let recognition = RecognitionOptions {
106 collect_phase_timings: true,
107 sampling: SamplingMode::Vertices,
111 ..RecognitionOptions::default()
112 };
113 Self {
114 recognition,
115 policy: ConversionPolicy::AllowFacetedFallback,
116 try_kernel_analytic_rebuild: true,
117 try_hybrid_rebuild: true,
118 weld_tolerance: -1.0,
119 coordinate_precision_tolerance: 0.0,
120 kernel_deflection_angle_degrees: 30.0,
121 kernel_fit_tolerance: 1.0e-3,
122 kernel_normal_tolerance_degrees: 15.0,
123 }
124 }
125}
126
127#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
129#[serde(rename_all = "snake_case")]
130pub enum ConversionBackend {
131 RansacSphere,
133 RansacTorus,
135 RansacCappedCylinder,
137 RansacCappedCone,
139 KernelAnalyticRebuild,
142 KernelSphericalUnion,
145 #[serde(alias = "hybrid_plane_cylinder_rebuild")]
148 HybridAnalyticRebuild,
149 FacetedRepair,
151 FacetedRepairCoplanarMerged,
154 Refused,
158}
159
160#[derive(Clone, Debug, Deserialize, Serialize)]
162pub struct ConversionRegionReport {
163 pub surface_type: SurfaceType,
165 pub surface: AnalyticSurface,
167 pub support_triangles: usize,
169 pub supported_area: f64,
171 pub rms_error: f64,
173 pub max_error: f64,
175 pub rms_normal_error_radians: f64,
177 pub max_normal_error_radians: f64,
179 pub confidence: f64,
181 pub orientation: i8,
183 pub reason: String,
185 pub phase_timings: PhaseTimings,
187}
188
189#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
191pub struct ConversionTimings {
192 #[serde(default)]
194 pub segmentation_seconds: f64,
195 pub recognition_seconds: f64,
197 pub topology_build_seconds: f64,
199 pub step_export_seconds: f64,
201 pub step_validation_seconds: f64,
203 pub total_seconds: f64,
205}
206
207#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
209pub struct HybridConversionReport {
210 pub total_faces: usize,
212 pub analytic_plane_faces: usize,
214 pub analytic_plane_triangles: usize,
216 pub analytic_cylinder_faces: usize,
218 pub analytic_cylinder_triangles: usize,
220 #[serde(default)]
222 pub analytic_cone_faces: usize,
223 #[serde(default)]
225 pub analytic_cone_triangles: usize,
226 #[serde(default)]
228 pub analytic_sphere_faces: usize,
229 #[serde(default)]
231 pub analytic_sphere_triangles: usize,
232 #[serde(default)]
234 pub analytic_torus_faces: usize,
235 #[serde(default)]
237 pub analytic_torus_triangles: usize,
238 pub faceted_faces: usize,
240 pub faceted_triangles: usize,
242 pub demoted_regions: usize,
244 pub demoted_triangles: usize,
246 pub segmentation_plane_regions: usize,
248 pub segmentation_cylinder_regions: usize,
250 #[serde(default)]
252 pub segmentation_cone_regions: usize,
253 #[serde(default)]
255 pub segmentation_sphere_regions: usize,
256 #[serde(default)]
258 pub segmentation_torus_regions: usize,
259 pub segmentation_unsupported_regions: usize,
261}
262
263#[derive(Clone, Debug, Deserialize, Serialize)]
266pub struct ComponentConversionReport {
267 pub index: usize,
269 pub first_triangle: usize,
272 pub input_vertices: usize,
274 pub input_triangles: usize,
276 pub backend: ConversionBackend,
278 pub backend_reason: String,
280 pub fallback_cause: Option<String>,
282 pub recognition_skipped: Option<String>,
284 pub recognized_regions: usize,
286 pub recognized_triangles: usize,
288 pub unresolved_triangles: usize,
290 pub source_closed_manifold: bool,
292 #[serde(default)]
297 pub analytic_used_repaired_mesh: bool,
298 pub hybrid_rebuild: Option<HybridConversionReport>,
300 pub faceted_faces_before_merge: Option<usize>,
302 pub faceted_faces_after_merge: Option<usize>,
304 pub faces: usize,
306 #[serde(default)]
309 pub refusal: Option<String>,
310 #[serde(default)]
313 pub source_name: Option<String>,
314}
315
316#[derive(Clone, Debug, Deserialize, Serialize)]
323pub struct StlConversionReport {
324 pub backend: ConversionBackend,
326 pub backend_reason: String,
328 pub fallback_cause: Option<String>,
330 pub input_vertices: usize,
332 pub input_triangles: usize,
334 pub recognized_regions: usize,
336 pub recognized_triangles: usize,
338 pub unresolved_triangles: usize,
340 #[serde(default)]
343 pub recognition_skipped: Option<String>,
344 pub source_closed_manifold: bool,
346 #[serde(default)]
350 pub analytic_used_repaired_mesh: bool,
351 #[serde(default)]
353 pub requested_distance_tolerance: f64,
354 #[serde(default)]
356 pub coordinate_precision_tolerance: f64,
357 #[serde(default)]
359 pub effective_distance_tolerance: f64,
360 pub region_type_counts: BTreeMap<String, usize>,
362 pub regions: Vec<ConversionRegionReport>,
364 pub topology_refit: Option<ConversionRegionReport>,
367 #[serde(default)]
370 pub hybrid_rebuild: Option<HybridConversionReport>,
371 #[serde(default)]
375 pub components: Vec<ComponentConversionReport>,
376 pub exported_step_bytes: usize,
378 pub exported_advanced_faces: usize,
380 #[serde(default)]
382 pub faceted_faces_before_merge: Option<usize>,
383 #[serde(default)]
387 pub faceted_faces_after_merge: Option<usize>,
388 pub roundtrip_solids: usize,
390 pub manifold_audit_issues: Vec<String>,
392 pub timings: ConversionTimings,
394 #[serde(default)]
396 pub recognition_work: RecognitionWorkReport,
397 #[serde(default)]
399 pub whole_carrier_scan: WholeCarrierScanReport,
400 pub messages: Vec<String>,
402}
403
404#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
406pub struct RecognitionWorkReport {
407 pub hypotheses: usize,
409 pub rounds: usize,
411 pub seeded_components: usize,
414}
415
416#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
422pub struct WholeCarrierScanReport {
423 pub accepted: usize,
425 pub declined: usize,
427 pub abstained: usize,
429}
430
431impl WholeCarrierScanReport {
432 pub(crate) fn add(&mut self, verdict: crate::hybrid_whole_freeform::ScanVerdict) {
433 use crate::hybrid_whole_freeform::ScanVerdict;
434 match verdict {
435 ScanVerdict::Accepted => self.accepted += 1,
436 ScanVerdict::Declined => self.declined += 1,
437 ScanVerdict::Abstained => self.abstained += 1,
438 }
439 }
440
441 fn sum(self, other: Self) -> Self {
442 Self {
443 accepted: self.accepted + other.accepted,
444 declined: self.declined + other.declined,
445 abstained: self.abstained + other.abstained,
446 }
447 }
448}
449
450#[derive(Clone, Debug, Deserialize, Serialize)]
452pub struct StlConversionOutput {
453 pub step_text: String,
455 pub report: StlConversionReport,
457}
458
459#[derive(Clone, Debug, PartialEq, Eq)]
461pub struct StlConversionError(pub String);
462
463impl Display for StlConversionError {
464 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
465 formatter.write_str(&self.0)
466 }
467}
468
469impl std::error::Error for StlConversionError {}
470
471impl From<String> for StlConversionError {
472 fn from(value: String) -> Self {
473 Self(value)
474 }
475}
476
477pub fn binary_stl_coordinate_precision_tolerance(mesh: &Mesh) -> f64 {
487 let maximum_absolute_coordinate = mesh
488 .vertices
489 .iter()
490 .flat_map(|point| [point.x.abs(), point.y.abs(), point.z.abs()])
491 .fold(0.0_f64, f64::max);
492 let coordinate_scale = maximum_absolute_coordinate.max(mesh_diagonal(mesh));
493 coordinate_scale * (f32::EPSILON as f64) * 0.5 * 3.0_f64.sqrt()
494}
495
496pub fn convert_stl_mesh_to_step(
508 mesh: &Mesh,
509 source_positions: &[f64],
510 source_indices: Option<&[u32]>,
511 options: &StlConversionOptions,
512 part_name: &str,
513 unit: &str,
514 timestamp: &str,
515) -> Result<StlConversionOutput, StlConversionError> {
516 convert_stl_mesh_to_step_with_colors(
517 mesh,
518 source_positions,
519 source_indices,
520 None,
521 options,
522 part_name,
523 unit,
524 timestamp,
525 )
526}
527
528#[allow(clippy::too_many_arguments)]
538pub fn convert_stl_mesh_to_step_with_colors(
539 mesh: &Mesh,
540 source_positions: &[f64],
541 source_indices: Option<&[u32]>,
542 triangle_colors: Option<&[Option<[u8; 3]>]>,
543 options: &StlConversionOptions,
544 part_name: &str,
545 unit: &str,
546 timestamp: &str,
547) -> Result<StlConversionOutput, StlConversionError> {
548 let total_started = Instant::now();
549 validate_source_buffers(mesh, source_positions, source_indices)?;
550 if !options.coordinate_precision_tolerance.is_finite()
551 || options.coordinate_precision_tolerance < 0.0
552 {
553 return Err(StlConversionError(
554 "coordinate_precision_tolerance must be finite and non-negative".to_owned(),
555 ));
556 }
557
558 let components = triangle_components(mesh);
559 let count = components.len();
560 if count == 0 {
561 return Err(StlConversionError(
562 "the mesh contains no triangles to reconstruct".to_owned(),
563 ));
564 }
565
566 let mut solids = Vec::with_capacity(count);
567 let mut component_ids = Vec::with_capacity(count);
568 let mut details = Vec::with_capacity(count);
569 let mut first_refusal = None;
570 for (index, triangles) in components.iter().enumerate() {
571 if count == 1 {
572 let built = convert_component(mesh, source_positions, source_indices, options)?;
573 solids.push(built.0);
574 component_ids.push(index);
575 details.push(built.1);
576 continue;
577 }
578 let part = component_mesh(mesh, source_positions, source_indices, triangles);
579 match convert_component(
580 &part.mesh,
581 &part.positions,
582 part.indices.as_deref(),
583 options,
584 ) {
585 Ok(built) => {
586 solids.push(built.0);
587 component_ids.push(index);
588 details.push(built.1);
589 }
590 Err(error) => {
594 first_refusal.get_or_insert_with(|| {
595 format!(
596 "body {} of {count} (from source triangle {}, {} triangle(s)): {error}",
597 index + 1,
598 triangles[0],
599 triangles.len()
600 )
601 });
602 details.push(refused_component(&part.mesh, triangles.len(), error.0));
603 }
604 }
605 }
606 if solids.is_empty() {
608 let refusal = first_refusal.unwrap_or_default();
609 return Err(StlConversionError(if count > 1 {
610 format!("{refusal} (every one of the {count} bodies was refused)")
611 } else {
612 refusal
613 }));
614 }
615
616 let (mut solids, cavity_messages) = cavities::assemble(mesh, &components, solids, &component_ids)?;
617 let export_started = Instant::now();
618 let colored = triangle_colors.filter(|colors| colors.iter().any(Option::is_some));
622 let mut color_message = None;
623 let step_text = match colored {
624 Some(triangle_colors) => {
625 let assignment = colors::assign(mesh, triangle_colors, &mut solids, part_name)?;
626 let named: Vec<(String, &brep_kernel::BrepSolid)> =
627 assignment.body_names.iter().cloned().zip(solids.iter()).collect();
628 color_message = Some(format!(
629 "source colours: {} of {} bodies and {} faces styled; {} coloured source triangles matched no face",
630 assignment.bodies,
631 solids.len(),
632 assignment.faces,
633 assignment.unmatched
634 ));
635 brep_kernel::export_step_report_named(
636 &named,
637 part_name,
638 unit,
639 timestamp,
640 None,
641 Some(&assignment.colors),
642 )
643 .map(|report| report.text)
644 }
645 None => export_step(&solids, part_name, unit, timestamp),
646 }
647 .map_err(|error| StlConversionError(format!("STEP export failed: {error}")))?;
648 let step_export_seconds = export_started.elapsed().as_secs_f64();
649
650 let validation_started = Instant::now();
651 let manifold_audit_issues = audit_step_manifold(&step_text);
652 if !manifold_audit_issues.is_empty() {
653 return Err(StlConversionError(format!(
654 "STEP manifold audit failed: {}",
655 manifold_audit_issues.join("; ")
656 )));
657 }
658 let imported = import_step(&step_text).map_err(|error| {
659 StlConversionError(format!("STEP round-trip validation failed: {error}"))
660 })?;
661 if let Some(issues) = imported
662 .iter()
663 .map(|solid| solid.validate())
664 .find(|issues| !issues.is_empty())
665 {
666 return Err(StlConversionError(format!(
667 "round-tripped STEP has invalid topology: {issues:?}"
668 )));
669 }
670 if imported.len() != solids.len() {
671 return Err(StlConversionError(format!(
672 "{} body/bodies were written but {} came back out of the STEP document",
673 solids.len(),
674 imported.len()
675 )));
676 }
677 let step_validation_seconds = validation_started.elapsed().as_secs_f64();
678
679 let mut report = aggregate_report(&components, details, count, options);
680 report.messages.extend(cavity_messages);
681 report.messages.extend(color_message);
682 report.messages.push(format!(
683 "STEP manifold audit and round-trip import passed for {} solid(s)",
684 imported.len()
685 ));
686 report.messages.push(report.backend_reason.clone());
687 report.exported_step_bytes = step_text.len();
688 report.exported_advanced_faces = step_text.matches("ADVANCED_FACE(").count();
689 report.roundtrip_solids = imported.len();
690 report.manifold_audit_issues = manifold_audit_issues;
691 report.timings.step_export_seconds = step_export_seconds;
692 report.timings.step_validation_seconds = step_validation_seconds;
693 report.timings.total_seconds = total_started.elapsed().as_secs_f64();
694 Ok(StlConversionOutput { step_text, report })
695}
696
697fn refused_component(mesh: &Mesh, triangles: usize, reason: String) -> ComponentDetail {
700 ComponentDetail {
701 backend: ConversionBackend::Refused,
702 backend_reason: format!("refused, not in the model: {reason}"),
703 fallback_cause: None,
704 input_vertices: mesh.vertices.len(),
705 input_triangles: triangles,
706 recognized_regions: 0,
707 recognized_triangles: 0,
708 unresolved_triangles: 0,
709 recognition_skipped: None,
710 source_closed_manifold: closed_manifold(mesh),
711 analytic_used_repaired_mesh: false,
712 requested_distance_tolerance: 0.0,
713 effective_distance_tolerance: 0.0,
714 region_type_counts: BTreeMap::new(),
715 regions: Vec::new(),
716 topology_refit: None,
717 hybrid_rebuild: None,
718 faceted_faces_before_merge: None,
719 faceted_faces_after_merge: None,
720 faces: 0,
721 segmentation_seconds: 0.0,
722 recognition_seconds: 0.0,
723 recognition_work: RecognitionWorkReport::default(),
724 whole_carrier_scan: WholeCarrierScanReport::default(),
725 topology_build_seconds: 0.0,
726 messages: vec![format!("refused, not in the model: {reason}")],
727 refusal: Some(reason),
728 }
729}
730
731struct ComponentDetail {
734 backend: ConversionBackend,
735 backend_reason: String,
736 fallback_cause: Option<String>,
737 input_vertices: usize,
738 input_triangles: usize,
739 recognized_regions: usize,
740 recognized_triangles: usize,
741 unresolved_triangles: usize,
742 recognition_skipped: Option<String>,
743 source_closed_manifold: bool,
744 analytic_used_repaired_mesh: bool,
745 requested_distance_tolerance: f64,
746 effective_distance_tolerance: f64,
747 region_type_counts: BTreeMap<String, usize>,
748 regions: Vec<ConversionRegionReport>,
749 topology_refit: Option<ConversionRegionReport>,
750 hybrid_rebuild: Option<HybridConversionReport>,
751 faceted_faces_before_merge: Option<usize>,
752 faceted_faces_after_merge: Option<usize>,
753 faces: usize,
754 segmentation_seconds: f64,
755 recognition_seconds: f64,
756 recognition_work: RecognitionWorkReport,
757 whole_carrier_scan: WholeCarrierScanReport,
758 topology_build_seconds: f64,
759 messages: Vec<String>,
760 refusal: Option<String>,
762}
763
764fn convert_component(
768 mesh: &Mesh,
769 source_positions: &[f64],
770 source_indices: Option<&[u32]>,
771 options: &StlConversionOptions,
772) -> Result<(brep_kernel::BrepSolid, ComponentDetail), StlConversionError> {
773 let outward = cavities::outward_component(mesh, source_positions, source_indices);
776 let (mesh, source_positions, source_indices) = match &outward {
777 Some(part) => (&part.mesh, part.positions.as_slice(), part.indices.as_deref()),
778 None => (mesh, source_positions, source_indices),
779 };
780 let mut recognition_options = options.recognition.clone();
781 recognition_options.collect_phase_timings = true;
782 let requested_distance_tolerance = recognition_options.distance_tolerance;
783 recognition_options.distance_tolerance = recognition_options
784 .distance_tolerance
785 .max(options.coordinate_precision_tolerance);
786 let encoding_distance_tolerance = recognition_options.distance_tolerance;
787
788 let source_closed_manifold = closed_manifold(mesh);
789 let input_vertices = mesh.vertices.len();
790 let input_triangles = mesh.triangles.len();
791 let repaired = if source_closed_manifold {
800 None
801 } else {
802 repaired_closed_mesh(source_positions, source_indices, options)
803 };
804 let (mesh, source_positions, source_indices) = match &repaired {
805 Some(repaired) => (
806 &repaired.mesh,
807 repaired.positions.as_slice(),
808 Some(repaired.indices.as_slice()),
809 ),
810 None => (mesh, source_positions, source_indices),
811 };
812 let closed_for_analytics = source_closed_manifold || repaired.is_some();
815
816 let segmentation_started = Instant::now();
817 let segmentation = if closed_for_analytics {
818 gate_segmentation(source_positions, source_indices, options)
819 } else {
820 Err("the source mesh is not a closed two-manifold".to_owned())
821 };
822 let segmentation_seconds = segmentation_started.elapsed().as_secs_f64();
823 let initial_recognition_gate = recognition_gate(&segmentation);
824 let geometric_gate_rescue = initial_recognition_gate.is_err()
825 && closed_for_analytics
826 && segmentation.as_ref().is_ok_and(|segmentation| {
827 primitive_gate::has_whole_primitive_candidate(mesh, segmentation, &recognition_options)
828 });
829 let recognition_gate = if geometric_gate_rescue {
830 Ok(())
831 } else {
832 initial_recognition_gate
833 };
834 let measured_region_deviation = match (&recognition_gate, &segmentation) {
843 (Ok(()), Ok(segmentation)) => segmentation
844 .regions
845 .iter()
846 .map(|region| region.max_deviation)
847 .fold(0.0_f64, f64::max),
848 _ => 0.0,
849 };
850 recognition_options.distance_tolerance = recognition_options
851 .distance_tolerance
852 .max(measured_region_deviation);
853 let effective_distance_tolerance = recognition_options.distance_tolerance;
854
855 let recognition_started = Instant::now();
856 let mut recognition_work = RecognitionWorkReport::default();
857 let (recognition, completed_plane_regions, completed_degenerate_triangles) = if recognition_gate.is_ok() {
858 let candidates = match &segmentation {
859 Ok(segmentation) if segment_carrier_enabled() => segmentation_candidates(mesh, segmentation),
860 _ => Vec::new(),
861 };
862 let (mut recognition, work) =
863 recognize_surfaces_with_candidates(mesh, &recognition_options, &candidates)
864 .map_err(|error| {
865 StlConversionError(format!("surface recognition failed: {error}"))
866 })?;
867 recognition_work = RecognitionWorkReport {
868 hypotheses: work.attempts,
869 rounds: work.rounds,
870 seeded_components: work.seeded_components,
871 };
872 feature_fragments::complete(
873 mesh,
874 &recognition_options,
875 &mut recognition.regions,
876 &mut recognition.unresolved_triangles,
877 )?;
878 let completed_plane_regions = complete_small_planar_regions(
879 mesh,
880 &recognition_options,
881 &mut recognition.regions,
882 &mut recognition.unresolved_triangles,
883 )?;
884 let completed_degenerate_triangles = degenerate_coverage::complete(
885 mesh, &recognition_options, &mut recognition.regions,
886 )?;
887 (recognition, completed_plane_regions, completed_degenerate_triangles)
888 } else {
889 (
890 crate::RecognitionResult {
891 regions: Vec::new(),
892 unresolved_triangles: Vec::new(),
893 unresolved_diagnostics: Vec::new(),
894 },
895 0,
896 0,
897 )
898 };
899 let recognition_seconds = if recognition_gate.is_ok() {
900 recognition_started.elapsed().as_secs_f64()
901 } else {
902 0.0
903 };
904 let recognized_triangles = recognition
905 .regions
906 .iter()
907 .map(|region| region.triangle_indices.len())
908 .sum();
909 let regions = recognition
910 .regions
911 .iter()
912 .map(region_report)
913 .collect::<Vec<_>>();
914 let mut region_type_counts = BTreeMap::new();
915 for region in &recognition.regions {
916 *region_type_counts
917 .entry(region.surface.surface_type().name().to_owned())
918 .or_insert(0) += 1;
919 }
920
921 let topology_started = Instant::now();
922 let analytic_coverage = recognition.unresolved_triangles.is_empty()
923 && recognized_triangles == mesh.triangles.len()
924 && disjoint_complete_partition(&recognition.regions, mesh.triangles.len());
925 let spherical_union = if closed_for_analytics && options.try_kernel_analytic_rebuild {
926 segmentation.as_ref().ok().and_then(|seg| {
927 brep_kernel::reconstruct_spherical_mesh_union(
928 source_positions,
929 source_indices.unwrap_or(&[]),
930 seg,
931 &segmentation_options(source_positions, options),
932 )
933 })
934 } else {
935 None
936 };
937 let mut whole_carrier_scan = WholeCarrierScanReport::default();
944 let primitive = if analytic_coverage
945 && closed_for_analytics
946 && recognition_work.seeded_components == 0
947 && whole_fits_pass_the_local_scan(mesh, &recognition.regions, encoding_distance_tolerance, &mut whole_carrier_scan)
948 {
949 direct_analytic_solid(mesh, &recognition.regions, &recognition_options)
950 } else {
951 None
952 };
953
954 let mut fallback_cause = None;
955 let mut messages = vec![match &recognition_gate {
956 Ok(()) => format!(
957 "RANSAC recognized {} region(s) covering {recognized_triangles}/{} triangles",
958 recognition.regions.len(),
959 mesh.triangles.len()
960 ),
961 Err(reason) => format!("RANSAC recognition skipped: {reason}"),
962 }];
963 if geometric_gate_rescue {
964 messages.push("whole-primitive recognition enabled by triangle coverage on a segmented curved carrier and its possible end caps".to_owned());
965 }
966 if outward.is_some() {
967 messages.push("inward source shell reconstructed outward before cavity assembly".into());
968 }
969 if let Some(repaired) = &repaired {
970 messages.push(repaired.message.clone());
971 }
972 if encoding_distance_tolerance > requested_distance_tolerance {
973 messages.push(format!(
974 "source coordinate precision raised the absolute recognition distance from {requested_distance_tolerance:.6e} to {encoding_distance_tolerance:.6e}"
975 ));
976 }
977 if effective_distance_tolerance > encoding_distance_tolerance {
978 messages.push(format!(
979 "the segmentation's measured vertex deviation raised the absolute recognition distance from {encoding_distance_tolerance:.6e} to {effective_distance_tolerance:.6e}"
980 ));
981 }
982 if completed_degenerate_triangles > 0 {
983 messages.push(format!(
984 "attached {completed_degenerate_triangles} numerically degenerate triangle(s) to their surrounding fitted carrier; excluded from fit support metrics"
985 ));
986 }
987 if completed_plane_regions > 0 {
988 messages.push(format!(
989 "a plane-only completion pass recovered {completed_plane_regions} small feature-bounded planar region(s)"
990 ));
991 }
992
993 let (
994 solid,
995 backend,
996 backend_reason,
997 topology_refit,
998 hybrid_rebuild,
999 faceted_faces_before_merge,
1000 faceted_faces_after_merge,
1001 ) = if let Some(primitive) = primitive {
1002 (
1003 primitive.solid?,
1004 primitive.backend,
1005 primitive.reason,
1006 primitive.topology_refit,
1007 None,
1008 None,
1009 None,
1010 )
1011 } else if let Some(solid) = spherical_union {
1012 (
1013 solid,
1014 ConversionBackend::KernelSphericalUnion,
1015 "precise sphere carriers and bidirectional mesh coverage verified a primitive union; rebuilt exact analytic intersections across the mesh boolean cut strips".to_owned(),
1016 None,
1017 None,
1018 None,
1019 None,
1020 )
1021 } else {
1022 let direct_cause = match &recognition_gate {
1023 Err(reason) => format!("whole-primitive recognition skipped: {reason}"),
1024 Ok(()) if !analytic_coverage => format!(
1025 "RANSAC did not prove complete analytic coverage ({} unresolved triangle(s))",
1026 recognition.unresolved_triangles.len()
1027 ),
1028 Ok(()) if !closed_for_analytics => {
1029 "recognized mesh is not a closed two-manifold".to_owned()
1030 }
1031 Ok(()) => {
1032 "recognized regions do not match a safe complete primitive topology".to_owned()
1033 }
1034 };
1035
1036 let kernel_eligible = segmentation.as_ref().is_ok_and(|segmentation| {
1037 segmentation_is_complete(segmentation)
1038 && segmentation.regions.iter().all(|region| {
1039 matches!(
1040 region.carrier,
1041 RegionCarrier::Plane { .. }
1042 | RegionCarrier::Cylinder { .. }
1043 | RegionCarrier::Cone { .. }
1044 )
1045 })
1046 });
1047 let kernel_attempt = if closed_for_analytics
1048 && kernel_eligible
1049 && options.try_kernel_analytic_rebuild
1050 {
1051 try_kernel_analytic(source_positions, source_indices, options)
1052 } else {
1053 Err("kernel analytic rebuild requires the kernel segmentation to prove complete plane/cylinder/cone coverage".to_owned())
1054 };
1055
1056 let analytic_attempt = match kernel_attempt {
1057 Ok(solid) => Ok((
1058 solid,
1059 ConversionBackend::KernelAnalyticRebuild,
1060 "kernel segmentation proved complete plane/cylinder/cone coverage; the kernel region rebuilder then reconstructed and validated the complete analytic shell".to_owned(),
1061 None,
1062 )),
1063 Err(kernel_error) => {
1064 let hybrid_attempt = if closed_for_analytics && options.try_hybrid_rebuild {
1065 try_hybrid_analytic(source_positions, source_indices, options)
1066 } else if !options.try_hybrid_rebuild {
1067 Err("mixed analytic/faceted rebuild was disabled".to_owned())
1068 } else {
1069 Err("mixed analytic/faceted rebuild requires a closed two-manifold".to_owned())
1070 };
1071 match hybrid_attempt {
1072 Ok(output) => {
1073 let stats = output.stats;
1074 whole_carrier_scan = whole_carrier_scan.sum(stats.whole_carrier_scan);
1075 if stats.split_freeform_regions
1076 + stats.split_declined_regions
1077 + stats.split_over_budget_regions
1078 + stats.split_budget_hit_regions
1079 + stats.split_band_declined_regions
1080 > 0
1081 {
1082 messages.push(format!(
1083 "freeform split: {} region(s) divided into carriers, {} tried and left as segmented, {} above the triangle budget and not tried, {} out of the hypothesis budget and left as segmented, {} declined by the normal-band pre-test",
1084 stats.split_freeform_regions,
1085 stats.split_declined_regions,
1086 stats.split_over_budget_regions,
1087 stats.split_budget_hit_regions,
1088 stats.split_band_declined_regions
1089 ));
1090 }
1091 let hybrid_report = hybrid_report(output.stats);
1092 if options.policy == ConversionPolicy::RequireFullyAnalytic
1093 && hybrid_report.faceted_faces != 0
1094 {
1095 Err(format!(
1096 "{kernel_error}; mixed rebuild retained {} faceted face(s) for {} source triangle(s)",
1097 hybrid_report.faceted_faces, hybrid_report.faceted_triangles
1098 ))
1099 } else {
1100 let reason = if hybrid_report.faceted_faces == 0 {
1101 "independent segmentation reconstructed and validated a fully analytic plane/cylinder/cone/sphere shell"
1102 } else {
1103 "independent segmentation reconstructed exact plane/cylinder/cone/sphere regions and retained unsupported or unsafe regions as facets"
1104 };
1105 Ok((
1106 output.solid,
1107 ConversionBackend::HybridAnalyticRebuild,
1108 reason.to_owned(),
1109 Some(hybrid_report),
1110 ))
1111 }
1112 }
1113 Err(hybrid_error) => Err(format!("{kernel_error}; {hybrid_error}")),
1114 }
1115 }
1116 };
1117
1118 match analytic_attempt {
1119 Ok((solid, backend, reason, hybrid)) => {
1120 (solid, backend, reason, None, hybrid, None, None)
1121 }
1122 Err(analytic_error) => {
1123 let cause = format!("{direct_cause}; {analytic_error}");
1124 if options.policy == ConversionPolicy::RequireFullyAnalytic {
1125 return Err(StlConversionError(format!(
1126 "strict analytic conversion refused faceted fallback: {cause}"
1127 )));
1128 }
1129 fallback_cause = Some(cause.clone());
1130 messages.push(format!("analytic rebuild unavailable: {cause}"));
1131 let faceted =
1132 mesh_to_faceted_brep(source_positions, source_indices, options.weld_tolerance)
1133 .map_err(|error| {
1134 StlConversionError(format!("faceted mesh repair failed: {error}"))
1135 })?;
1136 let faces_before = solid_face_count(&faceted);
1137 let merge_tolerance = coplanar_merge_tolerance(mesh);
1138 match merge_same_surface_faces(&faceted, merge_tolerance) {
1139 Ok(merged) => {
1140 let faces_after = solid_face_count(&merged);
1141 if faces_after < faces_before {
1142 messages.push(format!(
1143 "coplanar faceted merge reduced faces from {faces_before} to {faces_after} (tolerance {merge_tolerance:.3e})"
1144 ));
1145 (
1146 merged,
1147 ConversionBackend::FacetedRepairCoplanarMerged,
1148 "the complete source triangle set was repaired as a faceted BREP, then adjacent coplanar facets were merged without introducing curved replacement topology".to_owned(),
1149 None,
1150 None,
1151 Some(faces_before),
1152 Some(faces_after),
1153 )
1154 } else {
1155 messages.push(format!(
1156 "coplanar faceted merge found no mergeable faces (tolerance {merge_tolerance:.3e})"
1157 ));
1158 (
1159 faceted,
1160 ConversionBackend::FacetedRepair,
1161 "the complete source triangle set was repaired and preserved as a faceted BREP; no adjacent coplanar facets could be merged".to_owned(),
1162 None,
1163 None,
1164 Some(faces_before),
1165 Some(faces_before),
1166 )
1167 }
1168 }
1169 Err(error) => {
1170 messages.push(format!(
1171 "coplanar faceted merge was unavailable; retained the validated repaired mesh: {error}"
1172 ));
1173 (
1174 faceted,
1175 ConversionBackend::FacetedRepair,
1176 "the complete source triangle set was repaired and preserved as a faceted BREP; the optional coplanar merge was unavailable".to_owned(),
1177 None,
1178 None,
1179 Some(faces_before),
1180 Some(faces_before),
1181 )
1182 }
1183 }
1184 }
1185 }
1186 };
1187 let topology_build_seconds = topology_started.elapsed().as_secs_f64();
1188
1189 let topology_issues = solid.validate();
1190 if !topology_issues.is_empty() {
1191 return Err(StlConversionError(format!(
1192 "constructed BREP failed topology validation: {topology_issues:?}"
1193 )));
1194 }
1195
1196 let faces = solid_face_count(&solid);
1197 let detail = ComponentDetail {
1198 backend,
1199 backend_reason,
1200 fallback_cause,
1201 input_vertices,
1202 input_triangles,
1203 recognized_regions: recognition.regions.len(),
1204 recognized_triangles,
1205 unresolved_triangles: recognition.unresolved_triangles.len(),
1206 recognition_skipped: recognition_gate.err(),
1207 source_closed_manifold,
1208 analytic_used_repaired_mesh: repaired.is_some(),
1209 requested_distance_tolerance,
1210 effective_distance_tolerance,
1211 region_type_counts,
1212 regions,
1213 topology_refit,
1214 hybrid_rebuild,
1215 faceted_faces_before_merge,
1216 faceted_faces_after_merge,
1217 faces,
1218 segmentation_seconds,
1219 recognition_seconds,
1220 recognition_work,
1221 whole_carrier_scan,
1222 topology_build_seconds,
1223 messages,
1224 refusal: None,
1225 };
1226 Ok((solid, detail))
1227}
1228
1229fn aggregate_report(
1234 components: &[Vec<usize>],
1235 details: Vec<ComponentDetail>,
1236 count: usize,
1237 options: &StlConversionOptions,
1238) -> StlConversionReport {
1239 let mut messages = Vec::new();
1240 if count > 1 {
1241 messages.push(format!(
1242 "the triangle soup holds {count} vertex-connected components; each shell was reconstructed independently before cavity assembly"
1243 ));
1244 }
1245 let refused = details
1246 .iter()
1247 .enumerate()
1248 .filter_map(|(index, detail)| detail.refusal.as_ref().map(|reason| (index, reason)))
1249 .collect::<Vec<_>>();
1250 if !refused.is_empty() {
1251 messages.push(format!(
1252 "{} of {count} bodies could not be reconstructed and are NOT in the model: {}",
1253 refused.len(),
1254 refused
1255 .iter()
1256 .map(|(index, reason)| format!(
1257 "body {} (from source triangle {}): {reason}",
1258 index + 1,
1259 components[*index][0]
1260 ))
1261 .collect::<Vec<_>>()
1262 .join("; ")
1263 ));
1264 }
1265 for (index, detail) in details.iter().enumerate() {
1266 for message in &detail.messages {
1267 messages.push(if count > 1 {
1268 format!("body {}: {message}", index + 1)
1269 } else {
1270 message.clone()
1271 });
1272 }
1273 }
1274
1275 let first = details
1278 .iter()
1279 .find(|detail| detail.refusal.is_none())
1280 .unwrap_or(&details[0]);
1281 let backend_reason = if count == 1 {
1282 first.backend_reason.clone()
1283 } else if details
1284 .iter()
1285 .all(|detail| detail.backend == first.backend)
1286 {
1287 format!(
1288 "{count} components were reconstructed separately; each: {}",
1289 first.backend_reason
1290 )
1291 } else {
1292 format!(
1293 "{count} components were reconstructed separately: {}",
1294 details
1295 .iter()
1296 .enumerate()
1297 .map(|(index, detail)| format!("body {} — {}", index + 1, detail.backend_reason))
1298 .collect::<Vec<_>>()
1299 .join("; ")
1300 )
1301 };
1302 let fallback_cause = details
1303 .iter()
1304 .enumerate()
1305 .find_map(|(index, detail)| {
1306 detail.fallback_cause.as_ref().map(|cause| {
1307 if count > 1 {
1308 format!("body {}: {cause}", index + 1)
1309 } else {
1310 cause.clone()
1311 }
1312 })
1313 });
1314 let recognition_skipped = details
1315 .iter()
1316 .enumerate()
1317 .map(|(index, detail)| {
1318 detail
1319 .recognition_skipped
1320 .as_ref()
1321 .map(|reason| (index, reason))
1322 })
1323 .collect::<Option<Vec<_>>>()
1324 .map(|skipped| {
1325 if count == 1 || skipped.iter().all(|(_, reason)| *reason == skipped[0].1) {
1326 skipped[0].1.clone()
1327 } else {
1328 skipped
1329 .iter()
1330 .map(|(index, reason)| format!("body {}: {reason}", index + 1))
1331 .collect::<Vec<_>>()
1332 .join("; ")
1333 }
1334 });
1335
1336 let mut region_type_counts = BTreeMap::new();
1337 for detail in &details {
1338 for (name, count) in &detail.region_type_counts {
1339 *region_type_counts.entry(name.clone()).or_insert(0) += count;
1340 }
1341 }
1342 let hybrid_rebuild = details
1343 .iter()
1344 .any(|detail| detail.hybrid_rebuild.is_some())
1345 .then(|| {
1346 details
1347 .iter()
1348 .filter_map(|detail| detail.hybrid_rebuild)
1349 .fold(HybridConversionReport::default(), add_hybrid_reports)
1350 });
1351 let faceted_faces_before_merge = sum_optional(&details, |detail| detail.faceted_faces_before_merge);
1352 let faceted_faces_after_merge = sum_optional(&details, |detail| detail.faceted_faces_after_merge);
1353
1354 let component_reports = components
1355 .iter()
1356 .zip(&details)
1357 .enumerate()
1358 .map(|(index, (triangles, detail))| ComponentConversionReport {
1359 index,
1360 first_triangle: triangles[0],
1361 input_vertices: detail.input_vertices,
1362 input_triangles: detail.input_triangles,
1363 backend: detail.backend,
1364 backend_reason: detail.backend_reason.clone(),
1365 fallback_cause: detail.fallback_cause.clone(),
1366 recognition_skipped: detail.recognition_skipped.clone(),
1367 recognized_regions: detail.recognized_regions,
1368 recognized_triangles: detail.recognized_triangles,
1369 unresolved_triangles: detail.unresolved_triangles,
1370 source_closed_manifold: detail.source_closed_manifold,
1371 analytic_used_repaired_mesh: detail.analytic_used_repaired_mesh,
1372 hybrid_rebuild: detail.hybrid_rebuild,
1373 faceted_faces_before_merge: detail.faceted_faces_before_merge,
1374 faceted_faces_after_merge: detail.faceted_faces_after_merge,
1375 faces: detail.faces,
1376 refusal: detail.refusal.clone(),
1377 source_name: None,
1378 })
1379 .collect();
1380
1381 StlConversionReport {
1382 backend: first.backend,
1383 backend_reason,
1384 fallback_cause,
1385 input_vertices: details.iter().map(|detail| detail.input_vertices).sum(),
1386 input_triangles: details.iter().map(|detail| detail.input_triangles).sum(),
1387 recognized_regions: details.iter().map(|detail| detail.recognized_regions).sum(),
1388 recognized_triangles: details
1389 .iter()
1390 .map(|detail| detail.recognized_triangles)
1391 .sum(),
1392 unresolved_triangles: details
1393 .iter()
1394 .map(|detail| detail.unresolved_triangles)
1395 .sum(),
1396 recognition_skipped,
1397 source_closed_manifold: details.iter().all(|detail| detail.source_closed_manifold),
1398 analytic_used_repaired_mesh: details
1399 .iter()
1400 .any(|detail| detail.analytic_used_repaired_mesh),
1401 requested_distance_tolerance: first.requested_distance_tolerance,
1402 coordinate_precision_tolerance: options.coordinate_precision_tolerance,
1403 effective_distance_tolerance: first.effective_distance_tolerance,
1404 region_type_counts,
1405 regions: details
1406 .iter()
1407 .flat_map(|detail| detail.regions.iter().cloned())
1408 .collect(),
1409 topology_refit: details
1410 .iter()
1411 .find_map(|detail| detail.topology_refit.clone()),
1412 hybrid_rebuild,
1413 components: component_reports,
1414 exported_step_bytes: 0,
1415 exported_advanced_faces: 0,
1416 faceted_faces_before_merge,
1417 faceted_faces_after_merge,
1418 roundtrip_solids: 0,
1419 manifold_audit_issues: Vec::new(),
1420 timings: ConversionTimings {
1421 segmentation_seconds: details
1422 .iter()
1423 .map(|detail| detail.segmentation_seconds)
1424 .sum(),
1425 recognition_seconds: details
1426 .iter()
1427 .map(|detail| detail.recognition_seconds)
1428 .sum(),
1429 topology_build_seconds: details
1430 .iter()
1431 .map(|detail| detail.topology_build_seconds)
1432 .sum(),
1433 step_export_seconds: 0.0,
1434 step_validation_seconds: 0.0,
1435 total_seconds: 0.0,
1436 },
1437 recognition_work: details.iter().fold(RecognitionWorkReport::default(), |sum, detail| {
1438 RecognitionWorkReport {
1439 hypotheses: sum.hypotheses + detail.recognition_work.hypotheses,
1440 rounds: sum.rounds + detail.recognition_work.rounds,
1441 seeded_components: sum.seeded_components + detail.recognition_work.seeded_components,
1442 }
1443 }),
1444 whole_carrier_scan: details
1445 .iter()
1446 .fold(WholeCarrierScanReport::default(), |sum, detail| sum.sum(detail.whole_carrier_scan)),
1447 messages,
1448 }
1449}
1450
1451fn sum_optional(
1452 details: &[ComponentDetail],
1453 read: impl Fn(&ComponentDetail) -> Option<usize>,
1454) -> Option<usize> {
1455 details
1456 .iter()
1457 .any(|detail| read(detail).is_some())
1458 .then(|| details.iter().filter_map(read).sum())
1459}
1460
1461fn add_hybrid_reports(
1462 total: HybridConversionReport,
1463 body: HybridConversionReport,
1464) -> HybridConversionReport {
1465 HybridConversionReport {
1466 total_faces: total.total_faces + body.total_faces,
1467 analytic_plane_faces: total.analytic_plane_faces + body.analytic_plane_faces,
1468 analytic_plane_triangles: total.analytic_plane_triangles + body.analytic_plane_triangles,
1469 analytic_cylinder_faces: total.analytic_cylinder_faces + body.analytic_cylinder_faces,
1470 analytic_cylinder_triangles: total.analytic_cylinder_triangles
1471 + body.analytic_cylinder_triangles,
1472 analytic_cone_faces: total.analytic_cone_faces + body.analytic_cone_faces,
1473 analytic_cone_triangles: total.analytic_cone_triangles + body.analytic_cone_triangles,
1474 analytic_sphere_faces: total.analytic_sphere_faces + body.analytic_sphere_faces,
1475 analytic_sphere_triangles: total.analytic_sphere_triangles + body.analytic_sphere_triangles,
1476 analytic_torus_faces: total.analytic_torus_faces + body.analytic_torus_faces,
1477 analytic_torus_triangles: total.analytic_torus_triangles + body.analytic_torus_triangles,
1478 faceted_faces: total.faceted_faces + body.faceted_faces,
1479 faceted_triangles: total.faceted_triangles + body.faceted_triangles,
1480 demoted_regions: total.demoted_regions + body.demoted_regions,
1481 demoted_triangles: total.demoted_triangles + body.demoted_triangles,
1482 segmentation_plane_regions: total.segmentation_plane_regions
1483 + body.segmentation_plane_regions,
1484 segmentation_cylinder_regions: total.segmentation_cylinder_regions
1485 + body.segmentation_cylinder_regions,
1486 segmentation_cone_regions: total.segmentation_cone_regions + body.segmentation_cone_regions,
1487 segmentation_sphere_regions: total.segmentation_sphere_regions
1488 + body.segmentation_sphere_regions,
1489 segmentation_torus_regions: total.segmentation_torus_regions
1490 + body.segmentation_torus_regions,
1491 segmentation_unsupported_regions: total.segmentation_unsupported_regions
1492 + body.segmentation_unsupported_regions,
1493 }
1494}
1495
1496fn triangle_components(mesh: &Mesh) -> Vec<Vec<usize>> {
1511 fn find(parent: &mut [usize], mut node: usize) -> usize {
1512 while parent[node] != node {
1513 parent[node] = parent[parent[node]];
1514 node = parent[node];
1515 }
1516 node
1517 }
1518
1519 let bits = |value: f64| if value == 0.0 { 0_u64 } else { value.to_bits() };
1520 let mut position_ids = HashMap::<[u64; 3], usize>::new();
1521 let mut vertex_position = Vec::with_capacity(mesh.vertices.len());
1522 for point in &mesh.vertices {
1523 let key = [bits(point.x), bits(point.y), bits(point.z)];
1524 let next = position_ids.len();
1525 vertex_position.push(*position_ids.entry(key).or_insert(next));
1526 }
1527
1528 let mut parent = (0..position_ids.len()).collect::<Vec<_>>();
1529 for triangle in &mesh.triangles {
1530 let first = vertex_position[triangle[0] as usize];
1531 for &corner in &triangle[1..] {
1532 let (left, right) = (
1533 find(&mut parent, first),
1534 find(&mut parent, vertex_position[corner as usize]),
1535 );
1536 if left != right {
1537 parent[left] = right;
1538 }
1539 }
1540 }
1541
1542 let mut slots = HashMap::<usize, usize>::new();
1543 let mut components = Vec::<Vec<usize>>::new();
1544 for (index, triangle) in mesh.triangles.iter().enumerate() {
1545 let root = find(&mut parent, vertex_position[triangle[0] as usize]);
1546 let slot = match slots.get(&root) {
1547 Some(&slot) => slot,
1548 None => {
1549 components.push(Vec::new());
1550 slots.insert(root, components.len() - 1);
1551 components.len() - 1
1552 }
1553 };
1554 components[slot].push(index);
1555 }
1556 components
1557}
1558
1559struct ComponentMesh {
1561 mesh: Mesh,
1562 positions: Vec<f64>,
1563 indices: Option<Vec<u32>>,
1564}
1565
1566fn component_mesh(
1571 mesh: &Mesh,
1572 positions: &[f64],
1573 indices: Option<&[u32]>,
1574 triangles: &[usize],
1575) -> ComponentMesh {
1576 let mut vertex_map = HashMap::<u32, u32>::new();
1577 let mut vertices = Vec::new();
1578 let mut component_triangles = Vec::with_capacity(triangles.len());
1579 for &triangle in triangles {
1580 let mut corners = [0_u32; 3];
1581 for (corner, &index) in mesh.triangles[triangle].iter().enumerate() {
1582 corners[corner] = *vertex_map.entry(index).or_insert_with(|| {
1583 vertices.push(mesh.vertices[index as usize]);
1584 vertices.len() as u32 - 1
1585 });
1586 }
1587 component_triangles.push(corners);
1588 }
1589 let mut component = Mesh::new(vertices, component_triangles);
1590 if let Some(normals) = &mesh.vertex_normals {
1591 if normals.len() == mesh.vertices.len() {
1592 let mut component_normals = vec![Vec3::default(); component.vertices.len()];
1593 for (&source, &target) in &vertex_map {
1594 component_normals[target as usize] = normals[source as usize];
1595 }
1596 component.vertex_normals = Some(component_normals);
1597 }
1598 }
1599 if !mesh.source_metadata.is_empty() {
1600 let local = triangles
1601 .iter()
1602 .enumerate()
1603 .map(|(local, &source)| (source, local))
1604 .collect::<HashMap<_, _>>();
1605 component.source_metadata = mesh
1606 .source_metadata
1607 .iter()
1608 .filter_map(|metadata| {
1609 let triangle_indices = metadata
1610 .triangle_indices
1611 .iter()
1612 .filter_map(|source| local.get(source).copied())
1613 .collect::<Vec<_>>();
1614 (!triangle_indices.is_empty()).then(|| crate::SourceMetadata {
1615 triangle_indices,
1616 ..metadata.clone()
1617 })
1618 })
1619 .collect();
1620 }
1621
1622 let (component_positions, component_indices) = match indices {
1623 Some(indices) => {
1624 let mut source_map = HashMap::<u32, u32>::new();
1625 let mut component_positions = Vec::new();
1626 let mut component_indices = Vec::with_capacity(triangles.len() * 3);
1627 for &triangle in triangles {
1628 for &index in &indices[triangle * 3..triangle * 3 + 3] {
1629 let mapped = *source_map.entry(index).or_insert_with(|| {
1630 let base = index as usize * 3;
1631 component_positions.extend_from_slice(&positions[base..base + 3]);
1632 component_positions.len() as u32 / 3 - 1
1633 });
1634 component_indices.push(mapped);
1635 }
1636 }
1637 (component_positions, Some(component_indices))
1638 }
1639 None => {
1640 let mut component_positions = Vec::with_capacity(triangles.len() * 9);
1641 for &triangle in triangles {
1642 component_positions.extend_from_slice(&positions[triangle * 9..triangle * 9 + 9]);
1643 }
1644 (component_positions, None)
1645 }
1646 };
1647 ComponentMesh {
1648 mesh: component,
1649 positions: component_positions,
1650 indices: component_indices,
1651 }
1652}
1653
1654fn validate_source_buffers(
1655 mesh: &Mesh,
1656 positions: &[f64],
1657 indices: Option<&[u32]>,
1658) -> Result<(), StlConversionError> {
1659 if positions.is_empty() || !positions.len().is_multiple_of(3) {
1660 return Err(StlConversionError(
1661 "source position buffer must contain complete non-empty xyz triples".to_owned(),
1662 ));
1663 }
1664 if positions.iter().any(|value| !value.is_finite()) {
1665 return Err(StlConversionError(
1666 "source position buffer contains a non-finite coordinate".to_owned(),
1667 ));
1668 }
1669 let source_triangles = match indices {
1670 Some(indices) => {
1671 if !indices.len().is_multiple_of(3) {
1672 return Err(StlConversionError(
1673 "source index buffer must contain complete triangles".to_owned(),
1674 ));
1675 }
1676 if indices
1677 .iter()
1678 .any(|&index| index as usize >= positions.len() / 3)
1679 {
1680 return Err(StlConversionError(
1681 "source index buffer references a missing position".to_owned(),
1682 ));
1683 }
1684 indices.len() / 3
1685 }
1686 None => {
1687 if !positions.len().is_multiple_of(9) {
1688 return Err(StlConversionError(
1689 "unindexed source positions must be a triangle soup".to_owned(),
1690 ));
1691 }
1692 positions.len() / 9
1693 }
1694 };
1695 if source_triangles != mesh.triangles.len() {
1696 return Err(StlConversionError(format!(
1697 "source buffers contain {source_triangles} triangles but recognition mesh contains {}",
1698 mesh.triangles.len()
1699 )));
1700 }
1701 Ok(())
1702}
1703
1704fn complete_small_planar_regions(
1709 mesh: &Mesh,
1710 options: &RecognitionOptions,
1711 regions: &mut Vec<SurfaceRegion>,
1712 unresolved: &mut Vec<usize>,
1713) -> Result<usize, StlConversionError> {
1714 if unresolved.len() < 2 {
1715 return Ok(0);
1716 }
1717 let analyzed = mesh
1718 .analyze(&MeshAnalysisOptions {
1719 feature_angle: options.feature_angle,
1720 ..MeshAnalysisOptions::default()
1721 })
1722 .map_err(|error| {
1723 StlConversionError(format!("plane completion analysis failed: {error}"))
1724 })?;
1725 let allowed = unresolved.iter().copied().collect::<BTreeSet<_>>();
1726 let mut visited = BTreeSet::new();
1727 let mut accepted = BTreeSet::new();
1728 let mut completed = 0;
1729 let mut plane_options = options.clone();
1730 plane_options.minimum_support = 2;
1731
1732 for &seed in unresolved.iter() {
1733 if !visited.insert(seed) {
1734 continue;
1735 }
1736 let mut component = Vec::new();
1737 let mut queue = VecDeque::from([seed]);
1738 while let Some(triangle) = queue.pop_front() {
1739 component.push(triangle);
1740 let data = &analyzed.triangles[triangle];
1741 for edge in 0..3 {
1742 if data.feature_edges[edge] {
1743 continue;
1744 }
1745 if let Some(neighbor) = data.neighbors[edge] {
1746 if allowed.contains(&neighbor) && visited.insert(neighbor) {
1747 queue.push_back(neighbor);
1748 }
1749 }
1750 }
1751 }
1752 component.sort_unstable();
1753 if component.len() < 2 {
1754 continue;
1755 }
1756 let Ok(fit) = reconstruct_surface(
1757 mesh,
1758 &component,
1759 &SurfaceHint::KnownType {
1760 surface_type: SurfaceType::Plane,
1761 },
1762 &plane_options,
1763 ) else {
1764 continue;
1765 };
1766 if fit.metrics.support_triangles != component.len()
1767 || !matches!(fit.surface, AnalyticSurface::Plane(_))
1768 {
1769 continue;
1770 }
1771 accepted.extend(component.iter().copied());
1772 regions.push(SurfaceRegion {
1773 surface: fit.surface,
1774 orientation: fit.orientation,
1775 triangle_indices: component,
1776 metrics: fit.metrics,
1777 confidence: fit.confidence,
1778 diagnostics: fit.diagnostics,
1779 });
1780 completed += 1;
1781 }
1782 unresolved.retain(|triangle| !accepted.contains(triangle));
1783 Ok(completed)
1784}
1785
1786fn region_report(region: &SurfaceRegion) -> ConversionRegionReport {
1787 fit_fields(
1788 region.surface,
1789 region.orientation,
1790 ®ion.metrics,
1791 region.confidence,
1792 ®ion.diagnostics,
1793 )
1794}
1795
1796fn refit_report(fit: &SurfaceFitResult) -> ConversionRegionReport {
1797 fit_fields(
1798 fit.surface,
1799 fit.orientation,
1800 &fit.metrics,
1801 fit.confidence,
1802 &fit.diagnostics,
1803 )
1804}
1805
1806fn fit_fields(
1807 surface: AnalyticSurface,
1808 orientation: i8,
1809 metrics: &crate::FitMetrics,
1810 confidence: f64,
1811 diagnostics: &crate::FitDiagnostics,
1812) -> ConversionRegionReport {
1813 ConversionRegionReport {
1814 surface_type: surface.surface_type(),
1815 surface,
1816 support_triangles: metrics.support_triangles,
1817 supported_area: metrics.supported_area,
1818 rms_error: metrics.rms_error,
1819 max_error: metrics.max_error,
1820 rms_normal_error_radians: metrics.rms_normal_error,
1821 max_normal_error_radians: metrics.max_normal_error,
1822 confidence,
1823 orientation,
1824 reason: diagnostics.reason.clone(),
1825 phase_timings: diagnostics.phase_timings,
1826 }
1827}
1828
1829fn disjoint_complete_partition(regions: &[SurfaceRegion], triangle_count: usize) -> bool {
1830 let mut seen = BTreeSet::new();
1831 regions.iter().all(|region| {
1832 region
1833 .triangle_indices
1834 .iter()
1835 .all(|&triangle| triangle < triangle_count && seen.insert(triangle))
1836 }) && seen.len() == triangle_count
1837}
1838
1839struct RepairedMesh {
1842 mesh: Mesh,
1843 positions: Vec<f64>,
1844 indices: Vec<u32>,
1845 message: String,
1846}
1847
1848fn repaired_closed_mesh(
1853 source_positions: &[f64],
1854 source_indices: Option<&[u32]>,
1855 options: &StlConversionOptions,
1856) -> Option<RepairedMesh> {
1857 let owned_indices;
1861 let stitched_indices = match source_indices {
1862 Some(indices) => indices,
1863 None => {
1864 owned_indices = (0..source_positions.len() as u32 / 3).collect::<Vec<_>>();
1865 &owned_indices
1866 }
1867 };
1868 let stitch_tolerance = if options.weld_tolerance > 0.0 {
1869 options.weld_tolerance
1870 } else {
1871 mesh_diagonal_of(source_positions) * crate::stl::DEFAULT_RELATIVE_WELD_TOLERANCE
1872 };
1873 let (stitched, stitch) =
1874 t_junctions::stitch_t_junctions(source_positions, stitched_indices, stitch_tolerance);
1875 let (positions, indices, report) =
1876 repair_triangle_soup(source_positions, Some(&stitched), options.weld_tolerance).ok()?;
1877 let vertices = positions
1878 .chunks_exact(3)
1879 .map(|point| Vec3::new(point[0], point[1], point[2]))
1880 .collect::<Vec<_>>();
1881 let triangles = indices
1882 .chunks_exact(3)
1883 .map(|triangle| [triangle[0], triangle[1], triangle[2]])
1884 .collect::<Vec<_>>();
1885 let mesh = Mesh::new(vertices, triangles);
1886 if !closed_manifold(&mesh) {
1887 return None;
1888 }
1889 let message = format!(
1890 "the source mesh is not a closed two-manifold; the analytic lanes ran on the repaired mesh \
1891 ({}{} welded vertices, {} triangles: {} dropped as degenerate or duplicate, {} surplus \
1892 sheet(s) pruned, {} reoriented to agree with their neighbours, {} boundary loop(s) \
1893 capped with {} triangle(s))",
1894 if stitch.stitched_edges > 0 {
1895 format!(
1896 "{} T-junction edge(s) split at a vertex on their interior, {} triangle(s) added; ",
1897 stitch.stitched_edges, stitch.added_triangles
1898 )
1899 } else {
1900 String::new()
1901 },
1902 report.welded_vertices,
1903 report.triangles,
1904 report.dropped_triangles,
1905 report.pruned_triangles,
1906 report.reoriented_triangles,
1907 report.capped_loops,
1908 report.cap_triangles,
1909 );
1910 Some(RepairedMesh {
1911 mesh,
1912 positions,
1913 indices,
1914 message,
1915 })
1916}
1917
1918fn mesh_diagonal_of(positions: &[f64]) -> f64 {
1920 let mut low = [f64::INFINITY; 3];
1921 let mut high = [f64::NEG_INFINITY; 3];
1922 for point in positions.chunks_exact(3) {
1923 for axis in 0..3 {
1924 low[axis] = low[axis].min(point[axis]);
1925 high[axis] = high[axis].max(point[axis]);
1926 }
1927 }
1928 (0..3).map(|axis| (high[axis] - low[axis]).powi(2)).sum::<f64>().sqrt()
1929}
1930
1931fn closed_manifold(mesh: &Mesh) -> bool {
1932 closed_triangles(mesh.triangles.iter().copied())
1933}
1934
1935fn closed_triangles(triangles: impl Iterator<Item = [u32; 3]>) -> bool {
1936 let mut uses = BTreeMap::<(u32, u32), (usize, i32)>::new();
1937 for triangle in triangles {
1938 if triangle[0] == triangle[1] || triangle[1] == triangle[2] || triangle[2] == triangle[0] {
1939 return false;
1940 }
1941 for edge in [
1942 (triangle[0], triangle[1]),
1943 (triangle[1], triangle[2]),
1944 (triangle[2], triangle[0]),
1945 ] {
1946 let key = if edge.0 < edge.1 {
1947 (edge.0, edge.1)
1948 } else {
1949 (edge.1, edge.0)
1950 };
1951 let entry = uses.entry(key).or_default();
1952 entry.0 += 1;
1953 entry.1 += if edge == key { 1 } else { -1 };
1954 }
1955 }
1956 !uses.is_empty()
1957 && uses
1958 .values()
1959 .all(|&(count, sense)| count == 2 && sense == 0)
1960}
1961
1962struct DirectAnalyticBuild {
1963 solid: Result<brep_kernel::BrepSolid, StlConversionError>,
1964 backend: ConversionBackend,
1965 reason: String,
1966 topology_refit: Option<ConversionRegionReport>,
1967}
1968
1969fn direct_analytic_solid(
1970 mesh: &Mesh,
1971 regions: &[SurfaceRegion],
1972 options: &RecognitionOptions,
1973) -> Option<DirectAnalyticBuild> {
1974 if regions.len() == 1 {
1975 return match regions[0].surface {
1976 AnalyticSurface::Sphere(sphere) => Some(DirectAnalyticBuild {
1977 solid: make_sphere_brep(
1978 kernel_vec(sphere.center),
1979 sphere.radius,
1980 kernel_vec(Vec3::new(0.0, 0.0, 1.0)),
1981 )
1982 .map_err(StlConversionError),
1983 backend: ConversionBackend::RansacSphere,
1984 reason: "RANSAC proved complete closed spherical coverage; exported an exact analytic sphere".to_owned(),
1985 topology_refit: None,
1986 }),
1987 AnalyticSurface::Torus(torus) if torus.major_radius > torus.minor_radius => Some(DirectAnalyticBuild {
1988 solid: make_torus_brep(
1989 kernel_vec(torus.center),
1990 kernel_vec(torus.axis),
1991 torus.major_radius,
1992 torus.minor_radius,
1993 )
1994 .map_err(StlConversionError),
1995 backend: ConversionBackend::RansacTorus,
1996 reason: "RANSAC proved complete closed ring-torus coverage; exported an exact analytic torus".to_owned(),
1997 topology_refit: None,
1998 }),
1999 _ => None,
2000 };
2001 }
2002
2003 let curved = regions
2004 .iter()
2005 .filter(|region| !matches!(region.surface, AnalyticSurface::Plane(_)))
2006 .collect::<Vec<_>>();
2007 let planes = regions
2008 .iter()
2009 .filter_map(|region| match region.surface {
2010 AnalyticSurface::Plane(plane) => Some(plane),
2011 _ => None,
2012 })
2013 .collect::<Vec<_>>();
2014 if curved.len() != 1 {
2015 return None;
2016 }
2017 let topology_refit;
2023 let mut topology_refit_report = None;
2024 let curved = if let AnalyticSurface::Torus(torus) = curved[0].surface {
2025 let scale = mesh_diagonal(mesh);
2026 let collapsed = torus.major_radius.min(torus.minor_radius) > 20.0 * scale;
2027 let forced_type = match planes.len() {
2028 2 if collapsed => Some(SurfaceType::Cylinder),
2029 1 if collapsed => Some(SurfaceType::Cone),
2030 _ => None,
2031 };
2032 if let Some(surface_type) = forced_type {
2033 topology_refit = reconstruct_surface(
2034 mesh,
2035 &curved[0].triangle_indices,
2036 &SurfaceHint::KnownType { surface_type },
2037 options,
2038 )
2039 .ok()?;
2040 topology_refit_report = Some(refit_report(&topology_refit));
2041 &topology_refit.surface
2042 } else {
2043 &curved[0].surface
2044 }
2045 } else {
2046 &curved[0].surface
2047 };
2048 match *curved {
2049 AnalyticSurface::Cylinder(cylinder) if planes.len() == 2 => {
2050 let (low, high) = axial_range(mesh, cylinder.axis_origin, cylinder.axis)?;
2051 let tolerance = conversion_tolerance(mesh, options);
2052 if high - low <= tolerance
2053 || !caps_match(
2054 &planes,
2055 cylinder.axis_origin,
2056 cylinder.axis,
2057 &[low, high],
2058 tolerance,
2059 )
2060 {
2061 return None;
2062 }
2063 let base = cylinder.axis_origin + cylinder.axis * low;
2064 Some(DirectAnalyticBuild {
2065 solid: make_cylinder_brep(
2066 kernel_vec(base),
2067 kernel_vec(cylinder.axis),
2068 cylinder.radius,
2069 high - low,
2070 )
2071 .map_err(StlConversionError),
2072 backend: ConversionBackend::RansacCappedCylinder,
2073 reason: if topology_refit_report.is_some() {
2074 "two-cap topology disambiguated a large-radius torus limit; the production known-type cylinder refit passed all gates and supplied the exact capped-cylinder parameters".to_owned()
2075 } else {
2076 "RANSAC proved one cylindrical wall plus two matching planar caps; exported an exact capped cylinder".to_owned()
2077 },
2078 topology_refit: topology_refit_report,
2079 })
2080 }
2081 AnalyticSurface::Cone(cone) if planes.len() == 1 || planes.len() == 2 => {
2082 let (low, high) = axial_range(mesh, cone.apex, cone.axis)?;
2083 let tolerance = conversion_tolerance(mesh, options);
2084 if low < -tolerance || high - low <= tolerance {
2085 return None;
2086 }
2087 let expected_caps = if low <= tolerance {
2088 vec![high]
2089 } else {
2090 vec![low, high]
2091 };
2092 if planes.len() != expected_caps.len()
2093 || !caps_match(&planes, cone.apex, cone.axis, &expected_caps, tolerance)
2094 {
2095 return None;
2096 }
2097 let radius_bottom = low.max(0.0) * cone.half_angle.tan();
2098 let radius_top = high * cone.half_angle.tan();
2099 if radius_top <= tolerance || (low > tolerance && radius_bottom <= tolerance) {
2100 return None;
2101 }
2102 let (base, build_axis, build_bottom, build_top) = if low <= tolerance {
2103 (
2107 cone.apex + cone.axis * high,
2108 cone.axis * -1.0,
2109 radius_top,
2110 0.0,
2111 )
2112 } else {
2113 (
2114 cone.apex + cone.axis * low,
2115 cone.axis,
2116 radius_bottom,
2117 radius_top,
2118 )
2119 };
2120 Some(DirectAnalyticBuild {
2121 solid: make_cone_brep(
2122 kernel_vec(base),
2123 kernel_vec(build_axis),
2124 build_bottom,
2125 build_top,
2126 high - low.max(0.0),
2127 )
2128 .map_err(StlConversionError),
2129 backend: ConversionBackend::RansacCappedCone,
2130 reason: if topology_refit_report.is_some() {
2131 "cone-cap topology disambiguated a large-radius torus limit; the production known-type cone refit passed all gates and supplied the exact capped-cone parameters".to_owned()
2132 } else {
2133 "RANSAC proved one conical wall and matching planar cap topology; exported an exact capped cone".to_owned()
2134 },
2135 topology_refit: topology_refit_report,
2136 })
2137 }
2138 _ => None,
2139 }
2140}
2141
2142fn axial_range(mesh: &Mesh, origin: Vec3, axis: Vec3) -> Option<(f64, f64)> {
2143 let mut low = f64::INFINITY;
2144 let mut high = f64::NEG_INFINITY;
2145 for &point in &mesh.vertices {
2146 let height = (point - origin).dot(axis);
2147 low = low.min(height);
2148 high = high.max(height);
2149 }
2150 (low.is_finite() && high.is_finite()).then_some((low, high))
2151}
2152
2153fn caps_match(
2154 planes: &[crate::PlaneSurface],
2155 origin: Vec3,
2156 axis: Vec3,
2157 expected_heights: &[f64],
2158 tolerance: f64,
2159) -> bool {
2160 let mut actual = Vec::with_capacity(planes.len());
2161 for plane in planes {
2162 if plane.normal.dot(axis).abs() < 1.0 - 1.0e-6 {
2163 return false;
2164 }
2165 actual.push((plane.origin - origin).dot(axis));
2166 }
2167 actual.sort_by(f64::total_cmp);
2168 let mut expected = expected_heights.to_vec();
2169 expected.sort_by(f64::total_cmp);
2170 actual.len() == expected.len()
2171 && actual
2172 .iter()
2173 .zip(expected)
2174 .all(|(actual, expected)| (*actual - expected).abs() <= tolerance)
2175}
2176
2177fn mesh_tolerance(mesh: &Mesh) -> f64 {
2178 let mut low = mesh.vertices[0];
2179 let mut high = mesh.vertices[0];
2180 for &point in &mesh.vertices[1..] {
2181 low.x = low.x.min(point.x);
2182 low.y = low.y.min(point.y);
2183 low.z = low.z.min(point.z);
2184 high.x = high.x.max(point.x);
2185 high.y = high.y.max(point.y);
2186 high.z = high.z.max(point.z);
2187 }
2188 (high - low).length().max(1.0) * 1.0e-5
2189}
2190
2191fn conversion_tolerance(mesh: &Mesh, options: &RecognitionOptions) -> f64 {
2192 mesh_tolerance(mesh)
2193 .max(options.distance_tolerance + options.relative_tolerance * mesh_diagonal(mesh))
2194}
2195
2196fn mesh_diagonal(mesh: &Mesh) -> f64 {
2197 let mut low = mesh.vertices[0];
2198 let mut high = mesh.vertices[0];
2199 for &point in &mesh.vertices[1..] {
2200 low.x = low.x.min(point.x);
2201 low.y = low.y.min(point.y);
2202 low.z = low.z.min(point.z);
2203 high.x = high.x.max(point.x);
2204 high.y = high.y.max(point.y);
2205 high.z = high.z.max(point.z);
2206 }
2207 (high - low).length()
2208}
2209
2210fn coplanar_merge_tolerance(mesh: &Mesh) -> f64 {
2211 (mesh_diagonal(mesh) * 1.0e-9).max(1.0e-12)
2212}
2213
2214fn solid_face_count(solid: &brep_kernel::BrepSolid) -> usize {
2215 solid.shells.iter().map(|shell| shell.faces.len()).sum()
2216}
2217
2218const MAX_WHOLE_PRIMITIVE_REGIONS: usize = 3;
2222
2223fn segmentation_options(positions: &[f64], options: &StlConversionOptions) -> SegmentOptions {
2224 SegmentOptions {
2225 deflection_angle_deg: options.kernel_deflection_angle_degrees,
2226 fit_tolerance: options.kernel_fit_tolerance,
2227 normal_tolerance_deg: options.kernel_normal_tolerance_degrees,
2228 weld_tolerance: if options.weld_tolerance > 0.0 {
2233 options.weld_tolerance
2234 } else {
2235 let mut low = [f64::INFINITY; 3];
2236 let mut high = [f64::NEG_INFINITY; 3];
2237 for point in positions.chunks_exact(3) {
2238 for axis in 0..3 {
2239 low[axis] = low[axis].min(point[axis]);
2240 high[axis] = high[axis].max(point[axis]);
2241 }
2242 }
2243 let diagonal = (0..3)
2244 .map(|axis| (high[axis] - low[axis]).powi(2))
2245 .sum::<f64>()
2246 .sqrt();
2247 (diagonal * crate::stl::DEFAULT_RELATIVE_WELD_TOLERANCE).max(f64::MIN_POSITIVE)
2248 },
2249 ..SegmentOptions::default()
2250 }
2251}
2252
2253fn segment_carrier_enabled() -> bool {
2258 std::env::var("BREP_RECOG_SEGMENT_CARRIER").map_or(true, |v| v != "0")
2259}
2260
2261fn segmentation_candidates(mesh: &Mesh, segmentation: &MeshSegmentation) -> Vec<ComponentCandidate> {
2276 if segmentation.triangle_count != mesh.triangles.len()
2277 || segmentation.triangle_region_ids.len() != mesh.triangles.len()
2278 {
2279 return Vec::new();
2280 }
2281 let mut members = vec![Vec::new(); segmentation.regions.len()];
2282 for (triangle, ®ion) in segmentation.triangle_region_ids.iter().enumerate() {
2283 if let Some(list) = members.get_mut(region as usize) {
2284 list.push(triangle);
2285 }
2286 }
2287 segmentation
2288 .regions
2289 .iter()
2290 .zip(members)
2291 .filter_map(|(region, triangles)| {
2292 let (surface, parameters) = carrier_surface(®ion.carrier)?;
2293 if triangles.is_empty() || !surface.is_valid() {
2294 return None;
2295 }
2296 let indices: Vec<u32> = triangles.iter().flat_map(|&t| mesh.triangles[t]).collect();
2297 let (statistic, threshold) = crate::hybrid_whole_freeform::carrier_local_scan(
2298 &indices,
2299 |v| surface.signed_distance(mesh.vertices[v as usize]),
2300 parameters,
2301 );
2302 if std::env::var_os("BREP_DEBUG_SPLIT").is_some() {
2303 eprintln!(
2304 "[candidate] region {} {} tris {} local {statistic:.2} threshold {threshold:.2} {}",
2305 region.id,
2306 region.carrier.kind(),
2307 triangles.len(),
2308 if statistic <= threshold { "offered" } else { "declined" }
2309 );
2310 }
2311 (statistic <= threshold).then_some(ComponentCandidate { triangle_indices: triangles, surface })
2312 })
2313 .collect()
2314}
2315
2316fn whole_fits_pass_the_local_scan(
2331 mesh: &Mesh,
2332 regions: &[SurfaceRegion],
2333 requested: f64,
2334 tally: &mut WholeCarrierScanReport,
2335) -> bool {
2336 if !crate::hybrid_whole_freeform::whole_carrier_scan_enabled() {
2337 return true;
2338 }
2339 let mut passes = true;
2340 for region in regions.iter().filter(|region| region.metrics.max_error > requested) {
2341 let surface = ®ion.surface;
2342 let indices: Vec<u32> = region
2343 .triangle_indices
2344 .iter()
2345 .flat_map(|&t| mesh.triangles[t])
2346 .collect();
2347 let reading = crate::hybrid_whole_freeform::carrier_scan_verdict(
2348 &indices,
2349 |v| surface.signed_distance(mesh.vertices[v as usize]),
2350 analytic_parameters(surface),
2351 );
2352 if std::env::var_os("BREP_DEBUG_SPLIT").is_some() {
2353 eprintln!(
2354 "[whole-fit] {} tris {} local {:.2} threshold {:.2} independence {:.3} bound {:.3} edges {} {:?}",
2355 surface.surface_type().name(),
2356 region.triangle_indices.len(),
2357 reading.statistic,
2358 reading.threshold,
2359 reading.independence,
2360 reading.bound,
2361 reading.outside_edges,
2362 reading.verdict
2363 );
2364 }
2365 tally.add(reading.verdict);
2366 if reading.verdict == crate::hybrid_whole_freeform::ScanVerdict::Declined {
2367 passes = false;
2368 break;
2369 }
2370 }
2371 passes
2372}
2373
2374fn analytic_parameters(surface: &AnalyticSurface) -> usize {
2376 match surface {
2377 AnalyticSurface::Plane(_) => 3,
2378 AnalyticSurface::Cylinder(_) => 5,
2379 AnalyticSurface::Cone(_) => 6,
2380 AnalyticSurface::Sphere(_) => 4,
2381 AnalyticSurface::Torus(_) => 7,
2382 }
2383}
2384
2385fn carrier_surface(carrier: &RegionCarrier) -> Option<(AnalyticSurface, usize)> {
2387 let v = |p: brep_kernel::Vec3| crate::brep::vec3_from_kernel(p);
2388 Some(match *carrier {
2389 RegionCarrier::Plane { origin, normal } => {
2390 (AnalyticSurface::Plane(PlaneSurface { origin: v(origin), normal: v(normal) }), 3)
2391 }
2392 RegionCarrier::Cylinder { axis_point, axis_dir, radius, .. } => (
2393 AnalyticSurface::Cylinder(CylinderSurface { axis_origin: v(axis_point), axis: v(axis_dir), radius }),
2394 5,
2395 ),
2396 RegionCarrier::Cone { apex, axis_dir, half_angle_rad, .. } => (
2397 AnalyticSurface::Cone(ConeSurface { apex: v(apex), axis: v(axis_dir), half_angle: half_angle_rad }),
2398 6,
2399 ),
2400 RegionCarrier::Sphere { center, radius, .. } => {
2401 (AnalyticSurface::Sphere(SphereSurface { center: v(center), radius }), 4)
2402 }
2403 RegionCarrier::Torus { center, axis_dir, major_radius, minor_radius, .. } => (
2404 AnalyticSurface::Torus(TorusSurface { center: v(center), axis: v(axis_dir), major_radius, minor_radius }),
2405 7,
2406 ),
2407 RegionCarrier::Freeform => return None,
2408 })
2409}
2410
2411fn gate_segmentation(
2412 positions: &[f64],
2413 indices: Option<&[u32]>,
2414 options: &StlConversionOptions,
2415) -> Result<MeshSegmentation, String> {
2416 let owned_indices;
2417 let indices = match indices {
2418 Some(indices) => indices,
2419 None => {
2420 owned_indices = (0..positions.len() as u32 / 3).collect::<Vec<_>>();
2421 &owned_indices
2422 }
2423 };
2424 segment_mesh_faces(positions, indices, &segmentation_options(positions, options))
2425 .map_err(|error| format!("kernel segmentation failed: {error}"))
2426}
2427
2428fn segmentation_is_complete(segmentation: &MeshSegmentation) -> bool {
2430 !segmentation
2431 .triangle_region_ids
2432 .contains(&UNASSIGNED_REGION)
2433 && segmentation
2434 .regions
2435 .iter()
2436 .all(|region| !matches!(region.carrier, RegionCarrier::Freeform))
2437}
2438
2439fn recognition_gate(segmentation: &Result<MeshSegmentation, String>) -> Result<(), String> {
2448 let segmentation = segmentation.as_ref().map_err(Clone::clone)?;
2449 let unassigned = segmentation
2450 .triangle_region_ids
2451 .iter()
2452 .filter(|id| **id == UNASSIGNED_REGION)
2453 .count();
2454 let unsupported = segmentation
2455 .regions
2456 .iter()
2457 .filter(|region| matches!(region.carrier, RegionCarrier::Freeform))
2458 .count();
2459 if unassigned > 0 || unsupported > 0 {
2460 return Err(format!(
2461 "kernel segmentation left {unassigned} triangle(s) unassigned and {unsupported} region(s) without an analytic carrier, so no whole-primitive lane can fire"
2462 ));
2463 }
2464 if segmentation.regions.len() > MAX_WHOLE_PRIMITIVE_REGIONS {
2465 return Err(format!(
2466 "kernel segmentation found {} analytic regions; a whole primitive has at most {MAX_WHOLE_PRIMITIVE_REGIONS}",
2467 segmentation.regions.len()
2468 ));
2469 }
2470 Ok(())
2471}
2472
2473fn try_kernel_analytic(
2474 positions: &[f64],
2475 indices: Option<&[u32]>,
2476 options: &StlConversionOptions,
2477) -> Result<brep_kernel::BrepSolid, String> {
2478 let owned_indices;
2479 let indices = match indices {
2480 Some(indices) => indices,
2481 None => {
2482 owned_indices = (0..positions.len() as u32 / 3).collect::<Vec<_>>();
2483 &owned_indices
2484 }
2485 };
2486 let segmentation_options = segmentation_options(positions, options);
2487 let segmentation = segment_mesh_faces(positions, indices, &segmentation_options)?;
2488 if segmentation
2489 .triangle_region_ids
2490 .contains(&UNASSIGNED_REGION)
2491 {
2492 return Err("independent kernel segmentation left triangles unassigned".to_owned());
2493 }
2494 if let Some(region) = segmentation.regions.iter().find(|region| {
2495 matches!(
2496 region.carrier,
2497 RegionCarrier::Freeform | RegionCarrier::Sphere { .. } | RegionCarrier::Torus { .. }
2498 )
2499 }) {
2500 return Err(format!(
2501 "independent kernel segmentation produced unsupported {} region {}",
2502 region.carrier.kind(),
2503 region.id
2504 ));
2505 }
2506 mesh_regions_to_brep(positions, indices, &segmentation_options)
2507}
2508
2509fn try_hybrid_analytic(
2510 positions: &[f64],
2511 indices: Option<&[u32]>,
2512 options: &StlConversionOptions,
2513) -> Result<crate::hybrid_region_brep::HybridBrepOutput, String> {
2514 crate::hybrid_region_brep::hybrid_plane_cylinder_brep(
2515 positions,
2516 indices,
2517 &segmentation_options(positions, options),
2518 )
2519}
2520
2521fn hybrid_report(stats: crate::hybrid_region_brep::HybridBrepStats) -> HybridConversionReport {
2522 HybridConversionReport {
2523 total_faces: stats.total_faces,
2524 analytic_plane_faces: stats.analytic_plane_faces,
2525 analytic_plane_triangles: stats.analytic_plane_triangles,
2526 analytic_cylinder_faces: stats.analytic_cylinder_faces,
2527 analytic_cylinder_triangles: stats.analytic_cylinder_triangles,
2528 analytic_cone_faces: stats.analytic_cone_faces,
2529 analytic_cone_triangles: stats.analytic_cone_triangles,
2530 analytic_sphere_faces: stats.analytic_sphere_faces,
2531 analytic_sphere_triangles: stats.analytic_sphere_triangles,
2532 analytic_torus_faces: stats.analytic_torus_faces,
2533 analytic_torus_triangles: stats.analytic_torus_triangles,
2534 faceted_faces: stats.faceted_faces,
2535 faceted_triangles: stats.faceted_triangles,
2536 demoted_regions: stats.demoted_regions,
2537 demoted_triangles: stats.demoted_triangles,
2538 segmentation_plane_regions: stats.segmentation_plane_regions,
2539 segmentation_cylinder_regions: stats.segmentation_cylinder_regions,
2540 segmentation_cone_regions: stats.segmentation_cone_regions,
2541 segmentation_sphere_regions: stats.segmentation_sphere_regions,
2542 segmentation_torus_regions: stats.segmentation_torus_regions,
2543 segmentation_unsupported_regions: stats.segmentation_unsupported_regions,
2544 }
2545}
2546
2547fn kernel_vec(value: Vec3) -> brep_kernel::Vec3 {
2548 brep_kernel::Vec3::new(value.x, value.y, value.z)
2549}
2550