1mod cavities;
30mod degenerate_coverage;
31mod feature_fragments;
32mod primitive_gate;
33pub mod t_junctions;
34
35use crate::{
36 recognize_surfaces_with_candidates, reconstruct_surface, AnalyticSurface, ComponentCandidate,
37 ConeSurface, CylinderSurface, Mesh, PlaneSurface, SphereSurface, TorusSurface,
38 MeshAnalysisOptions, PhaseTimings, RecognitionOptions, SamplingMode, SurfaceFitResult,
39 SurfaceHint, SurfaceRegion, SurfaceType, Vec3,
40};
41use brep_kernel::{
42 audit_step_manifold, export_step, import_step, make_cone_brep, make_cylinder_brep,
43 make_sphere_brep, make_torus_brep, merge_same_surface_faces, mesh_regions_to_brep,
44 mesh_to_faceted_brep, repair_triangle_soup, segment_mesh_faces, MeshSegmentation,
45 RegionCarrier, SegmentOptions,
46 UNASSIGNED_REGION,
47};
48use serde::{Deserialize, Serialize};
49use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
50use std::fmt::{Display, Formatter};
51use web_time::Instant;
52
53#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
56pub enum ConversionPolicy {
57 #[default]
59 AllowFacetedFallback,
60 RequireFullyAnalytic,
62}
63
64#[derive(Clone, Debug, Deserialize, Serialize)]
66#[serde(default)]
67pub struct StlConversionOptions {
68 pub recognition: RecognitionOptions,
72 pub policy: ConversionPolicy,
74 pub try_kernel_analytic_rebuild: bool,
77 pub try_hybrid_rebuild: bool,
83 pub weld_tolerance: f64,
87 pub coordinate_precision_tolerance: f64,
94 pub kernel_deflection_angle_degrees: f64,
96 pub kernel_fit_tolerance: f64,
98 pub kernel_normal_tolerance_degrees: f64,
100}
101
102impl Default for StlConversionOptions {
103 fn default() -> Self {
104 let recognition = RecognitionOptions {
105 collect_phase_timings: true,
106 sampling: SamplingMode::Vertices,
110 ..RecognitionOptions::default()
111 };
112 Self {
113 recognition,
114 policy: ConversionPolicy::AllowFacetedFallback,
115 try_kernel_analytic_rebuild: true,
116 try_hybrid_rebuild: true,
117 weld_tolerance: -1.0,
118 coordinate_precision_tolerance: 0.0,
119 kernel_deflection_angle_degrees: 30.0,
120 kernel_fit_tolerance: 1.0e-3,
121 kernel_normal_tolerance_degrees: 15.0,
122 }
123 }
124}
125
126#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
128#[serde(rename_all = "snake_case")]
129pub enum ConversionBackend {
130 RansacSphere,
132 RansacTorus,
134 RansacCappedCylinder,
136 RansacCappedCone,
138 KernelAnalyticRebuild,
141 KernelSphericalUnion,
144 #[serde(alias = "hybrid_plane_cylinder_rebuild")]
147 HybridAnalyticRebuild,
148 FacetedRepair,
150 FacetedRepairCoplanarMerged,
153 Refused,
157}
158
159#[derive(Clone, Debug, Deserialize, Serialize)]
161pub struct ConversionRegionReport {
162 pub surface_type: SurfaceType,
164 pub surface: AnalyticSurface,
166 pub support_triangles: usize,
168 pub supported_area: f64,
170 pub rms_error: f64,
172 pub max_error: f64,
174 pub rms_normal_error_radians: f64,
176 pub max_normal_error_radians: f64,
178 pub confidence: f64,
180 pub orientation: i8,
182 pub reason: String,
184 pub phase_timings: PhaseTimings,
186}
187
188#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
190pub struct ConversionTimings {
191 #[serde(default)]
193 pub segmentation_seconds: f64,
194 pub recognition_seconds: f64,
196 pub topology_build_seconds: f64,
198 pub step_export_seconds: f64,
200 pub step_validation_seconds: f64,
202 pub total_seconds: f64,
204}
205
206#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
208pub struct HybridConversionReport {
209 pub total_faces: usize,
211 pub analytic_plane_faces: usize,
213 pub analytic_plane_triangles: usize,
215 pub analytic_cylinder_faces: usize,
217 pub analytic_cylinder_triangles: usize,
219 #[serde(default)]
221 pub analytic_cone_faces: usize,
222 #[serde(default)]
224 pub analytic_cone_triangles: usize,
225 #[serde(default)]
227 pub analytic_sphere_faces: usize,
228 #[serde(default)]
230 pub analytic_sphere_triangles: usize,
231 #[serde(default)]
233 pub analytic_torus_faces: usize,
234 #[serde(default)]
236 pub analytic_torus_triangles: usize,
237 pub faceted_faces: usize,
239 pub faceted_triangles: usize,
241 pub demoted_regions: usize,
243 pub demoted_triangles: usize,
245 pub segmentation_plane_regions: usize,
247 pub segmentation_cylinder_regions: usize,
249 #[serde(default)]
251 pub segmentation_cone_regions: usize,
252 #[serde(default)]
254 pub segmentation_sphere_regions: usize,
255 #[serde(default)]
257 pub segmentation_torus_regions: usize,
258 pub segmentation_unsupported_regions: usize,
260}
261
262#[derive(Clone, Debug, Deserialize, Serialize)]
265pub struct ComponentConversionReport {
266 pub index: usize,
268 pub first_triangle: usize,
271 pub input_vertices: usize,
273 pub input_triangles: usize,
275 pub backend: ConversionBackend,
277 pub backend_reason: String,
279 pub fallback_cause: Option<String>,
281 pub recognition_skipped: Option<String>,
283 pub recognized_regions: usize,
285 pub recognized_triangles: usize,
287 pub unresolved_triangles: usize,
289 pub source_closed_manifold: bool,
291 #[serde(default)]
296 pub analytic_used_repaired_mesh: bool,
297 pub hybrid_rebuild: Option<HybridConversionReport>,
299 pub faceted_faces_before_merge: Option<usize>,
301 pub faceted_faces_after_merge: Option<usize>,
303 pub faces: usize,
305 #[serde(default)]
308 pub refusal: Option<String>,
309 #[serde(default)]
312 pub source_name: Option<String>,
313}
314
315#[derive(Clone, Debug, Deserialize, Serialize)]
322pub struct StlConversionReport {
323 pub backend: ConversionBackend,
325 pub backend_reason: String,
327 pub fallback_cause: Option<String>,
329 pub input_vertices: usize,
331 pub input_triangles: usize,
333 pub recognized_regions: usize,
335 pub recognized_triangles: usize,
337 pub unresolved_triangles: usize,
339 #[serde(default)]
342 pub recognition_skipped: Option<String>,
343 pub source_closed_manifold: bool,
345 #[serde(default)]
349 pub analytic_used_repaired_mesh: bool,
350 #[serde(default)]
352 pub requested_distance_tolerance: f64,
353 #[serde(default)]
355 pub coordinate_precision_tolerance: f64,
356 #[serde(default)]
358 pub effective_distance_tolerance: f64,
359 pub region_type_counts: BTreeMap<String, usize>,
361 pub regions: Vec<ConversionRegionReport>,
363 pub topology_refit: Option<ConversionRegionReport>,
366 #[serde(default)]
369 pub hybrid_rebuild: Option<HybridConversionReport>,
370 #[serde(default)]
374 pub components: Vec<ComponentConversionReport>,
375 pub exported_step_bytes: usize,
377 pub exported_advanced_faces: usize,
379 #[serde(default)]
381 pub faceted_faces_before_merge: Option<usize>,
382 #[serde(default)]
386 pub faceted_faces_after_merge: Option<usize>,
387 pub roundtrip_solids: usize,
389 pub manifold_audit_issues: Vec<String>,
391 pub timings: ConversionTimings,
393 #[serde(default)]
395 pub recognition_work: RecognitionWorkReport,
396 #[serde(default)]
398 pub whole_carrier_scan: WholeCarrierScanReport,
399 pub messages: Vec<String>,
401}
402
403#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
405pub struct RecognitionWorkReport {
406 pub hypotheses: usize,
408 pub rounds: usize,
410 pub seeded_components: usize,
413}
414
415#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
421pub struct WholeCarrierScanReport {
422 pub accepted: usize,
424 pub declined: usize,
426 pub abstained: usize,
428}
429
430impl WholeCarrierScanReport {
431 pub(crate) fn add(&mut self, verdict: crate::hybrid_whole_freeform::ScanVerdict) {
432 use crate::hybrid_whole_freeform::ScanVerdict;
433 match verdict {
434 ScanVerdict::Accepted => self.accepted += 1,
435 ScanVerdict::Declined => self.declined += 1,
436 ScanVerdict::Abstained => self.abstained += 1,
437 }
438 }
439
440 fn sum(self, other: Self) -> Self {
441 Self {
442 accepted: self.accepted + other.accepted,
443 declined: self.declined + other.declined,
444 abstained: self.abstained + other.abstained,
445 }
446 }
447}
448
449#[derive(Clone, Debug, Deserialize, Serialize)]
451pub struct StlConversionOutput {
452 pub step_text: String,
454 pub report: StlConversionReport,
456}
457
458#[derive(Clone, Debug, PartialEq, Eq)]
460pub struct StlConversionError(pub String);
461
462impl Display for StlConversionError {
463 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
464 formatter.write_str(&self.0)
465 }
466}
467
468impl std::error::Error for StlConversionError {}
469
470impl From<String> for StlConversionError {
471 fn from(value: String) -> Self {
472 Self(value)
473 }
474}
475
476pub fn binary_stl_coordinate_precision_tolerance(mesh: &Mesh) -> f64 {
486 let maximum_absolute_coordinate = mesh
487 .vertices
488 .iter()
489 .flat_map(|point| [point.x.abs(), point.y.abs(), point.z.abs()])
490 .fold(0.0_f64, f64::max);
491 let coordinate_scale = maximum_absolute_coordinate.max(mesh_diagonal(mesh));
492 coordinate_scale * (f32::EPSILON as f64) * 0.5 * 3.0_f64.sqrt()
493}
494
495pub fn convert_stl_mesh_to_step(
507 mesh: &Mesh,
508 source_positions: &[f64],
509 source_indices: Option<&[u32]>,
510 options: &StlConversionOptions,
511 part_name: &str,
512 unit: &str,
513 timestamp: &str,
514) -> Result<StlConversionOutput, StlConversionError> {
515 let total_started = Instant::now();
516 validate_source_buffers(mesh, source_positions, source_indices)?;
517 if !options.coordinate_precision_tolerance.is_finite()
518 || options.coordinate_precision_tolerance < 0.0
519 {
520 return Err(StlConversionError(
521 "coordinate_precision_tolerance must be finite and non-negative".to_owned(),
522 ));
523 }
524
525 let components = triangle_components(mesh);
526 let count = components.len();
527 if count == 0 {
528 return Err(StlConversionError(
529 "the mesh contains no triangles to reconstruct".to_owned(),
530 ));
531 }
532
533 let mut solids = Vec::with_capacity(count);
534 let mut component_ids = Vec::with_capacity(count);
535 let mut details = Vec::with_capacity(count);
536 let mut first_refusal = None;
537 for (index, triangles) in components.iter().enumerate() {
538 if count == 1 {
539 let built = convert_component(mesh, source_positions, source_indices, options)?;
540 solids.push(built.0);
541 component_ids.push(index);
542 details.push(built.1);
543 continue;
544 }
545 let part = component_mesh(mesh, source_positions, source_indices, triangles);
546 match convert_component(
547 &part.mesh,
548 &part.positions,
549 part.indices.as_deref(),
550 options,
551 ) {
552 Ok(built) => {
553 solids.push(built.0);
554 component_ids.push(index);
555 details.push(built.1);
556 }
557 Err(error) => {
561 first_refusal.get_or_insert_with(|| {
562 format!(
563 "body {} of {count} (from source triangle {}, {} triangle(s)): {error}",
564 index + 1,
565 triangles[0],
566 triangles.len()
567 )
568 });
569 details.push(refused_component(&part.mesh, triangles.len(), error.0));
570 }
571 }
572 }
573 if solids.is_empty() {
575 let refusal = first_refusal.unwrap_or_default();
576 return Err(StlConversionError(if count > 1 {
577 format!("{refusal} (every one of the {count} bodies was refused)")
578 } else {
579 refusal
580 }));
581 }
582
583 let (solids, cavity_messages) = cavities::assemble(mesh, &components, solids, &component_ids)?;
584 let export_started = Instant::now();
585 let step_text = export_step(&solids, part_name, unit, timestamp)
586 .map_err(|error| StlConversionError(format!("STEP export failed: {error}")))?;
587 let step_export_seconds = export_started.elapsed().as_secs_f64();
588
589 let validation_started = Instant::now();
590 let manifold_audit_issues = audit_step_manifold(&step_text);
591 if !manifold_audit_issues.is_empty() {
592 return Err(StlConversionError(format!(
593 "STEP manifold audit failed: {}",
594 manifold_audit_issues.join("; ")
595 )));
596 }
597 let imported = import_step(&step_text).map_err(|error| {
598 StlConversionError(format!("STEP round-trip validation failed: {error}"))
599 })?;
600 if let Some(issues) = imported
601 .iter()
602 .map(|solid| solid.validate())
603 .find(|issues| !issues.is_empty())
604 {
605 return Err(StlConversionError(format!(
606 "round-tripped STEP has invalid topology: {issues:?}"
607 )));
608 }
609 if imported.len() != solids.len() {
610 return Err(StlConversionError(format!(
611 "{} body/bodies were written but {} came back out of the STEP document",
612 solids.len(),
613 imported.len()
614 )));
615 }
616 let step_validation_seconds = validation_started.elapsed().as_secs_f64();
617
618 let mut report = aggregate_report(&components, details, count, options);
619 report.messages.extend(cavity_messages);
620 report.messages.push(format!(
621 "STEP manifold audit and round-trip import passed for {} solid(s)",
622 imported.len()
623 ));
624 report.messages.push(report.backend_reason.clone());
625 report.exported_step_bytes = step_text.len();
626 report.exported_advanced_faces = step_text.matches("ADVANCED_FACE(").count();
627 report.roundtrip_solids = imported.len();
628 report.manifold_audit_issues = manifold_audit_issues;
629 report.timings.step_export_seconds = step_export_seconds;
630 report.timings.step_validation_seconds = step_validation_seconds;
631 report.timings.total_seconds = total_started.elapsed().as_secs_f64();
632 Ok(StlConversionOutput { step_text, report })
633}
634
635fn refused_component(mesh: &Mesh, triangles: usize, reason: String) -> ComponentDetail {
638 ComponentDetail {
639 backend: ConversionBackend::Refused,
640 backend_reason: format!("refused, not in the model: {reason}"),
641 fallback_cause: None,
642 input_vertices: mesh.vertices.len(),
643 input_triangles: triangles,
644 recognized_regions: 0,
645 recognized_triangles: 0,
646 unresolved_triangles: 0,
647 recognition_skipped: None,
648 source_closed_manifold: closed_manifold(mesh),
649 analytic_used_repaired_mesh: false,
650 requested_distance_tolerance: 0.0,
651 effective_distance_tolerance: 0.0,
652 region_type_counts: BTreeMap::new(),
653 regions: Vec::new(),
654 topology_refit: None,
655 hybrid_rebuild: None,
656 faceted_faces_before_merge: None,
657 faceted_faces_after_merge: None,
658 faces: 0,
659 segmentation_seconds: 0.0,
660 recognition_seconds: 0.0,
661 recognition_work: RecognitionWorkReport::default(),
662 whole_carrier_scan: WholeCarrierScanReport::default(),
663 topology_build_seconds: 0.0,
664 messages: vec![format!("refused, not in the model: {reason}")],
665 refusal: Some(reason),
666 }
667}
668
669struct ComponentDetail {
672 backend: ConversionBackend,
673 backend_reason: String,
674 fallback_cause: Option<String>,
675 input_vertices: usize,
676 input_triangles: usize,
677 recognized_regions: usize,
678 recognized_triangles: usize,
679 unresolved_triangles: usize,
680 recognition_skipped: Option<String>,
681 source_closed_manifold: bool,
682 analytic_used_repaired_mesh: bool,
683 requested_distance_tolerance: f64,
684 effective_distance_tolerance: f64,
685 region_type_counts: BTreeMap<String, usize>,
686 regions: Vec<ConversionRegionReport>,
687 topology_refit: Option<ConversionRegionReport>,
688 hybrid_rebuild: Option<HybridConversionReport>,
689 faceted_faces_before_merge: Option<usize>,
690 faceted_faces_after_merge: Option<usize>,
691 faces: usize,
692 segmentation_seconds: f64,
693 recognition_seconds: f64,
694 recognition_work: RecognitionWorkReport,
695 whole_carrier_scan: WholeCarrierScanReport,
696 topology_build_seconds: f64,
697 messages: Vec<String>,
698 refusal: Option<String>,
700}
701
702fn convert_component(
706 mesh: &Mesh,
707 source_positions: &[f64],
708 source_indices: Option<&[u32]>,
709 options: &StlConversionOptions,
710) -> Result<(brep_kernel::BrepSolid, ComponentDetail), StlConversionError> {
711 let outward = cavities::outward_component(mesh, source_positions, source_indices);
714 let (mesh, source_positions, source_indices) = match &outward {
715 Some(part) => (&part.mesh, part.positions.as_slice(), part.indices.as_deref()),
716 None => (mesh, source_positions, source_indices),
717 };
718 let mut recognition_options = options.recognition.clone();
719 recognition_options.collect_phase_timings = true;
720 let requested_distance_tolerance = recognition_options.distance_tolerance;
721 recognition_options.distance_tolerance = recognition_options
722 .distance_tolerance
723 .max(options.coordinate_precision_tolerance);
724 let encoding_distance_tolerance = recognition_options.distance_tolerance;
725
726 let source_closed_manifold = closed_manifold(mesh);
727 let input_vertices = mesh.vertices.len();
728 let input_triangles = mesh.triangles.len();
729 let repaired = if source_closed_manifold {
738 None
739 } else {
740 repaired_closed_mesh(source_positions, source_indices, options)
741 };
742 let (mesh, source_positions, source_indices) = match &repaired {
743 Some(repaired) => (
744 &repaired.mesh,
745 repaired.positions.as_slice(),
746 Some(repaired.indices.as_slice()),
747 ),
748 None => (mesh, source_positions, source_indices),
749 };
750 let closed_for_analytics = source_closed_manifold || repaired.is_some();
753
754 let segmentation_started = Instant::now();
755 let segmentation = if closed_for_analytics {
756 gate_segmentation(source_positions, source_indices, options)
757 } else {
758 Err("the source mesh is not a closed two-manifold".to_owned())
759 };
760 let segmentation_seconds = segmentation_started.elapsed().as_secs_f64();
761 let initial_recognition_gate = recognition_gate(&segmentation);
762 let geometric_gate_rescue = initial_recognition_gate.is_err()
763 && closed_for_analytics
764 && segmentation.as_ref().is_ok_and(|segmentation| {
765 primitive_gate::has_whole_primitive_candidate(mesh, segmentation, &recognition_options)
766 });
767 let recognition_gate = if geometric_gate_rescue {
768 Ok(())
769 } else {
770 initial_recognition_gate
771 };
772 let measured_region_deviation = match (&recognition_gate, &segmentation) {
781 (Ok(()), Ok(segmentation)) => segmentation
782 .regions
783 .iter()
784 .map(|region| region.max_deviation)
785 .fold(0.0_f64, f64::max),
786 _ => 0.0,
787 };
788 recognition_options.distance_tolerance = recognition_options
789 .distance_tolerance
790 .max(measured_region_deviation);
791 let effective_distance_tolerance = recognition_options.distance_tolerance;
792
793 let recognition_started = Instant::now();
794 let mut recognition_work = RecognitionWorkReport::default();
795 let (recognition, completed_plane_regions, completed_degenerate_triangles) = if recognition_gate.is_ok() {
796 let candidates = match &segmentation {
797 Ok(segmentation) if segment_carrier_enabled() => segmentation_candidates(mesh, segmentation),
798 _ => Vec::new(),
799 };
800 let (mut recognition, work) =
801 recognize_surfaces_with_candidates(mesh, &recognition_options, &candidates)
802 .map_err(|error| {
803 StlConversionError(format!("surface recognition failed: {error}"))
804 })?;
805 recognition_work = RecognitionWorkReport {
806 hypotheses: work.attempts,
807 rounds: work.rounds,
808 seeded_components: work.seeded_components,
809 };
810 feature_fragments::complete(
811 mesh,
812 &recognition_options,
813 &mut recognition.regions,
814 &mut recognition.unresolved_triangles,
815 )?;
816 let completed_plane_regions = complete_small_planar_regions(
817 mesh,
818 &recognition_options,
819 &mut recognition.regions,
820 &mut recognition.unresolved_triangles,
821 )?;
822 let completed_degenerate_triangles = degenerate_coverage::complete(
823 mesh, &recognition_options, &mut recognition.regions,
824 )?;
825 (recognition, completed_plane_regions, completed_degenerate_triangles)
826 } else {
827 (
828 crate::RecognitionResult {
829 regions: Vec::new(),
830 unresolved_triangles: Vec::new(),
831 unresolved_diagnostics: Vec::new(),
832 },
833 0,
834 0,
835 )
836 };
837 let recognition_seconds = if recognition_gate.is_ok() {
838 recognition_started.elapsed().as_secs_f64()
839 } else {
840 0.0
841 };
842 let recognized_triangles = recognition
843 .regions
844 .iter()
845 .map(|region| region.triangle_indices.len())
846 .sum();
847 let regions = recognition
848 .regions
849 .iter()
850 .map(region_report)
851 .collect::<Vec<_>>();
852 let mut region_type_counts = BTreeMap::new();
853 for region in &recognition.regions {
854 *region_type_counts
855 .entry(region.surface.surface_type().name().to_owned())
856 .or_insert(0) += 1;
857 }
858
859 let topology_started = Instant::now();
860 let analytic_coverage = recognition.unresolved_triangles.is_empty()
861 && recognized_triangles == mesh.triangles.len()
862 && disjoint_complete_partition(&recognition.regions, mesh.triangles.len());
863 let spherical_union = if closed_for_analytics && options.try_kernel_analytic_rebuild {
864 segmentation.as_ref().ok().and_then(|seg| {
865 brep_kernel::reconstruct_spherical_mesh_union(
866 source_positions,
867 source_indices.unwrap_or(&[]),
868 seg,
869 &segmentation_options(source_positions, options),
870 )
871 })
872 } else {
873 None
874 };
875 let mut whole_carrier_scan = WholeCarrierScanReport::default();
882 let primitive = if analytic_coverage
883 && closed_for_analytics
884 && recognition_work.seeded_components == 0
885 && whole_fits_pass_the_local_scan(mesh, &recognition.regions, encoding_distance_tolerance, &mut whole_carrier_scan)
886 {
887 direct_analytic_solid(mesh, &recognition.regions, &recognition_options)
888 } else {
889 None
890 };
891
892 let mut fallback_cause = None;
893 let mut messages = vec![match &recognition_gate {
894 Ok(()) => format!(
895 "RANSAC recognized {} region(s) covering {recognized_triangles}/{} triangles",
896 recognition.regions.len(),
897 mesh.triangles.len()
898 ),
899 Err(reason) => format!("RANSAC recognition skipped: {reason}"),
900 }];
901 if geometric_gate_rescue {
902 messages.push("whole-primitive recognition enabled by triangle coverage on a segmented curved carrier and its possible end caps".to_owned());
903 }
904 if outward.is_some() {
905 messages.push("inward source shell reconstructed outward before cavity assembly".into());
906 }
907 if let Some(repaired) = &repaired {
908 messages.push(repaired.message.clone());
909 }
910 if encoding_distance_tolerance > requested_distance_tolerance {
911 messages.push(format!(
912 "source coordinate precision raised the absolute recognition distance from {requested_distance_tolerance:.6e} to {encoding_distance_tolerance:.6e}"
913 ));
914 }
915 if effective_distance_tolerance > encoding_distance_tolerance {
916 messages.push(format!(
917 "the segmentation's measured vertex deviation raised the absolute recognition distance from {encoding_distance_tolerance:.6e} to {effective_distance_tolerance:.6e}"
918 ));
919 }
920 if completed_degenerate_triangles > 0 {
921 messages.push(format!(
922 "attached {completed_degenerate_triangles} numerically degenerate triangle(s) to their surrounding fitted carrier; excluded from fit support metrics"
923 ));
924 }
925 if completed_plane_regions > 0 {
926 messages.push(format!(
927 "a plane-only completion pass recovered {completed_plane_regions} small feature-bounded planar region(s)"
928 ));
929 }
930
931 let (
932 solid,
933 backend,
934 backend_reason,
935 topology_refit,
936 hybrid_rebuild,
937 faceted_faces_before_merge,
938 faceted_faces_after_merge,
939 ) = if let Some(primitive) = primitive {
940 (
941 primitive.solid?,
942 primitive.backend,
943 primitive.reason,
944 primitive.topology_refit,
945 None,
946 None,
947 None,
948 )
949 } else if let Some(solid) = spherical_union {
950 (
951 solid,
952 ConversionBackend::KernelSphericalUnion,
953 "precise sphere carriers and bidirectional mesh coverage verified a primitive union; rebuilt exact analytic intersections across the mesh boolean cut strips".to_owned(),
954 None,
955 None,
956 None,
957 None,
958 )
959 } else {
960 let direct_cause = match &recognition_gate {
961 Err(reason) => format!("whole-primitive recognition skipped: {reason}"),
962 Ok(()) if !analytic_coverage => format!(
963 "RANSAC did not prove complete analytic coverage ({} unresolved triangle(s))",
964 recognition.unresolved_triangles.len()
965 ),
966 Ok(()) if !closed_for_analytics => {
967 "recognized mesh is not a closed two-manifold".to_owned()
968 }
969 Ok(()) => {
970 "recognized regions do not match a safe complete primitive topology".to_owned()
971 }
972 };
973
974 let kernel_eligible = segmentation.as_ref().is_ok_and(|segmentation| {
975 segmentation_is_complete(segmentation)
976 && segmentation.regions.iter().all(|region| {
977 matches!(
978 region.carrier,
979 RegionCarrier::Plane { .. }
980 | RegionCarrier::Cylinder { .. }
981 | RegionCarrier::Cone { .. }
982 )
983 })
984 });
985 let kernel_attempt = if closed_for_analytics
986 && kernel_eligible
987 && options.try_kernel_analytic_rebuild
988 {
989 try_kernel_analytic(source_positions, source_indices, options)
990 } else {
991 Err("kernel analytic rebuild requires the kernel segmentation to prove complete plane/cylinder/cone coverage".to_owned())
992 };
993
994 let analytic_attempt = match kernel_attempt {
995 Ok(solid) => Ok((
996 solid,
997 ConversionBackend::KernelAnalyticRebuild,
998 "kernel segmentation proved complete plane/cylinder/cone coverage; the kernel region rebuilder then reconstructed and validated the complete analytic shell".to_owned(),
999 None,
1000 )),
1001 Err(kernel_error) => {
1002 let hybrid_attempt = if closed_for_analytics && options.try_hybrid_rebuild {
1003 try_hybrid_analytic(source_positions, source_indices, options)
1004 } else if !options.try_hybrid_rebuild {
1005 Err("mixed analytic/faceted rebuild was disabled".to_owned())
1006 } else {
1007 Err("mixed analytic/faceted rebuild requires a closed two-manifold".to_owned())
1008 };
1009 match hybrid_attempt {
1010 Ok(output) => {
1011 let stats = output.stats;
1012 whole_carrier_scan = whole_carrier_scan.sum(stats.whole_carrier_scan);
1013 if stats.split_freeform_regions
1014 + stats.split_declined_regions
1015 + stats.split_over_budget_regions
1016 + stats.split_budget_hit_regions
1017 + stats.split_band_declined_regions
1018 > 0
1019 {
1020 messages.push(format!(
1021 "freeform split: {} region(s) divided into carriers, {} tried and left as segmented, {} above the triangle budget and not tried, {} out of the hypothesis budget and left as segmented, {} declined by the normal-band pre-test",
1022 stats.split_freeform_regions,
1023 stats.split_declined_regions,
1024 stats.split_over_budget_regions,
1025 stats.split_budget_hit_regions,
1026 stats.split_band_declined_regions
1027 ));
1028 }
1029 let hybrid_report = hybrid_report(output.stats);
1030 if options.policy == ConversionPolicy::RequireFullyAnalytic
1031 && hybrid_report.faceted_faces != 0
1032 {
1033 Err(format!(
1034 "{kernel_error}; mixed rebuild retained {} faceted face(s) for {} source triangle(s)",
1035 hybrid_report.faceted_faces, hybrid_report.faceted_triangles
1036 ))
1037 } else {
1038 let reason = if hybrid_report.faceted_faces == 0 {
1039 "independent segmentation reconstructed and validated a fully analytic plane/cylinder/cone/sphere shell"
1040 } else {
1041 "independent segmentation reconstructed exact plane/cylinder/cone/sphere regions and retained unsupported or unsafe regions as facets"
1042 };
1043 Ok((
1044 output.solid,
1045 ConversionBackend::HybridAnalyticRebuild,
1046 reason.to_owned(),
1047 Some(hybrid_report),
1048 ))
1049 }
1050 }
1051 Err(hybrid_error) => Err(format!("{kernel_error}; {hybrid_error}")),
1052 }
1053 }
1054 };
1055
1056 match analytic_attempt {
1057 Ok((solid, backend, reason, hybrid)) => {
1058 (solid, backend, reason, None, hybrid, None, None)
1059 }
1060 Err(analytic_error) => {
1061 let cause = format!("{direct_cause}; {analytic_error}");
1062 if options.policy == ConversionPolicy::RequireFullyAnalytic {
1063 return Err(StlConversionError(format!(
1064 "strict analytic conversion refused faceted fallback: {cause}"
1065 )));
1066 }
1067 fallback_cause = Some(cause.clone());
1068 messages.push(format!("analytic rebuild unavailable: {cause}"));
1069 let faceted =
1070 mesh_to_faceted_brep(source_positions, source_indices, options.weld_tolerance)
1071 .map_err(|error| {
1072 StlConversionError(format!("faceted mesh repair failed: {error}"))
1073 })?;
1074 let faces_before = solid_face_count(&faceted);
1075 let merge_tolerance = coplanar_merge_tolerance(mesh);
1076 match merge_same_surface_faces(&faceted, merge_tolerance) {
1077 Ok(merged) => {
1078 let faces_after = solid_face_count(&merged);
1079 if faces_after < faces_before {
1080 messages.push(format!(
1081 "coplanar faceted merge reduced faces from {faces_before} to {faces_after} (tolerance {merge_tolerance:.3e})"
1082 ));
1083 (
1084 merged,
1085 ConversionBackend::FacetedRepairCoplanarMerged,
1086 "the complete source triangle set was repaired as a faceted BREP, then adjacent coplanar facets were merged without introducing curved replacement topology".to_owned(),
1087 None,
1088 None,
1089 Some(faces_before),
1090 Some(faces_after),
1091 )
1092 } else {
1093 messages.push(format!(
1094 "coplanar faceted merge found no mergeable faces (tolerance {merge_tolerance:.3e})"
1095 ));
1096 (
1097 faceted,
1098 ConversionBackend::FacetedRepair,
1099 "the complete source triangle set was repaired and preserved as a faceted BREP; no adjacent coplanar facets could be merged".to_owned(),
1100 None,
1101 None,
1102 Some(faces_before),
1103 Some(faces_before),
1104 )
1105 }
1106 }
1107 Err(error) => {
1108 messages.push(format!(
1109 "coplanar faceted merge was unavailable; retained the validated repaired mesh: {error}"
1110 ));
1111 (
1112 faceted,
1113 ConversionBackend::FacetedRepair,
1114 "the complete source triangle set was repaired and preserved as a faceted BREP; the optional coplanar merge was unavailable".to_owned(),
1115 None,
1116 None,
1117 Some(faces_before),
1118 Some(faces_before),
1119 )
1120 }
1121 }
1122 }
1123 }
1124 };
1125 let topology_build_seconds = topology_started.elapsed().as_secs_f64();
1126
1127 let topology_issues = solid.validate();
1128 if !topology_issues.is_empty() {
1129 return Err(StlConversionError(format!(
1130 "constructed BREP failed topology validation: {topology_issues:?}"
1131 )));
1132 }
1133
1134 let faces = solid_face_count(&solid);
1135 let detail = ComponentDetail {
1136 backend,
1137 backend_reason,
1138 fallback_cause,
1139 input_vertices,
1140 input_triangles,
1141 recognized_regions: recognition.regions.len(),
1142 recognized_triangles,
1143 unresolved_triangles: recognition.unresolved_triangles.len(),
1144 recognition_skipped: recognition_gate.err(),
1145 source_closed_manifold,
1146 analytic_used_repaired_mesh: repaired.is_some(),
1147 requested_distance_tolerance,
1148 effective_distance_tolerance,
1149 region_type_counts,
1150 regions,
1151 topology_refit,
1152 hybrid_rebuild,
1153 faceted_faces_before_merge,
1154 faceted_faces_after_merge,
1155 faces,
1156 segmentation_seconds,
1157 recognition_seconds,
1158 recognition_work,
1159 whole_carrier_scan,
1160 topology_build_seconds,
1161 messages,
1162 refusal: None,
1163 };
1164 Ok((solid, detail))
1165}
1166
1167fn aggregate_report(
1172 components: &[Vec<usize>],
1173 details: Vec<ComponentDetail>,
1174 count: usize,
1175 options: &StlConversionOptions,
1176) -> StlConversionReport {
1177 let mut messages = Vec::new();
1178 if count > 1 {
1179 messages.push(format!(
1180 "the triangle soup holds {count} vertex-connected components; each shell was reconstructed independently before cavity assembly"
1181 ));
1182 }
1183 let refused = details
1184 .iter()
1185 .enumerate()
1186 .filter_map(|(index, detail)| detail.refusal.as_ref().map(|reason| (index, reason)))
1187 .collect::<Vec<_>>();
1188 if !refused.is_empty() {
1189 messages.push(format!(
1190 "{} of {count} bodies could not be reconstructed and are NOT in the model: {}",
1191 refused.len(),
1192 refused
1193 .iter()
1194 .map(|(index, reason)| format!(
1195 "body {} (from source triangle {}): {reason}",
1196 index + 1,
1197 components[*index][0]
1198 ))
1199 .collect::<Vec<_>>()
1200 .join("; ")
1201 ));
1202 }
1203 for (index, detail) in details.iter().enumerate() {
1204 for message in &detail.messages {
1205 messages.push(if count > 1 {
1206 format!("body {}: {message}", index + 1)
1207 } else {
1208 message.clone()
1209 });
1210 }
1211 }
1212
1213 let first = details
1216 .iter()
1217 .find(|detail| detail.refusal.is_none())
1218 .unwrap_or(&details[0]);
1219 let backend_reason = if count == 1 {
1220 first.backend_reason.clone()
1221 } else if details
1222 .iter()
1223 .all(|detail| detail.backend == first.backend)
1224 {
1225 format!(
1226 "{count} components were reconstructed separately; each: {}",
1227 first.backend_reason
1228 )
1229 } else {
1230 format!(
1231 "{count} components were reconstructed separately: {}",
1232 details
1233 .iter()
1234 .enumerate()
1235 .map(|(index, detail)| format!("body {} — {}", index + 1, detail.backend_reason))
1236 .collect::<Vec<_>>()
1237 .join("; ")
1238 )
1239 };
1240 let fallback_cause = details
1241 .iter()
1242 .enumerate()
1243 .find_map(|(index, detail)| {
1244 detail.fallback_cause.as_ref().map(|cause| {
1245 if count > 1 {
1246 format!("body {}: {cause}", index + 1)
1247 } else {
1248 cause.clone()
1249 }
1250 })
1251 });
1252 let recognition_skipped = details
1253 .iter()
1254 .enumerate()
1255 .map(|(index, detail)| {
1256 detail
1257 .recognition_skipped
1258 .as_ref()
1259 .map(|reason| (index, reason))
1260 })
1261 .collect::<Option<Vec<_>>>()
1262 .map(|skipped| {
1263 if count == 1 || skipped.iter().all(|(_, reason)| *reason == skipped[0].1) {
1264 skipped[0].1.clone()
1265 } else {
1266 skipped
1267 .iter()
1268 .map(|(index, reason)| format!("body {}: {reason}", index + 1))
1269 .collect::<Vec<_>>()
1270 .join("; ")
1271 }
1272 });
1273
1274 let mut region_type_counts = BTreeMap::new();
1275 for detail in &details {
1276 for (name, count) in &detail.region_type_counts {
1277 *region_type_counts.entry(name.clone()).or_insert(0) += count;
1278 }
1279 }
1280 let hybrid_rebuild = details
1281 .iter()
1282 .any(|detail| detail.hybrid_rebuild.is_some())
1283 .then(|| {
1284 details
1285 .iter()
1286 .filter_map(|detail| detail.hybrid_rebuild)
1287 .fold(HybridConversionReport::default(), add_hybrid_reports)
1288 });
1289 let faceted_faces_before_merge = sum_optional(&details, |detail| detail.faceted_faces_before_merge);
1290 let faceted_faces_after_merge = sum_optional(&details, |detail| detail.faceted_faces_after_merge);
1291
1292 let component_reports = components
1293 .iter()
1294 .zip(&details)
1295 .enumerate()
1296 .map(|(index, (triangles, detail))| ComponentConversionReport {
1297 index,
1298 first_triangle: triangles[0],
1299 input_vertices: detail.input_vertices,
1300 input_triangles: detail.input_triangles,
1301 backend: detail.backend,
1302 backend_reason: detail.backend_reason.clone(),
1303 fallback_cause: detail.fallback_cause.clone(),
1304 recognition_skipped: detail.recognition_skipped.clone(),
1305 recognized_regions: detail.recognized_regions,
1306 recognized_triangles: detail.recognized_triangles,
1307 unresolved_triangles: detail.unresolved_triangles,
1308 source_closed_manifold: detail.source_closed_manifold,
1309 analytic_used_repaired_mesh: detail.analytic_used_repaired_mesh,
1310 hybrid_rebuild: detail.hybrid_rebuild,
1311 faceted_faces_before_merge: detail.faceted_faces_before_merge,
1312 faceted_faces_after_merge: detail.faceted_faces_after_merge,
1313 faces: detail.faces,
1314 refusal: detail.refusal.clone(),
1315 source_name: None,
1316 })
1317 .collect();
1318
1319 StlConversionReport {
1320 backend: first.backend,
1321 backend_reason,
1322 fallback_cause,
1323 input_vertices: details.iter().map(|detail| detail.input_vertices).sum(),
1324 input_triangles: details.iter().map(|detail| detail.input_triangles).sum(),
1325 recognized_regions: details.iter().map(|detail| detail.recognized_regions).sum(),
1326 recognized_triangles: details
1327 .iter()
1328 .map(|detail| detail.recognized_triangles)
1329 .sum(),
1330 unresolved_triangles: details
1331 .iter()
1332 .map(|detail| detail.unresolved_triangles)
1333 .sum(),
1334 recognition_skipped,
1335 source_closed_manifold: details.iter().all(|detail| detail.source_closed_manifold),
1336 analytic_used_repaired_mesh: details
1337 .iter()
1338 .any(|detail| detail.analytic_used_repaired_mesh),
1339 requested_distance_tolerance: first.requested_distance_tolerance,
1340 coordinate_precision_tolerance: options.coordinate_precision_tolerance,
1341 effective_distance_tolerance: first.effective_distance_tolerance,
1342 region_type_counts,
1343 regions: details
1344 .iter()
1345 .flat_map(|detail| detail.regions.iter().cloned())
1346 .collect(),
1347 topology_refit: details
1348 .iter()
1349 .find_map(|detail| detail.topology_refit.clone()),
1350 hybrid_rebuild,
1351 components: component_reports,
1352 exported_step_bytes: 0,
1353 exported_advanced_faces: 0,
1354 faceted_faces_before_merge,
1355 faceted_faces_after_merge,
1356 roundtrip_solids: 0,
1357 manifold_audit_issues: Vec::new(),
1358 timings: ConversionTimings {
1359 segmentation_seconds: details
1360 .iter()
1361 .map(|detail| detail.segmentation_seconds)
1362 .sum(),
1363 recognition_seconds: details
1364 .iter()
1365 .map(|detail| detail.recognition_seconds)
1366 .sum(),
1367 topology_build_seconds: details
1368 .iter()
1369 .map(|detail| detail.topology_build_seconds)
1370 .sum(),
1371 step_export_seconds: 0.0,
1372 step_validation_seconds: 0.0,
1373 total_seconds: 0.0,
1374 },
1375 recognition_work: details.iter().fold(RecognitionWorkReport::default(), |sum, detail| {
1376 RecognitionWorkReport {
1377 hypotheses: sum.hypotheses + detail.recognition_work.hypotheses,
1378 rounds: sum.rounds + detail.recognition_work.rounds,
1379 seeded_components: sum.seeded_components + detail.recognition_work.seeded_components,
1380 }
1381 }),
1382 whole_carrier_scan: details
1383 .iter()
1384 .fold(WholeCarrierScanReport::default(), |sum, detail| sum.sum(detail.whole_carrier_scan)),
1385 messages,
1386 }
1387}
1388
1389fn sum_optional(
1390 details: &[ComponentDetail],
1391 read: impl Fn(&ComponentDetail) -> Option<usize>,
1392) -> Option<usize> {
1393 details
1394 .iter()
1395 .any(|detail| read(detail).is_some())
1396 .then(|| details.iter().filter_map(read).sum())
1397}
1398
1399fn add_hybrid_reports(
1400 total: HybridConversionReport,
1401 body: HybridConversionReport,
1402) -> HybridConversionReport {
1403 HybridConversionReport {
1404 total_faces: total.total_faces + body.total_faces,
1405 analytic_plane_faces: total.analytic_plane_faces + body.analytic_plane_faces,
1406 analytic_plane_triangles: total.analytic_plane_triangles + body.analytic_plane_triangles,
1407 analytic_cylinder_faces: total.analytic_cylinder_faces + body.analytic_cylinder_faces,
1408 analytic_cylinder_triangles: total.analytic_cylinder_triangles
1409 + body.analytic_cylinder_triangles,
1410 analytic_cone_faces: total.analytic_cone_faces + body.analytic_cone_faces,
1411 analytic_cone_triangles: total.analytic_cone_triangles + body.analytic_cone_triangles,
1412 analytic_sphere_faces: total.analytic_sphere_faces + body.analytic_sphere_faces,
1413 analytic_sphere_triangles: total.analytic_sphere_triangles + body.analytic_sphere_triangles,
1414 analytic_torus_faces: total.analytic_torus_faces + body.analytic_torus_faces,
1415 analytic_torus_triangles: total.analytic_torus_triangles + body.analytic_torus_triangles,
1416 faceted_faces: total.faceted_faces + body.faceted_faces,
1417 faceted_triangles: total.faceted_triangles + body.faceted_triangles,
1418 demoted_regions: total.demoted_regions + body.demoted_regions,
1419 demoted_triangles: total.demoted_triangles + body.demoted_triangles,
1420 segmentation_plane_regions: total.segmentation_plane_regions
1421 + body.segmentation_plane_regions,
1422 segmentation_cylinder_regions: total.segmentation_cylinder_regions
1423 + body.segmentation_cylinder_regions,
1424 segmentation_cone_regions: total.segmentation_cone_regions + body.segmentation_cone_regions,
1425 segmentation_sphere_regions: total.segmentation_sphere_regions
1426 + body.segmentation_sphere_regions,
1427 segmentation_torus_regions: total.segmentation_torus_regions
1428 + body.segmentation_torus_regions,
1429 segmentation_unsupported_regions: total.segmentation_unsupported_regions
1430 + body.segmentation_unsupported_regions,
1431 }
1432}
1433
1434fn triangle_components(mesh: &Mesh) -> Vec<Vec<usize>> {
1449 fn find(parent: &mut [usize], mut node: usize) -> usize {
1450 while parent[node] != node {
1451 parent[node] = parent[parent[node]];
1452 node = parent[node];
1453 }
1454 node
1455 }
1456
1457 let bits = |value: f64| if value == 0.0 { 0_u64 } else { value.to_bits() };
1458 let mut position_ids = HashMap::<[u64; 3], usize>::new();
1459 let mut vertex_position = Vec::with_capacity(mesh.vertices.len());
1460 for point in &mesh.vertices {
1461 let key = [bits(point.x), bits(point.y), bits(point.z)];
1462 let next = position_ids.len();
1463 vertex_position.push(*position_ids.entry(key).or_insert(next));
1464 }
1465
1466 let mut parent = (0..position_ids.len()).collect::<Vec<_>>();
1467 for triangle in &mesh.triangles {
1468 let first = vertex_position[triangle[0] as usize];
1469 for &corner in &triangle[1..] {
1470 let (left, right) = (
1471 find(&mut parent, first),
1472 find(&mut parent, vertex_position[corner as usize]),
1473 );
1474 if left != right {
1475 parent[left] = right;
1476 }
1477 }
1478 }
1479
1480 let mut slots = HashMap::<usize, usize>::new();
1481 let mut components = Vec::<Vec<usize>>::new();
1482 for (index, triangle) in mesh.triangles.iter().enumerate() {
1483 let root = find(&mut parent, vertex_position[triangle[0] as usize]);
1484 let slot = match slots.get(&root) {
1485 Some(&slot) => slot,
1486 None => {
1487 components.push(Vec::new());
1488 slots.insert(root, components.len() - 1);
1489 components.len() - 1
1490 }
1491 };
1492 components[slot].push(index);
1493 }
1494 components
1495}
1496
1497struct ComponentMesh {
1499 mesh: Mesh,
1500 positions: Vec<f64>,
1501 indices: Option<Vec<u32>>,
1502}
1503
1504fn component_mesh(
1509 mesh: &Mesh,
1510 positions: &[f64],
1511 indices: Option<&[u32]>,
1512 triangles: &[usize],
1513) -> ComponentMesh {
1514 let mut vertex_map = HashMap::<u32, u32>::new();
1515 let mut vertices = Vec::new();
1516 let mut component_triangles = Vec::with_capacity(triangles.len());
1517 for &triangle in triangles {
1518 let mut corners = [0_u32; 3];
1519 for (corner, &index) in mesh.triangles[triangle].iter().enumerate() {
1520 corners[corner] = *vertex_map.entry(index).or_insert_with(|| {
1521 vertices.push(mesh.vertices[index as usize]);
1522 vertices.len() as u32 - 1
1523 });
1524 }
1525 component_triangles.push(corners);
1526 }
1527 let mut component = Mesh::new(vertices, component_triangles);
1528 if let Some(normals) = &mesh.vertex_normals {
1529 if normals.len() == mesh.vertices.len() {
1530 let mut component_normals = vec![Vec3::default(); component.vertices.len()];
1531 for (&source, &target) in &vertex_map {
1532 component_normals[target as usize] = normals[source as usize];
1533 }
1534 component.vertex_normals = Some(component_normals);
1535 }
1536 }
1537 if !mesh.source_metadata.is_empty() {
1538 let local = triangles
1539 .iter()
1540 .enumerate()
1541 .map(|(local, &source)| (source, local))
1542 .collect::<HashMap<_, _>>();
1543 component.source_metadata = mesh
1544 .source_metadata
1545 .iter()
1546 .filter_map(|metadata| {
1547 let triangle_indices = metadata
1548 .triangle_indices
1549 .iter()
1550 .filter_map(|source| local.get(source).copied())
1551 .collect::<Vec<_>>();
1552 (!triangle_indices.is_empty()).then(|| crate::SourceMetadata {
1553 triangle_indices,
1554 ..metadata.clone()
1555 })
1556 })
1557 .collect();
1558 }
1559
1560 let (component_positions, component_indices) = match indices {
1561 Some(indices) => {
1562 let mut source_map = HashMap::<u32, u32>::new();
1563 let mut component_positions = Vec::new();
1564 let mut component_indices = Vec::with_capacity(triangles.len() * 3);
1565 for &triangle in triangles {
1566 for &index in &indices[triangle * 3..triangle * 3 + 3] {
1567 let mapped = *source_map.entry(index).or_insert_with(|| {
1568 let base = index as usize * 3;
1569 component_positions.extend_from_slice(&positions[base..base + 3]);
1570 component_positions.len() as u32 / 3 - 1
1571 });
1572 component_indices.push(mapped);
1573 }
1574 }
1575 (component_positions, Some(component_indices))
1576 }
1577 None => {
1578 let mut component_positions = Vec::with_capacity(triangles.len() * 9);
1579 for &triangle in triangles {
1580 component_positions.extend_from_slice(&positions[triangle * 9..triangle * 9 + 9]);
1581 }
1582 (component_positions, None)
1583 }
1584 };
1585 ComponentMesh {
1586 mesh: component,
1587 positions: component_positions,
1588 indices: component_indices,
1589 }
1590}
1591
1592fn validate_source_buffers(
1593 mesh: &Mesh,
1594 positions: &[f64],
1595 indices: Option<&[u32]>,
1596) -> Result<(), StlConversionError> {
1597 if positions.is_empty() || !positions.len().is_multiple_of(3) {
1598 return Err(StlConversionError(
1599 "source position buffer must contain complete non-empty xyz triples".to_owned(),
1600 ));
1601 }
1602 if positions.iter().any(|value| !value.is_finite()) {
1603 return Err(StlConversionError(
1604 "source position buffer contains a non-finite coordinate".to_owned(),
1605 ));
1606 }
1607 let source_triangles = match indices {
1608 Some(indices) => {
1609 if !indices.len().is_multiple_of(3) {
1610 return Err(StlConversionError(
1611 "source index buffer must contain complete triangles".to_owned(),
1612 ));
1613 }
1614 if indices
1615 .iter()
1616 .any(|&index| index as usize >= positions.len() / 3)
1617 {
1618 return Err(StlConversionError(
1619 "source index buffer references a missing position".to_owned(),
1620 ));
1621 }
1622 indices.len() / 3
1623 }
1624 None => {
1625 if !positions.len().is_multiple_of(9) {
1626 return Err(StlConversionError(
1627 "unindexed source positions must be a triangle soup".to_owned(),
1628 ));
1629 }
1630 positions.len() / 9
1631 }
1632 };
1633 if source_triangles != mesh.triangles.len() {
1634 return Err(StlConversionError(format!(
1635 "source buffers contain {source_triangles} triangles but recognition mesh contains {}",
1636 mesh.triangles.len()
1637 )));
1638 }
1639 Ok(())
1640}
1641
1642fn complete_small_planar_regions(
1647 mesh: &Mesh,
1648 options: &RecognitionOptions,
1649 regions: &mut Vec<SurfaceRegion>,
1650 unresolved: &mut Vec<usize>,
1651) -> Result<usize, StlConversionError> {
1652 if unresolved.len() < 2 {
1653 return Ok(0);
1654 }
1655 let analyzed = mesh
1656 .analyze(&MeshAnalysisOptions {
1657 feature_angle: options.feature_angle,
1658 ..MeshAnalysisOptions::default()
1659 })
1660 .map_err(|error| {
1661 StlConversionError(format!("plane completion analysis failed: {error}"))
1662 })?;
1663 let allowed = unresolved.iter().copied().collect::<BTreeSet<_>>();
1664 let mut visited = BTreeSet::new();
1665 let mut accepted = BTreeSet::new();
1666 let mut completed = 0;
1667 let mut plane_options = options.clone();
1668 plane_options.minimum_support = 2;
1669
1670 for &seed in unresolved.iter() {
1671 if !visited.insert(seed) {
1672 continue;
1673 }
1674 let mut component = Vec::new();
1675 let mut queue = VecDeque::from([seed]);
1676 while let Some(triangle) = queue.pop_front() {
1677 component.push(triangle);
1678 let data = &analyzed.triangles[triangle];
1679 for edge in 0..3 {
1680 if data.feature_edges[edge] {
1681 continue;
1682 }
1683 if let Some(neighbor) = data.neighbors[edge] {
1684 if allowed.contains(&neighbor) && visited.insert(neighbor) {
1685 queue.push_back(neighbor);
1686 }
1687 }
1688 }
1689 }
1690 component.sort_unstable();
1691 if component.len() < 2 {
1692 continue;
1693 }
1694 let Ok(fit) = reconstruct_surface(
1695 mesh,
1696 &component,
1697 &SurfaceHint::KnownType {
1698 surface_type: SurfaceType::Plane,
1699 },
1700 &plane_options,
1701 ) else {
1702 continue;
1703 };
1704 if fit.metrics.support_triangles != component.len()
1705 || !matches!(fit.surface, AnalyticSurface::Plane(_))
1706 {
1707 continue;
1708 }
1709 accepted.extend(component.iter().copied());
1710 regions.push(SurfaceRegion {
1711 surface: fit.surface,
1712 orientation: fit.orientation,
1713 triangle_indices: component,
1714 metrics: fit.metrics,
1715 confidence: fit.confidence,
1716 diagnostics: fit.diagnostics,
1717 });
1718 completed += 1;
1719 }
1720 unresolved.retain(|triangle| !accepted.contains(triangle));
1721 Ok(completed)
1722}
1723
1724fn region_report(region: &SurfaceRegion) -> ConversionRegionReport {
1725 fit_fields(
1726 region.surface,
1727 region.orientation,
1728 ®ion.metrics,
1729 region.confidence,
1730 ®ion.diagnostics,
1731 )
1732}
1733
1734fn refit_report(fit: &SurfaceFitResult) -> ConversionRegionReport {
1735 fit_fields(
1736 fit.surface,
1737 fit.orientation,
1738 &fit.metrics,
1739 fit.confidence,
1740 &fit.diagnostics,
1741 )
1742}
1743
1744fn fit_fields(
1745 surface: AnalyticSurface,
1746 orientation: i8,
1747 metrics: &crate::FitMetrics,
1748 confidence: f64,
1749 diagnostics: &crate::FitDiagnostics,
1750) -> ConversionRegionReport {
1751 ConversionRegionReport {
1752 surface_type: surface.surface_type(),
1753 surface,
1754 support_triangles: metrics.support_triangles,
1755 supported_area: metrics.supported_area,
1756 rms_error: metrics.rms_error,
1757 max_error: metrics.max_error,
1758 rms_normal_error_radians: metrics.rms_normal_error,
1759 max_normal_error_radians: metrics.max_normal_error,
1760 confidence,
1761 orientation,
1762 reason: diagnostics.reason.clone(),
1763 phase_timings: diagnostics.phase_timings,
1764 }
1765}
1766
1767fn disjoint_complete_partition(regions: &[SurfaceRegion], triangle_count: usize) -> bool {
1768 let mut seen = BTreeSet::new();
1769 regions.iter().all(|region| {
1770 region
1771 .triangle_indices
1772 .iter()
1773 .all(|&triangle| triangle < triangle_count && seen.insert(triangle))
1774 }) && seen.len() == triangle_count
1775}
1776
1777struct RepairedMesh {
1780 mesh: Mesh,
1781 positions: Vec<f64>,
1782 indices: Vec<u32>,
1783 message: String,
1784}
1785
1786fn repaired_closed_mesh(
1791 source_positions: &[f64],
1792 source_indices: Option<&[u32]>,
1793 options: &StlConversionOptions,
1794) -> Option<RepairedMesh> {
1795 let owned_indices;
1799 let stitched_indices = match source_indices {
1800 Some(indices) => indices,
1801 None => {
1802 owned_indices = (0..source_positions.len() as u32 / 3).collect::<Vec<_>>();
1803 &owned_indices
1804 }
1805 };
1806 let stitch_tolerance = if options.weld_tolerance > 0.0 {
1807 options.weld_tolerance
1808 } else {
1809 mesh_diagonal_of(source_positions) * crate::stl::DEFAULT_RELATIVE_WELD_TOLERANCE
1810 };
1811 let (stitched, stitch) =
1812 t_junctions::stitch_t_junctions(source_positions, stitched_indices, stitch_tolerance);
1813 let (positions, indices, report) =
1814 repair_triangle_soup(source_positions, Some(&stitched), options.weld_tolerance).ok()?;
1815 let vertices = positions
1816 .chunks_exact(3)
1817 .map(|point| Vec3::new(point[0], point[1], point[2]))
1818 .collect::<Vec<_>>();
1819 let triangles = indices
1820 .chunks_exact(3)
1821 .map(|triangle| [triangle[0], triangle[1], triangle[2]])
1822 .collect::<Vec<_>>();
1823 let mesh = Mesh::new(vertices, triangles);
1824 if !closed_manifold(&mesh) {
1825 return None;
1826 }
1827 let message = format!(
1828 "the source mesh is not a closed two-manifold; the analytic lanes ran on the repaired mesh \
1829 ({}{} welded vertices, {} triangles: {} dropped as degenerate or duplicate, {} surplus \
1830 sheet(s) pruned, {} reoriented to agree with their neighbours, {} boundary loop(s) \
1831 capped with {} triangle(s))",
1832 if stitch.stitched_edges > 0 {
1833 format!(
1834 "{} T-junction edge(s) split at a vertex on their interior, {} triangle(s) added; ",
1835 stitch.stitched_edges, stitch.added_triangles
1836 )
1837 } else {
1838 String::new()
1839 },
1840 report.welded_vertices,
1841 report.triangles,
1842 report.dropped_triangles,
1843 report.pruned_triangles,
1844 report.reoriented_triangles,
1845 report.capped_loops,
1846 report.cap_triangles,
1847 );
1848 Some(RepairedMesh {
1849 mesh,
1850 positions,
1851 indices,
1852 message,
1853 })
1854}
1855
1856fn mesh_diagonal_of(positions: &[f64]) -> f64 {
1858 let mut low = [f64::INFINITY; 3];
1859 let mut high = [f64::NEG_INFINITY; 3];
1860 for point in positions.chunks_exact(3) {
1861 for axis in 0..3 {
1862 low[axis] = low[axis].min(point[axis]);
1863 high[axis] = high[axis].max(point[axis]);
1864 }
1865 }
1866 (0..3).map(|axis| (high[axis] - low[axis]).powi(2)).sum::<f64>().sqrt()
1867}
1868
1869fn closed_manifold(mesh: &Mesh) -> bool {
1870 closed_triangles(mesh.triangles.iter().copied())
1871}
1872
1873fn closed_triangles(triangles: impl Iterator<Item = [u32; 3]>) -> bool {
1874 let mut uses = BTreeMap::<(u32, u32), (usize, i32)>::new();
1875 for triangle in triangles {
1876 if triangle[0] == triangle[1] || triangle[1] == triangle[2] || triangle[2] == triangle[0] {
1877 return false;
1878 }
1879 for edge in [
1880 (triangle[0], triangle[1]),
1881 (triangle[1], triangle[2]),
1882 (triangle[2], triangle[0]),
1883 ] {
1884 let key = if edge.0 < edge.1 {
1885 (edge.0, edge.1)
1886 } else {
1887 (edge.1, edge.0)
1888 };
1889 let entry = uses.entry(key).or_default();
1890 entry.0 += 1;
1891 entry.1 += if edge == key { 1 } else { -1 };
1892 }
1893 }
1894 !uses.is_empty()
1895 && uses
1896 .values()
1897 .all(|&(count, sense)| count == 2 && sense == 0)
1898}
1899
1900struct DirectAnalyticBuild {
1901 solid: Result<brep_kernel::BrepSolid, StlConversionError>,
1902 backend: ConversionBackend,
1903 reason: String,
1904 topology_refit: Option<ConversionRegionReport>,
1905}
1906
1907fn direct_analytic_solid(
1908 mesh: &Mesh,
1909 regions: &[SurfaceRegion],
1910 options: &RecognitionOptions,
1911) -> Option<DirectAnalyticBuild> {
1912 if regions.len() == 1 {
1913 return match regions[0].surface {
1914 AnalyticSurface::Sphere(sphere) => Some(DirectAnalyticBuild {
1915 solid: make_sphere_brep(
1916 kernel_vec(sphere.center),
1917 sphere.radius,
1918 kernel_vec(Vec3::new(0.0, 0.0, 1.0)),
1919 )
1920 .map_err(StlConversionError),
1921 backend: ConversionBackend::RansacSphere,
1922 reason: "RANSAC proved complete closed spherical coverage; exported an exact analytic sphere".to_owned(),
1923 topology_refit: None,
1924 }),
1925 AnalyticSurface::Torus(torus) if torus.major_radius > torus.minor_radius => Some(DirectAnalyticBuild {
1926 solid: make_torus_brep(
1927 kernel_vec(torus.center),
1928 kernel_vec(torus.axis),
1929 torus.major_radius,
1930 torus.minor_radius,
1931 )
1932 .map_err(StlConversionError),
1933 backend: ConversionBackend::RansacTorus,
1934 reason: "RANSAC proved complete closed ring-torus coverage; exported an exact analytic torus".to_owned(),
1935 topology_refit: None,
1936 }),
1937 _ => None,
1938 };
1939 }
1940
1941 let curved = regions
1942 .iter()
1943 .filter(|region| !matches!(region.surface, AnalyticSurface::Plane(_)))
1944 .collect::<Vec<_>>();
1945 let planes = regions
1946 .iter()
1947 .filter_map(|region| match region.surface {
1948 AnalyticSurface::Plane(plane) => Some(plane),
1949 _ => None,
1950 })
1951 .collect::<Vec<_>>();
1952 if curved.len() != 1 {
1953 return None;
1954 }
1955 let topology_refit;
1961 let mut topology_refit_report = None;
1962 let curved = if let AnalyticSurface::Torus(torus) = curved[0].surface {
1963 let scale = mesh_diagonal(mesh);
1964 let collapsed = torus.major_radius.min(torus.minor_radius) > 20.0 * scale;
1965 let forced_type = match planes.len() {
1966 2 if collapsed => Some(SurfaceType::Cylinder),
1967 1 if collapsed => Some(SurfaceType::Cone),
1968 _ => None,
1969 };
1970 if let Some(surface_type) = forced_type {
1971 topology_refit = reconstruct_surface(
1972 mesh,
1973 &curved[0].triangle_indices,
1974 &SurfaceHint::KnownType { surface_type },
1975 options,
1976 )
1977 .ok()?;
1978 topology_refit_report = Some(refit_report(&topology_refit));
1979 &topology_refit.surface
1980 } else {
1981 &curved[0].surface
1982 }
1983 } else {
1984 &curved[0].surface
1985 };
1986 match *curved {
1987 AnalyticSurface::Cylinder(cylinder) if planes.len() == 2 => {
1988 let (low, high) = axial_range(mesh, cylinder.axis_origin, cylinder.axis)?;
1989 let tolerance = conversion_tolerance(mesh, options);
1990 if high - low <= tolerance
1991 || !caps_match(
1992 &planes,
1993 cylinder.axis_origin,
1994 cylinder.axis,
1995 &[low, high],
1996 tolerance,
1997 )
1998 {
1999 return None;
2000 }
2001 let base = cylinder.axis_origin + cylinder.axis * low;
2002 Some(DirectAnalyticBuild {
2003 solid: make_cylinder_brep(
2004 kernel_vec(base),
2005 kernel_vec(cylinder.axis),
2006 cylinder.radius,
2007 high - low,
2008 )
2009 .map_err(StlConversionError),
2010 backend: ConversionBackend::RansacCappedCylinder,
2011 reason: if topology_refit_report.is_some() {
2012 "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()
2013 } else {
2014 "RANSAC proved one cylindrical wall plus two matching planar caps; exported an exact capped cylinder".to_owned()
2015 },
2016 topology_refit: topology_refit_report,
2017 })
2018 }
2019 AnalyticSurface::Cone(cone) if planes.len() == 1 || planes.len() == 2 => {
2020 let (low, high) = axial_range(mesh, cone.apex, cone.axis)?;
2021 let tolerance = conversion_tolerance(mesh, options);
2022 if low < -tolerance || high - low <= tolerance {
2023 return None;
2024 }
2025 let expected_caps = if low <= tolerance {
2026 vec![high]
2027 } else {
2028 vec![low, high]
2029 };
2030 if planes.len() != expected_caps.len()
2031 || !caps_match(&planes, cone.apex, cone.axis, &expected_caps, tolerance)
2032 {
2033 return None;
2034 }
2035 let radius_bottom = low.max(0.0) * cone.half_angle.tan();
2036 let radius_top = high * cone.half_angle.tan();
2037 if radius_top <= tolerance || (low > tolerance && radius_bottom <= tolerance) {
2038 return None;
2039 }
2040 let (base, build_axis, build_bottom, build_top) = if low <= tolerance {
2041 (
2045 cone.apex + cone.axis * high,
2046 cone.axis * -1.0,
2047 radius_top,
2048 0.0,
2049 )
2050 } else {
2051 (
2052 cone.apex + cone.axis * low,
2053 cone.axis,
2054 radius_bottom,
2055 radius_top,
2056 )
2057 };
2058 Some(DirectAnalyticBuild {
2059 solid: make_cone_brep(
2060 kernel_vec(base),
2061 kernel_vec(build_axis),
2062 build_bottom,
2063 build_top,
2064 high - low.max(0.0),
2065 )
2066 .map_err(StlConversionError),
2067 backend: ConversionBackend::RansacCappedCone,
2068 reason: if topology_refit_report.is_some() {
2069 "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()
2070 } else {
2071 "RANSAC proved one conical wall and matching planar cap topology; exported an exact capped cone".to_owned()
2072 },
2073 topology_refit: topology_refit_report,
2074 })
2075 }
2076 _ => None,
2077 }
2078}
2079
2080fn axial_range(mesh: &Mesh, origin: Vec3, axis: Vec3) -> Option<(f64, f64)> {
2081 let mut low = f64::INFINITY;
2082 let mut high = f64::NEG_INFINITY;
2083 for &point in &mesh.vertices {
2084 let height = (point - origin).dot(axis);
2085 low = low.min(height);
2086 high = high.max(height);
2087 }
2088 (low.is_finite() && high.is_finite()).then_some((low, high))
2089}
2090
2091fn caps_match(
2092 planes: &[crate::PlaneSurface],
2093 origin: Vec3,
2094 axis: Vec3,
2095 expected_heights: &[f64],
2096 tolerance: f64,
2097) -> bool {
2098 let mut actual = Vec::with_capacity(planes.len());
2099 for plane in planes {
2100 if plane.normal.dot(axis).abs() < 1.0 - 1.0e-6 {
2101 return false;
2102 }
2103 actual.push((plane.origin - origin).dot(axis));
2104 }
2105 actual.sort_by(f64::total_cmp);
2106 let mut expected = expected_heights.to_vec();
2107 expected.sort_by(f64::total_cmp);
2108 actual.len() == expected.len()
2109 && actual
2110 .iter()
2111 .zip(expected)
2112 .all(|(actual, expected)| (*actual - expected).abs() <= tolerance)
2113}
2114
2115fn mesh_tolerance(mesh: &Mesh) -> f64 {
2116 let mut low = mesh.vertices[0];
2117 let mut high = mesh.vertices[0];
2118 for &point in &mesh.vertices[1..] {
2119 low.x = low.x.min(point.x);
2120 low.y = low.y.min(point.y);
2121 low.z = low.z.min(point.z);
2122 high.x = high.x.max(point.x);
2123 high.y = high.y.max(point.y);
2124 high.z = high.z.max(point.z);
2125 }
2126 (high - low).length().max(1.0) * 1.0e-5
2127}
2128
2129fn conversion_tolerance(mesh: &Mesh, options: &RecognitionOptions) -> f64 {
2130 mesh_tolerance(mesh)
2131 .max(options.distance_tolerance + options.relative_tolerance * mesh_diagonal(mesh))
2132}
2133
2134fn mesh_diagonal(mesh: &Mesh) -> f64 {
2135 let mut low = mesh.vertices[0];
2136 let mut high = mesh.vertices[0];
2137 for &point in &mesh.vertices[1..] {
2138 low.x = low.x.min(point.x);
2139 low.y = low.y.min(point.y);
2140 low.z = low.z.min(point.z);
2141 high.x = high.x.max(point.x);
2142 high.y = high.y.max(point.y);
2143 high.z = high.z.max(point.z);
2144 }
2145 (high - low).length()
2146}
2147
2148fn coplanar_merge_tolerance(mesh: &Mesh) -> f64 {
2149 (mesh_diagonal(mesh) * 1.0e-9).max(1.0e-12)
2150}
2151
2152fn solid_face_count(solid: &brep_kernel::BrepSolid) -> usize {
2153 solid.shells.iter().map(|shell| shell.faces.len()).sum()
2154}
2155
2156const MAX_WHOLE_PRIMITIVE_REGIONS: usize = 3;
2160
2161fn segmentation_options(positions: &[f64], options: &StlConversionOptions) -> SegmentOptions {
2162 SegmentOptions {
2163 deflection_angle_deg: options.kernel_deflection_angle_degrees,
2164 fit_tolerance: options.kernel_fit_tolerance,
2165 normal_tolerance_deg: options.kernel_normal_tolerance_degrees,
2166 weld_tolerance: if options.weld_tolerance > 0.0 {
2171 options.weld_tolerance
2172 } else {
2173 let mut low = [f64::INFINITY; 3];
2174 let mut high = [f64::NEG_INFINITY; 3];
2175 for point in positions.chunks_exact(3) {
2176 for axis in 0..3 {
2177 low[axis] = low[axis].min(point[axis]);
2178 high[axis] = high[axis].max(point[axis]);
2179 }
2180 }
2181 let diagonal = (0..3)
2182 .map(|axis| (high[axis] - low[axis]).powi(2))
2183 .sum::<f64>()
2184 .sqrt();
2185 (diagonal * crate::stl::DEFAULT_RELATIVE_WELD_TOLERANCE).max(f64::MIN_POSITIVE)
2186 },
2187 ..SegmentOptions::default()
2188 }
2189}
2190
2191fn segment_carrier_enabled() -> bool {
2196 std::env::var("BREP_RECOG_SEGMENT_CARRIER").map_or(true, |v| v != "0")
2197}
2198
2199fn segmentation_candidates(mesh: &Mesh, segmentation: &MeshSegmentation) -> Vec<ComponentCandidate> {
2214 if segmentation.triangle_count != mesh.triangles.len()
2215 || segmentation.triangle_region_ids.len() != mesh.triangles.len()
2216 {
2217 return Vec::new();
2218 }
2219 let mut members = vec![Vec::new(); segmentation.regions.len()];
2220 for (triangle, ®ion) in segmentation.triangle_region_ids.iter().enumerate() {
2221 if let Some(list) = members.get_mut(region as usize) {
2222 list.push(triangle);
2223 }
2224 }
2225 segmentation
2226 .regions
2227 .iter()
2228 .zip(members)
2229 .filter_map(|(region, triangles)| {
2230 let (surface, parameters) = carrier_surface(®ion.carrier)?;
2231 if triangles.is_empty() || !surface.is_valid() {
2232 return None;
2233 }
2234 let indices: Vec<u32> = triangles.iter().flat_map(|&t| mesh.triangles[t]).collect();
2235 let (statistic, threshold) = crate::hybrid_whole_freeform::carrier_local_scan(
2236 &indices,
2237 |v| surface.signed_distance(mesh.vertices[v as usize]),
2238 parameters,
2239 );
2240 if std::env::var_os("BREP_DEBUG_SPLIT").is_some() {
2241 eprintln!(
2242 "[candidate] region {} {} tris {} local {statistic:.2} threshold {threshold:.2} {}",
2243 region.id,
2244 region.carrier.kind(),
2245 triangles.len(),
2246 if statistic <= threshold { "offered" } else { "declined" }
2247 );
2248 }
2249 (statistic <= threshold).then_some(ComponentCandidate { triangle_indices: triangles, surface })
2250 })
2251 .collect()
2252}
2253
2254fn whole_fits_pass_the_local_scan(
2269 mesh: &Mesh,
2270 regions: &[SurfaceRegion],
2271 requested: f64,
2272 tally: &mut WholeCarrierScanReport,
2273) -> bool {
2274 if !crate::hybrid_whole_freeform::whole_carrier_scan_enabled() {
2275 return true;
2276 }
2277 let mut passes = true;
2278 for region in regions.iter().filter(|region| region.metrics.max_error > requested) {
2279 let surface = ®ion.surface;
2280 let indices: Vec<u32> = region
2281 .triangle_indices
2282 .iter()
2283 .flat_map(|&t| mesh.triangles[t])
2284 .collect();
2285 let reading = crate::hybrid_whole_freeform::carrier_scan_verdict(
2286 &indices,
2287 |v| surface.signed_distance(mesh.vertices[v as usize]),
2288 analytic_parameters(surface),
2289 );
2290 if std::env::var_os("BREP_DEBUG_SPLIT").is_some() {
2291 eprintln!(
2292 "[whole-fit] {} tris {} local {:.2} threshold {:.2} independence {:.3} bound {:.3} edges {} {:?}",
2293 surface.surface_type().name(),
2294 region.triangle_indices.len(),
2295 reading.statistic,
2296 reading.threshold,
2297 reading.independence,
2298 reading.bound,
2299 reading.outside_edges,
2300 reading.verdict
2301 );
2302 }
2303 tally.add(reading.verdict);
2304 if reading.verdict == crate::hybrid_whole_freeform::ScanVerdict::Declined {
2305 passes = false;
2306 break;
2307 }
2308 }
2309 passes
2310}
2311
2312fn analytic_parameters(surface: &AnalyticSurface) -> usize {
2314 match surface {
2315 AnalyticSurface::Plane(_) => 3,
2316 AnalyticSurface::Cylinder(_) => 5,
2317 AnalyticSurface::Cone(_) => 6,
2318 AnalyticSurface::Sphere(_) => 4,
2319 AnalyticSurface::Torus(_) => 7,
2320 }
2321}
2322
2323fn carrier_surface(carrier: &RegionCarrier) -> Option<(AnalyticSurface, usize)> {
2325 let v = |p: brep_kernel::Vec3| crate::brep::vec3_from_kernel(p);
2326 Some(match *carrier {
2327 RegionCarrier::Plane { origin, normal } => {
2328 (AnalyticSurface::Plane(PlaneSurface { origin: v(origin), normal: v(normal) }), 3)
2329 }
2330 RegionCarrier::Cylinder { axis_point, axis_dir, radius, .. } => (
2331 AnalyticSurface::Cylinder(CylinderSurface { axis_origin: v(axis_point), axis: v(axis_dir), radius }),
2332 5,
2333 ),
2334 RegionCarrier::Cone { apex, axis_dir, half_angle_rad, .. } => (
2335 AnalyticSurface::Cone(ConeSurface { apex: v(apex), axis: v(axis_dir), half_angle: half_angle_rad }),
2336 6,
2337 ),
2338 RegionCarrier::Sphere { center, radius, .. } => {
2339 (AnalyticSurface::Sphere(SphereSurface { center: v(center), radius }), 4)
2340 }
2341 RegionCarrier::Torus { center, axis_dir, major_radius, minor_radius, .. } => (
2342 AnalyticSurface::Torus(TorusSurface { center: v(center), axis: v(axis_dir), major_radius, minor_radius }),
2343 7,
2344 ),
2345 RegionCarrier::Freeform => return None,
2346 })
2347}
2348
2349fn gate_segmentation(
2350 positions: &[f64],
2351 indices: Option<&[u32]>,
2352 options: &StlConversionOptions,
2353) -> Result<MeshSegmentation, String> {
2354 let owned_indices;
2355 let indices = match indices {
2356 Some(indices) => indices,
2357 None => {
2358 owned_indices = (0..positions.len() as u32 / 3).collect::<Vec<_>>();
2359 &owned_indices
2360 }
2361 };
2362 segment_mesh_faces(positions, indices, &segmentation_options(positions, options))
2363 .map_err(|error| format!("kernel segmentation failed: {error}"))
2364}
2365
2366fn segmentation_is_complete(segmentation: &MeshSegmentation) -> bool {
2368 !segmentation
2369 .triangle_region_ids
2370 .contains(&UNASSIGNED_REGION)
2371 && segmentation
2372 .regions
2373 .iter()
2374 .all(|region| !matches!(region.carrier, RegionCarrier::Freeform))
2375}
2376
2377fn recognition_gate(segmentation: &Result<MeshSegmentation, String>) -> Result<(), String> {
2386 let segmentation = segmentation.as_ref().map_err(Clone::clone)?;
2387 let unassigned = segmentation
2388 .triangle_region_ids
2389 .iter()
2390 .filter(|id| **id == UNASSIGNED_REGION)
2391 .count();
2392 let unsupported = segmentation
2393 .regions
2394 .iter()
2395 .filter(|region| matches!(region.carrier, RegionCarrier::Freeform))
2396 .count();
2397 if unassigned > 0 || unsupported > 0 {
2398 return Err(format!(
2399 "kernel segmentation left {unassigned} triangle(s) unassigned and {unsupported} region(s) without an analytic carrier, so no whole-primitive lane can fire"
2400 ));
2401 }
2402 if segmentation.regions.len() > MAX_WHOLE_PRIMITIVE_REGIONS {
2403 return Err(format!(
2404 "kernel segmentation found {} analytic regions; a whole primitive has at most {MAX_WHOLE_PRIMITIVE_REGIONS}",
2405 segmentation.regions.len()
2406 ));
2407 }
2408 Ok(())
2409}
2410
2411fn try_kernel_analytic(
2412 positions: &[f64],
2413 indices: Option<&[u32]>,
2414 options: &StlConversionOptions,
2415) -> Result<brep_kernel::BrepSolid, String> {
2416 let owned_indices;
2417 let indices = match indices {
2418 Some(indices) => indices,
2419 None => {
2420 owned_indices = (0..positions.len() as u32 / 3).collect::<Vec<_>>();
2421 &owned_indices
2422 }
2423 };
2424 let segmentation_options = segmentation_options(positions, options);
2425 let segmentation = segment_mesh_faces(positions, indices, &segmentation_options)?;
2426 if segmentation
2427 .triangle_region_ids
2428 .contains(&UNASSIGNED_REGION)
2429 {
2430 return Err("independent kernel segmentation left triangles unassigned".to_owned());
2431 }
2432 if let Some(region) = segmentation.regions.iter().find(|region| {
2433 matches!(
2434 region.carrier,
2435 RegionCarrier::Freeform | RegionCarrier::Sphere { .. } | RegionCarrier::Torus { .. }
2436 )
2437 }) {
2438 return Err(format!(
2439 "independent kernel segmentation produced unsupported {} region {}",
2440 region.carrier.kind(),
2441 region.id
2442 ));
2443 }
2444 mesh_regions_to_brep(positions, indices, &segmentation_options)
2445}
2446
2447fn try_hybrid_analytic(
2448 positions: &[f64],
2449 indices: Option<&[u32]>,
2450 options: &StlConversionOptions,
2451) -> Result<crate::hybrid_region_brep::HybridBrepOutput, String> {
2452 crate::hybrid_region_brep::hybrid_plane_cylinder_brep(
2453 positions,
2454 indices,
2455 &segmentation_options(positions, options),
2456 )
2457}
2458
2459fn hybrid_report(stats: crate::hybrid_region_brep::HybridBrepStats) -> HybridConversionReport {
2460 HybridConversionReport {
2461 total_faces: stats.total_faces,
2462 analytic_plane_faces: stats.analytic_plane_faces,
2463 analytic_plane_triangles: stats.analytic_plane_triangles,
2464 analytic_cylinder_faces: stats.analytic_cylinder_faces,
2465 analytic_cylinder_triangles: stats.analytic_cylinder_triangles,
2466 analytic_cone_faces: stats.analytic_cone_faces,
2467 analytic_cone_triangles: stats.analytic_cone_triangles,
2468 analytic_sphere_faces: stats.analytic_sphere_faces,
2469 analytic_sphere_triangles: stats.analytic_sphere_triangles,
2470 analytic_torus_faces: stats.analytic_torus_faces,
2471 analytic_torus_triangles: stats.analytic_torus_triangles,
2472 faceted_faces: stats.faceted_faces,
2473 faceted_triangles: stats.faceted_triangles,
2474 demoted_regions: stats.demoted_regions,
2475 demoted_triangles: stats.demoted_triangles,
2476 segmentation_plane_regions: stats.segmentation_plane_regions,
2477 segmentation_cylinder_regions: stats.segmentation_cylinder_regions,
2478 segmentation_cone_regions: stats.segmentation_cone_regions,
2479 segmentation_sphere_regions: stats.segmentation_sphere_regions,
2480 segmentation_torus_regions: stats.segmentation_torus_regions,
2481 segmentation_unsupported_regions: stats.segmentation_unsupported_regions,
2482 }
2483}
2484
2485fn kernel_vec(value: Vec3) -> brep_kernel::Vec3 {
2486 brep_kernel::Vec3::new(value.x, value.y, value.z)
2487}
2488