1use crate::{
26 recognize_surfaces_with_unresolved, reconstruct_surface, AnalyticSurface, Mesh,
27 MeshAnalysisOptions, PhaseTimings, RecognitionOptions, SamplingMode, SurfaceFitResult,
28 SurfaceHint, SurfaceRegion, SurfaceType, Vec3,
29};
30use brep_kernel::{
31 audit_step_manifold, export_step, import_step, make_cone_brep, make_cylinder_brep,
32 make_sphere_brep, make_torus_brep, merge_same_surface_faces, mesh_regions_to_brep,
33 mesh_to_faceted_brep, repair_triangle_soup, segment_mesh_faces, MeshSegmentation,
34 RegionCarrier, SegmentOptions,
35 UNASSIGNED_REGION,
36};
37use serde::{Deserialize, Serialize};
38use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
39use std::fmt::{Display, Formatter};
40use web_time::Instant;
41
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
45pub enum ConversionPolicy {
46 #[default]
48 AllowFacetedFallback,
49 RequireFullyAnalytic,
51}
52
53#[derive(Clone, Debug, Deserialize, Serialize)]
55#[serde(default)]
56pub struct StlConversionOptions {
57 pub recognition: RecognitionOptions,
61 pub policy: ConversionPolicy,
63 pub try_kernel_analytic_rebuild: bool,
66 pub try_hybrid_rebuild: bool,
72 pub weld_tolerance: f64,
75 pub coordinate_precision_tolerance: f64,
82 pub kernel_deflection_angle_degrees: f64,
84 pub kernel_fit_tolerance: f64,
86 pub kernel_normal_tolerance_degrees: f64,
88}
89
90impl Default for StlConversionOptions {
91 fn default() -> Self {
92 let recognition = RecognitionOptions {
93 collect_phase_timings: true,
94 sampling: SamplingMode::Vertices,
98 ..RecognitionOptions::default()
99 };
100 Self {
101 recognition,
102 policy: ConversionPolicy::AllowFacetedFallback,
103 try_kernel_analytic_rebuild: true,
104 try_hybrid_rebuild: true,
105 weld_tolerance: -1.0,
106 coordinate_precision_tolerance: 0.0,
107 kernel_deflection_angle_degrees: 30.0,
108 kernel_fit_tolerance: 1.0e-3,
109 kernel_normal_tolerance_degrees: 15.0,
110 }
111 }
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
116#[serde(rename_all = "snake_case")]
117pub enum ConversionBackend {
118 RansacSphere,
120 RansacTorus,
122 RansacCappedCylinder,
124 RansacCappedCone,
126 KernelAnalyticRebuild,
129 #[serde(alias = "hybrid_plane_cylinder_rebuild")]
132 HybridAnalyticRebuild,
133 FacetedRepair,
135 FacetedRepairCoplanarMerged,
138}
139
140#[derive(Clone, Debug, Deserialize, Serialize)]
142pub struct ConversionRegionReport {
143 pub surface_type: SurfaceType,
145 pub surface: AnalyticSurface,
147 pub support_triangles: usize,
149 pub supported_area: f64,
151 pub rms_error: f64,
153 pub max_error: f64,
155 pub rms_normal_error_radians: f64,
157 pub max_normal_error_radians: f64,
159 pub confidence: f64,
161 pub orientation: i8,
163 pub reason: String,
165 pub phase_timings: PhaseTimings,
167}
168
169#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
171pub struct ConversionTimings {
172 #[serde(default)]
174 pub segmentation_seconds: f64,
175 pub recognition_seconds: f64,
177 pub topology_build_seconds: f64,
179 pub step_export_seconds: f64,
181 pub step_validation_seconds: f64,
183 pub total_seconds: f64,
185}
186
187#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
189pub struct HybridConversionReport {
190 pub total_faces: usize,
192 pub analytic_plane_faces: usize,
194 pub analytic_plane_triangles: usize,
196 pub analytic_cylinder_faces: usize,
198 pub analytic_cylinder_triangles: usize,
200 #[serde(default)]
202 pub analytic_cone_faces: usize,
203 #[serde(default)]
205 pub analytic_cone_triangles: usize,
206 #[serde(default)]
208 pub analytic_sphere_faces: usize,
209 #[serde(default)]
211 pub analytic_sphere_triangles: usize,
212 pub faceted_faces: usize,
214 pub faceted_triangles: usize,
216 pub demoted_regions: usize,
218 pub demoted_triangles: usize,
220 pub segmentation_plane_regions: usize,
222 pub segmentation_cylinder_regions: usize,
224 #[serde(default)]
226 pub segmentation_cone_regions: usize,
227 #[serde(default)]
229 pub segmentation_sphere_regions: usize,
230 pub segmentation_unsupported_regions: usize,
232}
233
234#[derive(Clone, Debug, Deserialize, Serialize)]
237pub struct ComponentConversionReport {
238 pub index: usize,
240 pub first_triangle: usize,
243 pub input_vertices: usize,
245 pub input_triangles: usize,
247 pub backend: ConversionBackend,
249 pub backend_reason: String,
251 pub fallback_cause: Option<String>,
253 pub recognition_skipped: Option<String>,
255 pub recognized_regions: usize,
257 pub recognized_triangles: usize,
259 pub unresolved_triangles: usize,
261 pub source_closed_manifold: bool,
263 #[serde(default)]
268 pub analytic_used_repaired_mesh: bool,
269 pub hybrid_rebuild: Option<HybridConversionReport>,
271 pub faceted_faces_before_merge: Option<usize>,
273 pub faceted_faces_after_merge: Option<usize>,
275 pub faces: usize,
277}
278
279#[derive(Clone, Debug, Deserialize, Serialize)]
286pub struct StlConversionReport {
287 pub backend: ConversionBackend,
289 pub backend_reason: String,
291 pub fallback_cause: Option<String>,
293 pub input_vertices: usize,
295 pub input_triangles: usize,
297 pub recognized_regions: usize,
299 pub recognized_triangles: usize,
301 pub unresolved_triangles: usize,
303 #[serde(default)]
306 pub recognition_skipped: Option<String>,
307 pub source_closed_manifold: bool,
309 #[serde(default)]
313 pub analytic_used_repaired_mesh: bool,
314 #[serde(default)]
316 pub requested_distance_tolerance: f64,
317 #[serde(default)]
319 pub coordinate_precision_tolerance: f64,
320 #[serde(default)]
322 pub effective_distance_tolerance: f64,
323 pub region_type_counts: BTreeMap<String, usize>,
325 pub regions: Vec<ConversionRegionReport>,
327 pub topology_refit: Option<ConversionRegionReport>,
330 #[serde(default)]
333 pub hybrid_rebuild: Option<HybridConversionReport>,
334 #[serde(default)]
338 pub components: Vec<ComponentConversionReport>,
339 pub exported_step_bytes: usize,
341 pub exported_advanced_faces: usize,
343 #[serde(default)]
345 pub faceted_faces_before_merge: Option<usize>,
346 #[serde(default)]
350 pub faceted_faces_after_merge: Option<usize>,
351 pub roundtrip_solids: usize,
353 pub manifold_audit_issues: Vec<String>,
355 pub timings: ConversionTimings,
357 pub messages: Vec<String>,
359}
360
361#[derive(Clone, Debug, Deserialize, Serialize)]
363pub struct StlConversionOutput {
364 pub step_text: String,
366 pub report: StlConversionReport,
368}
369
370#[derive(Clone, Debug, PartialEq, Eq)]
372pub struct StlConversionError(pub String);
373
374impl Display for StlConversionError {
375 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
376 formatter.write_str(&self.0)
377 }
378}
379
380impl std::error::Error for StlConversionError {}
381
382impl From<String> for StlConversionError {
383 fn from(value: String) -> Self {
384 Self(value)
385 }
386}
387
388pub fn binary_stl_coordinate_precision_tolerance(mesh: &Mesh) -> f64 {
398 let maximum_absolute_coordinate = mesh
399 .vertices
400 .iter()
401 .flat_map(|point| [point.x.abs(), point.y.abs(), point.z.abs()])
402 .fold(0.0_f64, f64::max);
403 let coordinate_scale = maximum_absolute_coordinate.max(mesh_diagonal(mesh));
404 coordinate_scale * (f32::EPSILON as f64) * 0.5 * 3.0_f64.sqrt()
405}
406
407pub fn convert_stl_mesh_to_step(
426 mesh: &Mesh,
427 source_positions: &[f64],
428 source_indices: Option<&[u32]>,
429 options: &StlConversionOptions,
430 part_name: &str,
431 unit: &str,
432 timestamp: &str,
433) -> Result<StlConversionOutput, StlConversionError> {
434 let total_started = Instant::now();
435 validate_source_buffers(mesh, source_positions, source_indices)?;
436 if !options.coordinate_precision_tolerance.is_finite()
437 || options.coordinate_precision_tolerance < 0.0
438 {
439 return Err(StlConversionError(
440 "coordinate_precision_tolerance must be finite and non-negative".to_owned(),
441 ));
442 }
443
444 let components = triangle_components(mesh);
445 let count = components.len();
446 if count == 0 {
447 return Err(StlConversionError(
448 "the mesh contains no triangles to reconstruct".to_owned(),
449 ));
450 }
451
452 let mut solids = Vec::with_capacity(count);
453 let mut details = Vec::with_capacity(count);
454 for (index, triangles) in components.iter().enumerate() {
455 let built = if count == 1 {
456 convert_component(mesh, source_positions, source_indices, options)
457 } else {
458 let part = component_mesh(mesh, source_positions, source_indices, triangles);
459 convert_component(
460 &part.mesh,
461 &part.positions,
462 part.indices.as_deref(),
463 options,
464 )
465 }
466 .map_err(|error| {
469 if count == 1 {
470 error
471 } else {
472 StlConversionError(format!(
473 "body {} of {count} (from source triangle {}, {} triangle(s)): {error}",
474 index + 1,
475 triangles[0],
476 triangles.len()
477 ))
478 }
479 })?;
480 solids.push(built.0);
481 details.push(built.1);
482 }
483
484 let export_started = Instant::now();
485 let step_text = export_step(&solids, part_name, unit, timestamp)
486 .map_err(|error| StlConversionError(format!("STEP export failed: {error}")))?;
487 let step_export_seconds = export_started.elapsed().as_secs_f64();
488
489 let validation_started = Instant::now();
490 let manifold_audit_issues = audit_step_manifold(&step_text);
491 if !manifold_audit_issues.is_empty() {
492 return Err(StlConversionError(format!(
493 "STEP manifold audit failed: {}",
494 manifold_audit_issues.join("; ")
495 )));
496 }
497 let imported = import_step(&step_text).map_err(|error| {
498 StlConversionError(format!("STEP round-trip validation failed: {error}"))
499 })?;
500 if let Some(issues) = imported
501 .iter()
502 .map(|solid| solid.validate())
503 .find(|issues| !issues.is_empty())
504 {
505 return Err(StlConversionError(format!(
506 "round-tripped STEP has invalid topology: {issues:?}"
507 )));
508 }
509 if imported.len() != solids.len() {
510 return Err(StlConversionError(format!(
511 "{} body/bodies were written but {} came back out of the STEP document",
512 solids.len(),
513 imported.len()
514 )));
515 }
516 let step_validation_seconds = validation_started.elapsed().as_secs_f64();
517
518 let mut report = aggregate_report(&components, details, count, options);
519 report.messages.push(format!(
520 "STEP manifold audit and round-trip import passed for {} solid(s)",
521 imported.len()
522 ));
523 report.messages.push(report.backend_reason.clone());
524 report.exported_step_bytes = step_text.len();
525 report.exported_advanced_faces = step_text.matches("ADVANCED_FACE(").count();
526 report.roundtrip_solids = imported.len();
527 report.manifold_audit_issues = manifold_audit_issues;
528 report.timings.step_export_seconds = step_export_seconds;
529 report.timings.step_validation_seconds = step_validation_seconds;
530 report.timings.total_seconds = total_started.elapsed().as_secs_f64();
531 Ok(StlConversionOutput { step_text, report })
532}
533
534struct ComponentDetail {
537 backend: ConversionBackend,
538 backend_reason: String,
539 fallback_cause: Option<String>,
540 input_vertices: usize,
541 input_triangles: usize,
542 recognized_regions: usize,
543 recognized_triangles: usize,
544 unresolved_triangles: usize,
545 recognition_skipped: Option<String>,
546 source_closed_manifold: bool,
547 analytic_used_repaired_mesh: bool,
548 requested_distance_tolerance: f64,
549 effective_distance_tolerance: f64,
550 region_type_counts: BTreeMap<String, usize>,
551 regions: Vec<ConversionRegionReport>,
552 topology_refit: Option<ConversionRegionReport>,
553 hybrid_rebuild: Option<HybridConversionReport>,
554 faceted_faces_before_merge: Option<usize>,
555 faceted_faces_after_merge: Option<usize>,
556 faces: usize,
557 segmentation_seconds: f64,
558 recognition_seconds: f64,
559 topology_build_seconds: f64,
560 messages: Vec<String>,
561}
562
563fn convert_component(
567 mesh: &Mesh,
568 source_positions: &[f64],
569 source_indices: Option<&[u32]>,
570 options: &StlConversionOptions,
571) -> Result<(brep_kernel::BrepSolid, ComponentDetail), StlConversionError> {
572 let mut recognition_options = options.recognition.clone();
573 recognition_options.collect_phase_timings = true;
574 let requested_distance_tolerance = recognition_options.distance_tolerance;
575 recognition_options.distance_tolerance = recognition_options
576 .distance_tolerance
577 .max(options.coordinate_precision_tolerance);
578 let encoding_distance_tolerance = recognition_options.distance_tolerance;
579
580 let source_closed_manifold = closed_manifold(mesh);
581 let input_vertices = mesh.vertices.len();
582 let input_triangles = mesh.triangles.len();
583 let repaired = if source_closed_manifold {
592 None
593 } else {
594 repaired_closed_mesh(source_positions, source_indices, options)
595 };
596 let (mesh, source_positions, source_indices) = match &repaired {
597 Some(repaired) => (
598 &repaired.mesh,
599 repaired.positions.as_slice(),
600 Some(repaired.indices.as_slice()),
601 ),
602 None => (mesh, source_positions, source_indices),
603 };
604 let closed_for_analytics = source_closed_manifold || repaired.is_some();
607
608 let segmentation_started = Instant::now();
609 let segmentation = if closed_for_analytics {
610 gate_segmentation(source_positions, source_indices, options)
611 } else {
612 Err("the source mesh is not a closed two-manifold".to_owned())
613 };
614 let segmentation_seconds = segmentation_started.elapsed().as_secs_f64();
615 let recognition_gate = recognition_gate(&segmentation);
616 let measured_region_deviation = match (&recognition_gate, &segmentation) {
625 (Ok(()), Ok(segmentation)) => segmentation
626 .regions
627 .iter()
628 .map(|region| region.max_deviation)
629 .fold(0.0_f64, f64::max),
630 _ => 0.0,
631 };
632 recognition_options.distance_tolerance = recognition_options
633 .distance_tolerance
634 .max(measured_region_deviation);
635 let effective_distance_tolerance = recognition_options.distance_tolerance;
636
637 let recognition_started = Instant::now();
638 let (recognition, completed_plane_regions) = if recognition_gate.is_ok() {
639 let mut recognition = recognize_surfaces_with_unresolved(mesh, &recognition_options)
640 .map_err(|error| {
641 StlConversionError(format!("surface recognition failed: {error}"))
642 })?;
643 let completed_plane_regions = complete_small_planar_regions(
644 mesh,
645 &recognition_options,
646 &mut recognition.regions,
647 &mut recognition.unresolved_triangles,
648 )?;
649 (recognition, completed_plane_regions)
650 } else {
651 (
652 crate::RecognitionResult {
653 regions: Vec::new(),
654 unresolved_triangles: Vec::new(),
655 unresolved_diagnostics: Vec::new(),
656 },
657 0,
658 )
659 };
660 let recognition_seconds = if recognition_gate.is_ok() {
661 recognition_started.elapsed().as_secs_f64()
662 } else {
663 0.0
664 };
665 let recognized_triangles = recognition
666 .regions
667 .iter()
668 .map(|region| region.triangle_indices.len())
669 .sum();
670 let regions = recognition
671 .regions
672 .iter()
673 .map(region_report)
674 .collect::<Vec<_>>();
675 let mut region_type_counts = BTreeMap::new();
676 for region in &recognition.regions {
677 *region_type_counts
678 .entry(region.surface.surface_type().name().to_owned())
679 .or_insert(0) += 1;
680 }
681
682 let topology_started = Instant::now();
683 let analytic_coverage = recognition.unresolved_triangles.is_empty()
684 && recognized_triangles == mesh.triangles.len()
685 && disjoint_complete_partition(&recognition.regions, mesh.triangles.len());
686 let primitive = if analytic_coverage && closed_for_analytics {
687 direct_analytic_solid(mesh, &recognition.regions, &recognition_options)
688 } else {
689 None
690 };
691
692 let mut fallback_cause = None;
693 let mut messages = vec![match &recognition_gate {
694 Ok(()) => format!(
695 "RANSAC recognized {} region(s) covering {recognized_triangles}/{} triangles",
696 recognition.regions.len(),
697 mesh.triangles.len()
698 ),
699 Err(reason) => format!("RANSAC recognition skipped: {reason}"),
700 }];
701 if let Some(repaired) = &repaired {
702 messages.push(repaired.message.clone());
703 }
704 if encoding_distance_tolerance > requested_distance_tolerance {
705 messages.push(format!(
706 "source coordinate precision raised the absolute recognition distance from {requested_distance_tolerance:.6e} to {encoding_distance_tolerance:.6e}"
707 ));
708 }
709 if effective_distance_tolerance > encoding_distance_tolerance {
710 messages.push(format!(
711 "the segmentation's measured vertex deviation raised the absolute recognition distance from {encoding_distance_tolerance:.6e} to {effective_distance_tolerance:.6e}"
712 ));
713 }
714 if completed_plane_regions > 0 {
715 messages.push(format!(
716 "a plane-only completion pass recovered {completed_plane_regions} small feature-bounded planar region(s)"
717 ));
718 }
719
720 let (
721 solid,
722 backend,
723 backend_reason,
724 topology_refit,
725 hybrid_rebuild,
726 faceted_faces_before_merge,
727 faceted_faces_after_merge,
728 ) = if let Some(primitive) = primitive {
729 (
730 primitive.solid?,
731 primitive.backend,
732 primitive.reason,
733 primitive.topology_refit,
734 None,
735 None,
736 None,
737 )
738 } else {
739 let direct_cause = match &recognition_gate {
740 Err(reason) => format!("whole-primitive recognition skipped: {reason}"),
741 Ok(()) if !analytic_coverage => format!(
742 "RANSAC did not prove complete analytic coverage ({} unresolved triangle(s))",
743 recognition.unresolved_triangles.len()
744 ),
745 Ok(()) if !closed_for_analytics => {
746 "recognized mesh is not a closed two-manifold".to_owned()
747 }
748 Ok(()) => {
749 "recognized regions do not match a safe complete primitive topology".to_owned()
750 }
751 };
752
753 let kernel_eligible = segmentation.as_ref().is_ok_and(|segmentation| {
754 segmentation_is_complete(segmentation)
755 && segmentation.regions.iter().all(|region| {
756 matches!(
757 region.carrier,
758 RegionCarrier::Plane { .. }
759 | RegionCarrier::Cylinder { .. }
760 | RegionCarrier::Cone { .. }
761 )
762 })
763 });
764 let kernel_attempt = if closed_for_analytics
765 && kernel_eligible
766 && options.try_kernel_analytic_rebuild
767 {
768 try_kernel_analytic(source_positions, source_indices, options)
769 } else {
770 Err("kernel analytic rebuild requires the kernel segmentation to prove complete plane/cylinder/cone coverage".to_owned())
771 };
772
773 let analytic_attempt = match kernel_attempt {
774 Ok(solid) => Ok((
775 solid,
776 ConversionBackend::KernelAnalyticRebuild,
777 "kernel segmentation proved complete plane/cylinder/cone coverage; the kernel region rebuilder then reconstructed and validated the complete analytic shell".to_owned(),
778 None,
779 )),
780 Err(kernel_error) => {
781 let hybrid_attempt = if closed_for_analytics && options.try_hybrid_rebuild {
782 try_hybrid_analytic(source_positions, source_indices, options)
783 } else if !options.try_hybrid_rebuild {
784 Err("mixed analytic/faceted rebuild was disabled".to_owned())
785 } else {
786 Err("mixed analytic/faceted rebuild requires a closed two-manifold".to_owned())
787 };
788 match hybrid_attempt {
789 Ok(output) => {
790 let hybrid_report = hybrid_report(output.stats);
791 if options.policy == ConversionPolicy::RequireFullyAnalytic
792 && hybrid_report.faceted_faces != 0
793 {
794 Err(format!(
795 "{kernel_error}; mixed rebuild retained {} faceted face(s) for {} source triangle(s)",
796 hybrid_report.faceted_faces, hybrid_report.faceted_triangles
797 ))
798 } else {
799 let reason = if hybrid_report.faceted_faces == 0 {
800 "independent segmentation reconstructed and validated a fully analytic plane/cylinder/cone/sphere shell"
801 } else {
802 "independent segmentation reconstructed exact plane/cylinder/cone/sphere regions and retained unsupported or unsafe regions as facets"
803 };
804 Ok((
805 output.solid,
806 ConversionBackend::HybridAnalyticRebuild,
807 reason.to_owned(),
808 Some(hybrid_report),
809 ))
810 }
811 }
812 Err(hybrid_error) => Err(format!("{kernel_error}; {hybrid_error}")),
813 }
814 }
815 };
816
817 match analytic_attempt {
818 Ok((solid, backend, reason, hybrid)) => {
819 (solid, backend, reason, None, hybrid, None, None)
820 }
821 Err(analytic_error) => {
822 let cause = format!("{direct_cause}; {analytic_error}");
823 if options.policy == ConversionPolicy::RequireFullyAnalytic {
824 return Err(StlConversionError(format!(
825 "strict analytic conversion refused faceted fallback: {cause}"
826 )));
827 }
828 fallback_cause = Some(cause.clone());
829 messages.push(format!("analytic rebuild unavailable: {cause}"));
830 let faceted =
831 mesh_to_faceted_brep(source_positions, source_indices, options.weld_tolerance)
832 .map_err(|error| {
833 StlConversionError(format!("faceted mesh repair failed: {error}"))
834 })?;
835 let faces_before = solid_face_count(&faceted);
836 let merge_tolerance = coplanar_merge_tolerance(mesh);
837 match merge_same_surface_faces(&faceted, merge_tolerance) {
838 Ok(merged) => {
839 let faces_after = solid_face_count(&merged);
840 if faces_after < faces_before {
841 messages.push(format!(
842 "coplanar faceted merge reduced faces from {faces_before} to {faces_after} (tolerance {merge_tolerance:.3e})"
843 ));
844 (
845 merged,
846 ConversionBackend::FacetedRepairCoplanarMerged,
847 "the complete source triangle set was repaired as a faceted BREP, then adjacent coplanar facets were merged without introducing curved replacement topology".to_owned(),
848 None,
849 None,
850 Some(faces_before),
851 Some(faces_after),
852 )
853 } else {
854 messages.push(format!(
855 "coplanar faceted merge found no mergeable faces (tolerance {merge_tolerance:.3e})"
856 ));
857 (
858 faceted,
859 ConversionBackend::FacetedRepair,
860 "the complete source triangle set was repaired and preserved as a faceted BREP; no adjacent coplanar facets could be merged".to_owned(),
861 None,
862 None,
863 Some(faces_before),
864 Some(faces_before),
865 )
866 }
867 }
868 Err(error) => {
869 messages.push(format!(
870 "coplanar faceted merge was unavailable; retained the validated repaired mesh: {error}"
871 ));
872 (
873 faceted,
874 ConversionBackend::FacetedRepair,
875 "the complete source triangle set was repaired and preserved as a faceted BREP; the optional coplanar merge was unavailable".to_owned(),
876 None,
877 None,
878 Some(faces_before),
879 Some(faces_before),
880 )
881 }
882 }
883 }
884 }
885 };
886 let topology_build_seconds = topology_started.elapsed().as_secs_f64();
887
888 let topology_issues = solid.validate();
889 if !topology_issues.is_empty() {
890 return Err(StlConversionError(format!(
891 "constructed BREP failed topology validation: {topology_issues:?}"
892 )));
893 }
894
895 let faces = solid_face_count(&solid);
896 let detail = ComponentDetail {
897 backend,
898 backend_reason,
899 fallback_cause,
900 input_vertices,
901 input_triangles,
902 recognized_regions: recognition.regions.len(),
903 recognized_triangles,
904 unresolved_triangles: recognition.unresolved_triangles.len(),
905 recognition_skipped: recognition_gate.err(),
906 source_closed_manifold,
907 analytic_used_repaired_mesh: repaired.is_some(),
908 requested_distance_tolerance,
909 effective_distance_tolerance,
910 region_type_counts,
911 regions,
912 topology_refit,
913 hybrid_rebuild,
914 faceted_faces_before_merge,
915 faceted_faces_after_merge,
916 faces,
917 segmentation_seconds,
918 recognition_seconds,
919 topology_build_seconds,
920 messages,
921 };
922 Ok((solid, detail))
923}
924
925fn aggregate_report(
930 components: &[Vec<usize>],
931 details: Vec<ComponentDetail>,
932 count: usize,
933 options: &StlConversionOptions,
934) -> StlConversionReport {
935 let mut messages = Vec::new();
936 if count > 1 {
937 messages.push(format!(
938 "the triangle soup holds {count} vertex-connected components; each was reconstructed as its own body"
939 ));
940 }
941 for (index, detail) in details.iter().enumerate() {
942 for message in &detail.messages {
943 messages.push(if count > 1 {
944 format!("body {}: {message}", index + 1)
945 } else {
946 message.clone()
947 });
948 }
949 }
950
951 let first = &details[0];
952 let backend_reason = if count == 1 {
953 first.backend_reason.clone()
954 } else if details
955 .iter()
956 .all(|detail| detail.backend == first.backend)
957 {
958 format!(
959 "{count} bodies were reconstructed separately; each: {}",
960 first.backend_reason
961 )
962 } else {
963 format!(
964 "{count} bodies were reconstructed separately: {}",
965 details
966 .iter()
967 .enumerate()
968 .map(|(index, detail)| format!("body {} — {}", index + 1, detail.backend_reason))
969 .collect::<Vec<_>>()
970 .join("; ")
971 )
972 };
973 let fallback_cause = details
974 .iter()
975 .enumerate()
976 .find_map(|(index, detail)| {
977 detail.fallback_cause.as_ref().map(|cause| {
978 if count > 1 {
979 format!("body {}: {cause}", index + 1)
980 } else {
981 cause.clone()
982 }
983 })
984 });
985 let recognition_skipped = details
986 .iter()
987 .enumerate()
988 .map(|(index, detail)| {
989 detail
990 .recognition_skipped
991 .as_ref()
992 .map(|reason| (index, reason))
993 })
994 .collect::<Option<Vec<_>>>()
995 .map(|skipped| {
996 if count == 1 || skipped.iter().all(|(_, reason)| *reason == skipped[0].1) {
997 skipped[0].1.clone()
998 } else {
999 skipped
1000 .iter()
1001 .map(|(index, reason)| format!("body {}: {reason}", index + 1))
1002 .collect::<Vec<_>>()
1003 .join("; ")
1004 }
1005 });
1006
1007 let mut region_type_counts = BTreeMap::new();
1008 for detail in &details {
1009 for (name, count) in &detail.region_type_counts {
1010 *region_type_counts.entry(name.clone()).or_insert(0) += count;
1011 }
1012 }
1013 let hybrid_rebuild = details
1014 .iter()
1015 .any(|detail| detail.hybrid_rebuild.is_some())
1016 .then(|| {
1017 details
1018 .iter()
1019 .filter_map(|detail| detail.hybrid_rebuild)
1020 .fold(HybridConversionReport::default(), add_hybrid_reports)
1021 });
1022 let faceted_faces_before_merge = sum_optional(&details, |detail| detail.faceted_faces_before_merge);
1023 let faceted_faces_after_merge = sum_optional(&details, |detail| detail.faceted_faces_after_merge);
1024
1025 let component_reports = components
1026 .iter()
1027 .zip(&details)
1028 .enumerate()
1029 .map(|(index, (triangles, detail))| ComponentConversionReport {
1030 index,
1031 first_triangle: triangles[0],
1032 input_vertices: detail.input_vertices,
1033 input_triangles: detail.input_triangles,
1034 backend: detail.backend,
1035 backend_reason: detail.backend_reason.clone(),
1036 fallback_cause: detail.fallback_cause.clone(),
1037 recognition_skipped: detail.recognition_skipped.clone(),
1038 recognized_regions: detail.recognized_regions,
1039 recognized_triangles: detail.recognized_triangles,
1040 unresolved_triangles: detail.unresolved_triangles,
1041 source_closed_manifold: detail.source_closed_manifold,
1042 analytic_used_repaired_mesh: detail.analytic_used_repaired_mesh,
1043 hybrid_rebuild: detail.hybrid_rebuild,
1044 faceted_faces_before_merge: detail.faceted_faces_before_merge,
1045 faceted_faces_after_merge: detail.faceted_faces_after_merge,
1046 faces: detail.faces,
1047 })
1048 .collect();
1049
1050 StlConversionReport {
1051 backend: first.backend,
1052 backend_reason,
1053 fallback_cause,
1054 input_vertices: details.iter().map(|detail| detail.input_vertices).sum(),
1055 input_triangles: details.iter().map(|detail| detail.input_triangles).sum(),
1056 recognized_regions: details.iter().map(|detail| detail.recognized_regions).sum(),
1057 recognized_triangles: details
1058 .iter()
1059 .map(|detail| detail.recognized_triangles)
1060 .sum(),
1061 unresolved_triangles: details
1062 .iter()
1063 .map(|detail| detail.unresolved_triangles)
1064 .sum(),
1065 recognition_skipped,
1066 source_closed_manifold: details.iter().all(|detail| detail.source_closed_manifold),
1067 analytic_used_repaired_mesh: details
1068 .iter()
1069 .any(|detail| detail.analytic_used_repaired_mesh),
1070 requested_distance_tolerance: first.requested_distance_tolerance,
1071 coordinate_precision_tolerance: options.coordinate_precision_tolerance,
1072 effective_distance_tolerance: first.effective_distance_tolerance,
1073 region_type_counts,
1074 regions: details
1075 .iter()
1076 .flat_map(|detail| detail.regions.iter().cloned())
1077 .collect(),
1078 topology_refit: details
1079 .iter()
1080 .find_map(|detail| detail.topology_refit.clone()),
1081 hybrid_rebuild,
1082 components: component_reports,
1083 exported_step_bytes: 0,
1084 exported_advanced_faces: 0,
1085 faceted_faces_before_merge,
1086 faceted_faces_after_merge,
1087 roundtrip_solids: 0,
1088 manifold_audit_issues: Vec::new(),
1089 timings: ConversionTimings {
1090 segmentation_seconds: details
1091 .iter()
1092 .map(|detail| detail.segmentation_seconds)
1093 .sum(),
1094 recognition_seconds: details
1095 .iter()
1096 .map(|detail| detail.recognition_seconds)
1097 .sum(),
1098 topology_build_seconds: details
1099 .iter()
1100 .map(|detail| detail.topology_build_seconds)
1101 .sum(),
1102 step_export_seconds: 0.0,
1103 step_validation_seconds: 0.0,
1104 total_seconds: 0.0,
1105 },
1106 messages,
1107 }
1108}
1109
1110fn sum_optional(
1111 details: &[ComponentDetail],
1112 read: impl Fn(&ComponentDetail) -> Option<usize>,
1113) -> Option<usize> {
1114 details
1115 .iter()
1116 .any(|detail| read(detail).is_some())
1117 .then(|| details.iter().filter_map(read).sum())
1118}
1119
1120fn add_hybrid_reports(
1121 total: HybridConversionReport,
1122 body: HybridConversionReport,
1123) -> HybridConversionReport {
1124 HybridConversionReport {
1125 total_faces: total.total_faces + body.total_faces,
1126 analytic_plane_faces: total.analytic_plane_faces + body.analytic_plane_faces,
1127 analytic_plane_triangles: total.analytic_plane_triangles + body.analytic_plane_triangles,
1128 analytic_cylinder_faces: total.analytic_cylinder_faces + body.analytic_cylinder_faces,
1129 analytic_cylinder_triangles: total.analytic_cylinder_triangles
1130 + body.analytic_cylinder_triangles,
1131 analytic_cone_faces: total.analytic_cone_faces + body.analytic_cone_faces,
1132 analytic_cone_triangles: total.analytic_cone_triangles + body.analytic_cone_triangles,
1133 analytic_sphere_faces: total.analytic_sphere_faces + body.analytic_sphere_faces,
1134 analytic_sphere_triangles: total.analytic_sphere_triangles + body.analytic_sphere_triangles,
1135 faceted_faces: total.faceted_faces + body.faceted_faces,
1136 faceted_triangles: total.faceted_triangles + body.faceted_triangles,
1137 demoted_regions: total.demoted_regions + body.demoted_regions,
1138 demoted_triangles: total.demoted_triangles + body.demoted_triangles,
1139 segmentation_plane_regions: total.segmentation_plane_regions
1140 + body.segmentation_plane_regions,
1141 segmentation_cylinder_regions: total.segmentation_cylinder_regions
1142 + body.segmentation_cylinder_regions,
1143 segmentation_cone_regions: total.segmentation_cone_regions + body.segmentation_cone_regions,
1144 segmentation_sphere_regions: total.segmentation_sphere_regions
1145 + body.segmentation_sphere_regions,
1146 segmentation_unsupported_regions: total.segmentation_unsupported_regions
1147 + body.segmentation_unsupported_regions,
1148 }
1149}
1150
1151fn triangle_components(mesh: &Mesh) -> Vec<Vec<usize>> {
1166 fn find(parent: &mut [usize], mut node: usize) -> usize {
1167 while parent[node] != node {
1168 parent[node] = parent[parent[node]];
1169 node = parent[node];
1170 }
1171 node
1172 }
1173
1174 let bits = |value: f64| if value == 0.0 { 0_u64 } else { value.to_bits() };
1175 let mut position_ids = HashMap::<[u64; 3], usize>::new();
1176 let mut vertex_position = Vec::with_capacity(mesh.vertices.len());
1177 for point in &mesh.vertices {
1178 let key = [bits(point.x), bits(point.y), bits(point.z)];
1179 let next = position_ids.len();
1180 vertex_position.push(*position_ids.entry(key).or_insert(next));
1181 }
1182
1183 let mut parent = (0..position_ids.len()).collect::<Vec<_>>();
1184 for triangle in &mesh.triangles {
1185 let first = vertex_position[triangle[0] as usize];
1186 for &corner in &triangle[1..] {
1187 let (left, right) = (
1188 find(&mut parent, first),
1189 find(&mut parent, vertex_position[corner as usize]),
1190 );
1191 if left != right {
1192 parent[left] = right;
1193 }
1194 }
1195 }
1196
1197 let mut slots = HashMap::<usize, usize>::new();
1198 let mut components = Vec::<Vec<usize>>::new();
1199 for (index, triangle) in mesh.triangles.iter().enumerate() {
1200 let root = find(&mut parent, vertex_position[triangle[0] as usize]);
1201 let slot = match slots.get(&root) {
1202 Some(&slot) => slot,
1203 None => {
1204 components.push(Vec::new());
1205 slots.insert(root, components.len() - 1);
1206 components.len() - 1
1207 }
1208 };
1209 components[slot].push(index);
1210 }
1211 components
1212}
1213
1214struct ComponentMesh {
1216 mesh: Mesh,
1217 positions: Vec<f64>,
1218 indices: Option<Vec<u32>>,
1219}
1220
1221fn component_mesh(
1226 mesh: &Mesh,
1227 positions: &[f64],
1228 indices: Option<&[u32]>,
1229 triangles: &[usize],
1230) -> ComponentMesh {
1231 let mut vertex_map = HashMap::<u32, u32>::new();
1232 let mut vertices = Vec::new();
1233 let mut component_triangles = Vec::with_capacity(triangles.len());
1234 for &triangle in triangles {
1235 let mut corners = [0_u32; 3];
1236 for (corner, &index) in mesh.triangles[triangle].iter().enumerate() {
1237 corners[corner] = *vertex_map.entry(index).or_insert_with(|| {
1238 vertices.push(mesh.vertices[index as usize]);
1239 vertices.len() as u32 - 1
1240 });
1241 }
1242 component_triangles.push(corners);
1243 }
1244 let mut component = Mesh::new(vertices, component_triangles);
1245 if let Some(normals) = &mesh.vertex_normals {
1246 if normals.len() == mesh.vertices.len() {
1247 let mut component_normals = vec![Vec3::default(); component.vertices.len()];
1248 for (&source, &target) in &vertex_map {
1249 component_normals[target as usize] = normals[source as usize];
1250 }
1251 component.vertex_normals = Some(component_normals);
1252 }
1253 }
1254 if !mesh.source_metadata.is_empty() {
1255 let local = triangles
1256 .iter()
1257 .enumerate()
1258 .map(|(local, &source)| (source, local))
1259 .collect::<HashMap<_, _>>();
1260 component.source_metadata = mesh
1261 .source_metadata
1262 .iter()
1263 .filter_map(|metadata| {
1264 let triangle_indices = metadata
1265 .triangle_indices
1266 .iter()
1267 .filter_map(|source| local.get(source).copied())
1268 .collect::<Vec<_>>();
1269 (!triangle_indices.is_empty()).then(|| crate::SourceMetadata {
1270 triangle_indices,
1271 ..metadata.clone()
1272 })
1273 })
1274 .collect();
1275 }
1276
1277 let (component_positions, component_indices) = match indices {
1278 Some(indices) => {
1279 let mut source_map = HashMap::<u32, u32>::new();
1280 let mut component_positions = Vec::new();
1281 let mut component_indices = Vec::with_capacity(triangles.len() * 3);
1282 for &triangle in triangles {
1283 for &index in &indices[triangle * 3..triangle * 3 + 3] {
1284 let mapped = *source_map.entry(index).or_insert_with(|| {
1285 let base = index as usize * 3;
1286 component_positions.extend_from_slice(&positions[base..base + 3]);
1287 component_positions.len() as u32 / 3 - 1
1288 });
1289 component_indices.push(mapped);
1290 }
1291 }
1292 (component_positions, Some(component_indices))
1293 }
1294 None => {
1295 let mut component_positions = Vec::with_capacity(triangles.len() * 9);
1296 for &triangle in triangles {
1297 component_positions.extend_from_slice(&positions[triangle * 9..triangle * 9 + 9]);
1298 }
1299 (component_positions, None)
1300 }
1301 };
1302 ComponentMesh {
1303 mesh: component,
1304 positions: component_positions,
1305 indices: component_indices,
1306 }
1307}
1308
1309fn validate_source_buffers(
1310 mesh: &Mesh,
1311 positions: &[f64],
1312 indices: Option<&[u32]>,
1313) -> Result<(), StlConversionError> {
1314 if positions.is_empty() || !positions.len().is_multiple_of(3) {
1315 return Err(StlConversionError(
1316 "source position buffer must contain complete non-empty xyz triples".to_owned(),
1317 ));
1318 }
1319 if positions.iter().any(|value| !value.is_finite()) {
1320 return Err(StlConversionError(
1321 "source position buffer contains a non-finite coordinate".to_owned(),
1322 ));
1323 }
1324 let source_triangles = match indices {
1325 Some(indices) => {
1326 if !indices.len().is_multiple_of(3) {
1327 return Err(StlConversionError(
1328 "source index buffer must contain complete triangles".to_owned(),
1329 ));
1330 }
1331 if indices
1332 .iter()
1333 .any(|&index| index as usize >= positions.len() / 3)
1334 {
1335 return Err(StlConversionError(
1336 "source index buffer references a missing position".to_owned(),
1337 ));
1338 }
1339 indices.len() / 3
1340 }
1341 None => {
1342 if !positions.len().is_multiple_of(9) {
1343 return Err(StlConversionError(
1344 "unindexed source positions must be a triangle soup".to_owned(),
1345 ));
1346 }
1347 positions.len() / 9
1348 }
1349 };
1350 if source_triangles != mesh.triangles.len() {
1351 return Err(StlConversionError(format!(
1352 "source buffers contain {source_triangles} triangles but recognition mesh contains {}",
1353 mesh.triangles.len()
1354 )));
1355 }
1356 Ok(())
1357}
1358
1359fn complete_small_planar_regions(
1364 mesh: &Mesh,
1365 options: &RecognitionOptions,
1366 regions: &mut Vec<SurfaceRegion>,
1367 unresolved: &mut Vec<usize>,
1368) -> Result<usize, StlConversionError> {
1369 if unresolved.len() < 2 {
1370 return Ok(0);
1371 }
1372 let analyzed = mesh
1373 .analyze(&MeshAnalysisOptions {
1374 feature_angle: options.feature_angle,
1375 ..MeshAnalysisOptions::default()
1376 })
1377 .map_err(|error| {
1378 StlConversionError(format!("plane completion analysis failed: {error}"))
1379 })?;
1380 let allowed = unresolved.iter().copied().collect::<BTreeSet<_>>();
1381 let mut visited = BTreeSet::new();
1382 let mut accepted = BTreeSet::new();
1383 let mut completed = 0;
1384 let mut plane_options = options.clone();
1385 plane_options.minimum_support = 2;
1386
1387 for &seed in unresolved.iter() {
1388 if !visited.insert(seed) {
1389 continue;
1390 }
1391 let mut component = Vec::new();
1392 let mut queue = VecDeque::from([seed]);
1393 while let Some(triangle) = queue.pop_front() {
1394 component.push(triangle);
1395 let data = &analyzed.triangles[triangle];
1396 for edge in 0..3 {
1397 if data.feature_edges[edge] {
1398 continue;
1399 }
1400 if let Some(neighbor) = data.neighbors[edge] {
1401 if allowed.contains(&neighbor) && visited.insert(neighbor) {
1402 queue.push_back(neighbor);
1403 }
1404 }
1405 }
1406 }
1407 component.sort_unstable();
1408 if component.len() < 2 {
1409 continue;
1410 }
1411 let Ok(fit) = reconstruct_surface(
1412 mesh,
1413 &component,
1414 &SurfaceHint::KnownType {
1415 surface_type: SurfaceType::Plane,
1416 },
1417 &plane_options,
1418 ) else {
1419 continue;
1420 };
1421 if fit.metrics.support_triangles != component.len()
1422 || !matches!(fit.surface, AnalyticSurface::Plane(_))
1423 {
1424 continue;
1425 }
1426 accepted.extend(component.iter().copied());
1427 regions.push(SurfaceRegion {
1428 surface: fit.surface,
1429 orientation: fit.orientation,
1430 triangle_indices: component,
1431 metrics: fit.metrics,
1432 confidence: fit.confidence,
1433 diagnostics: fit.diagnostics,
1434 });
1435 completed += 1;
1436 }
1437 unresolved.retain(|triangle| !accepted.contains(triangle));
1438 Ok(completed)
1439}
1440
1441fn region_report(region: &SurfaceRegion) -> ConversionRegionReport {
1442 fit_fields(
1443 region.surface,
1444 region.orientation,
1445 ®ion.metrics,
1446 region.confidence,
1447 ®ion.diagnostics,
1448 )
1449}
1450
1451fn refit_report(fit: &SurfaceFitResult) -> ConversionRegionReport {
1452 fit_fields(
1453 fit.surface,
1454 fit.orientation,
1455 &fit.metrics,
1456 fit.confidence,
1457 &fit.diagnostics,
1458 )
1459}
1460
1461fn fit_fields(
1462 surface: AnalyticSurface,
1463 orientation: i8,
1464 metrics: &crate::FitMetrics,
1465 confidence: f64,
1466 diagnostics: &crate::FitDiagnostics,
1467) -> ConversionRegionReport {
1468 ConversionRegionReport {
1469 surface_type: surface.surface_type(),
1470 surface,
1471 support_triangles: metrics.support_triangles,
1472 supported_area: metrics.supported_area,
1473 rms_error: metrics.rms_error,
1474 max_error: metrics.max_error,
1475 rms_normal_error_radians: metrics.rms_normal_error,
1476 max_normal_error_radians: metrics.max_normal_error,
1477 confidence,
1478 orientation,
1479 reason: diagnostics.reason.clone(),
1480 phase_timings: diagnostics.phase_timings,
1481 }
1482}
1483
1484fn disjoint_complete_partition(regions: &[SurfaceRegion], triangle_count: usize) -> bool {
1485 let mut seen = BTreeSet::new();
1486 regions.iter().all(|region| {
1487 region
1488 .triangle_indices
1489 .iter()
1490 .all(|&triangle| triangle < triangle_count && seen.insert(triangle))
1491 }) && seen.len() == triangle_count
1492}
1493
1494struct RepairedMesh {
1497 mesh: Mesh,
1498 positions: Vec<f64>,
1499 indices: Vec<u32>,
1500 message: String,
1501}
1502
1503fn repaired_closed_mesh(
1508 source_positions: &[f64],
1509 source_indices: Option<&[u32]>,
1510 options: &StlConversionOptions,
1511) -> Option<RepairedMesh> {
1512 let (positions, indices, report) =
1513 repair_triangle_soup(source_positions, source_indices, options.weld_tolerance).ok()?;
1514 let vertices = positions
1515 .chunks_exact(3)
1516 .map(|point| Vec3::new(point[0], point[1], point[2]))
1517 .collect::<Vec<_>>();
1518 let triangles = indices
1519 .chunks_exact(3)
1520 .map(|triangle| [triangle[0], triangle[1], triangle[2]])
1521 .collect::<Vec<_>>();
1522 let mesh = Mesh::new(vertices, triangles);
1523 if !closed_manifold(&mesh) {
1524 return None;
1525 }
1526 let message = format!(
1527 "the source mesh is not a closed two-manifold; the analytic lanes ran on the repaired mesh \
1528 ({} welded vertices, {} triangles: {} dropped as degenerate or duplicate, {} surplus \
1529 sheet(s) pruned, {} boundary loop(s) capped with {} triangle(s))",
1530 report.welded_vertices,
1531 report.triangles,
1532 report.dropped_triangles,
1533 report.pruned_triangles,
1534 report.capped_loops,
1535 report.cap_triangles,
1536 );
1537 Some(RepairedMesh {
1538 mesh,
1539 positions,
1540 indices,
1541 message,
1542 })
1543}
1544
1545fn closed_manifold(mesh: &Mesh) -> bool {
1546 let mut uses = BTreeMap::<(u32, u32), (usize, i32)>::new();
1547 for triangle in &mesh.triangles {
1548 if triangle[0] == triangle[1] || triangle[1] == triangle[2] || triangle[2] == triangle[0] {
1549 return false;
1550 }
1551 for edge in [
1552 (triangle[0], triangle[1]),
1553 (triangle[1], triangle[2]),
1554 (triangle[2], triangle[0]),
1555 ] {
1556 let key = if edge.0 < edge.1 {
1557 (edge.0, edge.1)
1558 } else {
1559 (edge.1, edge.0)
1560 };
1561 let entry = uses.entry(key).or_default();
1562 entry.0 += 1;
1563 entry.1 += if edge == key { 1 } else { -1 };
1564 }
1565 }
1566 !uses.is_empty()
1567 && uses
1568 .values()
1569 .all(|&(count, sense)| count == 2 && sense == 0)
1570}
1571
1572struct DirectAnalyticBuild {
1573 solid: Result<brep_kernel::BrepSolid, StlConversionError>,
1574 backend: ConversionBackend,
1575 reason: String,
1576 topology_refit: Option<ConversionRegionReport>,
1577}
1578
1579fn direct_analytic_solid(
1580 mesh: &Mesh,
1581 regions: &[SurfaceRegion],
1582 options: &RecognitionOptions,
1583) -> Option<DirectAnalyticBuild> {
1584 if regions.len() == 1 {
1585 return match regions[0].surface {
1586 AnalyticSurface::Sphere(sphere) => Some(DirectAnalyticBuild {
1587 solid: make_sphere_brep(
1588 kernel_vec(sphere.center),
1589 sphere.radius,
1590 kernel_vec(Vec3::new(0.0, 0.0, 1.0)),
1591 )
1592 .map_err(StlConversionError),
1593 backend: ConversionBackend::RansacSphere,
1594 reason: "RANSAC proved complete closed spherical coverage; exported an exact analytic sphere".to_owned(),
1595 topology_refit: None,
1596 }),
1597 AnalyticSurface::Torus(torus) if torus.major_radius > torus.minor_radius => Some(DirectAnalyticBuild {
1598 solid: make_torus_brep(
1599 kernel_vec(torus.center),
1600 kernel_vec(torus.axis),
1601 torus.major_radius,
1602 torus.minor_radius,
1603 )
1604 .map_err(StlConversionError),
1605 backend: ConversionBackend::RansacTorus,
1606 reason: "RANSAC proved complete closed ring-torus coverage; exported an exact analytic torus".to_owned(),
1607 topology_refit: None,
1608 }),
1609 _ => None,
1610 };
1611 }
1612
1613 let curved = regions
1614 .iter()
1615 .filter(|region| !matches!(region.surface, AnalyticSurface::Plane(_)))
1616 .collect::<Vec<_>>();
1617 let planes = regions
1618 .iter()
1619 .filter_map(|region| match region.surface {
1620 AnalyticSurface::Plane(plane) => Some(plane),
1621 _ => None,
1622 })
1623 .collect::<Vec<_>>();
1624 if curved.len() != 1 {
1625 return None;
1626 }
1627 let topology_refit;
1633 let mut topology_refit_report = None;
1634 let curved = if let AnalyticSurface::Torus(torus) = curved[0].surface {
1635 let scale = mesh_diagonal(mesh);
1636 let collapsed = torus.major_radius.min(torus.minor_radius) > 20.0 * scale;
1637 let forced_type = match planes.len() {
1638 2 if collapsed => Some(SurfaceType::Cylinder),
1639 1 if collapsed => Some(SurfaceType::Cone),
1640 _ => None,
1641 };
1642 if let Some(surface_type) = forced_type {
1643 topology_refit = reconstruct_surface(
1644 mesh,
1645 &curved[0].triangle_indices,
1646 &SurfaceHint::KnownType { surface_type },
1647 options,
1648 )
1649 .ok()?;
1650 topology_refit_report = Some(refit_report(&topology_refit));
1651 &topology_refit.surface
1652 } else {
1653 &curved[0].surface
1654 }
1655 } else {
1656 &curved[0].surface
1657 };
1658 match *curved {
1659 AnalyticSurface::Cylinder(cylinder) if planes.len() == 2 => {
1660 let (low, high) = axial_range(mesh, cylinder.axis_origin, cylinder.axis)?;
1661 let tolerance = conversion_tolerance(mesh, options);
1662 if high - low <= tolerance
1663 || !caps_match(
1664 &planes,
1665 cylinder.axis_origin,
1666 cylinder.axis,
1667 &[low, high],
1668 tolerance,
1669 )
1670 {
1671 return None;
1672 }
1673 let base = cylinder.axis_origin + cylinder.axis * low;
1674 Some(DirectAnalyticBuild {
1675 solid: make_cylinder_brep(
1676 kernel_vec(base),
1677 kernel_vec(cylinder.axis),
1678 cylinder.radius,
1679 high - low,
1680 )
1681 .map_err(StlConversionError),
1682 backend: ConversionBackend::RansacCappedCylinder,
1683 reason: if topology_refit_report.is_some() {
1684 "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()
1685 } else {
1686 "RANSAC proved one cylindrical wall plus two matching planar caps; exported an exact capped cylinder".to_owned()
1687 },
1688 topology_refit: topology_refit_report,
1689 })
1690 }
1691 AnalyticSurface::Cone(cone) if planes.len() == 1 || planes.len() == 2 => {
1692 let (low, high) = axial_range(mesh, cone.apex, cone.axis)?;
1693 let tolerance = conversion_tolerance(mesh, options);
1694 if low < -tolerance || high - low <= tolerance {
1695 return None;
1696 }
1697 let expected_caps = if low <= tolerance {
1698 vec![high]
1699 } else {
1700 vec![low, high]
1701 };
1702 if planes.len() != expected_caps.len()
1703 || !caps_match(&planes, cone.apex, cone.axis, &expected_caps, tolerance)
1704 {
1705 return None;
1706 }
1707 let radius_bottom = low.max(0.0) * cone.half_angle.tan();
1708 let radius_top = high * cone.half_angle.tan();
1709 if radius_top <= tolerance || (low > tolerance && radius_bottom <= tolerance) {
1710 return None;
1711 }
1712 let (base, build_axis, build_bottom, build_top) = if low <= tolerance {
1713 (
1717 cone.apex + cone.axis * high,
1718 cone.axis * -1.0,
1719 radius_top,
1720 0.0,
1721 )
1722 } else {
1723 (
1724 cone.apex + cone.axis * low,
1725 cone.axis,
1726 radius_bottom,
1727 radius_top,
1728 )
1729 };
1730 Some(DirectAnalyticBuild {
1731 solid: make_cone_brep(
1732 kernel_vec(base),
1733 kernel_vec(build_axis),
1734 build_bottom,
1735 build_top,
1736 high - low.max(0.0),
1737 )
1738 .map_err(StlConversionError),
1739 backend: ConversionBackend::RansacCappedCone,
1740 reason: if topology_refit_report.is_some() {
1741 "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()
1742 } else {
1743 "RANSAC proved one conical wall and matching planar cap topology; exported an exact capped cone".to_owned()
1744 },
1745 topology_refit: topology_refit_report,
1746 })
1747 }
1748 _ => None,
1749 }
1750}
1751
1752fn axial_range(mesh: &Mesh, origin: Vec3, axis: Vec3) -> Option<(f64, f64)> {
1753 let mut low = f64::INFINITY;
1754 let mut high = f64::NEG_INFINITY;
1755 for &point in &mesh.vertices {
1756 let height = (point - origin).dot(axis);
1757 low = low.min(height);
1758 high = high.max(height);
1759 }
1760 (low.is_finite() && high.is_finite()).then_some((low, high))
1761}
1762
1763fn caps_match(
1764 planes: &[crate::PlaneSurface],
1765 origin: Vec3,
1766 axis: Vec3,
1767 expected_heights: &[f64],
1768 tolerance: f64,
1769) -> bool {
1770 let mut actual = Vec::with_capacity(planes.len());
1771 for plane in planes {
1772 if plane.normal.dot(axis).abs() < 1.0 - 1.0e-6 {
1773 return false;
1774 }
1775 actual.push((plane.origin - origin).dot(axis));
1776 }
1777 actual.sort_by(f64::total_cmp);
1778 let mut expected = expected_heights.to_vec();
1779 expected.sort_by(f64::total_cmp);
1780 actual.len() == expected.len()
1781 && actual
1782 .iter()
1783 .zip(expected)
1784 .all(|(actual, expected)| (*actual - expected).abs() <= tolerance)
1785}
1786
1787fn mesh_tolerance(mesh: &Mesh) -> f64 {
1788 let mut low = mesh.vertices[0];
1789 let mut high = mesh.vertices[0];
1790 for &point in &mesh.vertices[1..] {
1791 low.x = low.x.min(point.x);
1792 low.y = low.y.min(point.y);
1793 low.z = low.z.min(point.z);
1794 high.x = high.x.max(point.x);
1795 high.y = high.y.max(point.y);
1796 high.z = high.z.max(point.z);
1797 }
1798 (high - low).length().max(1.0) * 1.0e-5
1799}
1800
1801fn conversion_tolerance(mesh: &Mesh, options: &RecognitionOptions) -> f64 {
1802 mesh_tolerance(mesh)
1803 .max(options.distance_tolerance + options.relative_tolerance * mesh_diagonal(mesh))
1804}
1805
1806fn mesh_diagonal(mesh: &Mesh) -> f64 {
1807 let mut low = mesh.vertices[0];
1808 let mut high = mesh.vertices[0];
1809 for &point in &mesh.vertices[1..] {
1810 low.x = low.x.min(point.x);
1811 low.y = low.y.min(point.y);
1812 low.z = low.z.min(point.z);
1813 high.x = high.x.max(point.x);
1814 high.y = high.y.max(point.y);
1815 high.z = high.z.max(point.z);
1816 }
1817 (high - low).length()
1818}
1819
1820fn coplanar_merge_tolerance(mesh: &Mesh) -> f64 {
1821 (mesh_diagonal(mesh) * 1.0e-9).max(1.0e-12)
1822}
1823
1824fn solid_face_count(solid: &brep_kernel::BrepSolid) -> usize {
1825 solid.shells.iter().map(|shell| shell.faces.len()).sum()
1826}
1827
1828const MAX_WHOLE_PRIMITIVE_REGIONS: usize = 3;
1832
1833fn segmentation_options(options: &StlConversionOptions) -> SegmentOptions {
1834 SegmentOptions {
1835 deflection_angle_deg: options.kernel_deflection_angle_degrees,
1836 fit_tolerance: options.kernel_fit_tolerance,
1837 normal_tolerance_deg: options.kernel_normal_tolerance_degrees,
1838 weld_tolerance: options.weld_tolerance,
1839 ..SegmentOptions::default()
1840 }
1841}
1842
1843fn gate_segmentation(
1846 positions: &[f64],
1847 indices: Option<&[u32]>,
1848 options: &StlConversionOptions,
1849) -> Result<MeshSegmentation, String> {
1850 let owned_indices;
1851 let indices = match indices {
1852 Some(indices) => indices,
1853 None => {
1854 owned_indices = (0..positions.len() as u32 / 3).collect::<Vec<_>>();
1855 &owned_indices
1856 }
1857 };
1858 segment_mesh_faces(positions, indices, &segmentation_options(options))
1859 .map_err(|error| format!("kernel segmentation failed: {error}"))
1860}
1861
1862fn segmentation_is_complete(segmentation: &MeshSegmentation) -> bool {
1864 !segmentation
1865 .triangle_region_ids
1866 .contains(&UNASSIGNED_REGION)
1867 && segmentation
1868 .regions
1869 .iter()
1870 .all(|region| !matches!(region.carrier, RegionCarrier::Freeform))
1871}
1872
1873fn recognition_gate(segmentation: &Result<MeshSegmentation, String>) -> Result<(), String> {
1881 let segmentation = segmentation.as_ref().map_err(Clone::clone)?;
1882 let unassigned = segmentation
1883 .triangle_region_ids
1884 .iter()
1885 .filter(|id| **id == UNASSIGNED_REGION)
1886 .count();
1887 let unsupported = segmentation
1888 .regions
1889 .iter()
1890 .filter(|region| matches!(region.carrier, RegionCarrier::Freeform))
1891 .count();
1892 if unassigned > 0 || unsupported > 0 {
1893 return Err(format!(
1894 "kernel segmentation left {unassigned} triangle(s) unassigned and {unsupported} region(s) without an analytic carrier, so no whole-primitive lane can fire"
1895 ));
1896 }
1897 if segmentation.regions.len() > MAX_WHOLE_PRIMITIVE_REGIONS {
1898 return Err(format!(
1899 "kernel segmentation found {} analytic regions; a whole primitive has at most {MAX_WHOLE_PRIMITIVE_REGIONS}",
1900 segmentation.regions.len()
1901 ));
1902 }
1903 Ok(())
1904}
1905
1906fn try_kernel_analytic(
1907 positions: &[f64],
1908 indices: Option<&[u32]>,
1909 options: &StlConversionOptions,
1910) -> Result<brep_kernel::BrepSolid, String> {
1911 let owned_indices;
1912 let indices = match indices {
1913 Some(indices) => indices,
1914 None => {
1915 owned_indices = (0..positions.len() as u32 / 3).collect::<Vec<_>>();
1916 &owned_indices
1917 }
1918 };
1919 let segmentation_options = segmentation_options(options);
1920 let segmentation = segment_mesh_faces(positions, indices, &segmentation_options)?;
1921 if segmentation
1922 .triangle_region_ids
1923 .contains(&UNASSIGNED_REGION)
1924 {
1925 return Err("independent kernel segmentation left triangles unassigned".to_owned());
1926 }
1927 if let Some(region) = segmentation.regions.iter().find(|region| {
1928 matches!(
1929 region.carrier,
1930 RegionCarrier::Freeform | RegionCarrier::Sphere { .. } | RegionCarrier::Torus { .. }
1931 )
1932 }) {
1933 return Err(format!(
1934 "independent kernel segmentation produced unsupported {} region {}",
1935 region.carrier.kind(),
1936 region.id
1937 ));
1938 }
1939 mesh_regions_to_brep(positions, indices, &segmentation_options)
1940}
1941
1942fn try_hybrid_analytic(
1943 positions: &[f64],
1944 indices: Option<&[u32]>,
1945 options: &StlConversionOptions,
1946) -> Result<crate::hybrid_region_brep::HybridBrepOutput, String> {
1947 crate::hybrid_region_brep::hybrid_plane_cylinder_brep(
1948 positions,
1949 indices,
1950 &segmentation_options(options),
1951 )
1952}
1953
1954fn hybrid_report(stats: crate::hybrid_region_brep::HybridBrepStats) -> HybridConversionReport {
1955 HybridConversionReport {
1956 total_faces: stats.total_faces,
1957 analytic_plane_faces: stats.analytic_plane_faces,
1958 analytic_plane_triangles: stats.analytic_plane_triangles,
1959 analytic_cylinder_faces: stats.analytic_cylinder_faces,
1960 analytic_cylinder_triangles: stats.analytic_cylinder_triangles,
1961 analytic_cone_faces: stats.analytic_cone_faces,
1962 analytic_cone_triangles: stats.analytic_cone_triangles,
1963 analytic_sphere_faces: stats.analytic_sphere_faces,
1964 analytic_sphere_triangles: stats.analytic_sphere_triangles,
1965 faceted_faces: stats.faceted_faces,
1966 faceted_triangles: stats.faceted_triangles,
1967 demoted_regions: stats.demoted_regions,
1968 demoted_triangles: stats.demoted_triangles,
1969 segmentation_plane_regions: stats.segmentation_plane_regions,
1970 segmentation_cylinder_regions: stats.segmentation_cylinder_regions,
1971 segmentation_cone_regions: stats.segmentation_cone_regions,
1972 segmentation_sphere_regions: stats.segmentation_sphere_regions,
1973 segmentation_unsupported_regions: stats.segmentation_unsupported_regions,
1974 }
1975}
1976
1977fn kernel_vec(value: Vec3) -> brep_kernel::Vec3 {
1978 brep_kernel::Vec3::new(value.x, value.y, value.z)
1979}