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