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