1use crate::{
11 recognize_surfaces_with_unresolved, reconstruct_surface, AnalyticSurface, Mesh,
12 MeshAnalysisOptions, PhaseTimings, RecognitionOptions, SamplingMode, SurfaceFitResult,
13 SurfaceHint, SurfaceRegion, SurfaceType, Vec3,
14};
15use brep_kernel::{
16 audit_step_manifold, export_step, import_step, make_cone_brep, make_cylinder_brep,
17 make_sphere_brep, make_torus_brep, merge_same_surface_faces, mesh_regions_to_brep,
18 mesh_to_faceted_brep, segment_mesh_faces, RegionCarrier, SegmentOptions, UNASSIGNED_REGION,
19};
20use serde::{Deserialize, Serialize};
21use std::collections::{BTreeMap, BTreeSet, VecDeque};
22use std::fmt::{Display, Formatter};
23use web_time::Instant;
24
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
28pub enum ConversionPolicy {
29 #[default]
31 AllowFacetedFallback,
32 RequireFullyAnalytic,
34}
35
36#[derive(Clone, Debug, Deserialize, Serialize)]
38#[serde(default)]
39pub struct StlConversionOptions {
40 pub recognition: RecognitionOptions,
42 pub policy: ConversionPolicy,
44 pub try_kernel_analytic_rebuild: bool,
47 pub try_hybrid_rebuild: bool,
53 pub weld_tolerance: f64,
56 pub coordinate_precision_tolerance: f64,
63 pub kernel_deflection_angle_degrees: f64,
65 pub kernel_fit_tolerance: f64,
67 pub kernel_normal_tolerance_degrees: f64,
69}
70
71impl Default for StlConversionOptions {
72 fn default() -> Self {
73 let recognition = RecognitionOptions {
74 collect_phase_timings: true,
75 sampling: SamplingMode::Vertices,
79 ..RecognitionOptions::default()
80 };
81 Self {
82 recognition,
83 policy: ConversionPolicy::AllowFacetedFallback,
84 try_kernel_analytic_rebuild: true,
85 try_hybrid_rebuild: true,
86 weld_tolerance: -1.0,
87 coordinate_precision_tolerance: 0.0,
88 kernel_deflection_angle_degrees: 30.0,
89 kernel_fit_tolerance: 1.0e-3,
90 kernel_normal_tolerance_degrees: 15.0,
91 }
92 }
93}
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
97#[serde(rename_all = "snake_case")]
98pub enum ConversionBackend {
99 RansacSphere,
101 RansacTorus,
103 RansacCappedCylinder,
105 RansacCappedCone,
107 KernelAnalyticRebuild,
110 #[serde(alias = "hybrid_plane_cylinder_rebuild")]
113 HybridAnalyticRebuild,
114 FacetedRepair,
116 FacetedRepairCoplanarMerged,
119}
120
121#[derive(Clone, Debug, Deserialize, Serialize)]
123pub struct ConversionRegionReport {
124 pub surface_type: SurfaceType,
126 pub surface: AnalyticSurface,
128 pub support_triangles: usize,
130 pub supported_area: f64,
132 pub rms_error: f64,
134 pub max_error: f64,
136 pub rms_normal_error_radians: f64,
138 pub max_normal_error_radians: f64,
140 pub confidence: f64,
142 pub orientation: i8,
144 pub reason: String,
146 pub phase_timings: PhaseTimings,
148}
149
150#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
152pub struct ConversionTimings {
153 pub recognition_seconds: f64,
155 pub topology_build_seconds: f64,
157 pub step_export_seconds: f64,
159 pub step_validation_seconds: f64,
161 pub total_seconds: f64,
163}
164
165#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
167pub struct HybridConversionReport {
168 pub total_faces: usize,
170 pub analytic_plane_faces: usize,
172 pub analytic_plane_triangles: usize,
174 pub analytic_cylinder_faces: usize,
176 pub analytic_cylinder_triangles: usize,
178 #[serde(default)]
180 pub analytic_cone_faces: usize,
181 #[serde(default)]
183 pub analytic_cone_triangles: usize,
184 pub faceted_faces: usize,
186 pub faceted_triangles: usize,
188 pub demoted_regions: usize,
190 pub demoted_triangles: usize,
192 pub segmentation_plane_regions: usize,
194 pub segmentation_cylinder_regions: usize,
196 #[serde(default)]
198 pub segmentation_cone_regions: usize,
199 pub segmentation_unsupported_regions: usize,
201}
202
203#[derive(Clone, Debug, Deserialize, Serialize)]
205pub struct StlConversionReport {
206 pub backend: ConversionBackend,
208 pub backend_reason: String,
210 pub fallback_cause: Option<String>,
212 pub input_vertices: usize,
214 pub input_triangles: usize,
216 pub recognized_regions: usize,
218 pub recognized_triangles: usize,
220 pub unresolved_triangles: usize,
222 pub source_closed_manifold: bool,
224 #[serde(default)]
226 pub requested_distance_tolerance: f64,
227 #[serde(default)]
229 pub coordinate_precision_tolerance: f64,
230 #[serde(default)]
232 pub effective_distance_tolerance: f64,
233 pub region_type_counts: BTreeMap<String, usize>,
235 pub regions: Vec<ConversionRegionReport>,
237 pub topology_refit: Option<ConversionRegionReport>,
240 #[serde(default)]
242 pub hybrid_rebuild: Option<HybridConversionReport>,
243 pub exported_step_bytes: usize,
245 pub exported_advanced_faces: usize,
247 #[serde(default)]
249 pub faceted_faces_before_merge: Option<usize>,
250 #[serde(default)]
254 pub faceted_faces_after_merge: Option<usize>,
255 pub roundtrip_solids: usize,
257 pub manifold_audit_issues: Vec<String>,
259 pub timings: ConversionTimings,
261 pub messages: Vec<String>,
263}
264
265#[derive(Clone, Debug, Deserialize, Serialize)]
267pub struct StlConversionOutput {
268 pub step_text: String,
270 pub report: StlConversionReport,
272}
273
274#[derive(Clone, Debug, PartialEq, Eq)]
276pub struct StlConversionError(pub String);
277
278impl Display for StlConversionError {
279 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
280 formatter.write_str(&self.0)
281 }
282}
283
284impl std::error::Error for StlConversionError {}
285
286impl From<String> for StlConversionError {
287 fn from(value: String) -> Self {
288 Self(value)
289 }
290}
291
292pub fn binary_stl_coordinate_precision_tolerance(mesh: &Mesh) -> f64 {
302 let maximum_absolute_coordinate = mesh
303 .vertices
304 .iter()
305 .flat_map(|point| [point.x.abs(), point.y.abs(), point.z.abs()])
306 .fold(0.0_f64, f64::max);
307 let coordinate_scale = maximum_absolute_coordinate.max(mesh_diagonal(mesh));
308 coordinate_scale * (f32::EPSILON as f64) * 0.5 * 3.0_f64.sqrt()
309}
310
311pub fn convert_stl_mesh_to_step(
318 mesh: &Mesh,
319 source_positions: &[f64],
320 source_indices: Option<&[u32]>,
321 options: &StlConversionOptions,
322 part_name: &str,
323 unit: &str,
324 timestamp: &str,
325) -> Result<StlConversionOutput, StlConversionError> {
326 let total_started = Instant::now();
327 validate_source_buffers(mesh, source_positions, source_indices)?;
328 if !options.coordinate_precision_tolerance.is_finite()
329 || options.coordinate_precision_tolerance < 0.0
330 {
331 return Err(StlConversionError(
332 "coordinate_precision_tolerance must be finite and non-negative".to_owned(),
333 ));
334 }
335
336 let mut recognition_options = options.recognition.clone();
337 recognition_options.collect_phase_timings = true;
338 let requested_distance_tolerance = recognition_options.distance_tolerance;
339 recognition_options.distance_tolerance = recognition_options
340 .distance_tolerance
341 .max(options.coordinate_precision_tolerance);
342 let effective_distance_tolerance = recognition_options.distance_tolerance;
343 let recognition_started = Instant::now();
344 let mut recognition = recognize_surfaces_with_unresolved(mesh, &recognition_options)
345 .map_err(|error| StlConversionError(format!("surface recognition failed: {error}")))?;
346 let completed_plane_regions = complete_small_planar_regions(
347 mesh,
348 &recognition_options,
349 &mut recognition.regions,
350 &mut recognition.unresolved_triangles,
351 )?;
352 let recognition_seconds = recognition_started.elapsed().as_secs_f64();
353
354 let source_closed_manifold = closed_manifold(mesh);
355 let recognized_triangles = recognition
356 .regions
357 .iter()
358 .map(|region| region.triangle_indices.len())
359 .sum();
360 let regions = recognition
361 .regions
362 .iter()
363 .map(region_report)
364 .collect::<Vec<_>>();
365 let mut region_type_counts = BTreeMap::new();
366 for region in &recognition.regions {
367 *region_type_counts
368 .entry(region.surface.surface_type().name().to_owned())
369 .or_insert(0) += 1;
370 }
371
372 let topology_started = Instant::now();
373 let analytic_coverage = recognition.unresolved_triangles.is_empty()
374 && recognized_triangles == mesh.triangles.len()
375 && disjoint_complete_partition(&recognition.regions, mesh.triangles.len());
376 let primitive = if analytic_coverage && source_closed_manifold {
377 direct_analytic_solid(mesh, &recognition.regions, &recognition_options)
378 } else {
379 None
380 };
381
382 let mut fallback_cause = None;
383 let mut messages = vec![format!(
384 "RANSAC recognized {} region(s) covering {recognized_triangles}/{} triangles",
385 recognition.regions.len(),
386 mesh.triangles.len()
387 )];
388 if effective_distance_tolerance > requested_distance_tolerance {
389 messages.push(format!(
390 "source coordinate precision raised the absolute recognition distance from {requested_distance_tolerance:.6e} to {effective_distance_tolerance:.6e}"
391 ));
392 }
393 if completed_plane_regions > 0 {
394 messages.push(format!(
395 "a plane-only completion pass recovered {completed_plane_regions} small feature-bounded planar region(s)"
396 ));
397 }
398
399 let (
400 solid,
401 backend,
402 backend_reason,
403 topology_refit,
404 hybrid_rebuild,
405 faceted_faces_before_merge,
406 faceted_faces_after_merge,
407 ) = if let Some(primitive) = primitive {
408 (
409 primitive.solid?,
410 primitive.backend,
411 primitive.reason,
412 primitive.topology_refit,
413 None,
414 None,
415 None,
416 )
417 } else {
418 let direct_cause = if !analytic_coverage {
419 format!(
420 "RANSAC did not prove complete analytic coverage ({} unresolved triangle(s))",
421 recognition.unresolved_triangles.len()
422 )
423 } else if !source_closed_manifold {
424 "recognized mesh is not a closed two-manifold".to_owned()
425 } else {
426 "recognized regions do not match a safe complete primitive topology".to_owned()
427 };
428
429 let kernel_eligible = analytic_coverage
430 && recognition.regions.iter().all(|region| {
431 matches!(
432 region.surface.surface_type(),
433 SurfaceType::Plane | SurfaceType::Cylinder | SurfaceType::Cone
434 )
435 });
436 let kernel_attempt = if source_closed_manifold
437 && kernel_eligible
438 && options.try_kernel_analytic_rebuild
439 {
440 try_kernel_analytic(source_positions, source_indices, options)
441 } else {
442 Err("independent kernel analytic rebuild requires complete RANSAC plane/cylinder/cone coverage".to_owned())
443 };
444
445 let analytic_attempt = match kernel_attempt {
446 Ok(solid) => Ok((
447 solid,
448 ConversionBackend::KernelAnalyticRebuild,
449 "RANSAC proved complete plane/cylinder/cone coverage; independent kernel segmentation then rebuilt and validated the complete analytic shell".to_owned(),
450 None,
451 )),
452 Err(kernel_error) => {
453 let hybrid_attempt = if source_closed_manifold && options.try_hybrid_rebuild {
454 try_hybrid_analytic(source_positions, source_indices, options)
455 } else if !options.try_hybrid_rebuild {
456 Err("mixed analytic/faceted rebuild was disabled".to_owned())
457 } else {
458 Err("mixed analytic/faceted rebuild requires a closed two-manifold".to_owned())
459 };
460 match hybrid_attempt {
461 Ok(output) => {
462 let hybrid_report = hybrid_report(output.stats);
463 if options.policy == ConversionPolicy::RequireFullyAnalytic
464 && hybrid_report.faceted_faces != 0
465 {
466 Err(format!(
467 "{kernel_error}; mixed rebuild retained {} faceted face(s) for {} source triangle(s)",
468 hybrid_report.faceted_faces, hybrid_report.faceted_triangles
469 ))
470 } else {
471 let reason = if hybrid_report.faceted_faces == 0 {
472 "independent segmentation reconstructed and validated a fully analytic plane/cylinder/cone shell"
473 } else {
474 "independent segmentation reconstructed exact plane/cylinder/cone regions and retained unsupported or unsafe regions as facets"
475 };
476 Ok((
477 output.solid,
478 ConversionBackend::HybridAnalyticRebuild,
479 reason.to_owned(),
480 Some(hybrid_report),
481 ))
482 }
483 }
484 Err(hybrid_error) => Err(format!("{kernel_error}; {hybrid_error}")),
485 }
486 }
487 };
488
489 match analytic_attempt {
490 Ok((solid, backend, reason, hybrid)) => {
491 (solid, backend, reason, None, hybrid, None, None)
492 }
493 Err(analytic_error) => {
494 let cause = format!("{direct_cause}; {analytic_error}");
495 if options.policy == ConversionPolicy::RequireFullyAnalytic {
496 return Err(StlConversionError(format!(
497 "strict analytic conversion refused faceted fallback: {cause}"
498 )));
499 }
500 fallback_cause = Some(cause.clone());
501 messages.push(format!("analytic rebuild unavailable: {cause}"));
502 let faceted =
503 mesh_to_faceted_brep(source_positions, source_indices, options.weld_tolerance)
504 .map_err(|error| {
505 StlConversionError(format!("faceted mesh repair failed: {error}"))
506 })?;
507 let faces_before = solid_face_count(&faceted);
508 let merge_tolerance = coplanar_merge_tolerance(mesh);
509 match merge_same_surface_faces(&faceted, merge_tolerance) {
510 Ok(merged) => {
511 let faces_after = solid_face_count(&merged);
512 if faces_after < faces_before {
513 messages.push(format!(
514 "coplanar faceted merge reduced faces from {faces_before} to {faces_after} (tolerance {merge_tolerance:.3e})"
515 ));
516 (
517 merged,
518 ConversionBackend::FacetedRepairCoplanarMerged,
519 "the complete source triangle set was repaired as a faceted BREP, then adjacent coplanar facets were merged without introducing curved replacement topology".to_owned(),
520 None,
521 None,
522 Some(faces_before),
523 Some(faces_after),
524 )
525 } else {
526 messages.push(format!(
527 "coplanar faceted merge found no mergeable faces (tolerance {merge_tolerance:.3e})"
528 ));
529 (
530 faceted,
531 ConversionBackend::FacetedRepair,
532 "the complete source triangle set was repaired and preserved as a faceted BREP; no adjacent coplanar facets could be merged".to_owned(),
533 None,
534 None,
535 Some(faces_before),
536 Some(faces_before),
537 )
538 }
539 }
540 Err(error) => {
541 messages.push(format!(
542 "coplanar faceted merge was unavailable; retained the validated repaired mesh: {error}"
543 ));
544 (
545 faceted,
546 ConversionBackend::FacetedRepair,
547 "the complete source triangle set was repaired and preserved as a faceted BREP; the optional coplanar merge was unavailable".to_owned(),
548 None,
549 None,
550 Some(faces_before),
551 Some(faces_before),
552 )
553 }
554 }
555 }
556 }
557 };
558 let topology_build_seconds = topology_started.elapsed().as_secs_f64();
559
560 let topology_issues = solid.validate();
561 if !topology_issues.is_empty() {
562 return Err(StlConversionError(format!(
563 "constructed BREP failed topology validation: {topology_issues:?}"
564 )));
565 }
566
567 let export_started = Instant::now();
568 let step_text = export_step(&[solid], part_name, unit, timestamp)
569 .map_err(|error| StlConversionError(format!("STEP export failed: {error}")))?;
570 let step_export_seconds = export_started.elapsed().as_secs_f64();
571
572 let validation_started = Instant::now();
573 let manifold_audit_issues = audit_step_manifold(&step_text);
574 if !manifold_audit_issues.is_empty() {
575 return Err(StlConversionError(format!(
576 "STEP manifold audit failed: {}",
577 manifold_audit_issues.join("; ")
578 )));
579 }
580 let imported = import_step(&step_text).map_err(|error| {
581 StlConversionError(format!("STEP round-trip validation failed: {error}"))
582 })?;
583 if let Some(issues) = imported
584 .iter()
585 .map(|solid| solid.validate())
586 .find(|issues| !issues.is_empty())
587 {
588 return Err(StlConversionError(format!(
589 "round-tripped STEP has invalid topology: {issues:?}"
590 )));
591 }
592 let step_validation_seconds = validation_started.elapsed().as_secs_f64();
593 messages.push(format!(
594 "STEP manifold audit and round-trip import passed for {} solid(s)",
595 imported.len()
596 ));
597 messages.push(backend_reason.clone());
598
599 let timings = ConversionTimings {
600 recognition_seconds,
601 topology_build_seconds,
602 step_export_seconds,
603 step_validation_seconds,
604 total_seconds: total_started.elapsed().as_secs_f64(),
605 };
606 let report = StlConversionReport {
607 backend,
608 backend_reason,
609 fallback_cause,
610 input_vertices: mesh.vertices.len(),
611 input_triangles: mesh.triangles.len(),
612 recognized_regions: recognition.regions.len(),
613 recognized_triangles,
614 unresolved_triangles: recognition.unresolved_triangles.len(),
615 source_closed_manifold,
616 requested_distance_tolerance,
617 coordinate_precision_tolerance: options.coordinate_precision_tolerance,
618 effective_distance_tolerance,
619 region_type_counts,
620 regions,
621 topology_refit,
622 hybrid_rebuild,
623 exported_step_bytes: step_text.len(),
624 exported_advanced_faces: step_text.matches("ADVANCED_FACE(").count(),
625 faceted_faces_before_merge,
626 faceted_faces_after_merge,
627 roundtrip_solids: imported.len(),
628 manifold_audit_issues,
629 timings,
630 messages,
631 };
632 Ok(StlConversionOutput { step_text, report })
633}
634
635fn validate_source_buffers(
636 mesh: &Mesh,
637 positions: &[f64],
638 indices: Option<&[u32]>,
639) -> Result<(), StlConversionError> {
640 if positions.is_empty() || !positions.len().is_multiple_of(3) {
641 return Err(StlConversionError(
642 "source position buffer must contain complete non-empty xyz triples".to_owned(),
643 ));
644 }
645 if positions.iter().any(|value| !value.is_finite()) {
646 return Err(StlConversionError(
647 "source position buffer contains a non-finite coordinate".to_owned(),
648 ));
649 }
650 let source_triangles = match indices {
651 Some(indices) => {
652 if !indices.len().is_multiple_of(3) {
653 return Err(StlConversionError(
654 "source index buffer must contain complete triangles".to_owned(),
655 ));
656 }
657 if indices
658 .iter()
659 .any(|&index| index as usize >= positions.len() / 3)
660 {
661 return Err(StlConversionError(
662 "source index buffer references a missing position".to_owned(),
663 ));
664 }
665 indices.len() / 3
666 }
667 None => {
668 if !positions.len().is_multiple_of(9) {
669 return Err(StlConversionError(
670 "unindexed source positions must be a triangle soup".to_owned(),
671 ));
672 }
673 positions.len() / 9
674 }
675 };
676 if source_triangles != mesh.triangles.len() {
677 return Err(StlConversionError(format!(
678 "source buffers contain {source_triangles} triangles but recognition mesh contains {}",
679 mesh.triangles.len()
680 )));
681 }
682 Ok(())
683}
684
685fn complete_small_planar_regions(
690 mesh: &Mesh,
691 options: &RecognitionOptions,
692 regions: &mut Vec<SurfaceRegion>,
693 unresolved: &mut Vec<usize>,
694) -> Result<usize, StlConversionError> {
695 if unresolved.len() < 2 {
696 return Ok(0);
697 }
698 let analyzed = mesh
699 .analyze(&MeshAnalysisOptions {
700 feature_angle: options.feature_angle,
701 ..MeshAnalysisOptions::default()
702 })
703 .map_err(|error| {
704 StlConversionError(format!("plane completion analysis failed: {error}"))
705 })?;
706 let allowed = unresolved.iter().copied().collect::<BTreeSet<_>>();
707 let mut visited = BTreeSet::new();
708 let mut accepted = BTreeSet::new();
709 let mut completed = 0;
710 let mut plane_options = options.clone();
711 plane_options.minimum_support = 2;
712
713 for &seed in unresolved.iter() {
714 if !visited.insert(seed) {
715 continue;
716 }
717 let mut component = Vec::new();
718 let mut queue = VecDeque::from([seed]);
719 while let Some(triangle) = queue.pop_front() {
720 component.push(triangle);
721 let data = &analyzed.triangles[triangle];
722 for edge in 0..3 {
723 if data.feature_edges[edge] {
724 continue;
725 }
726 if let Some(neighbor) = data.neighbors[edge] {
727 if allowed.contains(&neighbor) && visited.insert(neighbor) {
728 queue.push_back(neighbor);
729 }
730 }
731 }
732 }
733 component.sort_unstable();
734 if component.len() < 2 {
735 continue;
736 }
737 let Ok(fit) = reconstruct_surface(
738 mesh,
739 &component,
740 &SurfaceHint::KnownType {
741 surface_type: SurfaceType::Plane,
742 },
743 &plane_options,
744 ) else {
745 continue;
746 };
747 if fit.metrics.support_triangles != component.len()
748 || !matches!(fit.surface, AnalyticSurface::Plane(_))
749 {
750 continue;
751 }
752 accepted.extend(component.iter().copied());
753 regions.push(SurfaceRegion {
754 surface: fit.surface,
755 orientation: fit.orientation,
756 triangle_indices: component,
757 metrics: fit.metrics,
758 confidence: fit.confidence,
759 diagnostics: fit.diagnostics,
760 });
761 completed += 1;
762 }
763 unresolved.retain(|triangle| !accepted.contains(triangle));
764 Ok(completed)
765}
766
767fn region_report(region: &SurfaceRegion) -> ConversionRegionReport {
768 fit_fields(
769 region.surface,
770 region.orientation,
771 ®ion.metrics,
772 region.confidence,
773 ®ion.diagnostics,
774 )
775}
776
777fn refit_report(fit: &SurfaceFitResult) -> ConversionRegionReport {
778 fit_fields(
779 fit.surface,
780 fit.orientation,
781 &fit.metrics,
782 fit.confidence,
783 &fit.diagnostics,
784 )
785}
786
787fn fit_fields(
788 surface: AnalyticSurface,
789 orientation: i8,
790 metrics: &crate::FitMetrics,
791 confidence: f64,
792 diagnostics: &crate::FitDiagnostics,
793) -> ConversionRegionReport {
794 ConversionRegionReport {
795 surface_type: surface.surface_type(),
796 surface,
797 support_triangles: metrics.support_triangles,
798 supported_area: metrics.supported_area,
799 rms_error: metrics.rms_error,
800 max_error: metrics.max_error,
801 rms_normal_error_radians: metrics.rms_normal_error,
802 max_normal_error_radians: metrics.max_normal_error,
803 confidence,
804 orientation,
805 reason: diagnostics.reason.clone(),
806 phase_timings: diagnostics.phase_timings,
807 }
808}
809
810fn disjoint_complete_partition(regions: &[SurfaceRegion], triangle_count: usize) -> bool {
811 let mut seen = BTreeSet::new();
812 regions.iter().all(|region| {
813 region
814 .triangle_indices
815 .iter()
816 .all(|&triangle| triangle < triangle_count && seen.insert(triangle))
817 }) && seen.len() == triangle_count
818}
819
820fn closed_manifold(mesh: &Mesh) -> bool {
821 let mut uses = BTreeMap::<(u32, u32), (usize, i32)>::new();
822 for triangle in &mesh.triangles {
823 if triangle[0] == triangle[1] || triangle[1] == triangle[2] || triangle[2] == triangle[0] {
824 return false;
825 }
826 for edge in [
827 (triangle[0], triangle[1]),
828 (triangle[1], triangle[2]),
829 (triangle[2], triangle[0]),
830 ] {
831 let key = if edge.0 < edge.1 {
832 (edge.0, edge.1)
833 } else {
834 (edge.1, edge.0)
835 };
836 let entry = uses.entry(key).or_default();
837 entry.0 += 1;
838 entry.1 += if edge == key { 1 } else { -1 };
839 }
840 }
841 !uses.is_empty()
842 && uses
843 .values()
844 .all(|&(count, sense)| count == 2 && sense == 0)
845}
846
847struct DirectAnalyticBuild {
848 solid: Result<brep_kernel::BrepSolid, StlConversionError>,
849 backend: ConversionBackend,
850 reason: String,
851 topology_refit: Option<ConversionRegionReport>,
852}
853
854fn direct_analytic_solid(
855 mesh: &Mesh,
856 regions: &[SurfaceRegion],
857 options: &RecognitionOptions,
858) -> Option<DirectAnalyticBuild> {
859 if regions.len() == 1 {
860 return match regions[0].surface {
861 AnalyticSurface::Sphere(sphere) => Some(DirectAnalyticBuild {
862 solid: make_sphere_brep(
863 kernel_vec(sphere.center),
864 sphere.radius,
865 kernel_vec(Vec3::new(0.0, 0.0, 1.0)),
866 )
867 .map_err(StlConversionError),
868 backend: ConversionBackend::RansacSphere,
869 reason: "RANSAC proved complete closed spherical coverage; exported an exact analytic sphere".to_owned(),
870 topology_refit: None,
871 }),
872 AnalyticSurface::Torus(torus) if torus.major_radius > torus.minor_radius => Some(DirectAnalyticBuild {
873 solid: make_torus_brep(
874 kernel_vec(torus.center),
875 kernel_vec(torus.axis),
876 torus.major_radius,
877 torus.minor_radius,
878 )
879 .map_err(StlConversionError),
880 backend: ConversionBackend::RansacTorus,
881 reason: "RANSAC proved complete closed ring-torus coverage; exported an exact analytic torus".to_owned(),
882 topology_refit: None,
883 }),
884 _ => None,
885 };
886 }
887
888 let curved = regions
889 .iter()
890 .filter(|region| !matches!(region.surface, AnalyticSurface::Plane(_)))
891 .collect::<Vec<_>>();
892 let planes = regions
893 .iter()
894 .filter_map(|region| match region.surface {
895 AnalyticSurface::Plane(plane) => Some(plane),
896 _ => None,
897 })
898 .collect::<Vec<_>>();
899 if curved.len() != 1 {
900 return None;
901 }
902 let topology_refit;
908 let mut topology_refit_report = None;
909 let curved = if let AnalyticSurface::Torus(torus) = curved[0].surface {
910 let scale = mesh_diagonal(mesh);
911 let collapsed = torus.major_radius.min(torus.minor_radius) > 20.0 * scale;
912 let forced_type = match planes.len() {
913 2 if collapsed => Some(SurfaceType::Cylinder),
914 1 if collapsed => Some(SurfaceType::Cone),
915 _ => None,
916 };
917 if let Some(surface_type) = forced_type {
918 topology_refit = reconstruct_surface(
919 mesh,
920 &curved[0].triangle_indices,
921 &SurfaceHint::KnownType { surface_type },
922 options,
923 )
924 .ok()?;
925 topology_refit_report = Some(refit_report(&topology_refit));
926 &topology_refit.surface
927 } else {
928 &curved[0].surface
929 }
930 } else {
931 &curved[0].surface
932 };
933 match *curved {
934 AnalyticSurface::Cylinder(cylinder) if planes.len() == 2 => {
935 let (low, high) = axial_range(mesh, cylinder.axis_origin, cylinder.axis)?;
936 let tolerance = conversion_tolerance(mesh, options);
937 if high - low <= tolerance
938 || !caps_match(
939 &planes,
940 cylinder.axis_origin,
941 cylinder.axis,
942 &[low, high],
943 tolerance,
944 )
945 {
946 return None;
947 }
948 let base = cylinder.axis_origin + cylinder.axis * low;
949 Some(DirectAnalyticBuild {
950 solid: make_cylinder_brep(
951 kernel_vec(base),
952 kernel_vec(cylinder.axis),
953 cylinder.radius,
954 high - low,
955 )
956 .map_err(StlConversionError),
957 backend: ConversionBackend::RansacCappedCylinder,
958 reason: if topology_refit_report.is_some() {
959 "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()
960 } else {
961 "RANSAC proved one cylindrical wall plus two matching planar caps; exported an exact capped cylinder".to_owned()
962 },
963 topology_refit: topology_refit_report,
964 })
965 }
966 AnalyticSurface::Cone(cone) if planes.len() == 1 || planes.len() == 2 => {
967 let (low, high) = axial_range(mesh, cone.apex, cone.axis)?;
968 let tolerance = conversion_tolerance(mesh, options);
969 if low < -tolerance || high - low <= tolerance {
970 return None;
971 }
972 let expected_caps = if low <= tolerance {
973 vec![high]
974 } else {
975 vec![low, high]
976 };
977 if planes.len() != expected_caps.len()
978 || !caps_match(&planes, cone.apex, cone.axis, &expected_caps, tolerance)
979 {
980 return None;
981 }
982 let radius_bottom = low.max(0.0) * cone.half_angle.tan();
983 let radius_top = high * cone.half_angle.tan();
984 if radius_top <= tolerance || (low > tolerance && radius_bottom <= tolerance) {
985 return None;
986 }
987 let (base, build_axis, build_bottom, build_top) = if low <= tolerance {
988 (
992 cone.apex + cone.axis * high,
993 cone.axis * -1.0,
994 radius_top,
995 0.0,
996 )
997 } else {
998 (
999 cone.apex + cone.axis * low,
1000 cone.axis,
1001 radius_bottom,
1002 radius_top,
1003 )
1004 };
1005 Some(DirectAnalyticBuild {
1006 solid: make_cone_brep(
1007 kernel_vec(base),
1008 kernel_vec(build_axis),
1009 build_bottom,
1010 build_top,
1011 high - low.max(0.0),
1012 )
1013 .map_err(StlConversionError),
1014 backend: ConversionBackend::RansacCappedCone,
1015 reason: if topology_refit_report.is_some() {
1016 "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()
1017 } else {
1018 "RANSAC proved one conical wall and matching planar cap topology; exported an exact capped cone".to_owned()
1019 },
1020 topology_refit: topology_refit_report,
1021 })
1022 }
1023 _ => None,
1024 }
1025}
1026
1027fn axial_range(mesh: &Mesh, origin: Vec3, axis: Vec3) -> Option<(f64, f64)> {
1028 let mut low = f64::INFINITY;
1029 let mut high = f64::NEG_INFINITY;
1030 for &point in &mesh.vertices {
1031 let height = (point - origin).dot(axis);
1032 low = low.min(height);
1033 high = high.max(height);
1034 }
1035 (low.is_finite() && high.is_finite()).then_some((low, high))
1036}
1037
1038fn caps_match(
1039 planes: &[crate::PlaneSurface],
1040 origin: Vec3,
1041 axis: Vec3,
1042 expected_heights: &[f64],
1043 tolerance: f64,
1044) -> bool {
1045 let mut actual = Vec::with_capacity(planes.len());
1046 for plane in planes {
1047 if plane.normal.dot(axis).abs() < 1.0 - 1.0e-6 {
1048 return false;
1049 }
1050 actual.push((plane.origin - origin).dot(axis));
1051 }
1052 actual.sort_by(f64::total_cmp);
1053 let mut expected = expected_heights.to_vec();
1054 expected.sort_by(f64::total_cmp);
1055 actual.len() == expected.len()
1056 && actual
1057 .iter()
1058 .zip(expected)
1059 .all(|(actual, expected)| (*actual - expected).abs() <= tolerance)
1060}
1061
1062fn mesh_tolerance(mesh: &Mesh) -> f64 {
1063 let mut low = mesh.vertices[0];
1064 let mut high = mesh.vertices[0];
1065 for &point in &mesh.vertices[1..] {
1066 low.x = low.x.min(point.x);
1067 low.y = low.y.min(point.y);
1068 low.z = low.z.min(point.z);
1069 high.x = high.x.max(point.x);
1070 high.y = high.y.max(point.y);
1071 high.z = high.z.max(point.z);
1072 }
1073 (high - low).length().max(1.0) * 1.0e-5
1074}
1075
1076fn conversion_tolerance(mesh: &Mesh, options: &RecognitionOptions) -> f64 {
1077 mesh_tolerance(mesh)
1078 .max(options.distance_tolerance + options.relative_tolerance * mesh_diagonal(mesh))
1079}
1080
1081fn mesh_diagonal(mesh: &Mesh) -> f64 {
1082 let mut low = mesh.vertices[0];
1083 let mut high = mesh.vertices[0];
1084 for &point in &mesh.vertices[1..] {
1085 low.x = low.x.min(point.x);
1086 low.y = low.y.min(point.y);
1087 low.z = low.z.min(point.z);
1088 high.x = high.x.max(point.x);
1089 high.y = high.y.max(point.y);
1090 high.z = high.z.max(point.z);
1091 }
1092 (high - low).length()
1093}
1094
1095fn coplanar_merge_tolerance(mesh: &Mesh) -> f64 {
1096 (mesh_diagonal(mesh) * 1.0e-9).max(1.0e-12)
1097}
1098
1099fn solid_face_count(solid: &brep_kernel::BrepSolid) -> usize {
1100 solid.shells.iter().map(|shell| shell.faces.len()).sum()
1101}
1102
1103fn try_kernel_analytic(
1104 positions: &[f64],
1105 indices: Option<&[u32]>,
1106 options: &StlConversionOptions,
1107) -> Result<brep_kernel::BrepSolid, String> {
1108 let owned_indices;
1109 let indices = match indices {
1110 Some(indices) => indices,
1111 None => {
1112 owned_indices = (0..positions.len() as u32 / 3).collect::<Vec<_>>();
1113 &owned_indices
1114 }
1115 };
1116 let segmentation_options = SegmentOptions {
1117 deflection_angle_deg: options.kernel_deflection_angle_degrees,
1118 fit_tolerance: options.kernel_fit_tolerance,
1119 normal_tolerance_deg: options.kernel_normal_tolerance_degrees,
1120 weld_tolerance: options.weld_tolerance,
1121 ..SegmentOptions::default()
1122 };
1123 let segmentation = segment_mesh_faces(positions, indices, &segmentation_options)?;
1124 if segmentation
1125 .triangle_region_ids
1126 .contains(&UNASSIGNED_REGION)
1127 {
1128 return Err("independent kernel segmentation left triangles unassigned".to_owned());
1129 }
1130 if let Some(region) = segmentation.regions.iter().find(|region| {
1131 matches!(
1132 region.carrier,
1133 RegionCarrier::Freeform | RegionCarrier::Sphere { .. } | RegionCarrier::Torus { .. }
1134 )
1135 }) {
1136 return Err(format!(
1137 "independent kernel segmentation produced unsupported {} region {}",
1138 region.carrier.kind(),
1139 region.id
1140 ));
1141 }
1142 mesh_regions_to_brep(positions, indices, &segmentation_options)
1143}
1144
1145fn try_hybrid_analytic(
1146 positions: &[f64],
1147 indices: Option<&[u32]>,
1148 options: &StlConversionOptions,
1149) -> Result<crate::hybrid_region_brep::HybridBrepOutput, String> {
1150 let segmentation_options = SegmentOptions {
1151 deflection_angle_deg: options.kernel_deflection_angle_degrees,
1152 fit_tolerance: options.kernel_fit_tolerance,
1153 normal_tolerance_deg: options.kernel_normal_tolerance_degrees,
1154 weld_tolerance: options.weld_tolerance,
1155 ..SegmentOptions::default()
1156 };
1157 crate::hybrid_region_brep::hybrid_plane_cylinder_brep(positions, indices, &segmentation_options)
1158}
1159
1160fn hybrid_report(stats: crate::hybrid_region_brep::HybridBrepStats) -> HybridConversionReport {
1161 HybridConversionReport {
1162 total_faces: stats.total_faces,
1163 analytic_plane_faces: stats.analytic_plane_faces,
1164 analytic_plane_triangles: stats.analytic_plane_triangles,
1165 analytic_cylinder_faces: stats.analytic_cylinder_faces,
1166 analytic_cylinder_triangles: stats.analytic_cylinder_triangles,
1167 analytic_cone_faces: stats.analytic_cone_faces,
1168 analytic_cone_triangles: stats.analytic_cone_triangles,
1169 faceted_faces: stats.faceted_faces,
1170 faceted_triangles: stats.faceted_triangles,
1171 demoted_regions: stats.demoted_regions,
1172 demoted_triangles: stats.demoted_triangles,
1173 segmentation_plane_regions: stats.segmentation_plane_regions,
1174 segmentation_cylinder_regions: stats.segmentation_cylinder_regions,
1175 segmentation_cone_regions: stats.segmentation_cone_regions,
1176 segmentation_unsupported_regions: stats.segmentation_unsupported_regions,
1177 }
1178}
1179
1180fn kernel_vec(value: Vec3) -> brep_kernel::Vec3 {
1181 brep_kernel::Vec3::new(value.x, value.y, value.z)
1182}