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