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