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