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