Skip to main content

brep_reconstruction/
stl_conversion.rs

1//! STL-mesh to STEP conversion orchestration.
2//!
3//! Surface recognition is always run first. Safe plane, cylinder, and cone regions
4//! can be rebuilt analytically beside unsupported source-triangle faces; an
5//! analytic region is locally demoted when its shared boundary cannot be
6//! represented exactly. If no validated mixed shell can be built, the complete
7//! source mesh is sent through the kernel's repairable faceted importer. No
8//! source triangle is omitted.
9
10use 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/// Whether an unprovable analytic conversion may preserve the mesh as a
26/// repaired faceted STEP solid.
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
28pub enum ConversionPolicy {
29    /// Preserve arbitrary repairable input by falling back to faceted BREP.
30    #[default]
31    AllowFacetedFallback,
32    /// Return an error unless the complete mesh can be rebuilt analytically.
33    RequireFullyAnalytic,
34}
35
36/// Controls recognition and STEP topology construction.
37#[derive(Clone, Debug, Deserialize, Serialize)]
38#[serde(default)]
39pub struct StlConversionOptions {
40    /// Production RANSAC recognition settings.
41    pub recognition: RecognitionOptions,
42    /// Analytic-only versus repairable-faceted behavior.
43    pub policy: ConversionPolicy,
44    /// Try the kernel's independent plane/cylinder/cone region rebuilder after
45    /// RANSAC proves that the whole mesh is analytic.
46    pub try_kernel_analytic_rebuild: bool,
47    /// Try the conservative mixed plane/cylinder/cone/faceted topology builder.
48    ///
49    /// This is enabled by default so recognized safe regions remain analytic
50    /// even when other regions cannot be represented analytically. Disabling
51    /// it is primarily useful for diagnosing the fully faceted repair path.
52    pub try_hybrid_rebuild: bool,
53    /// Vertex weld tolerance for hybrid segmentation and faceted repair;
54    /// non-positive derives a scale-relative tolerance in the kernel.
55    pub weld_tolerance: f64,
56    /// Absolute coordinate uncertainty introduced by the source encoding.
57    ///
58    /// The converter will not run recognition below this distance even when
59    /// [`RecognitionOptions::distance_tolerance`] requests a smaller value.
60    /// Leave this at zero for text or double-precision sources. The CLI sets
61    /// it automatically for binary STL, whose coordinates are IEEE-754 f32.
62    pub coordinate_precision_tolerance: f64,
63    /// Deflection angle used by the kernel analytic segmentation paths.
64    pub kernel_deflection_angle_degrees: f64,
65    /// Scale-relative carrier tolerance used by the kernel analytic segmentation paths.
66    pub kernel_fit_tolerance: f64,
67    /// Normal gate used by the kernel analytic segmentation paths.
68    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            // Facet centroids lie on polygon chords rather than on the
76            // underlying design carrier. Welded STL vertices retain the best
77            // available samples of cylinders and other curved CAD surfaces.
78            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/// Topology path used to produce the final STEP body.
96#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
97#[serde(rename_all = "snake_case")]
98pub enum ConversionBackend {
99    /// Exact full sphere from the RANSAC carrier.
100    RansacSphere,
101    /// Exact full ring torus from the RANSAC carrier.
102    RansacTorus,
103    /// Exact cylinder wall and caps from RANSAC carriers.
104    RansacCappedCylinder,
105    /// Exact cone wall and cap or caps from RANSAC carriers.
106    RansacCappedCone,
107    /// RANSAC proved full analytic coverage, then the kernel independently
108    /// segmented and rebuilt a plane/cylinder/cone shell.
109    KernelAnalyticRebuild,
110    /// A conservative local topology builder reconstructed a complete shell
111    /// with exact plane/cylinder/cone regions and faceted unsupported regions.
112    #[serde(alias = "hybrid_plane_cylinder_rebuild")]
113    HybridAnalyticRebuild,
114    /// Complete source triangles were preserved through mesh repair.
115    FacetedRepair,
116    /// Complete source triangles were repaired, then adjacent coplanar
117    /// triangle faces were safely coalesced by the kernel.
118    FacetedRepairCoplanarMerged,
119}
120
121/// Human- and machine-readable details for one RANSAC region.
122#[derive(Clone, Debug, Deserialize, Serialize)]
123pub struct ConversionRegionReport {
124    /// Selected analytic family.
125    pub surface_type: SurfaceType,
126    /// Fitted carrier parameters.
127    pub surface: AnalyticSurface,
128    /// Number of supporting input triangles.
129    pub support_triangles: usize,
130    /// Total area of the supporting triangles.
131    pub supported_area: f64,
132    /// Root-mean-square positional residual.
133    pub rms_error: f64,
134    /// Maximum positional residual.
135    pub max_error: f64,
136    /// Root-mean-square normal residual, in radians.
137    pub rms_normal_error_radians: f64,
138    /// Maximum normal residual, in radians.
139    pub max_normal_error_radians: f64,
140    /// Residual-evidence confidence score.
141    pub confidence: f64,
142    /// Carrier orientation relative to mesh winding.
143    pub orientation: i8,
144    /// Recognition-path explanation.
145    pub reason: String,
146    /// Production recognizer's detailed phase measurements.
147    pub phase_timings: PhaseTimings,
148}
149
150/// Wall-clock measurements for conversion stages.
151#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
152pub struct ConversionTimings {
153    /// Time spent in analytic recognition.
154    pub recognition_seconds: f64,
155    /// Time spent building and validating in-memory topology.
156    pub topology_build_seconds: f64,
157    /// Time spent serializing STEP.
158    pub step_export_seconds: f64,
159    /// Time spent auditing and round-trip importing STEP.
160    pub step_validation_seconds: f64,
161    /// End-to-end conversion time.
162    pub total_seconds: f64,
163}
164
165/// Face and source-triangle accounting for a mixed analytic/faceted rebuild.
166#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
167pub struct HybridConversionReport {
168    /// Total output BREP faces.
169    pub total_faces: usize,
170    /// Exact analytic plane faces.
171    pub analytic_plane_faces: usize,
172    /// Source triangles represented by analytic plane faces.
173    pub analytic_plane_triangles: usize,
174    /// Exact analytic cylinder faces.
175    pub analytic_cylinder_faces: usize,
176    /// Source triangles represented by analytic cylinder faces.
177    pub analytic_cylinder_triangles: usize,
178    /// Exact analytic cone faces.
179    #[serde(default)]
180    pub analytic_cone_faces: usize,
181    /// Source triangles represented by analytic cone faces.
182    #[serde(default)]
183    pub analytic_cone_triangles: usize,
184    /// Coplanar planar faces retained for unsupported or unsafe regions.
185    pub faceted_faces: usize,
186    /// Source triangles represented by those coplanar fallback faces.
187    pub faceted_triangles: usize,
188    /// Segmented analytic regions conservatively demoted to facets.
189    pub demoted_regions: usize,
190    /// Source triangles in demoted regions.
191    pub demoted_triangles: usize,
192    /// Plane regions found by the independent topology segmentation.
193    pub segmentation_plane_regions: usize,
194    /// Cylinder regions found by the independent topology segmentation.
195    pub segmentation_cylinder_regions: usize,
196    /// Cone regions found by the independent topology segmentation.
197    #[serde(default)]
198    pub segmentation_cone_regions: usize,
199    /// Unsupported regions found by the independent topology segmentation.
200    pub segmentation_unsupported_regions: usize,
201}
202
203/// Detailed conversion outcome suitable for terminal or JSON reporting.
204#[derive(Clone, Debug, Deserialize, Serialize)]
205pub struct StlConversionReport {
206    /// Topology construction path used.
207    pub backend: ConversionBackend,
208    /// Explanation of why the backend was selected.
209    pub backend_reason: String,
210    /// Why an analytic backend was unavailable, for faceted output.
211    pub fallback_cause: Option<String>,
212    /// Number of vertices in the recognition mesh.
213    pub input_vertices: usize,
214    /// Number of triangles in the input.
215    pub input_triangles: usize,
216    /// Number of accepted analytic regions.
217    pub recognized_regions: usize,
218    /// Number of triangles assigned to analytic regions.
219    pub recognized_triangles: usize,
220    /// Number of non-degenerate triangles left unresolved.
221    pub unresolved_triangles: usize,
222    /// Whether indexed input topology is a consistently wound closed manifold.
223    pub source_closed_manifold: bool,
224    /// Absolute recognition distance requested by the caller.
225    #[serde(default)]
226    pub requested_distance_tolerance: f64,
227    /// Minimum distance imposed by the coordinate encoding.
228    #[serde(default)]
229    pub coordinate_precision_tolerance: f64,
230    /// Absolute distance actually supplied to recognition.
231    #[serde(default)]
232    pub effective_distance_tolerance: f64,
233    /// Accepted region count by analytic family name.
234    pub region_type_counts: BTreeMap<String, usize>,
235    /// Per-region carrier and quality diagnostics.
236    pub regions: Vec<ConversionRegionReport>,
237    /// Topology-informed production refit used by a direct backend, when the
238    /// initially selected carrier was an observational limiting family.
239    pub topology_refit: Option<ConversionRegionReport>,
240    /// Detailed accounting when the mixed analytic builder was used.
241    #[serde(default)]
242    pub hybrid_rebuild: Option<HybridConversionReport>,
243    /// Serialized STEP document size.
244    pub exported_step_bytes: usize,
245    /// Number of serialized `ADVANCED_FACE` entities.
246    pub exported_advanced_faces: usize,
247    /// Face count immediately after faceted repair, when that path was used.
248    #[serde(default)]
249    pub faceted_faces_before_merge: Option<usize>,
250    /// Face count after the safe coplanar merge attempt, when faceted repair
251    /// was used. This equals `faceted_faces_before_merge` if no merge was
252    /// possible or the validated merge was unavailable.
253    #[serde(default)]
254    pub faceted_faces_after_merge: Option<usize>,
255    /// Number of solids recovered by round-trip STEP import.
256    pub roundtrip_solids: usize,
257    /// Serialized manifold audit issues; empty on every successful result.
258    pub manifold_audit_issues: Vec<String>,
259    /// Stage and total wall-clock measurements.
260    pub timings: ConversionTimings,
261    /// Useful chronological terminal messages.
262    pub messages: Vec<String>,
263}
264
265/// STEP text and its complete conversion report.
266#[derive(Clone, Debug, Deserialize, Serialize)]
267pub struct StlConversionOutput {
268    /// Validated AP214 STEP Part 21 document.
269    pub step_text: String,
270    /// Detailed conversion outcome.
271    pub report: StlConversionReport,
272}
273
274/// A conversion failure. The message is intentionally ready for terminal use.
275#[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
292/// Returns a conservative positional-error bound for binary STL coordinates.
293///
294/// Binary STL stores each coordinate as an IEEE-754 `f32`. For a normally
295/// sized finite value, round-to-nearest introduces at most half a relative
296/// `f32` epsilon per coordinate. Multiplying the largest absolute coordinate
297/// by that bound and by `sqrt(3)` covers simultaneous x/y/z rounding. The
298/// model diagonal is also considered so origin-centered geometry receives a
299/// useful scale-aware floor. This is deliberately a source-encoding bound,
300/// not a general modeling tolerance.
301pub 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
311/// Recognize a parsed STL mesh and serialize a validated AP214 STEP solid.
312///
313/// `source_positions` and `source_indices` must describe the same triangles
314/// represented by `mesh`. They are kept separately because an STL reader may
315/// preserve the original triangle soup while welding a second copy for robust
316/// recognition. The original buffers are always used by faceted repair.
317pub 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
685/// Recover only small coplanar feature-bounded components that the generic
686/// pass skipped because its minimum support deliberately protects curved
687/// model selection. A two-triangle plane is fully determined without giving
688/// tiny cylinder/torus hypotheses authority over output topology.
689fn 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        &region.metrics,
772        region.confidence,
773        &region.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    // A finite cylinder/cone strip sampled at only a handful of axial levels
903    // can be represented to roundoff by the very-large-radius limit of a
904    // torus. Planar cap topology removes that ambiguity. In that narrowly
905    // gated case, ask the production recognizer for a topology-informed
906    // known-type refit and retain all of its ordinary residual/normal gates.
907    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                // The kernel's capped-cone constructor requires a positive
989                // bottom radius. Build a pointed cone from its wide end back
990                // toward the apex.
991                (
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}