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 chips = crate::hybrid_region_brep::hybrid_plane_cylinder_brep_before_promotion_16(
1211 source_positions,
1212 source_indices,
1213 &segmentation_options(source_positions, options),
1214 );
1215 if let Ok(Some(chips)) = chips {
1216 if solid_round_trips(&chips.solid).is_ok() {
1217 return Ok(chips);
1218 }
1219 }
1220 let crossing = crate::hybrid_region_brep::hybrid_plane_cylinder_brep_before_promotion_15(
1223 source_positions,
1224 source_indices,
1225 &segmentation_options(source_positions, options),
1226 );
1227 if let Ok(Some(crossing)) = crossing {
1228 if solid_round_trips(&crossing.solid).is_ok() {
1229 return Ok(crossing);
1230 }
1231 }
1232 let joined = crate::hybrid_region_brep::hybrid_plane_cylinder_brep_before_promotion_14(
1235 source_positions,
1236 source_indices,
1237 &segmentation_options(source_positions, options),
1238 );
1239 if let Ok(Some(joined)) = joined {
1240 if solid_round_trips(&joined.solid).is_ok() {
1241 return Ok(joined);
1242 }
1243 }
1244 let latest = crate::hybrid_region_brep::hybrid_plane_cylinder_brep_before_promotion_13(
1247 source_positions,
1248 source_indices,
1249 &segmentation_options(source_positions, options),
1250 );
1251 if let Ok(Some(latest)) = latest {
1252 if solid_round_trips(&latest.solid).is_ok() {
1253 return Ok(latest);
1254 }
1255 }
1256 let newest = crate::hybrid_region_brep::hybrid_plane_cylinder_brep_before_promotion_12(
1259 source_positions,
1260 source_indices,
1261 &segmentation_options(source_positions, options),
1262 );
1263 if let Ok(Some(newest)) = newest {
1264 if solid_round_trips(&newest.solid).is_ok() {
1265 return Ok(newest);
1266 }
1267 }
1268 let first = crate::hybrid_region_brep::hybrid_plane_cylinder_brep_before_promotion_11(
1271 source_positions,
1272 source_indices,
1273 &segmentation_options(source_positions, options),
1274 );
1275 if let Ok(Some(first)) = first {
1276 if solid_round_trips(&first.solid).is_ok() {
1277 return Ok(first);
1278 }
1279 }
1280 let second = crate::hybrid_region_brep::hybrid_plane_cylinder_brep_before_promotion_9(
1285 source_positions,
1286 source_indices,
1287 &segmentation_options(source_positions, options),
1288 );
1289 if let Ok(Some(second)) = second {
1290 if solid_round_trips(&second.solid).is_ok() {
1291 return Ok(second);
1292 }
1293 }
1294 for with_promotion_9 in [true, false] {
1298 let further = crate::hybrid_region_brep::hybrid_plane_cylinder_brep_before_promotion_10(
1299 source_positions,
1300 source_indices,
1301 &segmentation_options(source_positions, options),
1302 with_promotion_9,
1303 );
1304 if let Ok(Some(further)) = further {
1305 if solid_round_trips(&further.solid).is_ok() {
1306 return Ok(further);
1307 }
1308 }
1309 }
1310 return Err(format!(
1311 "{kernel_error}; the mixed rebuild's solid failed its STEP round trip: {error}"
1312 ));
1313 }
1314 }
1315 Ok(output)
1316 });
1317 match hybrid_attempt {
1318 Ok(output) => {
1319 let stats = &output.stats;
1320 whole_carrier_scan = whole_carrier_scan.sum(stats.whole_carrier_scan);
1321 if stats.split_freeform_regions
1322 + stats.split_declined_regions
1323 + stats.split_over_budget_regions
1324 + stats.split_budget_hit_regions
1325 + stats.split_band_declined_regions
1326 > 0
1327 {
1328 messages.push(format!(
1329 "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",
1330 stats.split_freeform_regions,
1331 stats.split_declined_regions,
1332 stats.split_over_budget_regions,
1333 stats.split_budget_hit_regions,
1334 stats.split_band_declined_regions
1335 ));
1336 }
1337 let hybrid_report = hybrid_report(&output.stats);
1338 if options.policy == ConversionPolicy::RequireFullyAnalytic
1339 && hybrid_report.faceted_faces != 0
1340 {
1341 Err(format!(
1342 "{kernel_error}; mixed rebuild retained {} faceted face(s) for {} source triangle(s)",
1343 hybrid_report.faceted_faces, hybrid_report.faceted_triangles
1344 ))
1345 } else {
1346 let reason = if hybrid_report.faceted_faces == 0 {
1347 "independent segmentation reconstructed and validated a fully analytic plane/cylinder/cone/sphere shell"
1348 } else {
1349 "independent segmentation reconstructed exact plane/cylinder/cone/sphere regions and retained unsupported or unsafe regions as facets"
1350 };
1351 hybrid_demotions = output.stats.demotions;
1352 Ok((
1353 output.solid,
1354 ConversionBackend::HybridAnalyticRebuild,
1355 reason.to_owned(),
1356 Some(hybrid_report),
1357 ))
1358 }
1359 }
1360 Err(hybrid_error) => Err(format!("{kernel_error}; {hybrid_error}")),
1361 }
1362 }
1363 };
1364
1365 match analytic_attempt {
1366 Ok((solid, backend, reason, hybrid)) => {
1367 (solid, backend, reason, None, hybrid, None, None)
1368 }
1369 Err(analytic_error) => {
1370 let cause = format!("{direct_cause}; {analytic_error}");
1371 if options.policy == ConversionPolicy::RequireFullyAnalytic {
1372 return Err(StlConversionError(format!(
1373 "strict analytic conversion refused faceted fallback: {cause}"
1374 )));
1375 }
1376 fallback_cause = Some(cause.clone());
1377 messages.push(format!("analytic rebuild unavailable: {cause}"));
1378 let faceted =
1379 mesh_to_faceted_brep(source_positions, source_indices, options.weld_tolerance)
1380 .map_err(|error| {
1381 StlConversionError(format!("faceted mesh repair failed: {error}"))
1382 })?;
1383 let faces_before = solid_face_count(&faceted);
1384 let merge_tolerance = coplanar_merge_tolerance(mesh);
1385 match merge_same_surface_faces(&faceted, merge_tolerance) {
1386 Ok(merged) => {
1387 let faces_after = solid_face_count(&merged);
1388 if faces_after < faces_before {
1389 messages.push(format!(
1390 "coplanar faceted merge reduced faces from {faces_before} to {faces_after} (tolerance {merge_tolerance:.3e})"
1391 ));
1392 (
1393 merged,
1394 ConversionBackend::FacetedRepairCoplanarMerged,
1395 "the complete source triangle set was repaired as a faceted BREP, then adjacent coplanar facets were merged without introducing curved replacement topology".to_owned(),
1396 None,
1397 None,
1398 Some(faces_before),
1399 Some(faces_after),
1400 )
1401 } else {
1402 messages.push(format!(
1403 "coplanar faceted merge found no mergeable faces (tolerance {merge_tolerance:.3e})"
1404 ));
1405 (
1406 faceted,
1407 ConversionBackend::FacetedRepair,
1408 "the complete source triangle set was repaired and preserved as a faceted BREP; no adjacent coplanar facets could be merged".to_owned(),
1409 None,
1410 None,
1411 Some(faces_before),
1412 Some(faces_before),
1413 )
1414 }
1415 }
1416 Err(error) => {
1417 messages.push(format!(
1418 "coplanar faceted merge was unavailable; retained the validated repaired mesh: {error}"
1419 ));
1420 (
1421 faceted,
1422 ConversionBackend::FacetedRepair,
1423 "the complete source triangle set was repaired and preserved as a faceted BREP; the optional coplanar merge was unavailable".to_owned(),
1424 None,
1425 None,
1426 Some(faces_before),
1427 Some(faces_before),
1428 )
1429 }
1430 }
1431 }
1432 }
1433 };
1434 let topology_build_seconds = topology_started.elapsed().as_secs_f64();
1435
1436 let topology_issues = solid.validate();
1437 if !topology_issues.is_empty() {
1438 return Err(StlConversionError(format!(
1439 "constructed BREP failed topology validation: {topology_issues:?}"
1440 )));
1441 }
1442
1443 let faces = solid_face_count(&solid);
1444 let detail = ComponentDetail {
1445 backend,
1446 backend_reason,
1447 fallback_cause,
1448 input_vertices,
1449 input_triangles,
1450 recognized_regions: recognition.regions.len(),
1451 recognized_triangles,
1452 unresolved_triangles: recognition.unresolved_triangles.len(),
1453 recognition_skipped: recognition_gate.err(),
1454 source_closed_manifold,
1455 analytic_used_repaired_mesh: repaired.is_some(),
1456 requested_distance_tolerance,
1457 effective_distance_tolerance,
1458 region_type_counts,
1459 regions,
1460 topology_refit,
1461 hybrid_rebuild,
1462 hybrid_demotions,
1463 faceted_faces_before_merge,
1464 faceted_faces_after_merge,
1465 faces,
1466 segmentation_seconds,
1467 recognition_seconds,
1468 recognition_work,
1469 whole_carrier_scan,
1470 topology_build_seconds,
1471 messages,
1472 refusal: None,
1473 };
1474 Ok((solid, detail))
1475}
1476
1477fn aggregate_report(
1482 components: &[Vec<usize>],
1483 details: Vec<ComponentDetail>,
1484 count: usize,
1485 options: &StlConversionOptions,
1486) -> StlConversionReport {
1487 let mut messages = Vec::new();
1488 if count > 1 {
1489 messages.push(format!(
1490 "the triangle soup holds {count} vertex-connected components; each shell was reconstructed independently before cavity assembly"
1491 ));
1492 }
1493 let refused = details
1494 .iter()
1495 .enumerate()
1496 .filter_map(|(index, detail)| detail.refusal.as_ref().map(|reason| (index, reason)))
1497 .collect::<Vec<_>>();
1498 if !refused.is_empty() {
1499 messages.push(format!(
1500 "{} of {count} bodies could not be reconstructed and are NOT in the model: {}",
1501 refused.len(),
1502 refused
1503 .iter()
1504 .map(|(index, reason)| format!(
1505 "body {} (from source triangle {}): {reason}",
1506 index + 1,
1507 components[*index][0]
1508 ))
1509 .collect::<Vec<_>>()
1510 .join("; ")
1511 ));
1512 }
1513 for (index, detail) in details.iter().enumerate() {
1514 for message in &detail.messages {
1515 messages.push(if count > 1 {
1516 format!("body {}: {message}", index + 1)
1517 } else {
1518 message.clone()
1519 });
1520 }
1521 }
1522
1523 let first = details
1526 .iter()
1527 .find(|detail| detail.refusal.is_none())
1528 .unwrap_or(&details[0]);
1529 let backend_reason = if count == 1 {
1530 first.backend_reason.clone()
1531 } else if details
1532 .iter()
1533 .all(|detail| detail.backend == first.backend)
1534 {
1535 format!(
1536 "{count} components were reconstructed separately; each: {}",
1537 first.backend_reason
1538 )
1539 } else {
1540 format!(
1541 "{count} components were reconstructed separately: {}",
1542 details
1543 .iter()
1544 .enumerate()
1545 .map(|(index, detail)| format!("body {} — {}", index + 1, detail.backend_reason))
1546 .collect::<Vec<_>>()
1547 .join("; ")
1548 )
1549 };
1550 let fallback_cause = details
1551 .iter()
1552 .enumerate()
1553 .find_map(|(index, detail)| {
1554 detail.fallback_cause.as_ref().map(|cause| {
1555 if count > 1 {
1556 format!("body {}: {cause}", index + 1)
1557 } else {
1558 cause.clone()
1559 }
1560 })
1561 });
1562 let recognition_skipped = details
1563 .iter()
1564 .enumerate()
1565 .map(|(index, detail)| {
1566 detail
1567 .recognition_skipped
1568 .as_ref()
1569 .map(|reason| (index, reason))
1570 })
1571 .collect::<Option<Vec<_>>>()
1572 .map(|skipped| {
1573 if count == 1 || skipped.iter().all(|(_, reason)| *reason == skipped[0].1) {
1574 skipped[0].1.clone()
1575 } else {
1576 skipped
1577 .iter()
1578 .map(|(index, reason)| format!("body {}: {reason}", index + 1))
1579 .collect::<Vec<_>>()
1580 .join("; ")
1581 }
1582 });
1583
1584 let mut region_type_counts = BTreeMap::new();
1585 for detail in &details {
1586 for (name, count) in &detail.region_type_counts {
1587 *region_type_counts.entry(name.clone()).or_insert(0) += count;
1588 }
1589 }
1590 let hybrid_rebuild = details
1591 .iter()
1592 .any(|detail| detail.hybrid_rebuild.is_some())
1593 .then(|| {
1594 details
1595 .iter()
1596 .filter_map(|detail| detail.hybrid_rebuild)
1597 .fold(HybridConversionReport::default(), add_hybrid_reports)
1598 });
1599 let faceted_faces_before_merge = sum_optional(&details, |detail| detail.faceted_faces_before_merge);
1600 let faceted_faces_after_merge = sum_optional(&details, |detail| detail.faceted_faces_after_merge);
1601
1602 let component_reports = components
1603 .iter()
1604 .zip(&details)
1605 .enumerate()
1606 .map(|(index, (triangles, detail))| ComponentConversionReport {
1607 index,
1608 first_triangle: triangles[0],
1609 input_vertices: detail.input_vertices,
1610 input_triangles: detail.input_triangles,
1611 backend: detail.backend,
1612 backend_reason: detail.backend_reason.clone(),
1613 fallback_cause: detail.fallback_cause.clone(),
1614 recognition_skipped: detail.recognition_skipped.clone(),
1615 recognized_regions: detail.recognized_regions,
1616 recognized_triangles: detail.recognized_triangles,
1617 unresolved_triangles: detail.unresolved_triangles,
1618 source_closed_manifold: detail.source_closed_manifold,
1619 analytic_used_repaired_mesh: detail.analytic_used_repaired_mesh,
1620 hybrid_rebuild: detail.hybrid_rebuild,
1621 hybrid_demotions: detail.hybrid_demotions.clone(),
1622 faceted_faces_before_merge: detail.faceted_faces_before_merge,
1623 faceted_faces_after_merge: detail.faceted_faces_after_merge,
1624 faces: detail.faces,
1625 refusal: detail.refusal.clone(),
1626 source_name: None,
1627 })
1628 .collect();
1629
1630 StlConversionReport {
1631 backend: first.backend,
1632 backend_reason,
1633 fallback_cause,
1634 input_vertices: details.iter().map(|detail| detail.input_vertices).sum(),
1635 input_triangles: details.iter().map(|detail| detail.input_triangles).sum(),
1636 recognized_regions: details.iter().map(|detail| detail.recognized_regions).sum(),
1637 recognized_triangles: details
1638 .iter()
1639 .map(|detail| detail.recognized_triangles)
1640 .sum(),
1641 unresolved_triangles: details
1642 .iter()
1643 .map(|detail| detail.unresolved_triangles)
1644 .sum(),
1645 recognition_skipped,
1646 source_closed_manifold: details.iter().all(|detail| detail.source_closed_manifold),
1647 analytic_used_repaired_mesh: details
1648 .iter()
1649 .any(|detail| detail.analytic_used_repaired_mesh),
1650 requested_distance_tolerance: first.requested_distance_tolerance,
1651 coordinate_precision_tolerance: options.coordinate_precision_tolerance,
1652 effective_distance_tolerance: first.effective_distance_tolerance,
1653 region_type_counts,
1654 regions: details
1655 .iter()
1656 .flat_map(|detail| detail.regions.iter().cloned())
1657 .collect(),
1658 topology_refit: details
1659 .iter()
1660 .find_map(|detail| detail.topology_refit.clone()),
1661 hybrid_rebuild,
1662 hybrid_demotions: details
1663 .iter()
1664 .flat_map(|detail| detail.hybrid_demotions.iter().cloned())
1665 .collect(),
1666 components: component_reports,
1667 exported_step_bytes: 0,
1668 exported_advanced_faces: 0,
1669 faceted_faces_before_merge,
1670 faceted_faces_after_merge,
1671 roundtrip_solids: 0,
1672 manifold_audit_issues: Vec::new(),
1673 timings: ConversionTimings {
1674 segmentation_seconds: details
1675 .iter()
1676 .map(|detail| detail.segmentation_seconds)
1677 .sum(),
1678 recognition_seconds: details
1679 .iter()
1680 .map(|detail| detail.recognition_seconds)
1681 .sum(),
1682 topology_build_seconds: details
1683 .iter()
1684 .map(|detail| detail.topology_build_seconds)
1685 .sum(),
1686 step_export_seconds: 0.0,
1687 step_validation_seconds: 0.0,
1688 total_seconds: 0.0,
1689 },
1690 recognition_work: details.iter().fold(RecognitionWorkReport::default(), |sum, detail| {
1691 RecognitionWorkReport {
1692 hypotheses: sum.hypotheses + detail.recognition_work.hypotheses,
1693 rounds: sum.rounds + detail.recognition_work.rounds,
1694 seeded_components: sum.seeded_components + detail.recognition_work.seeded_components,
1695 }
1696 }),
1697 whole_carrier_scan: details
1698 .iter()
1699 .fold(WholeCarrierScanReport::default(), |sum, detail| sum.sum(detail.whole_carrier_scan)),
1700 messages,
1701 }
1702}
1703
1704fn sum_optional(
1705 details: &[ComponentDetail],
1706 read: impl Fn(&ComponentDetail) -> Option<usize>,
1707) -> Option<usize> {
1708 details
1709 .iter()
1710 .any(|detail| read(detail).is_some())
1711 .then(|| details.iter().filter_map(read).sum())
1712}
1713
1714fn add_hybrid_reports(
1715 total: HybridConversionReport,
1716 body: HybridConversionReport,
1717) -> HybridConversionReport {
1718 HybridConversionReport {
1719 total_faces: total.total_faces + body.total_faces,
1720 analytic_plane_faces: total.analytic_plane_faces + body.analytic_plane_faces,
1721 analytic_plane_triangles: total.analytic_plane_triangles + body.analytic_plane_triangles,
1722 analytic_cylinder_faces: total.analytic_cylinder_faces + body.analytic_cylinder_faces,
1723 analytic_cylinder_triangles: total.analytic_cylinder_triangles
1724 + body.analytic_cylinder_triangles,
1725 analytic_cone_faces: total.analytic_cone_faces + body.analytic_cone_faces,
1726 analytic_cone_triangles: total.analytic_cone_triangles + body.analytic_cone_triangles,
1727 analytic_sphere_faces: total.analytic_sphere_faces + body.analytic_sphere_faces,
1728 analytic_sphere_triangles: total.analytic_sphere_triangles + body.analytic_sphere_triangles,
1729 analytic_torus_faces: total.analytic_torus_faces + body.analytic_torus_faces,
1730 analytic_torus_triangles: total.analytic_torus_triangles + body.analytic_torus_triangles,
1731 faceted_faces: total.faceted_faces + body.faceted_faces,
1732 faceted_triangles: total.faceted_triangles + body.faceted_triangles,
1733 demoted_regions: total.demoted_regions + body.demoted_regions,
1734 demoted_triangles: total.demoted_triangles + body.demoted_triangles,
1735 segmentation_plane_regions: total.segmentation_plane_regions
1736 + body.segmentation_plane_regions,
1737 segmentation_cylinder_regions: total.segmentation_cylinder_regions
1738 + body.segmentation_cylinder_regions,
1739 segmentation_cone_regions: total.segmentation_cone_regions + body.segmentation_cone_regions,
1740 segmentation_sphere_regions: total.segmentation_sphere_regions
1741 + body.segmentation_sphere_regions,
1742 segmentation_torus_regions: total.segmentation_torus_regions
1743 + body.segmentation_torus_regions,
1744 segmentation_unsupported_regions: total.segmentation_unsupported_regions
1745 + body.segmentation_unsupported_regions,
1746 }
1747}
1748
1749fn triangle_components(mesh: &Mesh) -> Vec<Vec<usize>> {
1764 fn find(parent: &mut [usize], mut node: usize) -> usize {
1765 while parent[node] != node {
1766 parent[node] = parent[parent[node]];
1767 node = parent[node];
1768 }
1769 node
1770 }
1771
1772 let bits = |value: f64| if value == 0.0 { 0_u64 } else { value.to_bits() };
1773 let mut position_ids = HashMap::<[u64; 3], usize>::new();
1774 let mut vertex_position = Vec::with_capacity(mesh.vertices.len());
1775 for point in &mesh.vertices {
1776 let key = [bits(point.x), bits(point.y), bits(point.z)];
1777 let next = position_ids.len();
1778 vertex_position.push(*position_ids.entry(key).or_insert(next));
1779 }
1780
1781 let mut parent = (0..position_ids.len()).collect::<Vec<_>>();
1782 for triangle in &mesh.triangles {
1783 let first = vertex_position[triangle[0] as usize];
1784 for &corner in &triangle[1..] {
1785 let (left, right) = (
1786 find(&mut parent, first),
1787 find(&mut parent, vertex_position[corner as usize]),
1788 );
1789 if left != right {
1790 parent[left] = right;
1791 }
1792 }
1793 }
1794
1795 let mut slots = HashMap::<usize, usize>::new();
1796 let mut components = Vec::<Vec<usize>>::new();
1797 for (index, triangle) in mesh.triangles.iter().enumerate() {
1798 let root = find(&mut parent, vertex_position[triangle[0] as usize]);
1799 let slot = match slots.get(&root) {
1800 Some(&slot) => slot,
1801 None => {
1802 components.push(Vec::new());
1803 slots.insert(root, components.len() - 1);
1804 components.len() - 1
1805 }
1806 };
1807 components[slot].push(index);
1808 }
1809 components
1810}
1811
1812struct ComponentMesh {
1814 mesh: Mesh,
1815 positions: Vec<f64>,
1816 indices: Option<Vec<u32>>,
1817}
1818
1819fn component_mesh(
1824 mesh: &Mesh,
1825 positions: &[f64],
1826 indices: Option<&[u32]>,
1827 triangles: &[usize],
1828) -> ComponentMesh {
1829 let mut vertex_map = HashMap::<u32, u32>::new();
1830 let mut vertices = Vec::new();
1831 let mut component_triangles = Vec::with_capacity(triangles.len());
1832 for &triangle in triangles {
1833 let mut corners = [0_u32; 3];
1834 for (corner, &index) in mesh.triangles[triangle].iter().enumerate() {
1835 corners[corner] = *vertex_map.entry(index).or_insert_with(|| {
1836 vertices.push(mesh.vertices[index as usize]);
1837 vertices.len() as u32 - 1
1838 });
1839 }
1840 component_triangles.push(corners);
1841 }
1842 let mut component = Mesh::new(vertices, component_triangles);
1843 if let Some(normals) = &mesh.vertex_normals {
1844 if normals.len() == mesh.vertices.len() {
1845 let mut component_normals = vec![Vec3::default(); component.vertices.len()];
1846 for (&source, &target) in &vertex_map {
1847 component_normals[target as usize] = normals[source as usize];
1848 }
1849 component.vertex_normals = Some(component_normals);
1850 }
1851 }
1852 if !mesh.source_metadata.is_empty() {
1853 let local = triangles
1854 .iter()
1855 .enumerate()
1856 .map(|(local, &source)| (source, local))
1857 .collect::<HashMap<_, _>>();
1858 component.source_metadata = mesh
1859 .source_metadata
1860 .iter()
1861 .filter_map(|metadata| {
1862 let triangle_indices = metadata
1863 .triangle_indices
1864 .iter()
1865 .filter_map(|source| local.get(source).copied())
1866 .collect::<Vec<_>>();
1867 (!triangle_indices.is_empty()).then(|| crate::SourceMetadata {
1868 triangle_indices,
1869 ..metadata.clone()
1870 })
1871 })
1872 .collect();
1873 }
1874
1875 let (component_positions, component_indices) = match indices {
1876 Some(indices) => {
1877 let mut source_map = HashMap::<u32, u32>::new();
1878 let mut component_positions = Vec::new();
1879 let mut component_indices = Vec::with_capacity(triangles.len() * 3);
1880 for &triangle in triangles {
1881 for &index in &indices[triangle * 3..triangle * 3 + 3] {
1882 let mapped = *source_map.entry(index).or_insert_with(|| {
1883 let base = index as usize * 3;
1884 component_positions.extend_from_slice(&positions[base..base + 3]);
1885 component_positions.len() as u32 / 3 - 1
1886 });
1887 component_indices.push(mapped);
1888 }
1889 }
1890 (component_positions, Some(component_indices))
1891 }
1892 None => {
1893 let mut component_positions = Vec::with_capacity(triangles.len() * 9);
1894 for &triangle in triangles {
1895 component_positions.extend_from_slice(&positions[triangle * 9..triangle * 9 + 9]);
1896 }
1897 (component_positions, None)
1898 }
1899 };
1900 ComponentMesh {
1901 mesh: component,
1902 positions: component_positions,
1903 indices: component_indices,
1904 }
1905}
1906
1907fn validate_source_buffers(
1908 mesh: &Mesh,
1909 positions: &[f64],
1910 indices: Option<&[u32]>,
1911) -> Result<(), StlConversionError> {
1912 if positions.is_empty() || !positions.len().is_multiple_of(3) {
1913 return Err(StlConversionError(
1914 "source position buffer must contain complete non-empty xyz triples".to_owned(),
1915 ));
1916 }
1917 if positions.iter().any(|value| !value.is_finite()) {
1918 return Err(StlConversionError(
1919 "source position buffer contains a non-finite coordinate".to_owned(),
1920 ));
1921 }
1922 let source_triangles = match indices {
1923 Some(indices) => {
1924 if !indices.len().is_multiple_of(3) {
1925 return Err(StlConversionError(
1926 "source index buffer must contain complete triangles".to_owned(),
1927 ));
1928 }
1929 if indices
1930 .iter()
1931 .any(|&index| index as usize >= positions.len() / 3)
1932 {
1933 return Err(StlConversionError(
1934 "source index buffer references a missing position".to_owned(),
1935 ));
1936 }
1937 indices.len() / 3
1938 }
1939 None => {
1940 if !positions.len().is_multiple_of(9) {
1941 return Err(StlConversionError(
1942 "unindexed source positions must be a triangle soup".to_owned(),
1943 ));
1944 }
1945 positions.len() / 9
1946 }
1947 };
1948 if source_triangles != mesh.triangles.len() {
1949 return Err(StlConversionError(format!(
1950 "source buffers contain {source_triangles} triangles but recognition mesh contains {}",
1951 mesh.triangles.len()
1952 )));
1953 }
1954 Ok(())
1955}
1956
1957fn complete_small_planar_regions(
1962 mesh: &Mesh,
1963 options: &RecognitionOptions,
1964 regions: &mut Vec<SurfaceRegion>,
1965 unresolved: &mut Vec<usize>,
1966) -> Result<usize, StlConversionError> {
1967 if unresolved.len() < 2 {
1968 return Ok(0);
1969 }
1970 let analyzed = mesh
1971 .analyze(&MeshAnalysisOptions {
1972 feature_angle: options.feature_angle,
1973 ..MeshAnalysisOptions::default()
1974 })
1975 .map_err(|error| {
1976 StlConversionError(format!("plane completion analysis failed: {error}"))
1977 })?;
1978 let allowed = unresolved.iter().copied().collect::<BTreeSet<_>>();
1979 let mut visited = BTreeSet::new();
1980 let mut accepted = BTreeSet::new();
1981 let mut completed = 0;
1982 let mut plane_options = options.clone();
1983 plane_options.minimum_support = 2;
1984
1985 for &seed in unresolved.iter() {
1986 if !visited.insert(seed) {
1987 continue;
1988 }
1989 let mut component = Vec::new();
1990 let mut queue = VecDeque::from([seed]);
1991 while let Some(triangle) = queue.pop_front() {
1992 component.push(triangle);
1993 let data = &analyzed.triangles[triangle];
1994 for edge in 0..3 {
1995 if data.feature_edges[edge] {
1996 continue;
1997 }
1998 if let Some(neighbor) = data.neighbors[edge] {
1999 if allowed.contains(&neighbor) && visited.insert(neighbor) {
2000 queue.push_back(neighbor);
2001 }
2002 }
2003 }
2004 }
2005 component.sort_unstable();
2006 if component.len() < 2 {
2007 continue;
2008 }
2009 let Ok(fit) = reconstruct_surface(
2010 mesh,
2011 &component,
2012 &SurfaceHint::KnownType {
2013 surface_type: SurfaceType::Plane,
2014 },
2015 &plane_options,
2016 ) else {
2017 continue;
2018 };
2019 if fit.metrics.support_triangles != component.len()
2020 || !matches!(fit.surface, AnalyticSurface::Plane(_))
2021 {
2022 continue;
2023 }
2024 accepted.extend(component.iter().copied());
2025 regions.push(SurfaceRegion {
2026 surface: fit.surface,
2027 orientation: fit.orientation,
2028 triangle_indices: component,
2029 metrics: fit.metrics,
2030 confidence: fit.confidence,
2031 diagnostics: fit.diagnostics,
2032 });
2033 completed += 1;
2034 }
2035 unresolved.retain(|triangle| !accepted.contains(triangle));
2036 Ok(completed)
2037}
2038
2039fn region_report(region: &SurfaceRegion) -> ConversionRegionReport {
2040 fit_fields(
2041 region.surface,
2042 region.orientation,
2043 ®ion.metrics,
2044 region.confidence,
2045 ®ion.diagnostics,
2046 )
2047}
2048
2049fn refit_report(fit: &SurfaceFitResult) -> ConversionRegionReport {
2050 fit_fields(
2051 fit.surface,
2052 fit.orientation,
2053 &fit.metrics,
2054 fit.confidence,
2055 &fit.diagnostics,
2056 )
2057}
2058
2059fn fit_fields(
2060 surface: AnalyticSurface,
2061 orientation: i8,
2062 metrics: &crate::FitMetrics,
2063 confidence: f64,
2064 diagnostics: &crate::FitDiagnostics,
2065) -> ConversionRegionReport {
2066 ConversionRegionReport {
2067 surface_type: surface.surface_type(),
2068 surface,
2069 support_triangles: metrics.support_triangles,
2070 supported_area: metrics.supported_area,
2071 rms_error: metrics.rms_error,
2072 max_error: metrics.max_error,
2073 rms_normal_error_radians: metrics.rms_normal_error,
2074 max_normal_error_radians: metrics.max_normal_error,
2075 confidence,
2076 orientation,
2077 reason: diagnostics.reason.clone(),
2078 phase_timings: diagnostics.phase_timings,
2079 }
2080}
2081
2082fn disjoint_complete_partition(regions: &[SurfaceRegion], triangle_count: usize) -> bool {
2083 let mut seen = BTreeSet::new();
2084 regions.iter().all(|region| {
2085 region
2086 .triangle_indices
2087 .iter()
2088 .all(|&triangle| triangle < triangle_count && seen.insert(triangle))
2089 }) && seen.len() == triangle_count
2090}
2091
2092pub(crate) struct RepairedMesh {
2095 mesh: Mesh,
2096 pub(crate) positions: Vec<f64>,
2097 pub(crate) indices: Vec<u32>,
2098 message: String,
2099 pub(crate) stitched_edges: usize,
2101}
2102
2103pub(crate) fn repaired_closed_mesh(
2108 source_positions: &[f64],
2109 source_indices: Option<&[u32]>,
2110 options: &StlConversionOptions,
2111) -> Option<RepairedMesh> {
2112 let owned_indices;
2116 let stitched_indices = match source_indices {
2117 Some(indices) => indices,
2118 None => {
2119 owned_indices = (0..source_positions.len() as u32 / 3).collect::<Vec<_>>();
2120 &owned_indices
2121 }
2122 };
2123 let stitch_tolerance = if options.weld_tolerance > 0.0 {
2124 options.weld_tolerance
2125 } else {
2126 mesh_diagonal_of(source_positions) * crate::stl::DEFAULT_RELATIVE_WELD_TOLERANCE
2127 };
2128 let (stitched, stitch) =
2129 t_junctions::stitch_t_junctions(source_positions, stitched_indices, stitch_tolerance);
2130 let (positions, indices, report) =
2131 repair_triangle_soup(source_positions, Some(&stitched), options.weld_tolerance).ok()?;
2132 let vertices = positions
2133 .chunks_exact(3)
2134 .map(|point| Vec3::new(point[0], point[1], point[2]))
2135 .collect::<Vec<_>>();
2136 let triangles = indices
2137 .chunks_exact(3)
2138 .map(|triangle| [triangle[0], triangle[1], triangle[2]])
2139 .collect::<Vec<_>>();
2140 let mesh = Mesh::new(vertices, triangles);
2141 if !closed_manifold(&mesh) {
2142 return None;
2143 }
2144 let message = format!(
2145 "the source mesh is not a closed two-manifold; the analytic lanes ran on the repaired mesh \
2146 ({}{} welded vertices, {} triangles: {} dropped as degenerate or duplicate, {} surplus \
2147 sheet(s) pruned, {} reoriented to agree with their neighbours, {} boundary loop(s) \
2148 capped with {} triangle(s))",
2149 if stitch.stitched_edges > 0 {
2150 format!(
2151 "{} T-junction edge(s) split at a vertex on their interior, {} triangle(s) added; ",
2152 stitch.stitched_edges, stitch.added_triangles
2153 )
2154 } else {
2155 String::new()
2156 },
2157 report.welded_vertices,
2158 report.triangles,
2159 report.dropped_triangles,
2160 report.pruned_triangles,
2161 report.reoriented_triangles,
2162 report.capped_loops,
2163 report.cap_triangles,
2164 );
2165 Some(RepairedMesh {
2166 mesh,
2167 positions,
2168 indices,
2169 message,
2170 stitched_edges: stitch.stitched_edges,
2171 })
2172}
2173
2174fn mesh_diagonal_of(positions: &[f64]) -> f64 {
2176 let mut low = [f64::INFINITY; 3];
2177 let mut high = [f64::NEG_INFINITY; 3];
2178 for point in positions.chunks_exact(3) {
2179 for axis in 0..3 {
2180 low[axis] = low[axis].min(point[axis]);
2181 high[axis] = high[axis].max(point[axis]);
2182 }
2183 }
2184 (0..3).map(|axis| (high[axis] - low[axis]).powi(2)).sum::<f64>().sqrt()
2185}
2186
2187fn closed_manifold(mesh: &Mesh) -> bool {
2188 closed_triangles(mesh.triangles.iter().copied())
2189}
2190
2191fn closed_triangles(triangles: impl Iterator<Item = [u32; 3]>) -> bool {
2192 let mut uses = BTreeMap::<(u32, u32), (usize, i32)>::new();
2193 for triangle in triangles {
2194 if triangle[0] == triangle[1] || triangle[1] == triangle[2] || triangle[2] == triangle[0] {
2195 return false;
2196 }
2197 for edge in [
2198 (triangle[0], triangle[1]),
2199 (triangle[1], triangle[2]),
2200 (triangle[2], triangle[0]),
2201 ] {
2202 let key = if edge.0 < edge.1 {
2203 (edge.0, edge.1)
2204 } else {
2205 (edge.1, edge.0)
2206 };
2207 let entry = uses.entry(key).or_default();
2208 entry.0 += 1;
2209 entry.1 += if edge == key { 1 } else { -1 };
2210 }
2211 }
2212 !uses.is_empty()
2213 && uses
2214 .values()
2215 .all(|&(count, sense)| count == 2 && sense == 0)
2216}
2217
2218struct DirectAnalyticBuild {
2219 solid: Result<brep_kernel::BrepSolid, StlConversionError>,
2220 backend: ConversionBackend,
2221 reason: String,
2222 topology_refit: Option<ConversionRegionReport>,
2223}
2224
2225fn direct_analytic_solid(
2226 mesh: &Mesh,
2227 regions: &[SurfaceRegion],
2228 options: &RecognitionOptions,
2229) -> Option<DirectAnalyticBuild> {
2230 if regions.len() == 1 {
2231 return match regions[0].surface {
2232 AnalyticSurface::Sphere(sphere) => Some(DirectAnalyticBuild {
2233 solid: make_sphere_brep(
2234 kernel_vec(sphere.center),
2235 sphere.radius,
2236 kernel_vec(Vec3::new(0.0, 0.0, 1.0)),
2237 )
2238 .map_err(StlConversionError),
2239 backend: ConversionBackend::RansacSphere,
2240 reason: "RANSAC proved complete closed spherical coverage; exported an exact analytic sphere".to_owned(),
2241 topology_refit: None,
2242 }),
2243 AnalyticSurface::Torus(torus) if torus.major_radius > torus.minor_radius => Some(DirectAnalyticBuild {
2244 solid: make_torus_brep(
2245 kernel_vec(torus.center),
2246 kernel_vec(torus.axis),
2247 torus.major_radius,
2248 torus.minor_radius,
2249 )
2250 .map_err(StlConversionError),
2251 backend: ConversionBackend::RansacTorus,
2252 reason: "RANSAC proved complete closed ring-torus coverage; exported an exact analytic torus".to_owned(),
2253 topology_refit: None,
2254 }),
2255 _ => None,
2256 };
2257 }
2258
2259 let curved = regions
2260 .iter()
2261 .filter(|region| !matches!(region.surface, AnalyticSurface::Plane(_)))
2262 .collect::<Vec<_>>();
2263 let planes = regions
2264 .iter()
2265 .filter_map(|region| match region.surface {
2266 AnalyticSurface::Plane(plane) => Some(plane),
2267 _ => None,
2268 })
2269 .collect::<Vec<_>>();
2270 if curved.len() != 1 {
2271 return None;
2272 }
2273 let topology_refit;
2279 let mut topology_refit_report = None;
2280 let curved = if let AnalyticSurface::Torus(torus) = curved[0].surface {
2281 let scale = mesh_diagonal(mesh);
2282 let collapsed = torus.major_radius.min(torus.minor_radius) > 20.0 * scale;
2283 let forced_type = match planes.len() {
2284 2 if collapsed => Some(SurfaceType::Cylinder),
2285 1 if collapsed => Some(SurfaceType::Cone),
2286 _ => None,
2287 };
2288 if let Some(surface_type) = forced_type {
2289 topology_refit = reconstruct_surface(
2290 mesh,
2291 &curved[0].triangle_indices,
2292 &SurfaceHint::KnownType { surface_type },
2293 options,
2294 )
2295 .ok()?;
2296 topology_refit_report = Some(refit_report(&topology_refit));
2297 &topology_refit.surface
2298 } else {
2299 &curved[0].surface
2300 }
2301 } else {
2302 &curved[0].surface
2303 };
2304 match *curved {
2305 AnalyticSurface::Cylinder(cylinder) if planes.len() == 2 => {
2306 let (low, high) = axial_range(mesh, cylinder.axis_origin, cylinder.axis)?;
2307 let tolerance = conversion_tolerance(mesh, options);
2308 if high - low <= tolerance
2309 || !caps_match(
2310 &planes,
2311 cylinder.axis_origin,
2312 cylinder.axis,
2313 &[low, high],
2314 tolerance,
2315 )
2316 {
2317 return None;
2318 }
2319 let base = cylinder.axis_origin + cylinder.axis * low;
2320 Some(DirectAnalyticBuild {
2321 solid: make_cylinder_brep(
2322 kernel_vec(base),
2323 kernel_vec(cylinder.axis),
2324 cylinder.radius,
2325 high - low,
2326 )
2327 .map_err(StlConversionError),
2328 backend: ConversionBackend::RansacCappedCylinder,
2329 reason: if topology_refit_report.is_some() {
2330 "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()
2331 } else {
2332 "RANSAC proved one cylindrical wall plus two matching planar caps; exported an exact capped cylinder".to_owned()
2333 },
2334 topology_refit: topology_refit_report,
2335 })
2336 }
2337 AnalyticSurface::Cone(cone) if planes.len() == 1 || planes.len() == 2 => {
2338 let (low, high) = axial_range(mesh, cone.apex, cone.axis)?;
2339 let tolerance = conversion_tolerance(mesh, options);
2340 if low < -tolerance || high - low <= tolerance {
2341 return None;
2342 }
2343 let expected_caps = if low <= tolerance {
2344 vec![high]
2345 } else {
2346 vec![low, high]
2347 };
2348 if planes.len() != expected_caps.len()
2349 || !caps_match(&planes, cone.apex, cone.axis, &expected_caps, tolerance)
2350 {
2351 return None;
2352 }
2353 let radius_bottom = low.max(0.0) * cone.half_angle.tan();
2354 let radius_top = high * cone.half_angle.tan();
2355 if radius_top <= tolerance || (low > tolerance && radius_bottom <= tolerance) {
2356 return None;
2357 }
2358 let (base, build_axis, build_bottom, build_top) = if low <= tolerance {
2359 (
2363 cone.apex + cone.axis * high,
2364 cone.axis * -1.0,
2365 radius_top,
2366 0.0,
2367 )
2368 } else {
2369 (
2370 cone.apex + cone.axis * low,
2371 cone.axis,
2372 radius_bottom,
2373 radius_top,
2374 )
2375 };
2376 Some(DirectAnalyticBuild {
2377 solid: make_cone_brep(
2378 kernel_vec(base),
2379 kernel_vec(build_axis),
2380 build_bottom,
2381 build_top,
2382 high - low.max(0.0),
2383 )
2384 .map_err(StlConversionError),
2385 backend: ConversionBackend::RansacCappedCone,
2386 reason: if topology_refit_report.is_some() {
2387 "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()
2388 } else {
2389 "RANSAC proved one conical wall and matching planar cap topology; exported an exact capped cone".to_owned()
2390 },
2391 topology_refit: topology_refit_report,
2392 })
2393 }
2394 _ => None,
2395 }
2396}
2397
2398fn axial_range(mesh: &Mesh, origin: Vec3, axis: Vec3) -> Option<(f64, f64)> {
2399 let mut low = f64::INFINITY;
2400 let mut high = f64::NEG_INFINITY;
2401 for &point in &mesh.vertices {
2402 let height = (point - origin).dot(axis);
2403 low = low.min(height);
2404 high = high.max(height);
2405 }
2406 (low.is_finite() && high.is_finite()).then_some((low, high))
2407}
2408
2409fn caps_match(
2410 planes: &[crate::PlaneSurface],
2411 origin: Vec3,
2412 axis: Vec3,
2413 expected_heights: &[f64],
2414 tolerance: f64,
2415) -> bool {
2416 let mut actual = Vec::with_capacity(planes.len());
2417 for plane in planes {
2418 if plane.normal.dot(axis).abs() < 1.0 - 1.0e-6 {
2419 return false;
2420 }
2421 actual.push((plane.origin - origin).dot(axis));
2422 }
2423 actual.sort_by(f64::total_cmp);
2424 let mut expected = expected_heights.to_vec();
2425 expected.sort_by(f64::total_cmp);
2426 actual.len() == expected.len()
2427 && actual
2428 .iter()
2429 .zip(expected)
2430 .all(|(actual, expected)| (*actual - expected).abs() <= tolerance)
2431}
2432
2433fn mesh_tolerance(mesh: &Mesh) -> f64 {
2434 let mut low = mesh.vertices[0];
2435 let mut high = mesh.vertices[0];
2436 for &point in &mesh.vertices[1..] {
2437 low.x = low.x.min(point.x);
2438 low.y = low.y.min(point.y);
2439 low.z = low.z.min(point.z);
2440 high.x = high.x.max(point.x);
2441 high.y = high.y.max(point.y);
2442 high.z = high.z.max(point.z);
2443 }
2444 (high - low).length().max(1.0) * 1.0e-5
2445}
2446
2447fn conversion_tolerance(mesh: &Mesh, options: &RecognitionOptions) -> f64 {
2448 mesh_tolerance(mesh)
2449 .max(options.distance_tolerance + options.relative_tolerance * mesh_diagonal(mesh))
2450}
2451
2452fn mesh_diagonal(mesh: &Mesh) -> f64 {
2453 let mut low = mesh.vertices[0];
2454 let mut high = mesh.vertices[0];
2455 for &point in &mesh.vertices[1..] {
2456 low.x = low.x.min(point.x);
2457 low.y = low.y.min(point.y);
2458 low.z = low.z.min(point.z);
2459 high.x = high.x.max(point.x);
2460 high.y = high.y.max(point.y);
2461 high.z = high.z.max(point.z);
2462 }
2463 (high - low).length()
2464}
2465
2466fn coplanar_merge_tolerance(mesh: &Mesh) -> f64 {
2467 (mesh_diagonal(mesh) * 1.0e-9).max(1.0e-12)
2468}
2469
2470fn solid_face_count(solid: &brep_kernel::BrepSolid) -> usize {
2471 solid.shells.iter().map(|shell| shell.faces.len()).sum()
2472}
2473
2474const MAX_WHOLE_PRIMITIVE_REGIONS: usize = 3;
2478
2479pub(crate) fn segmentation_options(positions: &[f64], options: &StlConversionOptions) -> SegmentOptions {
2480 SegmentOptions {
2481 deflection_angle_deg: options.kernel_deflection_angle_degrees,
2482 fit_tolerance: options.kernel_fit_tolerance,
2483 normal_tolerance_deg: options.kernel_normal_tolerance_degrees,
2484 weld_tolerance: if options.weld_tolerance > 0.0 {
2489 options.weld_tolerance
2490 } else {
2491 let mut low = [f64::INFINITY; 3];
2492 let mut high = [f64::NEG_INFINITY; 3];
2493 for point in positions.chunks_exact(3) {
2494 for axis in 0..3 {
2495 low[axis] = low[axis].min(point[axis]);
2496 high[axis] = high[axis].max(point[axis]);
2497 }
2498 }
2499 let diagonal = (0..3)
2500 .map(|axis| (high[axis] - low[axis]).powi(2))
2501 .sum::<f64>()
2502 .sqrt();
2503 (diagonal * crate::stl::DEFAULT_RELATIVE_WELD_TOLERANCE).max(f64::MIN_POSITIVE)
2504 },
2505 ..SegmentOptions::default()
2506 }
2507}
2508
2509fn hybrid_roundtrip_fallback_enabled() -> bool {
2523 brep_kernel::switch!(live "BREP_HYBRID_ROUNDTRIP_FALLBACK", true)
2524}
2525
2526fn solid_round_trips(solid: &brep_kernel::BrepSolid) -> Result<(), String> {
2529 let text = export_step(std::slice::from_ref(solid), "round-trip", "millimeter", "2026-01-01T00:00:00Z")
2530 .map_err(|error| format!("export: {error}"))?;
2531 let imported = import_step(&text).map_err(|error| error.to_string())?;
2532 match imported.iter().map(|solid| solid.validate()).find(|issues| !issues.is_empty()) {
2533 Some(issues) => Err(format!("imported solid invalid: {issues:?}")),
2534 None => Ok(()),
2535 }
2536}
2537
2538fn tjunction_strip_hybrid_enabled() -> bool {
2550 brep_kernel::switch!(live "BREP_TJUNCTION_STRIP_HYBRID", true)
2551}
2552
2553fn hybrid_over_kernel_strips(
2558 kernel: &brep_kernel::BrepSolid,
2559 positions: &[f64],
2560 indices: Option<&[u32]>,
2561 options: &StlConversionOptions,
2562) -> Option<crate::hybrid_region_brep::HybridBrepOutput> {
2563 let output = try_hybrid_analytic(positions, indices, options).ok()?;
2564 if hybrid_report(&output.stats).faceted_faces != 0
2565 || solid_face_count(&output.solid) >= solid_face_count(kernel)
2566 {
2567 return None;
2568 }
2569 let (before, after) = (
2570 brep_kernel::solid_signed_volume(kernel).ok()?,
2571 brep_kernel::solid_signed_volume(&output.solid).ok()?,
2572 );
2573 if !((after - before).abs() <= 1e-2 * before.abs()) {
2574 return None;
2575 }
2576 solid_round_trips(&output.solid).ok()?;
2577 Some(output)
2578}
2579
2580fn segment_carrier_enabled() -> bool {
2581 brep_kernel::switch!(live "BREP_RECOG_SEGMENT_CARRIER", true)
2582}
2583
2584fn segmentation_candidates(mesh: &Mesh, segmentation: &MeshSegmentation) -> Vec<ComponentCandidate> {
2599 if segmentation.triangle_count != mesh.triangles.len()
2600 || segmentation.triangle_region_ids.len() != mesh.triangles.len()
2601 {
2602 return Vec::new();
2603 }
2604 let mut members = vec![Vec::new(); segmentation.regions.len()];
2605 for (triangle, ®ion) in segmentation.triangle_region_ids.iter().enumerate() {
2606 if let Some(list) = members.get_mut(region as usize) {
2607 list.push(triangle);
2608 }
2609 }
2610 segmentation
2611 .regions
2612 .iter()
2613 .zip(members)
2614 .filter_map(|(region, triangles)| {
2615 let (surface, parameters) = carrier_surface(®ion.carrier)?;
2616 if triangles.is_empty() || !surface.is_valid() {
2617 return None;
2618 }
2619 let indices: Vec<u32> = triangles.iter().flat_map(|&t| mesh.triangles[t]).collect();
2620 let (statistic, threshold) = crate::hybrid_whole_freeform::carrier_local_scan(
2621 &indices,
2622 |v| surface.signed_distance(mesh.vertices[v as usize]),
2623 parameters,
2624 );
2625 if std::env::var_os("BREP_DEBUG_SPLIT").is_some() {
2626 eprintln!(
2627 "[candidate] region {} {} tris {} local {statistic:.2} threshold {threshold:.2} {}",
2628 region.id,
2629 region.carrier.kind(),
2630 triangles.len(),
2631 if statistic <= threshold { "offered" } else { "declined" }
2632 );
2633 }
2634 (statistic <= threshold).then_some(ComponentCandidate { triangle_indices: triangles, surface })
2635 })
2636 .collect()
2637}
2638
2639fn whole_fits_pass_the_local_scan(
2654 mesh: &Mesh,
2655 regions: &[SurfaceRegion],
2656 requested: f64,
2657 tally: &mut WholeCarrierScanReport,
2658) -> bool {
2659 if !crate::hybrid_whole_freeform::whole_carrier_scan_enabled() {
2660 return true;
2661 }
2662 let mut passes = true;
2663 for region in regions.iter().filter(|region| region.metrics.max_error > requested) {
2664 let surface = ®ion.surface;
2665 let indices: Vec<u32> = region
2666 .triangle_indices
2667 .iter()
2668 .flat_map(|&t| mesh.triangles[t])
2669 .collect();
2670 let reading = crate::hybrid_whole_freeform::carrier_scan_verdict(
2671 &indices,
2672 |v| surface.signed_distance(mesh.vertices[v as usize]),
2673 analytic_parameters(surface),
2674 );
2675 if std::env::var_os("BREP_DEBUG_SPLIT").is_some() {
2676 eprintln!(
2677 "[whole-fit] {} tris {} local {:.2} threshold {:.2} independence {:.3} bound {:.3} edges {} {:?}",
2678 surface.surface_type().name(),
2679 region.triangle_indices.len(),
2680 reading.statistic,
2681 reading.threshold,
2682 reading.independence,
2683 reading.bound,
2684 reading.outside_edges,
2685 reading.verdict
2686 );
2687 }
2688 tally.add(reading.verdict);
2689 if reading.verdict == crate::hybrid_whole_freeform::ScanVerdict::Declined {
2690 passes = false;
2691 break;
2692 }
2693 }
2694 passes
2695}
2696
2697fn analytic_parameters(surface: &AnalyticSurface) -> usize {
2699 match surface {
2700 AnalyticSurface::Plane(_) => 3,
2701 AnalyticSurface::Cylinder(_) => 5,
2702 AnalyticSurface::Cone(_) => 6,
2703 AnalyticSurface::Sphere(_) => 4,
2704 AnalyticSurface::Torus(_) => 7,
2705 }
2706}
2707
2708fn carrier_surface(carrier: &RegionCarrier) -> Option<(AnalyticSurface, usize)> {
2710 let v = |p: brep_kernel::Vec3| crate::brep::vec3_from_kernel(p);
2711 Some(match *carrier {
2712 RegionCarrier::Plane { origin, normal } => {
2713 (AnalyticSurface::Plane(PlaneSurface { origin: v(origin), normal: v(normal) }), 3)
2714 }
2715 RegionCarrier::Cylinder { axis_point, axis_dir, radius, .. } => (
2716 AnalyticSurface::Cylinder(CylinderSurface { axis_origin: v(axis_point), axis: v(axis_dir), radius }),
2717 5,
2718 ),
2719 RegionCarrier::Cone { apex, axis_dir, half_angle_rad, .. } => (
2720 AnalyticSurface::Cone(ConeSurface { apex: v(apex), axis: v(axis_dir), half_angle: half_angle_rad }),
2721 6,
2722 ),
2723 RegionCarrier::Sphere { center, radius, .. } => {
2724 (AnalyticSurface::Sphere(SphereSurface { center: v(center), radius }), 4)
2725 }
2726 RegionCarrier::Torus { center, axis_dir, major_radius, minor_radius, .. } => (
2727 AnalyticSurface::Torus(TorusSurface { center: v(center), axis: v(axis_dir), major_radius, minor_radius }),
2728 7,
2729 ),
2730 RegionCarrier::Freeform => return None,
2731 })
2732}
2733
2734fn gate_segmentation(
2735 positions: &[f64],
2736 indices: Option<&[u32]>,
2737 options: &StlConversionOptions,
2738) -> Result<MeshSegmentation, String> {
2739 let owned_indices;
2740 let indices = match indices {
2741 Some(indices) => indices,
2742 None => {
2743 owned_indices = (0..positions.len() as u32 / 3).collect::<Vec<_>>();
2744 &owned_indices
2745 }
2746 };
2747 segment_mesh_faces(positions, indices, &segmentation_options(positions, options))
2748 .map_err(|error| format!("kernel segmentation failed: {error}"))
2749}
2750
2751fn segmentation_is_complete(segmentation: &MeshSegmentation) -> bool {
2753 !segmentation
2754 .triangle_region_ids
2755 .contains(&UNASSIGNED_REGION)
2756 && segmentation
2757 .regions
2758 .iter()
2759 .all(|region| !matches!(region.carrier, RegionCarrier::Freeform))
2760}
2761
2762fn recognition_gate(segmentation: &Result<MeshSegmentation, String>) -> Result<(), String> {
2771 let segmentation = segmentation.as_ref().map_err(Clone::clone)?;
2772 let unassigned = segmentation
2773 .triangle_region_ids
2774 .iter()
2775 .filter(|id| **id == UNASSIGNED_REGION)
2776 .count();
2777 let unsupported = segmentation
2778 .regions
2779 .iter()
2780 .filter(|region| matches!(region.carrier, RegionCarrier::Freeform))
2781 .count();
2782 if unassigned > 0 || unsupported > 0 {
2783 return Err(format!(
2784 "kernel segmentation left {unassigned} triangle(s) unassigned and {unsupported} region(s) without an analytic carrier, so no whole-primitive lane can fire"
2785 ));
2786 }
2787 if segmentation.regions.len() > MAX_WHOLE_PRIMITIVE_REGIONS {
2788 return Err(format!(
2789 "kernel segmentation found {} analytic regions; a whole primitive has at most {MAX_WHOLE_PRIMITIVE_REGIONS}",
2790 segmentation.regions.len()
2791 ));
2792 }
2793 Ok(())
2794}
2795
2796fn try_kernel_analytic(
2797 positions: &[f64],
2798 indices: Option<&[u32]>,
2799 options: &StlConversionOptions,
2800) -> Result<brep_kernel::BrepSolid, String> {
2801 let owned_indices;
2802 let indices = match indices {
2803 Some(indices) => indices,
2804 None => {
2805 owned_indices = (0..positions.len() as u32 / 3).collect::<Vec<_>>();
2806 &owned_indices
2807 }
2808 };
2809 let segmentation_options = segmentation_options(positions, options);
2810 let segmentation = segment_mesh_faces(positions, indices, &segmentation_options)?;
2811 if segmentation
2812 .triangle_region_ids
2813 .contains(&UNASSIGNED_REGION)
2814 {
2815 return Err("independent kernel segmentation left triangles unassigned".to_owned());
2816 }
2817 if let Some(region) = segmentation.regions.iter().find(|region| {
2818 matches!(
2819 region.carrier,
2820 RegionCarrier::Freeform | RegionCarrier::Sphere { .. } | RegionCarrier::Torus { .. }
2821 )
2822 }) {
2823 return Err(format!(
2824 "independent kernel segmentation produced unsupported {} region {}",
2825 region.carrier.kind(),
2826 region.id
2827 ));
2828 }
2829 mesh_regions_to_brep(positions, indices, &segmentation_options)
2830}
2831
2832fn try_hybrid_analytic(
2833 positions: &[f64],
2834 indices: Option<&[u32]>,
2835 options: &StlConversionOptions,
2836) -> Result<crate::hybrid_region_brep::HybridBrepOutput, String> {
2837 crate::hybrid_region_brep::hybrid_plane_cylinder_brep(
2838 positions,
2839 indices,
2840 &segmentation_options(positions, options),
2841 )
2842}
2843
2844fn hybrid_report(stats: &crate::hybrid_region_brep::HybridBrepStats) -> HybridConversionReport {
2845 HybridConversionReport {
2846 total_faces: stats.total_faces,
2847 analytic_plane_faces: stats.analytic_plane_faces,
2848 analytic_plane_triangles: stats.analytic_plane_triangles,
2849 analytic_cylinder_faces: stats.analytic_cylinder_faces,
2850 analytic_cylinder_triangles: stats.analytic_cylinder_triangles,
2851 analytic_cone_faces: stats.analytic_cone_faces,
2852 analytic_cone_triangles: stats.analytic_cone_triangles,
2853 analytic_sphere_faces: stats.analytic_sphere_faces,
2854 analytic_sphere_triangles: stats.analytic_sphere_triangles,
2855 analytic_torus_faces: stats.analytic_torus_faces,
2856 analytic_torus_triangles: stats.analytic_torus_triangles,
2857 faceted_faces: stats.faceted_faces,
2858 faceted_triangles: stats.faceted_triangles,
2859 demoted_regions: stats.demoted_regions,
2860 demoted_triangles: stats.demoted_triangles,
2861 segmentation_plane_regions: stats.segmentation_plane_regions,
2862 segmentation_cylinder_regions: stats.segmentation_cylinder_regions,
2863 segmentation_cone_regions: stats.segmentation_cone_regions,
2864 segmentation_sphere_regions: stats.segmentation_sphere_regions,
2865 segmentation_torus_regions: stats.segmentation_torus_regions,
2866 segmentation_unsupported_regions: stats.segmentation_unsupported_regions,
2867 }
2868}
2869
2870fn kernel_vec(value: Vec3) -> brep_kernel::Vec3 {
2871 brep_kernel::Vec3::new(value.x, value.y, value.z)
2872}
2873