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 mut recognition = RecognitionOptions::default();
106 recognition.collect_phase_timings = true;
107 recognition.sampling = SamplingMode::Vertices;
111 recognition.torus_fast = crate::hypothesis_switch::torus_fast();
112 Self {
113 recognition,
114 policy: ConversionPolicy::AllowFacetedFallback,
115 try_kernel_analytic_rebuild: true,
116 try_hybrid_rebuild: true,
117 weld_tolerance: -1.0,
118 coordinate_precision_tolerance: 0.0,
119 kernel_deflection_angle_degrees: 30.0,
120 kernel_fit_tolerance: 1.0e-3,
121 kernel_normal_tolerance_degrees: 15.0,
122 }
123 }
124}
125
126#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
128#[serde(rename_all = "snake_case")]
129pub enum ConversionBackend {
130 RansacSphere,
132 RansacTorus,
134 RansacCappedCylinder,
136 RansacCappedCone,
138 KernelAnalyticRebuild,
141 KernelSphericalUnion,
144 #[serde(alias = "hybrid_plane_cylinder_rebuild")]
147 HybridAnalyticRebuild,
148 FacetedRepair,
150 FacetedRepairCoplanarMerged,
153 Refused,
157}
158
159#[derive(Clone, Debug, Deserialize, Serialize)]
161pub struct ConversionRegionReport {
162 pub surface_type: SurfaceType,
164 pub surface: AnalyticSurface,
166 pub support_triangles: usize,
168 pub supported_area: f64,
170 pub rms_error: f64,
172 pub max_error: f64,
174 pub rms_normal_error_radians: f64,
176 pub max_normal_error_radians: f64,
178 pub confidence: f64,
180 pub orientation: i8,
182 pub reason: String,
184 pub phase_timings: PhaseTimings,
186}
187
188#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
190pub struct ConversionTimings {
191 #[serde(default)]
193 pub segmentation_seconds: f64,
194 pub recognition_seconds: f64,
196 pub topology_build_seconds: f64,
198 pub step_export_seconds: f64,
200 pub step_validation_seconds: f64,
202 pub total_seconds: f64,
204}
205
206#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
208pub struct HybridConversionReport {
209 pub total_faces: usize,
211 pub analytic_plane_faces: usize,
213 pub analytic_plane_triangles: usize,
215 pub analytic_cylinder_faces: usize,
217 pub analytic_cylinder_triangles: usize,
219 #[serde(default)]
221 pub analytic_cone_faces: usize,
222 #[serde(default)]
224 pub analytic_cone_triangles: usize,
225 #[serde(default)]
227 pub analytic_sphere_faces: usize,
228 #[serde(default)]
230 pub analytic_sphere_triangles: usize,
231 #[serde(default)]
233 pub analytic_torus_faces: usize,
234 #[serde(default)]
236 pub analytic_torus_triangles: usize,
237 pub faceted_faces: usize,
239 pub faceted_triangles: usize,
241 pub demoted_regions: usize,
243 pub demoted_triangles: usize,
245 pub segmentation_plane_regions: usize,
247 pub segmentation_cylinder_regions: usize,
249 #[serde(default)]
251 pub segmentation_cone_regions: usize,
252 #[serde(default)]
254 pub segmentation_sphere_regions: usize,
255 #[serde(default)]
257 pub segmentation_torus_regions: usize,
258 pub segmentation_unsupported_regions: usize,
260}
261
262#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
267#[serde(rename_all = "snake_case")]
268pub enum DemotionCause {
269 CylinderConeEdge,
271 CylinderCylinderEdge,
273 SphereRingEdge,
275 TorusRingEdge,
277 ConeConeEdge,
279 SameKindRevolveEdge,
281 PlaneNeighbourEdge,
283 FacetNeighbourEdge,
285 DemotedNeighbourNotRuling,
288 CylinderAxisSnap,
290 ConeSnap,
292 ConeSpan,
294 CylinderSurface,
296 PeriodicSeam,
298 ConeSurface,
300 SphereSurface,
302 TorusSurface,
304 ChainEdgeCurved,
306 ChainEdgeCone,
308 ChainEdgeCylinder,
310 ChainEdgeVertexPlacer,
313 CylinderFace,
315 ConeFace,
317 SphereFace,
319 TorusFace,
321 WholeBodyCheck,
324 Injected,
326}
327
328#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
330#[serde(rename_all = "snake_case")]
331pub enum DemotedKind {
332 Cylinder,
334 Cone,
336 Sphere,
338 Torus,
340}
341
342#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
344pub struct HybridDemotion {
345 pub region: u32,
347 pub kind: DemotedKind,
349 pub triangles: usize,
351 pub cause: DemotionCause,
353 pub root_cause: DemotionCause,
356 pub neighbour: Option<u32>,
358 pub round: usize,
360 pub message: Option<String>,
362}
363
364#[derive(Clone, Debug, Deserialize, Serialize)]
367pub struct ComponentConversionReport {
368 pub index: usize,
370 pub first_triangle: usize,
373 pub input_vertices: usize,
375 pub input_triangles: usize,
377 pub backend: ConversionBackend,
379 pub backend_reason: String,
381 pub fallback_cause: Option<String>,
383 pub recognition_skipped: Option<String>,
385 pub recognized_regions: usize,
387 pub recognized_triangles: usize,
389 pub unresolved_triangles: usize,
391 pub source_closed_manifold: bool,
393 #[serde(default)]
398 pub analytic_used_repaired_mesh: bool,
399 pub hybrid_rebuild: Option<HybridConversionReport>,
401 #[serde(default)]
404 pub hybrid_demotions: Vec<HybridDemotion>,
405 pub faceted_faces_before_merge: Option<usize>,
407 pub faceted_faces_after_merge: Option<usize>,
409 pub faces: usize,
411 #[serde(default)]
414 pub refusal: Option<String>,
415 #[serde(default)]
418 pub source_name: Option<String>,
419}
420
421#[derive(Clone, Debug, Deserialize, Serialize)]
428pub struct StlConversionReport {
429 pub backend: ConversionBackend,
431 pub backend_reason: String,
433 pub fallback_cause: Option<String>,
435 pub input_vertices: usize,
437 pub input_triangles: usize,
439 pub recognized_regions: usize,
441 pub recognized_triangles: usize,
443 pub unresolved_triangles: usize,
445 #[serde(default)]
448 pub recognition_skipped: Option<String>,
449 pub source_closed_manifold: bool,
451 #[serde(default)]
455 pub analytic_used_repaired_mesh: bool,
456 #[serde(default)]
458 pub requested_distance_tolerance: f64,
459 #[serde(default)]
461 pub coordinate_precision_tolerance: f64,
462 #[serde(default)]
464 pub effective_distance_tolerance: f64,
465 pub region_type_counts: BTreeMap<String, usize>,
467 pub regions: Vec<ConversionRegionReport>,
469 pub topology_refit: Option<ConversionRegionReport>,
472 #[serde(default)]
475 pub hybrid_rebuild: Option<HybridConversionReport>,
476 #[serde(default)]
478 pub hybrid_demotions: Vec<HybridDemotion>,
479 #[serde(default)]
483 pub components: Vec<ComponentConversionReport>,
484 pub exported_step_bytes: usize,
486 pub exported_advanced_faces: usize,
488 #[serde(default)]
490 pub faceted_faces_before_merge: Option<usize>,
491 #[serde(default)]
495 pub faceted_faces_after_merge: Option<usize>,
496 pub roundtrip_solids: usize,
498 pub manifold_audit_issues: Vec<String>,
500 pub timings: ConversionTimings,
502 #[serde(default)]
504 pub recognition_work: RecognitionWorkReport,
505 #[serde(default)]
507 pub whole_carrier_scan: WholeCarrierScanReport,
508 pub messages: Vec<String>,
510}
511
512#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
514pub struct RecognitionWorkReport {
515 pub hypotheses: usize,
517 pub rounds: usize,
519 pub seeded_components: usize,
522}
523
524#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
530pub struct WholeCarrierScanReport {
531 pub accepted: usize,
533 pub declined: usize,
535 pub abstained: usize,
537}
538
539impl WholeCarrierScanReport {
540 pub(crate) fn add(&mut self, verdict: crate::hybrid_whole_freeform::ScanVerdict) {
541 use crate::hybrid_whole_freeform::ScanVerdict;
542 match verdict {
543 ScanVerdict::Accepted => self.accepted += 1,
544 ScanVerdict::Declined => self.declined += 1,
545 ScanVerdict::Abstained => self.abstained += 1,
546 }
547 }
548
549 fn sum(self, other: Self) -> Self {
550 Self {
551 accepted: self.accepted + other.accepted,
552 declined: self.declined + other.declined,
553 abstained: self.abstained + other.abstained,
554 }
555 }
556}
557
558#[derive(Clone, Debug, Deserialize, Serialize)]
560pub struct StlConversionOutput {
561 pub step_text: String,
563 pub report: StlConversionReport,
565}
566
567#[derive(Clone, Debug, PartialEq, Eq)]
569pub struct StlConversionError(pub String);
570
571impl Display for StlConversionError {
572 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
573 formatter.write_str(&self.0)
574 }
575}
576
577impl std::error::Error for StlConversionError {}
578
579impl From<String> for StlConversionError {
580 fn from(value: String) -> Self {
581 Self(value)
582 }
583}
584
585pub fn binary_stl_coordinate_precision_tolerance(mesh: &Mesh) -> f64 {
595 let maximum_absolute_coordinate = mesh
596 .vertices
597 .iter()
598 .flat_map(|point| [point.x.abs(), point.y.abs(), point.z.abs()])
599 .fold(0.0_f64, f64::max);
600 let coordinate_scale = maximum_absolute_coordinate.max(mesh_diagonal(mesh));
601 coordinate_scale * (f32::EPSILON as f64) * 0.5 * 3.0_f64.sqrt()
602}
603
604pub fn convert_stl_mesh_to_step(
616 mesh: &Mesh,
617 source_positions: &[f64],
618 source_indices: Option<&[u32]>,
619 options: &StlConversionOptions,
620 part_name: &str,
621 unit: &str,
622 timestamp: &str,
623) -> Result<StlConversionOutput, StlConversionError> {
624 convert_stl_mesh_to_step_with_colors(
625 mesh,
626 source_positions,
627 source_indices,
628 None,
629 options,
630 part_name,
631 unit,
632 timestamp,
633 )
634}
635
636#[allow(clippy::too_many_arguments)]
646pub fn convert_stl_mesh_to_step_with_colors(
647 mesh: &Mesh,
648 source_positions: &[f64],
649 source_indices: Option<&[u32]>,
650 triangle_colors: Option<&[Option<[u8; 3]>]>,
651 options: &StlConversionOptions,
652 part_name: &str,
653 unit: &str,
654 timestamp: &str,
655) -> Result<StlConversionOutput, StlConversionError> {
656 let total_started = Instant::now();
657 validate_source_buffers(mesh, source_positions, source_indices)?;
658 if !options.coordinate_precision_tolerance.is_finite()
659 || options.coordinate_precision_tolerance < 0.0
660 {
661 return Err(StlConversionError(
662 "coordinate_precision_tolerance must be finite and non-negative".to_owned(),
663 ));
664 }
665
666 let components = triangle_components(mesh);
667 let count = components.len();
668 if count == 0 {
669 return Err(StlConversionError(
670 "the mesh contains no triangles to reconstruct".to_owned(),
671 ));
672 }
673
674 let mut solids = Vec::with_capacity(count);
675 let mut component_ids = Vec::with_capacity(count);
676 let mut details = Vec::with_capacity(count);
677 let mut first_refusal = None;
678 for (index, triangles) in components.iter().enumerate() {
679 if count == 1 {
680 let built = convert_component(mesh, source_positions, source_indices, options)?;
681 solids.push(built.0);
682 component_ids.push(index);
683 details.push(built.1);
684 continue;
685 }
686 let part = component_mesh(mesh, source_positions, source_indices, triangles);
687 match convert_component(
688 &part.mesh,
689 &part.positions,
690 part.indices.as_deref(),
691 options,
692 ) {
693 Ok(built) => {
694 solids.push(built.0);
695 component_ids.push(index);
696 details.push(built.1);
697 }
698 Err(error) => {
702 first_refusal.get_or_insert_with(|| {
703 format!(
704 "body {} of {count} (from source triangle {}, {} triangle(s)): {error}",
705 index + 1,
706 triangles[0],
707 triangles.len()
708 )
709 });
710 details.push(refused_component(&part.mesh, triangles.len(), error.0));
711 }
712 }
713 }
714 if solids.is_empty() {
716 let refusal = first_refusal.unwrap_or_default();
717 return Err(StlConversionError(if count > 1 {
718 format!("{refusal} (every one of the {count} bodies was refused)")
719 } else {
720 refusal
721 }));
722 }
723
724 let (mut solids, cavity_messages) = cavities::assemble(mesh, &components, solids, &component_ids)?;
725 let export_started = Instant::now();
726 let colored = triangle_colors.filter(|colors| colors.iter().any(Option::is_some));
730 let mut color_message = None;
731 let step_text = match colored {
732 Some(triangle_colors) => {
733 let assignment = colors::assign(mesh, triangle_colors, &mut solids, part_name)?;
734 let named: Vec<(String, &brep_kernel::BrepSolid)> =
735 assignment.body_names.iter().cloned().zip(solids.iter()).collect();
736 color_message = Some(format!(
737 "source colours: {} of {} bodies and {} faces styled; {} coloured source triangles matched no face",
738 assignment.bodies,
739 solids.len(),
740 assignment.faces,
741 assignment.unmatched
742 ));
743 brep_kernel::export_step_report_named(
744 &named,
745 part_name,
746 unit,
747 timestamp,
748 None,
749 Some(&assignment.colors),
750 )
751 .map(|report| report.text)
752 }
753 None => export_step(&solids, part_name, unit, timestamp),
754 }
755 .map_err(|error| StlConversionError(format!("STEP export failed: {error}")))?;
756 let step_export_seconds = export_started.elapsed().as_secs_f64();
757
758 let validation_started = Instant::now();
759 let manifold_audit_issues = audit_step_manifold(&step_text);
760 if !manifold_audit_issues.is_empty() {
761 return Err(StlConversionError(format!(
762 "STEP manifold audit failed: {}",
763 manifold_audit_issues.join("; ")
764 )));
765 }
766 let imported = import_step(&step_text).map_err(|error| {
767 StlConversionError(format!("STEP round-trip validation failed: {error}"))
768 })?;
769 if let Some(issues) = imported
770 .iter()
771 .map(|solid| solid.validate())
772 .find(|issues| !issues.is_empty())
773 {
774 return Err(StlConversionError(format!(
775 "round-tripped STEP has invalid topology: {issues:?}"
776 )));
777 }
778 if imported.len() != solids.len() {
779 return Err(StlConversionError(format!(
780 "{} body/bodies were written but {} came back out of the STEP document",
781 solids.len(),
782 imported.len()
783 )));
784 }
785 let step_validation_seconds = validation_started.elapsed().as_secs_f64();
786
787 let mut report = aggregate_report(&components, details, count, options);
788 report.messages.extend(cavity_messages);
789 report.messages.extend(color_message);
790 report.messages.push(format!(
791 "STEP manifold audit and round-trip import passed for {} solid(s)",
792 imported.len()
793 ));
794 report.messages.push(report.backend_reason.clone());
795 report.exported_step_bytes = step_text.len();
796 report.exported_advanced_faces = step_text.matches("ADVANCED_FACE(").count();
797 report.roundtrip_solids = imported.len();
798 report.manifold_audit_issues = manifold_audit_issues;
799 report.timings.step_export_seconds = step_export_seconds;
800 report.timings.step_validation_seconds = step_validation_seconds;
801 report.timings.total_seconds = total_started.elapsed().as_secs_f64();
802 Ok(StlConversionOutput { step_text, report })
803}
804
805fn refused_component(mesh: &Mesh, triangles: usize, reason: String) -> ComponentDetail {
808 ComponentDetail {
809 backend: ConversionBackend::Refused,
810 backend_reason: format!("refused, not in the model: {reason}"),
811 fallback_cause: None,
812 input_vertices: mesh.vertices.len(),
813 input_triangles: triangles,
814 recognized_regions: 0,
815 recognized_triangles: 0,
816 unresolved_triangles: 0,
817 recognition_skipped: None,
818 source_closed_manifold: closed_manifold(mesh),
819 analytic_used_repaired_mesh: false,
820 requested_distance_tolerance: 0.0,
821 effective_distance_tolerance: 0.0,
822 region_type_counts: BTreeMap::new(),
823 regions: Vec::new(),
824 topology_refit: None,
825 hybrid_rebuild: None,
826 hybrid_demotions: Vec::new(),
827 faceted_faces_before_merge: None,
828 faceted_faces_after_merge: None,
829 faces: 0,
830 segmentation_seconds: 0.0,
831 recognition_seconds: 0.0,
832 recognition_work: RecognitionWorkReport::default(),
833 whole_carrier_scan: WholeCarrierScanReport::default(),
834 topology_build_seconds: 0.0,
835 messages: vec![format!("refused, not in the model: {reason}")],
836 refusal: Some(reason),
837 }
838}
839
840struct ComponentDetail {
843 backend: ConversionBackend,
844 backend_reason: String,
845 fallback_cause: Option<String>,
846 input_vertices: usize,
847 input_triangles: usize,
848 recognized_regions: usize,
849 recognized_triangles: usize,
850 unresolved_triangles: usize,
851 recognition_skipped: Option<String>,
852 source_closed_manifold: bool,
853 analytic_used_repaired_mesh: bool,
854 requested_distance_tolerance: f64,
855 effective_distance_tolerance: f64,
856 region_type_counts: BTreeMap<String, usize>,
857 regions: Vec<ConversionRegionReport>,
858 topology_refit: Option<ConversionRegionReport>,
859 hybrid_rebuild: Option<HybridConversionReport>,
860 hybrid_demotions: Vec<HybridDemotion>,
861 faceted_faces_before_merge: Option<usize>,
862 faceted_faces_after_merge: Option<usize>,
863 faces: usize,
864 segmentation_seconds: f64,
865 recognition_seconds: f64,
866 recognition_work: RecognitionWorkReport,
867 whole_carrier_scan: WholeCarrierScanReport,
868 topology_build_seconds: f64,
869 messages: Vec<String>,
870 refusal: Option<String>,
872}
873
874fn convert_component(
878 mesh: &Mesh,
879 source_positions: &[f64],
880 source_indices: Option<&[u32]>,
881 options: &StlConversionOptions,
882) -> Result<(brep_kernel::BrepSolid, ComponentDetail), StlConversionError> {
883 let outward = cavities::outward_component(mesh, source_positions, source_indices);
886 let (mesh, source_positions, source_indices) = match &outward {
887 Some(part) => (&part.mesh, part.positions.as_slice(), part.indices.as_deref()),
888 None => (mesh, source_positions, source_indices),
889 };
890 let mut recognition_options = options.recognition.clone();
891 recognition_options.collect_phase_timings = true;
892 let requested_distance_tolerance = recognition_options.distance_tolerance;
893 recognition_options.distance_tolerance = recognition_options
894 .distance_tolerance
895 .max(options.coordinate_precision_tolerance);
896 let encoding_distance_tolerance = recognition_options.distance_tolerance;
897
898 let source_closed_manifold = closed_manifold(mesh);
899 let input_vertices = mesh.vertices.len();
900 let input_triangles = mesh.triangles.len();
901 let repaired = if source_closed_manifold {
910 None
911 } else {
912 repaired_closed_mesh(source_positions, source_indices, options)
913 };
914 let (mesh, source_positions, source_indices) = match &repaired {
915 Some(repaired) => (
916 &repaired.mesh,
917 repaired.positions.as_slice(),
918 Some(repaired.indices.as_slice()),
919 ),
920 None => (mesh, source_positions, source_indices),
921 };
922 let closed_for_analytics = source_closed_manifold || repaired.is_some();
925
926 let segmentation_started = Instant::now();
927 let segmentation = if closed_for_analytics {
928 gate_segmentation(source_positions, source_indices, options)
929 } else {
930 Err("the source mesh is not a closed two-manifold".to_owned())
931 };
932 let segmentation_seconds = segmentation_started.elapsed().as_secs_f64();
933 let initial_recognition_gate = recognition_gate(&segmentation);
934 let geometric_gate_rescue = initial_recognition_gate.is_err()
935 && closed_for_analytics
936 && segmentation.as_ref().is_ok_and(|segmentation| {
937 primitive_gate::has_whole_primitive_candidate(mesh, segmentation, &recognition_options)
938 });
939 let recognition_gate = if geometric_gate_rescue {
940 Ok(())
941 } else {
942 initial_recognition_gate
943 };
944 let measured_region_deviation = match (&recognition_gate, &segmentation) {
953 (Ok(()), Ok(segmentation)) => segmentation
954 .regions
955 .iter()
956 .map(|region| region.max_deviation)
957 .fold(0.0_f64, f64::max),
958 _ => 0.0,
959 };
960 recognition_options.distance_tolerance = recognition_options
961 .distance_tolerance
962 .max(measured_region_deviation);
963 let effective_distance_tolerance = recognition_options.distance_tolerance;
964
965 let recognition_started = Instant::now();
966 let mut recognition_work = RecognitionWorkReport::default();
967 let (recognition, completed_plane_regions, completed_degenerate_triangles) = if recognition_gate.is_ok() {
968 let candidates = match &segmentation {
969 Ok(segmentation) if segment_carrier_enabled() => segmentation_candidates(mesh, segmentation),
970 _ => Vec::new(),
971 };
972 let (mut recognition, work) =
973 recognize_surfaces_with_candidates(mesh, &recognition_options, &candidates)
974 .map_err(|error| {
975 StlConversionError(format!("surface recognition failed: {error}"))
976 })?;
977 recognition_work = RecognitionWorkReport {
978 hypotheses: work.attempts,
979 rounds: work.rounds,
980 seeded_components: work.seeded_components,
981 };
982 feature_fragments::complete(
983 mesh,
984 &recognition_options,
985 &mut recognition.regions,
986 &mut recognition.unresolved_triangles,
987 )?;
988 let completed_plane_regions = complete_small_planar_regions(
989 mesh,
990 &recognition_options,
991 &mut recognition.regions,
992 &mut recognition.unresolved_triangles,
993 )?;
994 let completed_degenerate_triangles = degenerate_coverage::complete(
995 mesh, &recognition_options, &mut recognition.regions,
996 )?;
997 (recognition, completed_plane_regions, completed_degenerate_triangles)
998 } else {
999 (
1000 crate::RecognitionResult {
1001 regions: Vec::new(),
1002 unresolved_triangles: Vec::new(),
1003 unresolved_diagnostics: Vec::new(),
1004 },
1005 0,
1006 0,
1007 )
1008 };
1009 let recognition_seconds = if recognition_gate.is_ok() {
1010 recognition_started.elapsed().as_secs_f64()
1011 } else {
1012 0.0
1013 };
1014 let recognized_triangles = recognition
1015 .regions
1016 .iter()
1017 .map(|region| region.triangle_indices.len())
1018 .sum();
1019 let regions = recognition
1020 .regions
1021 .iter()
1022 .map(region_report)
1023 .collect::<Vec<_>>();
1024 let mut region_type_counts = BTreeMap::new();
1025 for region in &recognition.regions {
1026 *region_type_counts
1027 .entry(region.surface.surface_type().name().to_owned())
1028 .or_insert(0) += 1;
1029 }
1030
1031 let topology_started = Instant::now();
1032 let analytic_coverage = recognition.unresolved_triangles.is_empty()
1033 && recognized_triangles == mesh.triangles.len()
1034 && disjoint_complete_partition(&recognition.regions, mesh.triangles.len());
1035 let spherical_union = if closed_for_analytics && options.try_kernel_analytic_rebuild {
1036 segmentation.as_ref().ok().and_then(|seg| {
1037 brep_kernel::reconstruct_spherical_mesh_union(
1038 source_positions,
1039 source_indices.unwrap_or(&[]),
1040 seg,
1041 &segmentation_options(source_positions, options),
1042 )
1043 })
1044 } else {
1045 None
1046 };
1047 let mut whole_carrier_scan = WholeCarrierScanReport::default();
1054 let primitive = if analytic_coverage
1055 && closed_for_analytics
1056 && recognition_work.seeded_components == 0
1057 && whole_fits_pass_the_local_scan(mesh, &recognition.regions, encoding_distance_tolerance, &mut whole_carrier_scan)
1058 {
1059 direct_analytic_solid(mesh, &recognition.regions, &recognition_options)
1060 } else {
1061 None
1062 };
1063
1064 let mut fallback_cause = None;
1065 let mut messages = vec![match &recognition_gate {
1066 Ok(()) => format!(
1067 "RANSAC recognized {} region(s) covering {recognized_triangles}/{} triangles",
1068 recognition.regions.len(),
1069 mesh.triangles.len()
1070 ),
1071 Err(reason) => format!("RANSAC recognition skipped: {reason}"),
1072 }];
1073 if geometric_gate_rescue {
1074 messages.push("whole-primitive recognition enabled by triangle coverage on a segmented curved carrier and its possible end caps".to_owned());
1075 }
1076 if outward.is_some() {
1077 messages.push("inward source shell reconstructed outward before cavity assembly".into());
1078 }
1079 if let Some(repaired) = &repaired {
1080 messages.push(repaired.message.clone());
1081 }
1082 if encoding_distance_tolerance > requested_distance_tolerance {
1083 messages.push(format!(
1084 "source coordinate precision raised the absolute recognition distance from {requested_distance_tolerance:.6e} to {encoding_distance_tolerance:.6e}"
1085 ));
1086 }
1087 if effective_distance_tolerance > encoding_distance_tolerance {
1088 messages.push(format!(
1089 "the segmentation's measured vertex deviation raised the absolute recognition distance from {encoding_distance_tolerance:.6e} to {effective_distance_tolerance:.6e}"
1090 ));
1091 }
1092 if completed_degenerate_triangles > 0 {
1093 messages.push(format!(
1094 "attached {completed_degenerate_triangles} numerically degenerate triangle(s) to their surrounding fitted carrier; excluded from fit support metrics"
1095 ));
1096 }
1097 if completed_plane_regions > 0 {
1098 messages.push(format!(
1099 "a plane-only completion pass recovered {completed_plane_regions} small feature-bounded planar region(s)"
1100 ));
1101 }
1102
1103 let mut hybrid_demotions = Vec::new();
1104 let (
1105 solid,
1106 backend,
1107 backend_reason,
1108 topology_refit,
1109 hybrid_rebuild,
1110 faceted_faces_before_merge,
1111 faceted_faces_after_merge,
1112 ) = if let Some(primitive) = primitive {
1113 (
1114 primitive.solid?,
1115 primitive.backend,
1116 primitive.reason,
1117 primitive.topology_refit,
1118 None,
1119 None,
1120 None,
1121 )
1122 } else if let Some(solid) = spherical_union {
1123 (
1124 solid,
1125 ConversionBackend::KernelSphericalUnion,
1126 "precise sphere carriers and bidirectional mesh coverage verified a primitive union; rebuilt exact analytic intersections across the mesh boolean cut strips".to_owned(),
1127 None,
1128 None,
1129 None,
1130 None,
1131 )
1132 } else {
1133 let direct_cause = match &recognition_gate {
1134 Err(reason) => format!("whole-primitive recognition skipped: {reason}"),
1135 Ok(()) if !analytic_coverage => format!(
1136 "RANSAC did not prove complete analytic coverage ({} unresolved triangle(s))",
1137 recognition.unresolved_triangles.len()
1138 ),
1139 Ok(()) if !closed_for_analytics => {
1140 "recognized mesh is not a closed two-manifold".to_owned()
1141 }
1142 Ok(()) => {
1143 "recognized regions do not match a safe complete primitive topology".to_owned()
1144 }
1145 };
1146
1147 let kernel_eligible = segmentation.as_ref().is_ok_and(|segmentation| {
1148 segmentation_is_complete(segmentation)
1149 && segmentation.regions.iter().all(|region| {
1150 matches!(
1151 region.carrier,
1152 RegionCarrier::Plane { .. }
1153 | RegionCarrier::Cylinder { .. }
1154 | RegionCarrier::Cone { .. }
1155 )
1156 })
1157 });
1158 let kernel_attempt = if closed_for_analytics
1159 && kernel_eligible
1160 && options.try_kernel_analytic_rebuild
1161 {
1162 try_kernel_analytic(source_positions, source_indices, options)
1163 } else {
1164 Err("kernel analytic rebuild requires the kernel segmentation to prove complete plane/cylinder/cone coverage".to_owned())
1165 };
1166
1167 let analytic_attempt = match kernel_attempt {
1168 Ok(solid) => {
1169 let stitched = repaired.as_ref().is_some_and(|repaired| repaired.stitched_edges > 0);
1170 let strips = if stitched && tjunction_strip_hybrid_enabled() {
1171 hybrid_over_kernel_strips(&solid, source_positions, source_indices, options)
1172 } else {
1173 None
1174 };
1175 match strips {
1176 Some(output) => {
1177 whole_carrier_scan = whole_carrier_scan.sum(output.stats.whole_carrier_scan);
1178 let report = hybrid_report(&output.stats);
1179 hybrid_demotions = output.stats.demotions;
1180 Ok((
1181 output.solid,
1182 ConversionBackend::HybridAnalyticRebuild,
1183 "the kernel region rebuilder's shell carried a T-junction's chord as a plane strip; independent segmentation reconstructed and validated a fully analytic shell with fewer faces".to_owned(),
1184 Some(report),
1185 ))
1186 }
1187 None => Ok((
1188 solid,
1189 ConversionBackend::KernelAnalyticRebuild,
1190 "kernel segmentation proved complete plane/cylinder/cone coverage; the kernel region rebuilder then reconstructed and validated the complete analytic shell".to_owned(),
1191 None,
1192 )),
1193 }
1194 }
1195 Err(kernel_error) => {
1196 let hybrid_attempt = if closed_for_analytics && options.try_hybrid_rebuild {
1197 try_hybrid_analytic(source_positions, source_indices, options)
1198 } else if !options.try_hybrid_rebuild {
1199 Err("mixed analytic/faceted rebuild was disabled".to_owned())
1200 } else {
1201 Err("mixed analytic/faceted rebuild requires a closed two-manifold".to_owned())
1202 };
1203 let hybrid_attempt = hybrid_attempt.and_then(|output| {
1206 if hybrid_roundtrip_fallback_enabled() {
1207 if let Err(error) = solid_round_trips(&output.solid) {
1208 let joined = crate::hybrid_region_brep::hybrid_plane_cylinder_brep_before_promotion_14(
1211 source_positions,
1212 source_indices,
1213 &segmentation_options(source_positions, options),
1214 );
1215 if let Ok(Some(joined)) = joined {
1216 if solid_round_trips(&joined.solid).is_ok() {
1217 return Ok(joined);
1218 }
1219 }
1220 let latest = crate::hybrid_region_brep::hybrid_plane_cylinder_brep_before_promotion_13(
1223 source_positions,
1224 source_indices,
1225 &segmentation_options(source_positions, options),
1226 );
1227 if let Ok(Some(latest)) = latest {
1228 if solid_round_trips(&latest.solid).is_ok() {
1229 return Ok(latest);
1230 }
1231 }
1232 let newest = crate::hybrid_region_brep::hybrid_plane_cylinder_brep_before_promotion_12(
1235 source_positions,
1236 source_indices,
1237 &segmentation_options(source_positions, options),
1238 );
1239 if let Ok(Some(newest)) = newest {
1240 if solid_round_trips(&newest.solid).is_ok() {
1241 return Ok(newest);
1242 }
1243 }
1244 let first = crate::hybrid_region_brep::hybrid_plane_cylinder_brep_before_promotion_11(
1247 source_positions,
1248 source_indices,
1249 &segmentation_options(source_positions, options),
1250 );
1251 if let Ok(Some(first)) = first {
1252 if solid_round_trips(&first.solid).is_ok() {
1253 return Ok(first);
1254 }
1255 }
1256 let second = crate::hybrid_region_brep::hybrid_plane_cylinder_brep_before_promotion_9(
1261 source_positions,
1262 source_indices,
1263 &segmentation_options(source_positions, options),
1264 );
1265 if let Ok(Some(second)) = second {
1266 if solid_round_trips(&second.solid).is_ok() {
1267 return Ok(second);
1268 }
1269 }
1270 for with_promotion_9 in [true, false] {
1274 let further = crate::hybrid_region_brep::hybrid_plane_cylinder_brep_before_promotion_10(
1275 source_positions,
1276 source_indices,
1277 &segmentation_options(source_positions, options),
1278 with_promotion_9,
1279 );
1280 if let Ok(Some(further)) = further {
1281 if solid_round_trips(&further.solid).is_ok() {
1282 return Ok(further);
1283 }
1284 }
1285 }
1286 return Err(format!(
1287 "{kernel_error}; the mixed rebuild's solid failed its STEP round trip: {error}"
1288 ));
1289 }
1290 }
1291 Ok(output)
1292 });
1293 match hybrid_attempt {
1294 Ok(output) => {
1295 let stats = &output.stats;
1296 whole_carrier_scan = whole_carrier_scan.sum(stats.whole_carrier_scan);
1297 if stats.split_freeform_regions
1298 + stats.split_declined_regions
1299 + stats.split_over_budget_regions
1300 + stats.split_budget_hit_regions
1301 + stats.split_band_declined_regions
1302 > 0
1303 {
1304 messages.push(format!(
1305 "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",
1306 stats.split_freeform_regions,
1307 stats.split_declined_regions,
1308 stats.split_over_budget_regions,
1309 stats.split_budget_hit_regions,
1310 stats.split_band_declined_regions
1311 ));
1312 }
1313 let hybrid_report = hybrid_report(&output.stats);
1314 if options.policy == ConversionPolicy::RequireFullyAnalytic
1315 && hybrid_report.faceted_faces != 0
1316 {
1317 Err(format!(
1318 "{kernel_error}; mixed rebuild retained {} faceted face(s) for {} source triangle(s)",
1319 hybrid_report.faceted_faces, hybrid_report.faceted_triangles
1320 ))
1321 } else {
1322 let reason = if hybrid_report.faceted_faces == 0 {
1323 "independent segmentation reconstructed and validated a fully analytic plane/cylinder/cone/sphere shell"
1324 } else {
1325 "independent segmentation reconstructed exact plane/cylinder/cone/sphere regions and retained unsupported or unsafe regions as facets"
1326 };
1327 hybrid_demotions = output.stats.demotions;
1328 Ok((
1329 output.solid,
1330 ConversionBackend::HybridAnalyticRebuild,
1331 reason.to_owned(),
1332 Some(hybrid_report),
1333 ))
1334 }
1335 }
1336 Err(hybrid_error) => Err(format!("{kernel_error}; {hybrid_error}")),
1337 }
1338 }
1339 };
1340
1341 match analytic_attempt {
1342 Ok((solid, backend, reason, hybrid)) => {
1343 (solid, backend, reason, None, hybrid, None, None)
1344 }
1345 Err(analytic_error) => {
1346 let cause = format!("{direct_cause}; {analytic_error}");
1347 if options.policy == ConversionPolicy::RequireFullyAnalytic {
1348 return Err(StlConversionError(format!(
1349 "strict analytic conversion refused faceted fallback: {cause}"
1350 )));
1351 }
1352 fallback_cause = Some(cause.clone());
1353 messages.push(format!("analytic rebuild unavailable: {cause}"));
1354 let faceted =
1355 mesh_to_faceted_brep(source_positions, source_indices, options.weld_tolerance)
1356 .map_err(|error| {
1357 StlConversionError(format!("faceted mesh repair failed: {error}"))
1358 })?;
1359 let faces_before = solid_face_count(&faceted);
1360 let merge_tolerance = coplanar_merge_tolerance(mesh);
1361 match merge_same_surface_faces(&faceted, merge_tolerance) {
1362 Ok(merged) => {
1363 let faces_after = solid_face_count(&merged);
1364 if faces_after < faces_before {
1365 messages.push(format!(
1366 "coplanar faceted merge reduced faces from {faces_before} to {faces_after} (tolerance {merge_tolerance:.3e})"
1367 ));
1368 (
1369 merged,
1370 ConversionBackend::FacetedRepairCoplanarMerged,
1371 "the complete source triangle set was repaired as a faceted BREP, then adjacent coplanar facets were merged without introducing curved replacement topology".to_owned(),
1372 None,
1373 None,
1374 Some(faces_before),
1375 Some(faces_after),
1376 )
1377 } else {
1378 messages.push(format!(
1379 "coplanar faceted merge found no mergeable faces (tolerance {merge_tolerance:.3e})"
1380 ));
1381 (
1382 faceted,
1383 ConversionBackend::FacetedRepair,
1384 "the complete source triangle set was repaired and preserved as a faceted BREP; no adjacent coplanar facets could be merged".to_owned(),
1385 None,
1386 None,
1387 Some(faces_before),
1388 Some(faces_before),
1389 )
1390 }
1391 }
1392 Err(error) => {
1393 messages.push(format!(
1394 "coplanar faceted merge was unavailable; retained the validated repaired mesh: {error}"
1395 ));
1396 (
1397 faceted,
1398 ConversionBackend::FacetedRepair,
1399 "the complete source triangle set was repaired and preserved as a faceted BREP; the optional coplanar merge was unavailable".to_owned(),
1400 None,
1401 None,
1402 Some(faces_before),
1403 Some(faces_before),
1404 )
1405 }
1406 }
1407 }
1408 }
1409 };
1410 let topology_build_seconds = topology_started.elapsed().as_secs_f64();
1411
1412 let topology_issues = solid.validate();
1413 if !topology_issues.is_empty() {
1414 return Err(StlConversionError(format!(
1415 "constructed BREP failed topology validation: {topology_issues:?}"
1416 )));
1417 }
1418
1419 let faces = solid_face_count(&solid);
1420 let detail = ComponentDetail {
1421 backend,
1422 backend_reason,
1423 fallback_cause,
1424 input_vertices,
1425 input_triangles,
1426 recognized_regions: recognition.regions.len(),
1427 recognized_triangles,
1428 unresolved_triangles: recognition.unresolved_triangles.len(),
1429 recognition_skipped: recognition_gate.err(),
1430 source_closed_manifold,
1431 analytic_used_repaired_mesh: repaired.is_some(),
1432 requested_distance_tolerance,
1433 effective_distance_tolerance,
1434 region_type_counts,
1435 regions,
1436 topology_refit,
1437 hybrid_rebuild,
1438 hybrid_demotions,
1439 faceted_faces_before_merge,
1440 faceted_faces_after_merge,
1441 faces,
1442 segmentation_seconds,
1443 recognition_seconds,
1444 recognition_work,
1445 whole_carrier_scan,
1446 topology_build_seconds,
1447 messages,
1448 refusal: None,
1449 };
1450 Ok((solid, detail))
1451}
1452
1453fn aggregate_report(
1458 components: &[Vec<usize>],
1459 details: Vec<ComponentDetail>,
1460 count: usize,
1461 options: &StlConversionOptions,
1462) -> StlConversionReport {
1463 let mut messages = Vec::new();
1464 if count > 1 {
1465 messages.push(format!(
1466 "the triangle soup holds {count} vertex-connected components; each shell was reconstructed independently before cavity assembly"
1467 ));
1468 }
1469 let refused = details
1470 .iter()
1471 .enumerate()
1472 .filter_map(|(index, detail)| detail.refusal.as_ref().map(|reason| (index, reason)))
1473 .collect::<Vec<_>>();
1474 if !refused.is_empty() {
1475 messages.push(format!(
1476 "{} of {count} bodies could not be reconstructed and are NOT in the model: {}",
1477 refused.len(),
1478 refused
1479 .iter()
1480 .map(|(index, reason)| format!(
1481 "body {} (from source triangle {}): {reason}",
1482 index + 1,
1483 components[*index][0]
1484 ))
1485 .collect::<Vec<_>>()
1486 .join("; ")
1487 ));
1488 }
1489 for (index, detail) in details.iter().enumerate() {
1490 for message in &detail.messages {
1491 messages.push(if count > 1 {
1492 format!("body {}: {message}", index + 1)
1493 } else {
1494 message.clone()
1495 });
1496 }
1497 }
1498
1499 let first = details
1502 .iter()
1503 .find(|detail| detail.refusal.is_none())
1504 .unwrap_or(&details[0]);
1505 let backend_reason = if count == 1 {
1506 first.backend_reason.clone()
1507 } else if details
1508 .iter()
1509 .all(|detail| detail.backend == first.backend)
1510 {
1511 format!(
1512 "{count} components were reconstructed separately; each: {}",
1513 first.backend_reason
1514 )
1515 } else {
1516 format!(
1517 "{count} components were reconstructed separately: {}",
1518 details
1519 .iter()
1520 .enumerate()
1521 .map(|(index, detail)| format!("body {} — {}", index + 1, detail.backend_reason))
1522 .collect::<Vec<_>>()
1523 .join("; ")
1524 )
1525 };
1526 let fallback_cause = details
1527 .iter()
1528 .enumerate()
1529 .find_map(|(index, detail)| {
1530 detail.fallback_cause.as_ref().map(|cause| {
1531 if count > 1 {
1532 format!("body {}: {cause}", index + 1)
1533 } else {
1534 cause.clone()
1535 }
1536 })
1537 });
1538 let recognition_skipped = details
1539 .iter()
1540 .enumerate()
1541 .map(|(index, detail)| {
1542 detail
1543 .recognition_skipped
1544 .as_ref()
1545 .map(|reason| (index, reason))
1546 })
1547 .collect::<Option<Vec<_>>>()
1548 .map(|skipped| {
1549 if count == 1 || skipped.iter().all(|(_, reason)| *reason == skipped[0].1) {
1550 skipped[0].1.clone()
1551 } else {
1552 skipped
1553 .iter()
1554 .map(|(index, reason)| format!("body {}: {reason}", index + 1))
1555 .collect::<Vec<_>>()
1556 .join("; ")
1557 }
1558 });
1559
1560 let mut region_type_counts = BTreeMap::new();
1561 for detail in &details {
1562 for (name, count) in &detail.region_type_counts {
1563 *region_type_counts.entry(name.clone()).or_insert(0) += count;
1564 }
1565 }
1566 let hybrid_rebuild = details
1567 .iter()
1568 .any(|detail| detail.hybrid_rebuild.is_some())
1569 .then(|| {
1570 details
1571 .iter()
1572 .filter_map(|detail| detail.hybrid_rebuild)
1573 .fold(HybridConversionReport::default(), add_hybrid_reports)
1574 });
1575 let faceted_faces_before_merge = sum_optional(&details, |detail| detail.faceted_faces_before_merge);
1576 let faceted_faces_after_merge = sum_optional(&details, |detail| detail.faceted_faces_after_merge);
1577
1578 let component_reports = components
1579 .iter()
1580 .zip(&details)
1581 .enumerate()
1582 .map(|(index, (triangles, detail))| ComponentConversionReport {
1583 index,
1584 first_triangle: triangles[0],
1585 input_vertices: detail.input_vertices,
1586 input_triangles: detail.input_triangles,
1587 backend: detail.backend,
1588 backend_reason: detail.backend_reason.clone(),
1589 fallback_cause: detail.fallback_cause.clone(),
1590 recognition_skipped: detail.recognition_skipped.clone(),
1591 recognized_regions: detail.recognized_regions,
1592 recognized_triangles: detail.recognized_triangles,
1593 unresolved_triangles: detail.unresolved_triangles,
1594 source_closed_manifold: detail.source_closed_manifold,
1595 analytic_used_repaired_mesh: detail.analytic_used_repaired_mesh,
1596 hybrid_rebuild: detail.hybrid_rebuild,
1597 hybrid_demotions: detail.hybrid_demotions.clone(),
1598 faceted_faces_before_merge: detail.faceted_faces_before_merge,
1599 faceted_faces_after_merge: detail.faceted_faces_after_merge,
1600 faces: detail.faces,
1601 refusal: detail.refusal.clone(),
1602 source_name: None,
1603 })
1604 .collect();
1605
1606 StlConversionReport {
1607 backend: first.backend,
1608 backend_reason,
1609 fallback_cause,
1610 input_vertices: details.iter().map(|detail| detail.input_vertices).sum(),
1611 input_triangles: details.iter().map(|detail| detail.input_triangles).sum(),
1612 recognized_regions: details.iter().map(|detail| detail.recognized_regions).sum(),
1613 recognized_triangles: details
1614 .iter()
1615 .map(|detail| detail.recognized_triangles)
1616 .sum(),
1617 unresolved_triangles: details
1618 .iter()
1619 .map(|detail| detail.unresolved_triangles)
1620 .sum(),
1621 recognition_skipped,
1622 source_closed_manifold: details.iter().all(|detail| detail.source_closed_manifold),
1623 analytic_used_repaired_mesh: details
1624 .iter()
1625 .any(|detail| detail.analytic_used_repaired_mesh),
1626 requested_distance_tolerance: first.requested_distance_tolerance,
1627 coordinate_precision_tolerance: options.coordinate_precision_tolerance,
1628 effective_distance_tolerance: first.effective_distance_tolerance,
1629 region_type_counts,
1630 regions: details
1631 .iter()
1632 .flat_map(|detail| detail.regions.iter().cloned())
1633 .collect(),
1634 topology_refit: details
1635 .iter()
1636 .find_map(|detail| detail.topology_refit.clone()),
1637 hybrid_rebuild,
1638 hybrid_demotions: details
1639 .iter()
1640 .flat_map(|detail| detail.hybrid_demotions.iter().cloned())
1641 .collect(),
1642 components: component_reports,
1643 exported_step_bytes: 0,
1644 exported_advanced_faces: 0,
1645 faceted_faces_before_merge,
1646 faceted_faces_after_merge,
1647 roundtrip_solids: 0,
1648 manifold_audit_issues: Vec::new(),
1649 timings: ConversionTimings {
1650 segmentation_seconds: details
1651 .iter()
1652 .map(|detail| detail.segmentation_seconds)
1653 .sum(),
1654 recognition_seconds: details
1655 .iter()
1656 .map(|detail| detail.recognition_seconds)
1657 .sum(),
1658 topology_build_seconds: details
1659 .iter()
1660 .map(|detail| detail.topology_build_seconds)
1661 .sum(),
1662 step_export_seconds: 0.0,
1663 step_validation_seconds: 0.0,
1664 total_seconds: 0.0,
1665 },
1666 recognition_work: details.iter().fold(RecognitionWorkReport::default(), |sum, detail| {
1667 RecognitionWorkReport {
1668 hypotheses: sum.hypotheses + detail.recognition_work.hypotheses,
1669 rounds: sum.rounds + detail.recognition_work.rounds,
1670 seeded_components: sum.seeded_components + detail.recognition_work.seeded_components,
1671 }
1672 }),
1673 whole_carrier_scan: details
1674 .iter()
1675 .fold(WholeCarrierScanReport::default(), |sum, detail| sum.sum(detail.whole_carrier_scan)),
1676 messages,
1677 }
1678}
1679
1680fn sum_optional(
1681 details: &[ComponentDetail],
1682 read: impl Fn(&ComponentDetail) -> Option<usize>,
1683) -> Option<usize> {
1684 details
1685 .iter()
1686 .any(|detail| read(detail).is_some())
1687 .then(|| details.iter().filter_map(read).sum())
1688}
1689
1690fn add_hybrid_reports(
1691 total: HybridConversionReport,
1692 body: HybridConversionReport,
1693) -> HybridConversionReport {
1694 HybridConversionReport {
1695 total_faces: total.total_faces + body.total_faces,
1696 analytic_plane_faces: total.analytic_plane_faces + body.analytic_plane_faces,
1697 analytic_plane_triangles: total.analytic_plane_triangles + body.analytic_plane_triangles,
1698 analytic_cylinder_faces: total.analytic_cylinder_faces + body.analytic_cylinder_faces,
1699 analytic_cylinder_triangles: total.analytic_cylinder_triangles
1700 + body.analytic_cylinder_triangles,
1701 analytic_cone_faces: total.analytic_cone_faces + body.analytic_cone_faces,
1702 analytic_cone_triangles: total.analytic_cone_triangles + body.analytic_cone_triangles,
1703 analytic_sphere_faces: total.analytic_sphere_faces + body.analytic_sphere_faces,
1704 analytic_sphere_triangles: total.analytic_sphere_triangles + body.analytic_sphere_triangles,
1705 analytic_torus_faces: total.analytic_torus_faces + body.analytic_torus_faces,
1706 analytic_torus_triangles: total.analytic_torus_triangles + body.analytic_torus_triangles,
1707 faceted_faces: total.faceted_faces + body.faceted_faces,
1708 faceted_triangles: total.faceted_triangles + body.faceted_triangles,
1709 demoted_regions: total.demoted_regions + body.demoted_regions,
1710 demoted_triangles: total.demoted_triangles + body.demoted_triangles,
1711 segmentation_plane_regions: total.segmentation_plane_regions
1712 + body.segmentation_plane_regions,
1713 segmentation_cylinder_regions: total.segmentation_cylinder_regions
1714 + body.segmentation_cylinder_regions,
1715 segmentation_cone_regions: total.segmentation_cone_regions + body.segmentation_cone_regions,
1716 segmentation_sphere_regions: total.segmentation_sphere_regions
1717 + body.segmentation_sphere_regions,
1718 segmentation_torus_regions: total.segmentation_torus_regions
1719 + body.segmentation_torus_regions,
1720 segmentation_unsupported_regions: total.segmentation_unsupported_regions
1721 + body.segmentation_unsupported_regions,
1722 }
1723}
1724
1725fn triangle_components(mesh: &Mesh) -> Vec<Vec<usize>> {
1740 fn find(parent: &mut [usize], mut node: usize) -> usize {
1741 while parent[node] != node {
1742 parent[node] = parent[parent[node]];
1743 node = parent[node];
1744 }
1745 node
1746 }
1747
1748 let bits = |value: f64| if value == 0.0 { 0_u64 } else { value.to_bits() };
1749 let mut position_ids = HashMap::<[u64; 3], usize>::new();
1750 let mut vertex_position = Vec::with_capacity(mesh.vertices.len());
1751 for point in &mesh.vertices {
1752 let key = [bits(point.x), bits(point.y), bits(point.z)];
1753 let next = position_ids.len();
1754 vertex_position.push(*position_ids.entry(key).or_insert(next));
1755 }
1756
1757 let mut parent = (0..position_ids.len()).collect::<Vec<_>>();
1758 for triangle in &mesh.triangles {
1759 let first = vertex_position[triangle[0] as usize];
1760 for &corner in &triangle[1..] {
1761 let (left, right) = (
1762 find(&mut parent, first),
1763 find(&mut parent, vertex_position[corner as usize]),
1764 );
1765 if left != right {
1766 parent[left] = right;
1767 }
1768 }
1769 }
1770
1771 let mut slots = HashMap::<usize, usize>::new();
1772 let mut components = Vec::<Vec<usize>>::new();
1773 for (index, triangle) in mesh.triangles.iter().enumerate() {
1774 let root = find(&mut parent, vertex_position[triangle[0] as usize]);
1775 let slot = match slots.get(&root) {
1776 Some(&slot) => slot,
1777 None => {
1778 components.push(Vec::new());
1779 slots.insert(root, components.len() - 1);
1780 components.len() - 1
1781 }
1782 };
1783 components[slot].push(index);
1784 }
1785 components
1786}
1787
1788struct ComponentMesh {
1790 mesh: Mesh,
1791 positions: Vec<f64>,
1792 indices: Option<Vec<u32>>,
1793}
1794
1795fn component_mesh(
1800 mesh: &Mesh,
1801 positions: &[f64],
1802 indices: Option<&[u32]>,
1803 triangles: &[usize],
1804) -> ComponentMesh {
1805 let mut vertex_map = HashMap::<u32, u32>::new();
1806 let mut vertices = Vec::new();
1807 let mut component_triangles = Vec::with_capacity(triangles.len());
1808 for &triangle in triangles {
1809 let mut corners = [0_u32; 3];
1810 for (corner, &index) in mesh.triangles[triangle].iter().enumerate() {
1811 corners[corner] = *vertex_map.entry(index).or_insert_with(|| {
1812 vertices.push(mesh.vertices[index as usize]);
1813 vertices.len() as u32 - 1
1814 });
1815 }
1816 component_triangles.push(corners);
1817 }
1818 let mut component = Mesh::new(vertices, component_triangles);
1819 if let Some(normals) = &mesh.vertex_normals {
1820 if normals.len() == mesh.vertices.len() {
1821 let mut component_normals = vec![Vec3::default(); component.vertices.len()];
1822 for (&source, &target) in &vertex_map {
1823 component_normals[target as usize] = normals[source as usize];
1824 }
1825 component.vertex_normals = Some(component_normals);
1826 }
1827 }
1828 if !mesh.source_metadata.is_empty() {
1829 let local = triangles
1830 .iter()
1831 .enumerate()
1832 .map(|(local, &source)| (source, local))
1833 .collect::<HashMap<_, _>>();
1834 component.source_metadata = mesh
1835 .source_metadata
1836 .iter()
1837 .filter_map(|metadata| {
1838 let triangle_indices = metadata
1839 .triangle_indices
1840 .iter()
1841 .filter_map(|source| local.get(source).copied())
1842 .collect::<Vec<_>>();
1843 (!triangle_indices.is_empty()).then(|| crate::SourceMetadata {
1844 triangle_indices,
1845 ..metadata.clone()
1846 })
1847 })
1848 .collect();
1849 }
1850
1851 let (component_positions, component_indices) = match indices {
1852 Some(indices) => {
1853 let mut source_map = HashMap::<u32, u32>::new();
1854 let mut component_positions = Vec::new();
1855 let mut component_indices = Vec::with_capacity(triangles.len() * 3);
1856 for &triangle in triangles {
1857 for &index in &indices[triangle * 3..triangle * 3 + 3] {
1858 let mapped = *source_map.entry(index).or_insert_with(|| {
1859 let base = index as usize * 3;
1860 component_positions.extend_from_slice(&positions[base..base + 3]);
1861 component_positions.len() as u32 / 3 - 1
1862 });
1863 component_indices.push(mapped);
1864 }
1865 }
1866 (component_positions, Some(component_indices))
1867 }
1868 None => {
1869 let mut component_positions = Vec::with_capacity(triangles.len() * 9);
1870 for &triangle in triangles {
1871 component_positions.extend_from_slice(&positions[triangle * 9..triangle * 9 + 9]);
1872 }
1873 (component_positions, None)
1874 }
1875 };
1876 ComponentMesh {
1877 mesh: component,
1878 positions: component_positions,
1879 indices: component_indices,
1880 }
1881}
1882
1883fn validate_source_buffers(
1884 mesh: &Mesh,
1885 positions: &[f64],
1886 indices: Option<&[u32]>,
1887) -> Result<(), StlConversionError> {
1888 if positions.is_empty() || !positions.len().is_multiple_of(3) {
1889 return Err(StlConversionError(
1890 "source position buffer must contain complete non-empty xyz triples".to_owned(),
1891 ));
1892 }
1893 if positions.iter().any(|value| !value.is_finite()) {
1894 return Err(StlConversionError(
1895 "source position buffer contains a non-finite coordinate".to_owned(),
1896 ));
1897 }
1898 let source_triangles = match indices {
1899 Some(indices) => {
1900 if !indices.len().is_multiple_of(3) {
1901 return Err(StlConversionError(
1902 "source index buffer must contain complete triangles".to_owned(),
1903 ));
1904 }
1905 if indices
1906 .iter()
1907 .any(|&index| index as usize >= positions.len() / 3)
1908 {
1909 return Err(StlConversionError(
1910 "source index buffer references a missing position".to_owned(),
1911 ));
1912 }
1913 indices.len() / 3
1914 }
1915 None => {
1916 if !positions.len().is_multiple_of(9) {
1917 return Err(StlConversionError(
1918 "unindexed source positions must be a triangle soup".to_owned(),
1919 ));
1920 }
1921 positions.len() / 9
1922 }
1923 };
1924 if source_triangles != mesh.triangles.len() {
1925 return Err(StlConversionError(format!(
1926 "source buffers contain {source_triangles} triangles but recognition mesh contains {}",
1927 mesh.triangles.len()
1928 )));
1929 }
1930 Ok(())
1931}
1932
1933fn complete_small_planar_regions(
1938 mesh: &Mesh,
1939 options: &RecognitionOptions,
1940 regions: &mut Vec<SurfaceRegion>,
1941 unresolved: &mut Vec<usize>,
1942) -> Result<usize, StlConversionError> {
1943 if unresolved.len() < 2 {
1944 return Ok(0);
1945 }
1946 let analyzed = mesh
1947 .analyze(&MeshAnalysisOptions {
1948 feature_angle: options.feature_angle,
1949 ..MeshAnalysisOptions::default()
1950 })
1951 .map_err(|error| {
1952 StlConversionError(format!("plane completion analysis failed: {error}"))
1953 })?;
1954 let allowed = unresolved.iter().copied().collect::<BTreeSet<_>>();
1955 let mut visited = BTreeSet::new();
1956 let mut accepted = BTreeSet::new();
1957 let mut completed = 0;
1958 let mut plane_options = options.clone();
1959 plane_options.minimum_support = 2;
1960
1961 for &seed in unresolved.iter() {
1962 if !visited.insert(seed) {
1963 continue;
1964 }
1965 let mut component = Vec::new();
1966 let mut queue = VecDeque::from([seed]);
1967 while let Some(triangle) = queue.pop_front() {
1968 component.push(triangle);
1969 let data = &analyzed.triangles[triangle];
1970 for edge in 0..3 {
1971 if data.feature_edges[edge] {
1972 continue;
1973 }
1974 if let Some(neighbor) = data.neighbors[edge] {
1975 if allowed.contains(&neighbor) && visited.insert(neighbor) {
1976 queue.push_back(neighbor);
1977 }
1978 }
1979 }
1980 }
1981 component.sort_unstable();
1982 if component.len() < 2 {
1983 continue;
1984 }
1985 let Ok(fit) = reconstruct_surface(
1986 mesh,
1987 &component,
1988 &SurfaceHint::KnownType {
1989 surface_type: SurfaceType::Plane,
1990 },
1991 &plane_options,
1992 ) else {
1993 continue;
1994 };
1995 if fit.metrics.support_triangles != component.len()
1996 || !matches!(fit.surface, AnalyticSurface::Plane(_))
1997 {
1998 continue;
1999 }
2000 accepted.extend(component.iter().copied());
2001 regions.push(SurfaceRegion {
2002 surface: fit.surface,
2003 orientation: fit.orientation,
2004 triangle_indices: component,
2005 metrics: fit.metrics,
2006 confidence: fit.confidence,
2007 diagnostics: fit.diagnostics,
2008 });
2009 completed += 1;
2010 }
2011 unresolved.retain(|triangle| !accepted.contains(triangle));
2012 Ok(completed)
2013}
2014
2015fn region_report(region: &SurfaceRegion) -> ConversionRegionReport {
2016 fit_fields(
2017 region.surface,
2018 region.orientation,
2019 ®ion.metrics,
2020 region.confidence,
2021 ®ion.diagnostics,
2022 )
2023}
2024
2025fn refit_report(fit: &SurfaceFitResult) -> ConversionRegionReport {
2026 fit_fields(
2027 fit.surface,
2028 fit.orientation,
2029 &fit.metrics,
2030 fit.confidence,
2031 &fit.diagnostics,
2032 )
2033}
2034
2035fn fit_fields(
2036 surface: AnalyticSurface,
2037 orientation: i8,
2038 metrics: &crate::FitMetrics,
2039 confidence: f64,
2040 diagnostics: &crate::FitDiagnostics,
2041) -> ConversionRegionReport {
2042 ConversionRegionReport {
2043 surface_type: surface.surface_type(),
2044 surface,
2045 support_triangles: metrics.support_triangles,
2046 supported_area: metrics.supported_area,
2047 rms_error: metrics.rms_error,
2048 max_error: metrics.max_error,
2049 rms_normal_error_radians: metrics.rms_normal_error,
2050 max_normal_error_radians: metrics.max_normal_error,
2051 confidence,
2052 orientation,
2053 reason: diagnostics.reason.clone(),
2054 phase_timings: diagnostics.phase_timings,
2055 }
2056}
2057
2058fn disjoint_complete_partition(regions: &[SurfaceRegion], triangle_count: usize) -> bool {
2059 let mut seen = BTreeSet::new();
2060 regions.iter().all(|region| {
2061 region
2062 .triangle_indices
2063 .iter()
2064 .all(|&triangle| triangle < triangle_count && seen.insert(triangle))
2065 }) && seen.len() == triangle_count
2066}
2067
2068pub(crate) struct RepairedMesh {
2071 mesh: Mesh,
2072 pub(crate) positions: Vec<f64>,
2073 pub(crate) indices: Vec<u32>,
2074 message: String,
2075 pub(crate) stitched_edges: usize,
2077}
2078
2079pub(crate) fn repaired_closed_mesh(
2084 source_positions: &[f64],
2085 source_indices: Option<&[u32]>,
2086 options: &StlConversionOptions,
2087) -> Option<RepairedMesh> {
2088 let owned_indices;
2092 let stitched_indices = match source_indices {
2093 Some(indices) => indices,
2094 None => {
2095 owned_indices = (0..source_positions.len() as u32 / 3).collect::<Vec<_>>();
2096 &owned_indices
2097 }
2098 };
2099 let stitch_tolerance = if options.weld_tolerance > 0.0 {
2100 options.weld_tolerance
2101 } else {
2102 mesh_diagonal_of(source_positions) * crate::stl::DEFAULT_RELATIVE_WELD_TOLERANCE
2103 };
2104 let (stitched, stitch) =
2105 t_junctions::stitch_t_junctions(source_positions, stitched_indices, stitch_tolerance);
2106 let (positions, indices, report) =
2107 repair_triangle_soup(source_positions, Some(&stitched), options.weld_tolerance).ok()?;
2108 let vertices = positions
2109 .chunks_exact(3)
2110 .map(|point| Vec3::new(point[0], point[1], point[2]))
2111 .collect::<Vec<_>>();
2112 let triangles = indices
2113 .chunks_exact(3)
2114 .map(|triangle| [triangle[0], triangle[1], triangle[2]])
2115 .collect::<Vec<_>>();
2116 let mesh = Mesh::new(vertices, triangles);
2117 if !closed_manifold(&mesh) {
2118 return None;
2119 }
2120 let message = format!(
2121 "the source mesh is not a closed two-manifold; the analytic lanes ran on the repaired mesh \
2122 ({}{} welded vertices, {} triangles: {} dropped as degenerate or duplicate, {} surplus \
2123 sheet(s) pruned, {} reoriented to agree with their neighbours, {} boundary loop(s) \
2124 capped with {} triangle(s))",
2125 if stitch.stitched_edges > 0 {
2126 format!(
2127 "{} T-junction edge(s) split at a vertex on their interior, {} triangle(s) added; ",
2128 stitch.stitched_edges, stitch.added_triangles
2129 )
2130 } else {
2131 String::new()
2132 },
2133 report.welded_vertices,
2134 report.triangles,
2135 report.dropped_triangles,
2136 report.pruned_triangles,
2137 report.reoriented_triangles,
2138 report.capped_loops,
2139 report.cap_triangles,
2140 );
2141 Some(RepairedMesh {
2142 mesh,
2143 positions,
2144 indices,
2145 message,
2146 stitched_edges: stitch.stitched_edges,
2147 })
2148}
2149
2150fn mesh_diagonal_of(positions: &[f64]) -> f64 {
2152 let mut low = [f64::INFINITY; 3];
2153 let mut high = [f64::NEG_INFINITY; 3];
2154 for point in positions.chunks_exact(3) {
2155 for axis in 0..3 {
2156 low[axis] = low[axis].min(point[axis]);
2157 high[axis] = high[axis].max(point[axis]);
2158 }
2159 }
2160 (0..3).map(|axis| (high[axis] - low[axis]).powi(2)).sum::<f64>().sqrt()
2161}
2162
2163fn closed_manifold(mesh: &Mesh) -> bool {
2164 closed_triangles(mesh.triangles.iter().copied())
2165}
2166
2167fn closed_triangles(triangles: impl Iterator<Item = [u32; 3]>) -> bool {
2168 let mut uses = BTreeMap::<(u32, u32), (usize, i32)>::new();
2169 for triangle in triangles {
2170 if triangle[0] == triangle[1] || triangle[1] == triangle[2] || triangle[2] == triangle[0] {
2171 return false;
2172 }
2173 for edge in [
2174 (triangle[0], triangle[1]),
2175 (triangle[1], triangle[2]),
2176 (triangle[2], triangle[0]),
2177 ] {
2178 let key = if edge.0 < edge.1 {
2179 (edge.0, edge.1)
2180 } else {
2181 (edge.1, edge.0)
2182 };
2183 let entry = uses.entry(key).or_default();
2184 entry.0 += 1;
2185 entry.1 += if edge == key { 1 } else { -1 };
2186 }
2187 }
2188 !uses.is_empty()
2189 && uses
2190 .values()
2191 .all(|&(count, sense)| count == 2 && sense == 0)
2192}
2193
2194struct DirectAnalyticBuild {
2195 solid: Result<brep_kernel::BrepSolid, StlConversionError>,
2196 backend: ConversionBackend,
2197 reason: String,
2198 topology_refit: Option<ConversionRegionReport>,
2199}
2200
2201fn direct_analytic_solid(
2202 mesh: &Mesh,
2203 regions: &[SurfaceRegion],
2204 options: &RecognitionOptions,
2205) -> Option<DirectAnalyticBuild> {
2206 if regions.len() == 1 {
2207 return match regions[0].surface {
2208 AnalyticSurface::Sphere(sphere) => Some(DirectAnalyticBuild {
2209 solid: make_sphere_brep(
2210 kernel_vec(sphere.center),
2211 sphere.radius,
2212 kernel_vec(Vec3::new(0.0, 0.0, 1.0)),
2213 )
2214 .map_err(StlConversionError),
2215 backend: ConversionBackend::RansacSphere,
2216 reason: "RANSAC proved complete closed spherical coverage; exported an exact analytic sphere".to_owned(),
2217 topology_refit: None,
2218 }),
2219 AnalyticSurface::Torus(torus) if torus.major_radius > torus.minor_radius => Some(DirectAnalyticBuild {
2220 solid: make_torus_brep(
2221 kernel_vec(torus.center),
2222 kernel_vec(torus.axis),
2223 torus.major_radius,
2224 torus.minor_radius,
2225 )
2226 .map_err(StlConversionError),
2227 backend: ConversionBackend::RansacTorus,
2228 reason: "RANSAC proved complete closed ring-torus coverage; exported an exact analytic torus".to_owned(),
2229 topology_refit: None,
2230 }),
2231 _ => None,
2232 };
2233 }
2234
2235 let curved = regions
2236 .iter()
2237 .filter(|region| !matches!(region.surface, AnalyticSurface::Plane(_)))
2238 .collect::<Vec<_>>();
2239 let planes = regions
2240 .iter()
2241 .filter_map(|region| match region.surface {
2242 AnalyticSurface::Plane(plane) => Some(plane),
2243 _ => None,
2244 })
2245 .collect::<Vec<_>>();
2246 if curved.len() != 1 {
2247 return None;
2248 }
2249 let topology_refit;
2255 let mut topology_refit_report = None;
2256 let curved = if let AnalyticSurface::Torus(torus) = curved[0].surface {
2257 let scale = mesh_diagonal(mesh);
2258 let collapsed = torus.major_radius.min(torus.minor_radius) > 20.0 * scale;
2259 let forced_type = match planes.len() {
2260 2 if collapsed => Some(SurfaceType::Cylinder),
2261 1 if collapsed => Some(SurfaceType::Cone),
2262 _ => None,
2263 };
2264 if let Some(surface_type) = forced_type {
2265 topology_refit = reconstruct_surface(
2266 mesh,
2267 &curved[0].triangle_indices,
2268 &SurfaceHint::KnownType { surface_type },
2269 options,
2270 )
2271 .ok()?;
2272 topology_refit_report = Some(refit_report(&topology_refit));
2273 &topology_refit.surface
2274 } else {
2275 &curved[0].surface
2276 }
2277 } else {
2278 &curved[0].surface
2279 };
2280 match *curved {
2281 AnalyticSurface::Cylinder(cylinder) if planes.len() == 2 => {
2282 let (low, high) = axial_range(mesh, cylinder.axis_origin, cylinder.axis)?;
2283 let tolerance = conversion_tolerance(mesh, options);
2284 if high - low <= tolerance
2285 || !caps_match(
2286 &planes,
2287 cylinder.axis_origin,
2288 cylinder.axis,
2289 &[low, high],
2290 tolerance,
2291 )
2292 {
2293 return None;
2294 }
2295 let base = cylinder.axis_origin + cylinder.axis * low;
2296 Some(DirectAnalyticBuild {
2297 solid: make_cylinder_brep(
2298 kernel_vec(base),
2299 kernel_vec(cylinder.axis),
2300 cylinder.radius,
2301 high - low,
2302 )
2303 .map_err(StlConversionError),
2304 backend: ConversionBackend::RansacCappedCylinder,
2305 reason: if topology_refit_report.is_some() {
2306 "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()
2307 } else {
2308 "RANSAC proved one cylindrical wall plus two matching planar caps; exported an exact capped cylinder".to_owned()
2309 },
2310 topology_refit: topology_refit_report,
2311 })
2312 }
2313 AnalyticSurface::Cone(cone) if planes.len() == 1 || planes.len() == 2 => {
2314 let (low, high) = axial_range(mesh, cone.apex, cone.axis)?;
2315 let tolerance = conversion_tolerance(mesh, options);
2316 if low < -tolerance || high - low <= tolerance {
2317 return None;
2318 }
2319 let expected_caps = if low <= tolerance {
2320 vec![high]
2321 } else {
2322 vec![low, high]
2323 };
2324 if planes.len() != expected_caps.len()
2325 || !caps_match(&planes, cone.apex, cone.axis, &expected_caps, tolerance)
2326 {
2327 return None;
2328 }
2329 let radius_bottom = low.max(0.0) * cone.half_angle.tan();
2330 let radius_top = high * cone.half_angle.tan();
2331 if radius_top <= tolerance || (low > tolerance && radius_bottom <= tolerance) {
2332 return None;
2333 }
2334 let (base, build_axis, build_bottom, build_top) = if low <= tolerance {
2335 (
2339 cone.apex + cone.axis * high,
2340 cone.axis * -1.0,
2341 radius_top,
2342 0.0,
2343 )
2344 } else {
2345 (
2346 cone.apex + cone.axis * low,
2347 cone.axis,
2348 radius_bottom,
2349 radius_top,
2350 )
2351 };
2352 Some(DirectAnalyticBuild {
2353 solid: make_cone_brep(
2354 kernel_vec(base),
2355 kernel_vec(build_axis),
2356 build_bottom,
2357 build_top,
2358 high - low.max(0.0),
2359 )
2360 .map_err(StlConversionError),
2361 backend: ConversionBackend::RansacCappedCone,
2362 reason: if topology_refit_report.is_some() {
2363 "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()
2364 } else {
2365 "RANSAC proved one conical wall and matching planar cap topology; exported an exact capped cone".to_owned()
2366 },
2367 topology_refit: topology_refit_report,
2368 })
2369 }
2370 _ => None,
2371 }
2372}
2373
2374fn axial_range(mesh: &Mesh, origin: Vec3, axis: Vec3) -> Option<(f64, f64)> {
2375 let mut low = f64::INFINITY;
2376 let mut high = f64::NEG_INFINITY;
2377 for &point in &mesh.vertices {
2378 let height = (point - origin).dot(axis);
2379 low = low.min(height);
2380 high = high.max(height);
2381 }
2382 (low.is_finite() && high.is_finite()).then_some((low, high))
2383}
2384
2385fn caps_match(
2386 planes: &[crate::PlaneSurface],
2387 origin: Vec3,
2388 axis: Vec3,
2389 expected_heights: &[f64],
2390 tolerance: f64,
2391) -> bool {
2392 let mut actual = Vec::with_capacity(planes.len());
2393 for plane in planes {
2394 if plane.normal.dot(axis).abs() < 1.0 - 1.0e-6 {
2395 return false;
2396 }
2397 actual.push((plane.origin - origin).dot(axis));
2398 }
2399 actual.sort_by(f64::total_cmp);
2400 let mut expected = expected_heights.to_vec();
2401 expected.sort_by(f64::total_cmp);
2402 actual.len() == expected.len()
2403 && actual
2404 .iter()
2405 .zip(expected)
2406 .all(|(actual, expected)| (*actual - expected).abs() <= tolerance)
2407}
2408
2409fn mesh_tolerance(mesh: &Mesh) -> f64 {
2410 let mut low = mesh.vertices[0];
2411 let mut high = mesh.vertices[0];
2412 for &point in &mesh.vertices[1..] {
2413 low.x = low.x.min(point.x);
2414 low.y = low.y.min(point.y);
2415 low.z = low.z.min(point.z);
2416 high.x = high.x.max(point.x);
2417 high.y = high.y.max(point.y);
2418 high.z = high.z.max(point.z);
2419 }
2420 (high - low).length().max(1.0) * 1.0e-5
2421}
2422
2423fn conversion_tolerance(mesh: &Mesh, options: &RecognitionOptions) -> f64 {
2424 mesh_tolerance(mesh)
2425 .max(options.distance_tolerance + options.relative_tolerance * mesh_diagonal(mesh))
2426}
2427
2428fn mesh_diagonal(mesh: &Mesh) -> f64 {
2429 let mut low = mesh.vertices[0];
2430 let mut high = mesh.vertices[0];
2431 for &point in &mesh.vertices[1..] {
2432 low.x = low.x.min(point.x);
2433 low.y = low.y.min(point.y);
2434 low.z = low.z.min(point.z);
2435 high.x = high.x.max(point.x);
2436 high.y = high.y.max(point.y);
2437 high.z = high.z.max(point.z);
2438 }
2439 (high - low).length()
2440}
2441
2442fn coplanar_merge_tolerance(mesh: &Mesh) -> f64 {
2443 (mesh_diagonal(mesh) * 1.0e-9).max(1.0e-12)
2444}
2445
2446fn solid_face_count(solid: &brep_kernel::BrepSolid) -> usize {
2447 solid.shells.iter().map(|shell| shell.faces.len()).sum()
2448}
2449
2450const MAX_WHOLE_PRIMITIVE_REGIONS: usize = 3;
2454
2455pub(crate) fn segmentation_options(positions: &[f64], options: &StlConversionOptions) -> SegmentOptions {
2456 SegmentOptions {
2457 deflection_angle_deg: options.kernel_deflection_angle_degrees,
2458 fit_tolerance: options.kernel_fit_tolerance,
2459 normal_tolerance_deg: options.kernel_normal_tolerance_degrees,
2460 weld_tolerance: if options.weld_tolerance > 0.0 {
2465 options.weld_tolerance
2466 } else {
2467 let mut low = [f64::INFINITY; 3];
2468 let mut high = [f64::NEG_INFINITY; 3];
2469 for point in positions.chunks_exact(3) {
2470 for axis in 0..3 {
2471 low[axis] = low[axis].min(point[axis]);
2472 high[axis] = high[axis].max(point[axis]);
2473 }
2474 }
2475 let diagonal = (0..3)
2476 .map(|axis| (high[axis] - low[axis]).powi(2))
2477 .sum::<f64>()
2478 .sqrt();
2479 (diagonal * crate::stl::DEFAULT_RELATIVE_WELD_TOLERANCE).max(f64::MIN_POSITIVE)
2480 },
2481 ..SegmentOptions::default()
2482 }
2483}
2484
2485fn hybrid_roundtrip_fallback_enabled() -> bool {
2499 brep_kernel::switch!(live "BREP_HYBRID_ROUNDTRIP_FALLBACK", true)
2500}
2501
2502fn solid_round_trips(solid: &brep_kernel::BrepSolid) -> Result<(), String> {
2505 let text = export_step(std::slice::from_ref(solid), "round-trip", "millimeter", "2026-01-01T00:00:00Z")
2506 .map_err(|error| format!("export: {error}"))?;
2507 let imported = import_step(&text).map_err(|error| error.to_string())?;
2508 match imported.iter().map(|solid| solid.validate()).find(|issues| !issues.is_empty()) {
2509 Some(issues) => Err(format!("imported solid invalid: {issues:?}")),
2510 None => Ok(()),
2511 }
2512}
2513
2514fn tjunction_strip_hybrid_enabled() -> bool {
2526 brep_kernel::switch!(live "BREP_TJUNCTION_STRIP_HYBRID", false)
2527}
2528
2529fn hybrid_over_kernel_strips(
2534 kernel: &brep_kernel::BrepSolid,
2535 positions: &[f64],
2536 indices: Option<&[u32]>,
2537 options: &StlConversionOptions,
2538) -> Option<crate::hybrid_region_brep::HybridBrepOutput> {
2539 let output = try_hybrid_analytic(positions, indices, options).ok()?;
2540 if hybrid_report(&output.stats).faceted_faces != 0
2541 || solid_face_count(&output.solid) >= solid_face_count(kernel)
2542 {
2543 return None;
2544 }
2545 let (before, after) = (
2546 brep_kernel::solid_signed_volume(kernel).ok()?,
2547 brep_kernel::solid_signed_volume(&output.solid).ok()?,
2548 );
2549 if !((after - before).abs() <= 1e-2 * before.abs()) {
2550 return None;
2551 }
2552 solid_round_trips(&output.solid).ok()?;
2553 Some(output)
2554}
2555
2556fn segment_carrier_enabled() -> bool {
2557 brep_kernel::switch!(live "BREP_RECOG_SEGMENT_CARRIER", true)
2558}
2559
2560fn segmentation_candidates(mesh: &Mesh, segmentation: &MeshSegmentation) -> Vec<ComponentCandidate> {
2575 if segmentation.triangle_count != mesh.triangles.len()
2576 || segmentation.triangle_region_ids.len() != mesh.triangles.len()
2577 {
2578 return Vec::new();
2579 }
2580 let mut members = vec![Vec::new(); segmentation.regions.len()];
2581 for (triangle, ®ion) in segmentation.triangle_region_ids.iter().enumerate() {
2582 if let Some(list) = members.get_mut(region as usize) {
2583 list.push(triangle);
2584 }
2585 }
2586 segmentation
2587 .regions
2588 .iter()
2589 .zip(members)
2590 .filter_map(|(region, triangles)| {
2591 let (surface, parameters) = carrier_surface(®ion.carrier)?;
2592 if triangles.is_empty() || !surface.is_valid() {
2593 return None;
2594 }
2595 let indices: Vec<u32> = triangles.iter().flat_map(|&t| mesh.triangles[t]).collect();
2596 let (statistic, threshold) = crate::hybrid_whole_freeform::carrier_local_scan(
2597 &indices,
2598 |v| surface.signed_distance(mesh.vertices[v as usize]),
2599 parameters,
2600 );
2601 if std::env::var_os("BREP_DEBUG_SPLIT").is_some() {
2602 eprintln!(
2603 "[candidate] region {} {} tris {} local {statistic:.2} threshold {threshold:.2} {}",
2604 region.id,
2605 region.carrier.kind(),
2606 triangles.len(),
2607 if statistic <= threshold { "offered" } else { "declined" }
2608 );
2609 }
2610 (statistic <= threshold).then_some(ComponentCandidate { triangle_indices: triangles, surface })
2611 })
2612 .collect()
2613}
2614
2615fn whole_fits_pass_the_local_scan(
2630 mesh: &Mesh,
2631 regions: &[SurfaceRegion],
2632 requested: f64,
2633 tally: &mut WholeCarrierScanReport,
2634) -> bool {
2635 if !crate::hybrid_whole_freeform::whole_carrier_scan_enabled() {
2636 return true;
2637 }
2638 let mut passes = true;
2639 for region in regions.iter().filter(|region| region.metrics.max_error > requested) {
2640 let surface = ®ion.surface;
2641 let indices: Vec<u32> = region
2642 .triangle_indices
2643 .iter()
2644 .flat_map(|&t| mesh.triangles[t])
2645 .collect();
2646 let reading = crate::hybrid_whole_freeform::carrier_scan_verdict(
2647 &indices,
2648 |v| surface.signed_distance(mesh.vertices[v as usize]),
2649 analytic_parameters(surface),
2650 );
2651 if std::env::var_os("BREP_DEBUG_SPLIT").is_some() {
2652 eprintln!(
2653 "[whole-fit] {} tris {} local {:.2} threshold {:.2} independence {:.3} bound {:.3} edges {} {:?}",
2654 surface.surface_type().name(),
2655 region.triangle_indices.len(),
2656 reading.statistic,
2657 reading.threshold,
2658 reading.independence,
2659 reading.bound,
2660 reading.outside_edges,
2661 reading.verdict
2662 );
2663 }
2664 tally.add(reading.verdict);
2665 if reading.verdict == crate::hybrid_whole_freeform::ScanVerdict::Declined {
2666 passes = false;
2667 break;
2668 }
2669 }
2670 passes
2671}
2672
2673fn analytic_parameters(surface: &AnalyticSurface) -> usize {
2675 match surface {
2676 AnalyticSurface::Plane(_) => 3,
2677 AnalyticSurface::Cylinder(_) => 5,
2678 AnalyticSurface::Cone(_) => 6,
2679 AnalyticSurface::Sphere(_) => 4,
2680 AnalyticSurface::Torus(_) => 7,
2681 }
2682}
2683
2684fn carrier_surface(carrier: &RegionCarrier) -> Option<(AnalyticSurface, usize)> {
2686 let v = |p: brep_kernel::Vec3| crate::brep::vec3_from_kernel(p);
2687 Some(match *carrier {
2688 RegionCarrier::Plane { origin, normal } => {
2689 (AnalyticSurface::Plane(PlaneSurface { origin: v(origin), normal: v(normal) }), 3)
2690 }
2691 RegionCarrier::Cylinder { axis_point, axis_dir, radius, .. } => (
2692 AnalyticSurface::Cylinder(CylinderSurface { axis_origin: v(axis_point), axis: v(axis_dir), radius }),
2693 5,
2694 ),
2695 RegionCarrier::Cone { apex, axis_dir, half_angle_rad, .. } => (
2696 AnalyticSurface::Cone(ConeSurface { apex: v(apex), axis: v(axis_dir), half_angle: half_angle_rad }),
2697 6,
2698 ),
2699 RegionCarrier::Sphere { center, radius, .. } => {
2700 (AnalyticSurface::Sphere(SphereSurface { center: v(center), radius }), 4)
2701 }
2702 RegionCarrier::Torus { center, axis_dir, major_radius, minor_radius, .. } => (
2703 AnalyticSurface::Torus(TorusSurface { center: v(center), axis: v(axis_dir), major_radius, minor_radius }),
2704 7,
2705 ),
2706 RegionCarrier::Freeform => return None,
2707 })
2708}
2709
2710fn gate_segmentation(
2711 positions: &[f64],
2712 indices: Option<&[u32]>,
2713 options: &StlConversionOptions,
2714) -> Result<MeshSegmentation, String> {
2715 let owned_indices;
2716 let indices = match indices {
2717 Some(indices) => indices,
2718 None => {
2719 owned_indices = (0..positions.len() as u32 / 3).collect::<Vec<_>>();
2720 &owned_indices
2721 }
2722 };
2723 segment_mesh_faces(positions, indices, &segmentation_options(positions, options))
2724 .map_err(|error| format!("kernel segmentation failed: {error}"))
2725}
2726
2727fn segmentation_is_complete(segmentation: &MeshSegmentation) -> bool {
2729 !segmentation
2730 .triangle_region_ids
2731 .contains(&UNASSIGNED_REGION)
2732 && segmentation
2733 .regions
2734 .iter()
2735 .all(|region| !matches!(region.carrier, RegionCarrier::Freeform))
2736}
2737
2738fn recognition_gate(segmentation: &Result<MeshSegmentation, String>) -> Result<(), String> {
2747 let segmentation = segmentation.as_ref().map_err(Clone::clone)?;
2748 let unassigned = segmentation
2749 .triangle_region_ids
2750 .iter()
2751 .filter(|id| **id == UNASSIGNED_REGION)
2752 .count();
2753 let unsupported = segmentation
2754 .regions
2755 .iter()
2756 .filter(|region| matches!(region.carrier, RegionCarrier::Freeform))
2757 .count();
2758 if unassigned > 0 || unsupported > 0 {
2759 return Err(format!(
2760 "kernel segmentation left {unassigned} triangle(s) unassigned and {unsupported} region(s) without an analytic carrier, so no whole-primitive lane can fire"
2761 ));
2762 }
2763 if segmentation.regions.len() > MAX_WHOLE_PRIMITIVE_REGIONS {
2764 return Err(format!(
2765 "kernel segmentation found {} analytic regions; a whole primitive has at most {MAX_WHOLE_PRIMITIVE_REGIONS}",
2766 segmentation.regions.len()
2767 ));
2768 }
2769 Ok(())
2770}
2771
2772fn try_kernel_analytic(
2773 positions: &[f64],
2774 indices: Option<&[u32]>,
2775 options: &StlConversionOptions,
2776) -> Result<brep_kernel::BrepSolid, String> {
2777 let owned_indices;
2778 let indices = match indices {
2779 Some(indices) => indices,
2780 None => {
2781 owned_indices = (0..positions.len() as u32 / 3).collect::<Vec<_>>();
2782 &owned_indices
2783 }
2784 };
2785 let segmentation_options = segmentation_options(positions, options);
2786 let segmentation = segment_mesh_faces(positions, indices, &segmentation_options)?;
2787 if segmentation
2788 .triangle_region_ids
2789 .contains(&UNASSIGNED_REGION)
2790 {
2791 return Err("independent kernel segmentation left triangles unassigned".to_owned());
2792 }
2793 if let Some(region) = segmentation.regions.iter().find(|region| {
2794 matches!(
2795 region.carrier,
2796 RegionCarrier::Freeform | RegionCarrier::Sphere { .. } | RegionCarrier::Torus { .. }
2797 )
2798 }) {
2799 return Err(format!(
2800 "independent kernel segmentation produced unsupported {} region {}",
2801 region.carrier.kind(),
2802 region.id
2803 ));
2804 }
2805 mesh_regions_to_brep(positions, indices, &segmentation_options)
2806}
2807
2808fn try_hybrid_analytic(
2809 positions: &[f64],
2810 indices: Option<&[u32]>,
2811 options: &StlConversionOptions,
2812) -> Result<crate::hybrid_region_brep::HybridBrepOutput, String> {
2813 crate::hybrid_region_brep::hybrid_plane_cylinder_brep(
2814 positions,
2815 indices,
2816 &segmentation_options(positions, options),
2817 )
2818}
2819
2820fn hybrid_report(stats: &crate::hybrid_region_brep::HybridBrepStats) -> HybridConversionReport {
2821 HybridConversionReport {
2822 total_faces: stats.total_faces,
2823 analytic_plane_faces: stats.analytic_plane_faces,
2824 analytic_plane_triangles: stats.analytic_plane_triangles,
2825 analytic_cylinder_faces: stats.analytic_cylinder_faces,
2826 analytic_cylinder_triangles: stats.analytic_cylinder_triangles,
2827 analytic_cone_faces: stats.analytic_cone_faces,
2828 analytic_cone_triangles: stats.analytic_cone_triangles,
2829 analytic_sphere_faces: stats.analytic_sphere_faces,
2830 analytic_sphere_triangles: stats.analytic_sphere_triangles,
2831 analytic_torus_faces: stats.analytic_torus_faces,
2832 analytic_torus_triangles: stats.analytic_torus_triangles,
2833 faceted_faces: stats.faceted_faces,
2834 faceted_triangles: stats.faceted_triangles,
2835 demoted_regions: stats.demoted_regions,
2836 demoted_triangles: stats.demoted_triangles,
2837 segmentation_plane_regions: stats.segmentation_plane_regions,
2838 segmentation_cylinder_regions: stats.segmentation_cylinder_regions,
2839 segmentation_cone_regions: stats.segmentation_cone_regions,
2840 segmentation_sphere_regions: stats.segmentation_sphere_regions,
2841 segmentation_torus_regions: stats.segmentation_torus_regions,
2842 segmentation_unsupported_regions: stats.segmentation_unsupported_regions,
2843 }
2844}
2845
2846fn kernel_vec(value: Vec3) -> brep_kernel::Vec3 {
2847 brep_kernel::Vec3::new(value.x, value.y, value.z)
2848}
2849