Skip to main content

brep_reconstruction/
stl_conversion.rs

1//! STL-mesh to STEP conversion orchestration.
2//!
3//! The kernel's mesh segmentation runs first on every closed mesh. When it
4//! reads the mesh as a whole primitive — every triangle on one of at most
5//! three analytic regions — RANSAC recognition runs and its fitted
6//! parameters build an exact sphere, torus, capped cylinder or capped cone.
7//! If tessellation features fragmented the segmentation, recognition is also
8//! eligible when every triangle lies on one segmented curved carrier or its
9//! possible end caps. This eligibility check never substitutes for recognition.
10//! Otherwise RANSAC is skipped: every consumer of its result needs that
11//! whole-primitive shape, and on an eleven-thousand-facet part it cost 75 s
12//! for a result nothing read. Safe plane, cylinder, cone, and sphere regions
13//! can be rebuilt analytically beside unsupported source-triangle faces; an
14//! analytic region is locally demoted when its shared boundary cannot be
15//! represented exactly. If no validated mixed shell can be built, the complete
16//! source mesh is sent through the kernel's repairable faceted importer. No
17//! source triangle is omitted.
18//!
19//! Every one of those analytic lanes needs a closed two-manifold, which a mesh
20//! FILE frequently is not — a dropped facet, a duplicated sheet, a degenerate
21//! sliver. Such a body is repaired FIRST (`repair_triangle_soup`, the same
22//! weld/prune/cap the faceted importer applies) and the analytic lanes run on
23//! the repaired mesh, so a defect costs a body only the facets it touches
24//! instead of every curved surface in it. A body whose repair still does not
25//! close keeps the refusal it has always had. Closed shells retain their mesh
26//! geometry; inward shells are oriented outward for reconstruction and restored
27//! as cavities when an enclosing component is present.
28
29mod cavities;
30mod degenerate_coverage;
31mod feature_fragments;
32mod primitive_gate;
33
34use crate::{
35    recognize_surfaces_with_unresolved, reconstruct_surface, AnalyticSurface, Mesh,
36    MeshAnalysisOptions, PhaseTimings, RecognitionOptions, SamplingMode, SurfaceFitResult,
37    SurfaceHint, SurfaceRegion, SurfaceType, Vec3,
38};
39use brep_kernel::{
40    audit_step_manifold, export_step, import_step, make_cone_brep, make_cylinder_brep,
41    make_sphere_brep, make_torus_brep, merge_same_surface_faces, mesh_regions_to_brep,
42    mesh_to_faceted_brep, repair_triangle_soup, segment_mesh_faces, MeshSegmentation,
43    RegionCarrier, SegmentOptions,
44    UNASSIGNED_REGION,
45};
46use serde::{Deserialize, Serialize};
47use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
48use std::fmt::{Display, Formatter};
49use web_time::Instant;
50
51/// Whether an unprovable analytic conversion may preserve the mesh as a
52/// repaired faceted STEP solid.
53#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
54pub enum ConversionPolicy {
55    /// Preserve arbitrary repairable input by falling back to faceted BREP.
56    #[default]
57    AllowFacetedFallback,
58    /// Return an error unless the complete mesh can be rebuilt analytically.
59    RequireFullyAnalytic,
60}
61
62/// Controls recognition and STEP topology construction.
63#[derive(Clone, Debug, Deserialize, Serialize)]
64#[serde(default)]
65pub struct StlConversionOptions {
66    /// RANSAC recognition settings. Recognition runs only when the kernel
67    /// segmentation reads the mesh as a whole primitive (see the module doc);
68    /// every other mesh is rebuilt from the segmentation alone.
69    pub recognition: RecognitionOptions,
70    /// Analytic-only versus repairable-faceted behavior.
71    pub policy: ConversionPolicy,
72    /// Try the kernel's plane/cylinder/cone region rebuilder when the kernel
73    /// segmentation proves that the whole mesh is plane/cylinder/cone.
74    pub try_kernel_analytic_rebuild: bool,
75    /// Try the conservative mixed plane/cylinder/cone/sphere/faceted topology builder.
76    ///
77    /// This is enabled by default so recognized safe regions remain analytic
78    /// even when other regions cannot be represented analytically. Disabling
79    /// it is primarily useful for diagnosing the fully faceted repair path.
80    pub try_hybrid_rebuild: bool,
81    /// Vertex weld tolerance for hybrid segmentation and faceted repair;
82    /// Non-positive uses the STL reader's scale-relative tolerance for
83    /// segmentation; faceted repair retains the kernel's default tolerance.
84    pub weld_tolerance: f64,
85    /// Absolute coordinate uncertainty introduced by the source encoding.
86    ///
87    /// The converter will not run recognition below this distance even when
88    /// [`RecognitionOptions::distance_tolerance`] requests a smaller value.
89    /// Leave this at zero for text or double-precision sources. The CLI sets
90    /// it automatically for binary STL, whose coordinates are IEEE-754 f32.
91    pub coordinate_precision_tolerance: f64,
92    /// Deflection angle used by the kernel analytic segmentation paths.
93    pub kernel_deflection_angle_degrees: f64,
94    /// Scale-relative carrier tolerance used by the kernel analytic segmentation paths.
95    pub kernel_fit_tolerance: f64,
96    /// Normal gate used by the kernel analytic segmentation paths.
97    pub kernel_normal_tolerance_degrees: f64,
98}
99
100impl Default for StlConversionOptions {
101    fn default() -> Self {
102        let recognition = RecognitionOptions {
103            collect_phase_timings: true,
104            // Facet centroids lie on polygon chords rather than on the
105            // underlying design carrier. Welded STL vertices retain the best
106            // available samples of cylinders and other curved CAD surfaces.
107            sampling: SamplingMode::Vertices,
108            ..RecognitionOptions::default()
109        };
110        Self {
111            recognition,
112            policy: ConversionPolicy::AllowFacetedFallback,
113            try_kernel_analytic_rebuild: true,
114            try_hybrid_rebuild: true,
115            weld_tolerance: -1.0,
116            coordinate_precision_tolerance: 0.0,
117            kernel_deflection_angle_degrees: 30.0,
118            kernel_fit_tolerance: 1.0e-3,
119            kernel_normal_tolerance_degrees: 15.0,
120        }
121    }
122}
123
124/// Topology path used to produce the final STEP body.
125#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
126#[serde(rename_all = "snake_case")]
127pub enum ConversionBackend {
128    /// Exact full sphere from the RANSAC carrier.
129    RansacSphere,
130    /// Exact full ring torus from the RANSAC carrier.
131    RansacTorus,
132    /// Exact cylinder wall and caps from RANSAC carriers.
133    RansacCappedCylinder,
134    /// Exact cone wall and cap or caps from RANSAC carriers.
135    RansacCappedCone,
136    /// The kernel segmentation proved full plane/cylinder/cone coverage and
137    /// the kernel region rebuilder built the shell from it.
138    KernelAnalyticRebuild,
139    /// A mesh boolean cut spherical facets; precise carriers and bidirectional
140    /// mesh coverage proved a primitive union rebuilt with analytic CSG trims.
141    KernelSphericalUnion,
142    /// A conservative local topology builder reconstructed a complete shell
143    /// with exact plane/cylinder/cone/sphere regions and faceted unsupported regions.
144    #[serde(alias = "hybrid_plane_cylinder_rebuild")]
145    HybridAnalyticRebuild,
146    /// Complete source triangles were preserved through mesh repair.
147    FacetedRepair,
148    /// Complete source triangles were repaired, then adjacent coplanar
149    /// triangle faces were safely coalesced by the kernel.
150    FacetedRepairCoplanarMerged,
151    /// No body: every lane, faceted repair included, refused this component
152    /// of a multi-body mesh. It is NOT in the STEP document; its record's
153    /// `refusal` says why. The document's own backend is never this.
154    Refused,
155}
156
157/// Human- and machine-readable details for one RANSAC region.
158#[derive(Clone, Debug, Deserialize, Serialize)]
159pub struct ConversionRegionReport {
160    /// Selected analytic family.
161    pub surface_type: SurfaceType,
162    /// Fitted carrier parameters.
163    pub surface: AnalyticSurface,
164    /// Number of supporting input triangles.
165    pub support_triangles: usize,
166    /// Total area of the supporting triangles.
167    pub supported_area: f64,
168    /// Root-mean-square positional residual.
169    pub rms_error: f64,
170    /// Maximum positional residual.
171    pub max_error: f64,
172    /// Root-mean-square normal residual, in radians.
173    pub rms_normal_error_radians: f64,
174    /// Maximum normal residual, in radians.
175    pub max_normal_error_radians: f64,
176    /// Residual-evidence confidence score.
177    pub confidence: f64,
178    /// Carrier orientation relative to mesh winding.
179    pub orientation: i8,
180    /// Recognition-path explanation.
181    pub reason: String,
182    /// Production recognizer's detailed phase measurements.
183    pub phase_timings: PhaseTimings,
184}
185
186/// Wall-clock measurements for conversion stages.
187#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
188pub struct ConversionTimings {
189    /// Time spent in the kernel mesh segmentation that gates recognition.
190    #[serde(default)]
191    pub segmentation_seconds: f64,
192    /// Time spent in RANSAC recognition; zero when it was skipped.
193    pub recognition_seconds: f64,
194    /// Time spent building and validating in-memory topology.
195    pub topology_build_seconds: f64,
196    /// Time spent serializing STEP.
197    pub step_export_seconds: f64,
198    /// Time spent auditing and round-trip importing STEP.
199    pub step_validation_seconds: f64,
200    /// End-to-end conversion time.
201    pub total_seconds: f64,
202}
203
204/// Face and source-triangle accounting for a mixed analytic/faceted rebuild.
205#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
206pub struct HybridConversionReport {
207    /// Total output BREP faces.
208    pub total_faces: usize,
209    /// Exact analytic plane faces.
210    pub analytic_plane_faces: usize,
211    /// Source triangles represented by analytic plane faces.
212    pub analytic_plane_triangles: usize,
213    /// Exact analytic cylinder faces.
214    pub analytic_cylinder_faces: usize,
215    /// Source triangles represented by analytic cylinder faces.
216    pub analytic_cylinder_triangles: usize,
217    /// Exact analytic cone faces.
218    #[serde(default)]
219    pub analytic_cone_faces: usize,
220    /// Source triangles represented by analytic cone faces.
221    #[serde(default)]
222    pub analytic_cone_triangles: usize,
223    /// Exact analytic sphere faces.
224    #[serde(default)]
225    pub analytic_sphere_faces: usize,
226    /// Source triangles represented by analytic sphere faces.
227    #[serde(default)]
228    pub analytic_sphere_triangles: usize,
229    /// Exact analytic torus faces.
230    #[serde(default)]
231    pub analytic_torus_faces: usize,
232    /// Source triangles represented by analytic torus faces.
233    #[serde(default)]
234    pub analytic_torus_triangles: usize,
235    /// Coplanar planar faces retained for unsupported or unsafe regions.
236    pub faceted_faces: usize,
237    /// Source triangles represented by those coplanar fallback faces.
238    pub faceted_triangles: usize,
239    /// Segmented analytic regions conservatively demoted to facets.
240    pub demoted_regions: usize,
241    /// Source triangles in demoted regions.
242    pub demoted_triangles: usize,
243    /// Plane regions found by the independent topology segmentation.
244    pub segmentation_plane_regions: usize,
245    /// Cylinder regions found by the independent topology segmentation.
246    pub segmentation_cylinder_regions: usize,
247    /// Cone regions found by the independent topology segmentation.
248    #[serde(default)]
249    pub segmentation_cone_regions: usize,
250    /// Sphere regions found by the independent topology segmentation.
251    #[serde(default)]
252    pub segmentation_sphere_regions: usize,
253    /// Torus regions found by the independent topology segmentation.
254    #[serde(default)]
255    pub segmentation_torus_regions: usize,
256    /// Unsupported regions found by the independent topology segmentation.
257    pub segmentation_unsupported_regions: usize,
258}
259
260/// One body of the document: a single vertex-connected component of the input
261/// mesh, reconstructed on its own.
262#[derive(Clone, Debug, Deserialize, Serialize)]
263pub struct ComponentConversionReport {
264    /// Position in the document, counting from zero.
265    pub index: usize,
266    /// The lowest source triangle index this component holds; what orders the
267    /// bodies, and what names one in a refusal.
268    pub first_triangle: usize,
269    /// Vertices of this component's recognition mesh.
270    pub input_vertices: usize,
271    /// Triangles of this component.
272    pub input_triangles: usize,
273    /// Topology construction path this body took.
274    pub backend: ConversionBackend,
275    /// Explanation of why that backend was selected.
276    pub backend_reason: String,
277    /// Why an analytic backend was unavailable, for a faceted body.
278    pub fallback_cause: Option<String>,
279    /// Why RANSAC recognition did not run for this body; `None` when it ran.
280    pub recognition_skipped: Option<String>,
281    /// Accepted analytic regions for this body.
282    pub recognized_regions: usize,
283    /// Triangles assigned to those regions.
284    pub recognized_triangles: usize,
285    /// Non-degenerate triangles left unresolved.
286    pub unresolved_triangles: usize,
287    /// Whether this component alone is a consistently wound closed manifold.
288    pub source_closed_manifold: bool,
289    /// Whether the analytic lanes ran on the REPAIRED mesh because the source
290    /// was not a closed two-manifold. The `input_*` counts above are always
291    /// the source's. This says only which mesh the lanes were given, never
292    /// that one of them succeeded — `backend` says that.
293    #[serde(default)]
294    pub analytic_used_repaired_mesh: bool,
295    /// Mixed analytic/faceted accounting for this body.
296    pub hybrid_rebuild: Option<HybridConversionReport>,
297    /// Face count immediately after faceted repair, when that path was used.
298    pub faceted_faces_before_merge: Option<usize>,
299    /// Face count after the safe coplanar merge attempt.
300    pub faceted_faces_after_merge: Option<usize>,
301    /// BREP faces of the constructed solid.
302    pub faces: usize,
303    /// Why this body is NOT in the document: every lane refused it, and the
304    /// other bodies were built without it. `None` for every built body.
305    #[serde(default)]
306    pub refusal: Option<String>,
307    /// The source's own name for this body (a 3MF object's `name`), when the
308    /// caller knows it. The converter never sets it.
309    #[serde(default)]
310    pub source_name: Option<String>,
311}
312
313/// Detailed conversion outcome suitable for terminal or JSON reporting.
314///
315/// Counts are TOTALS over every body and `regions` is their concatenation. The
316/// fields that can only describe one body — `backend`, `topology_refit`, the
317/// recognition tolerances — are the FIRST body's; `components` is the per-body
318/// record, and `backend_reason` names every body when there is more than one.
319#[derive(Clone, Debug, Deserialize, Serialize)]
320pub struct StlConversionReport {
321    /// Topology construction path used.
322    pub backend: ConversionBackend,
323    /// Explanation of why the backend was selected.
324    pub backend_reason: String,
325    /// Why an analytic backend was unavailable, for faceted output.
326    pub fallback_cause: Option<String>,
327    /// Number of vertices in the recognition mesh.
328    pub input_vertices: usize,
329    /// Number of triangles in the input.
330    pub input_triangles: usize,
331    /// Number of accepted analytic regions.
332    pub recognized_regions: usize,
333    /// Number of triangles assigned to analytic regions.
334    pub recognized_triangles: usize,
335    /// Number of non-degenerate triangles left unresolved.
336    pub unresolved_triangles: usize,
337    /// Why RANSAC recognition did not run; `None` when it ran. The region
338    /// counts above are zero when this is set.
339    #[serde(default)]
340    pub recognition_skipped: Option<String>,
341    /// Whether indexed input topology is a consistently wound closed manifold.
342    pub source_closed_manifold: bool,
343    /// Whether ANY body's analytic lanes ran on a repaired mesh because the
344    /// source was not a closed two-manifold. Which mesh they were given, not
345    /// whether one of them succeeded.
346    #[serde(default)]
347    pub analytic_used_repaired_mesh: bool,
348    /// Absolute recognition distance requested by the caller.
349    #[serde(default)]
350    pub requested_distance_tolerance: f64,
351    /// Minimum distance imposed by the coordinate encoding.
352    #[serde(default)]
353    pub coordinate_precision_tolerance: f64,
354    /// Absolute distance actually supplied to recognition.
355    #[serde(default)]
356    pub effective_distance_tolerance: f64,
357    /// Accepted region count by analytic family name.
358    pub region_type_counts: BTreeMap<String, usize>,
359    /// Per-region carrier and quality diagnostics.
360    pub regions: Vec<ConversionRegionReport>,
361    /// Topology-informed production refit used by a direct backend, when the
362    /// initially selected carrier was an observational limiting family.
363    pub topology_refit: Option<ConversionRegionReport>,
364    /// Detailed accounting when the mixed analytic builder was used. With
365    /// several bodies this sums every body that took that backend.
366    #[serde(default)]
367    pub hybrid_rebuild: Option<HybridConversionReport>,
368    /// One record per body, in document order — the authoritative per-body
369    /// account when the input held several closed shells. Always populated;
370    /// a single-body conversion has exactly one entry.
371    #[serde(default)]
372    pub components: Vec<ComponentConversionReport>,
373    /// Serialized STEP document size.
374    pub exported_step_bytes: usize,
375    /// Number of serialized `ADVANCED_FACE` entities.
376    pub exported_advanced_faces: usize,
377    /// Face count immediately after faceted repair, when that path was used.
378    #[serde(default)]
379    pub faceted_faces_before_merge: Option<usize>,
380    /// Face count after the safe coplanar merge attempt, when faceted repair
381    /// was used. This equals `faceted_faces_before_merge` if no merge was
382    /// possible or the validated merge was unavailable.
383    #[serde(default)]
384    pub faceted_faces_after_merge: Option<usize>,
385    /// Number of solids recovered by round-trip STEP import.
386    pub roundtrip_solids: usize,
387    /// Serialized manifold audit issues; empty on every successful result.
388    pub manifold_audit_issues: Vec<String>,
389    /// Stage and total wall-clock measurements.
390    pub timings: ConversionTimings,
391    /// Useful chronological terminal messages.
392    pub messages: Vec<String>,
393}
394
395/// STEP text and its complete conversion report.
396#[derive(Clone, Debug, Deserialize, Serialize)]
397pub struct StlConversionOutput {
398    /// Validated AP242 STEP Part 21 document.
399    pub step_text: String,
400    /// Detailed conversion outcome.
401    pub report: StlConversionReport,
402}
403
404/// A conversion failure. The message is intentionally ready for terminal use.
405#[derive(Clone, Debug, PartialEq, Eq)]
406pub struct StlConversionError(pub String);
407
408impl Display for StlConversionError {
409    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
410        formatter.write_str(&self.0)
411    }
412}
413
414impl std::error::Error for StlConversionError {}
415
416impl From<String> for StlConversionError {
417    fn from(value: String) -> Self {
418        Self(value)
419    }
420}
421
422/// Returns a conservative positional-error bound for binary STL coordinates.
423///
424/// Binary STL stores each coordinate as an IEEE-754 `f32`. For a normally
425/// sized finite value, round-to-nearest introduces at most half a relative
426/// `f32` epsilon per coordinate. Multiplying the largest absolute coordinate
427/// by that bound and by `sqrt(3)` covers simultaneous x/y/z rounding. The
428/// model diagonal is also considered so origin-centered geometry receives a
429/// useful scale-aware floor. This is deliberately a source-encoding bound,
430/// not a general modeling tolerance.
431pub fn binary_stl_coordinate_precision_tolerance(mesh: &Mesh) -> f64 {
432    let maximum_absolute_coordinate = mesh
433        .vertices
434        .iter()
435        .flat_map(|point| [point.x.abs(), point.y.abs(), point.z.abs()])
436        .fold(0.0_f64, f64::max);
437    let coordinate_scale = maximum_absolute_coordinate.max(mesh_diagonal(mesh));
438    coordinate_scale * (f32::EPSILON as f64) * 0.5 * 3.0_f64.sqrt()
439}
440
441/// Recognize a parsed STL mesh and serialize a validated AP242 STEP document.
442///
443/// Each vertex-connected shell is reconstructed independently. Closed inward
444/// shells contained by an outward component are restored as cavities of that
445/// body; other components remain independent solids. STEP round-trip validation
446/// certifies the assembled cavity topology and containment.
447///
448/// `source_positions` and `source_indices` must describe the same triangles
449/// represented by `mesh`. They are kept separately because an STL reader may
450/// preserve the original triangle soup while welding a second copy for robust
451/// recognition. The original buffers are always used by faceted repair.
452pub fn convert_stl_mesh_to_step(
453    mesh: &Mesh,
454    source_positions: &[f64],
455    source_indices: Option<&[u32]>,
456    options: &StlConversionOptions,
457    part_name: &str,
458    unit: &str,
459    timestamp: &str,
460) -> Result<StlConversionOutput, StlConversionError> {
461    let total_started = Instant::now();
462    validate_source_buffers(mesh, source_positions, source_indices)?;
463    if !options.coordinate_precision_tolerance.is_finite()
464        || options.coordinate_precision_tolerance < 0.0
465    {
466        return Err(StlConversionError(
467            "coordinate_precision_tolerance must be finite and non-negative".to_owned(),
468        ));
469    }
470
471    let components = triangle_components(mesh);
472    let count = components.len();
473    if count == 0 {
474        return Err(StlConversionError(
475            "the mesh contains no triangles to reconstruct".to_owned(),
476        ));
477    }
478
479    let mut solids = Vec::with_capacity(count);
480    let mut component_ids = Vec::with_capacity(count);
481    let mut details = Vec::with_capacity(count);
482    let mut first_refusal = None;
483    for (index, triangles) in components.iter().enumerate() {
484        if count == 1 {
485            let built = convert_component(mesh, source_positions, source_indices, options)?;
486            solids.push(built.0);
487            component_ids.push(index);
488            details.push(built.1);
489            continue;
490        }
491        let part = component_mesh(mesh, source_positions, source_indices, triangles);
492        match convert_component(
493            &part.mesh,
494            &part.positions,
495            part.indices.as_deref(),
496            options,
497        ) {
498            Ok(built) => {
499                solids.push(built.0);
500                component_ids.push(index);
501                details.push(built.1);
502            }
503            // One body that no lane can build — faceted repair included —
504            // must not sink the others: it is left out of the document and
505            // its record says why, by body and first source triangle.
506            Err(error) => {
507                first_refusal.get_or_insert_with(|| {
508                    format!(
509                        "body {} of {count} (from source triangle {}, {} triangle(s)): {error}",
510                        index + 1,
511                        triangles[0],
512                        triangles.len()
513                    )
514                });
515                details.push(refused_component(&part.mesh, triangles.len(), error.0));
516            }
517        }
518    }
519    // Nothing survived: the first body's refusal is the import's.
520    if solids.is_empty() {
521        let refusal = first_refusal.unwrap_or_default();
522        return Err(StlConversionError(if count > 1 {
523            format!("{refusal} (every one of the {count} bodies was refused)")
524        } else {
525            refusal
526        }));
527    }
528
529    let (solids, cavity_messages) = cavities::assemble(mesh, &components, solids, &component_ids)?;
530    let export_started = Instant::now();
531    let step_text = export_step(&solids, part_name, unit, timestamp)
532        .map_err(|error| StlConversionError(format!("STEP export failed: {error}")))?;
533    let step_export_seconds = export_started.elapsed().as_secs_f64();
534
535    let validation_started = Instant::now();
536    let manifold_audit_issues = audit_step_manifold(&step_text);
537    if !manifold_audit_issues.is_empty() {
538        return Err(StlConversionError(format!(
539            "STEP manifold audit failed: {}",
540            manifold_audit_issues.join("; ")
541        )));
542    }
543    let imported = import_step(&step_text).map_err(|error| {
544        StlConversionError(format!("STEP round-trip validation failed: {error}"))
545    })?;
546    if let Some(issues) = imported
547        .iter()
548        .map(|solid| solid.validate())
549        .find(|issues| !issues.is_empty())
550    {
551        return Err(StlConversionError(format!(
552            "round-tripped STEP has invalid topology: {issues:?}"
553        )));
554    }
555    if imported.len() != solids.len() {
556        return Err(StlConversionError(format!(
557            "{} body/bodies were written but {} came back out of the STEP document",
558            solids.len(),
559            imported.len()
560        )));
561    }
562    let step_validation_seconds = validation_started.elapsed().as_secs_f64();
563
564    let mut report = aggregate_report(&components, details, count, options);
565    report.messages.extend(cavity_messages);
566    report.messages.push(format!(
567        "STEP manifold audit and round-trip import passed for {} solid(s)",
568        imported.len()
569    ));
570    report.messages.push(report.backend_reason.clone());
571    report.exported_step_bytes = step_text.len();
572    report.exported_advanced_faces = step_text.matches("ADVANCED_FACE(").count();
573    report.roundtrip_solids = imported.len();
574    report.manifold_audit_issues = manifold_audit_issues;
575    report.timings.step_export_seconds = step_export_seconds;
576    report.timings.step_validation_seconds = step_validation_seconds;
577    report.timings.total_seconds = total_started.elapsed().as_secs_f64();
578    Ok(StlConversionOutput { step_text, report })
579}
580
581/// The record of a body every lane refused: its source counts, the refusal,
582/// and nothing else — it contributes no face, region or timing, and no solid.
583fn refused_component(mesh: &Mesh, triangles: usize, reason: String) -> ComponentDetail {
584    ComponentDetail {
585        backend: ConversionBackend::Refused,
586        backend_reason: format!("refused, not in the model: {reason}"),
587        fallback_cause: None,
588        input_vertices: mesh.vertices.len(),
589        input_triangles: triangles,
590        recognized_regions: 0,
591        recognized_triangles: 0,
592        unresolved_triangles: 0,
593        recognition_skipped: None,
594        source_closed_manifold: closed_manifold(mesh),
595        analytic_used_repaired_mesh: false,
596        requested_distance_tolerance: 0.0,
597        effective_distance_tolerance: 0.0,
598        region_type_counts: BTreeMap::new(),
599        regions: Vec::new(),
600        topology_refit: None,
601        hybrid_rebuild: None,
602        faceted_faces_before_merge: None,
603        faceted_faces_after_merge: None,
604        faces: 0,
605        segmentation_seconds: 0.0,
606        recognition_seconds: 0.0,
607        topology_build_seconds: 0.0,
608        messages: vec![format!("refused, not in the model: {reason}")],
609        refusal: Some(reason),
610    }
611}
612
613/// Everything one component contributes to the document's report, plus the
614/// per-body record the report publishes.
615struct ComponentDetail {
616    backend: ConversionBackend,
617    backend_reason: String,
618    fallback_cause: Option<String>,
619    input_vertices: usize,
620    input_triangles: usize,
621    recognized_regions: usize,
622    recognized_triangles: usize,
623    unresolved_triangles: usize,
624    recognition_skipped: Option<String>,
625    source_closed_manifold: bool,
626    analytic_used_repaired_mesh: bool,
627    requested_distance_tolerance: f64,
628    effective_distance_tolerance: f64,
629    region_type_counts: BTreeMap<String, usize>,
630    regions: Vec<ConversionRegionReport>,
631    topology_refit: Option<ConversionRegionReport>,
632    hybrid_rebuild: Option<HybridConversionReport>,
633    faceted_faces_before_merge: Option<usize>,
634    faceted_faces_after_merge: Option<usize>,
635    faces: usize,
636    segmentation_seconds: f64,
637    recognition_seconds: f64,
638    topology_build_seconds: f64,
639    messages: Vec<String>,
640    /// Why every lane refused this body; set only by [`refused_component`].
641    refusal: Option<String>,
642}
643
644/// ONE body: the complete mesh-to-BREP chain over a single closed shell,
645/// stopping at the validated solid. Export, the manifold audit and the STEP
646/// round trip belong to the document and run once over every body.
647fn convert_component(
648    mesh: &Mesh,
649    source_positions: &[f64],
650    source_indices: Option<&[u32]>,
651    options: &StlConversionOptions,
652) -> Result<(brep_kernel::BrepSolid, ComponentDetail), StlConversionError> {
653    // Reconstruct each closed shell outward; assembly restores inward shells
654    // as cavities after their analytic topology has been validated.
655    let outward = cavities::outward_component(mesh, source_positions, source_indices);
656    let (mesh, source_positions, source_indices) = match &outward {
657        Some(part) => (&part.mesh, part.positions.as_slice(), part.indices.as_deref()),
658        None => (mesh, source_positions, source_indices),
659    };
660    let mut recognition_options = options.recognition.clone();
661    recognition_options.collect_phase_timings = true;
662    let requested_distance_tolerance = recognition_options.distance_tolerance;
663    recognition_options.distance_tolerance = recognition_options
664        .distance_tolerance
665        .max(options.coordinate_precision_tolerance);
666    let encoding_distance_tolerance = recognition_options.distance_tolerance;
667
668    let source_closed_manifold = closed_manifold(mesh);
669    let input_vertices = mesh.vertices.len();
670    let input_triangles = mesh.triangles.len();
671    // A mesh file is routinely a few triangles short of a closed
672    // two-manifold — one dropped facet, a duplicated sheet, a degenerate
673    // sliver — and EVERY analytic lane below requires one. The faceted
674    // fallback already repairs exactly that and builds a valid closed solid
675    // from the result, so the repaired mesh is offered to the analytic lanes
676    // first instead of letting a handful of stray triangles cost the body
677    // every cylinder, cone and sphere the segmentation can see. A closed
678    // source is never repaired, so its conversion is unchanged.
679    let repaired = if source_closed_manifold {
680        None
681    } else {
682        repaired_closed_mesh(source_positions, source_indices, options)
683    };
684    let (mesh, source_positions, source_indices) = match &repaired {
685        Some(repaired) => (
686            &repaired.mesh,
687            repaired.positions.as_slice(),
688            Some(repaired.indices.as_slice()),
689        ),
690        None => (mesh, source_positions, source_indices),
691    };
692    // What the lanes below may assume, as opposed to what the FILE was;
693    // `source_closed_manifold` stays the report's record of the input.
694    let closed_for_analytics = source_closed_manifold || repaired.is_some();
695
696    let segmentation_started = Instant::now();
697    let segmentation = if closed_for_analytics {
698        gate_segmentation(source_positions, source_indices, options)
699    } else {
700        Err("the source mesh is not a closed two-manifold".to_owned())
701    };
702    let segmentation_seconds = segmentation_started.elapsed().as_secs_f64();
703    let initial_recognition_gate = recognition_gate(&segmentation);
704    let geometric_gate_rescue = initial_recognition_gate.is_err()
705        && closed_for_analytics
706        && segmentation.as_ref().is_ok_and(|segmentation| {
707            primitive_gate::has_whole_primitive_candidate(mesh, segmentation, &recognition_options)
708        });
709    let recognition_gate = if geometric_gate_rescue {
710        Ok(())
711    } else {
712        initial_recognition_gate
713    };
714    // The encoding floor above bounds only what storing a coordinate cost.
715    // An exporter can snap vertices far more coarsely than that — OpenSCAD's
716    // spheres, cylinders AND flat caps sit ~6e-5 off their carriers where
717    // f32 accounts for ~4e-6 — and RANSAC held below the data's own noise
718    // shreds one sphere into slivers, costs minutes doing it, and never proves
719    // the coverage its lanes need. When the gate passes, the segmentation has
720    // already fitted every region and measured how far every VERTEX sits from
721    // its carrier, so recognition is not asked to be tighter than that.
722    let measured_region_deviation = match (&recognition_gate, &segmentation) {
723        (Ok(()), Ok(segmentation)) => segmentation
724            .regions
725            .iter()
726            .map(|region| region.max_deviation)
727            .fold(0.0_f64, f64::max),
728        _ => 0.0,
729    };
730    recognition_options.distance_tolerance = recognition_options
731        .distance_tolerance
732        .max(measured_region_deviation);
733    let effective_distance_tolerance = recognition_options.distance_tolerance;
734
735    let recognition_started = Instant::now();
736    let (recognition, completed_plane_regions, completed_degenerate_triangles) = if recognition_gate.is_ok() {
737        let mut recognition = recognize_surfaces_with_unresolved(mesh, &recognition_options)
738            .map_err(|error| {
739                StlConversionError(format!("surface recognition failed: {error}"))
740            })?;
741        feature_fragments::complete(
742            mesh,
743            &recognition_options,
744            &mut recognition.regions,
745            &mut recognition.unresolved_triangles,
746        )?;
747        let completed_plane_regions = complete_small_planar_regions(
748            mesh,
749            &recognition_options,
750            &mut recognition.regions,
751            &mut recognition.unresolved_triangles,
752        )?;
753        let completed_degenerate_triangles = degenerate_coverage::complete(
754            mesh, &recognition_options, &mut recognition.regions,
755        )?;
756        (recognition, completed_plane_regions, completed_degenerate_triangles)
757    } else {
758        (
759            crate::RecognitionResult {
760                regions: Vec::new(),
761                unresolved_triangles: Vec::new(),
762                unresolved_diagnostics: Vec::new(),
763            },
764            0,
765            0,
766        )
767    };
768    let recognition_seconds = if recognition_gate.is_ok() {
769        recognition_started.elapsed().as_secs_f64()
770    } else {
771        0.0
772    };
773    let recognized_triangles = recognition
774        .regions
775        .iter()
776        .map(|region| region.triangle_indices.len())
777        .sum();
778    let regions = recognition
779        .regions
780        .iter()
781        .map(region_report)
782        .collect::<Vec<_>>();
783    let mut region_type_counts = BTreeMap::new();
784    for region in &recognition.regions {
785        *region_type_counts
786            .entry(region.surface.surface_type().name().to_owned())
787            .or_insert(0) += 1;
788    }
789
790    let topology_started = Instant::now();
791    let analytic_coverage = recognition.unresolved_triangles.is_empty()
792        && recognized_triangles == mesh.triangles.len()
793        && disjoint_complete_partition(&recognition.regions, mesh.triangles.len());
794    let spherical_union = if closed_for_analytics && options.try_kernel_analytic_rebuild {
795        segmentation.as_ref().ok().and_then(|seg| {
796            brep_kernel::reconstruct_spherical_mesh_union(
797                source_positions,
798                source_indices.unwrap_or(&[]),
799                seg,
800                &segmentation_options(source_positions, options),
801            )
802        })
803    } else {
804        None
805    };
806    let primitive = if analytic_coverage && closed_for_analytics {
807        direct_analytic_solid(mesh, &recognition.regions, &recognition_options)
808    } else {
809        None
810    };
811
812    let mut fallback_cause = None;
813    let mut messages = vec![match &recognition_gate {
814        Ok(()) => format!(
815            "RANSAC recognized {} region(s) covering {recognized_triangles}/{} triangles",
816            recognition.regions.len(),
817            mesh.triangles.len()
818        ),
819        Err(reason) => format!("RANSAC recognition skipped: {reason}"),
820    }];
821    if geometric_gate_rescue {
822        messages.push("whole-primitive recognition enabled by triangle coverage on a segmented curved carrier and its possible end caps".to_owned());
823    }
824    if outward.is_some() {
825        messages.push("inward source shell reconstructed outward before cavity assembly".into());
826    }
827    if let Some(repaired) = &repaired {
828        messages.push(repaired.message.clone());
829    }
830    if encoding_distance_tolerance > requested_distance_tolerance {
831        messages.push(format!(
832            "source coordinate precision raised the absolute recognition distance from {requested_distance_tolerance:.6e} to {encoding_distance_tolerance:.6e}"
833        ));
834    }
835    if effective_distance_tolerance > encoding_distance_tolerance {
836        messages.push(format!(
837            "the segmentation's measured vertex deviation raised the absolute recognition distance from {encoding_distance_tolerance:.6e} to {effective_distance_tolerance:.6e}"
838        ));
839    }
840    if completed_degenerate_triangles > 0 {
841        messages.push(format!(
842            "attached {completed_degenerate_triangles} numerically degenerate triangle(s) to their surrounding fitted carrier; excluded from fit support metrics"
843        ));
844    }
845    if completed_plane_regions > 0 {
846        messages.push(format!(
847            "a plane-only completion pass recovered {completed_plane_regions} small feature-bounded planar region(s)"
848        ));
849    }
850
851    let (
852        solid,
853        backend,
854        backend_reason,
855        topology_refit,
856        hybrid_rebuild,
857        faceted_faces_before_merge,
858        faceted_faces_after_merge,
859    ) = if let Some(primitive) = primitive {
860        (
861            primitive.solid?,
862            primitive.backend,
863            primitive.reason,
864            primitive.topology_refit,
865            None,
866            None,
867            None,
868        )
869    } else if let Some(solid) = spherical_union {
870        (
871            solid,
872            ConversionBackend::KernelSphericalUnion,
873            "precise sphere carriers and bidirectional mesh coverage verified a primitive union; rebuilt exact analytic intersections across the mesh boolean cut strips".to_owned(),
874            None,
875            None,
876            None,
877            None,
878        )
879    } else {
880        let direct_cause = match &recognition_gate {
881            Err(reason) => format!("whole-primitive recognition skipped: {reason}"),
882            Ok(()) if !analytic_coverage => format!(
883                "RANSAC did not prove complete analytic coverage ({} unresolved triangle(s))",
884                recognition.unresolved_triangles.len()
885            ),
886            Ok(()) if !closed_for_analytics => {
887                "recognized mesh is not a closed two-manifold".to_owned()
888            }
889            Ok(()) => {
890                "recognized regions do not match a safe complete primitive topology".to_owned()
891            }
892        };
893
894        let kernel_eligible = segmentation.as_ref().is_ok_and(|segmentation| {
895            segmentation_is_complete(segmentation)
896                && segmentation.regions.iter().all(|region| {
897                    matches!(
898                        region.carrier,
899                        RegionCarrier::Plane { .. }
900                            | RegionCarrier::Cylinder { .. }
901                            | RegionCarrier::Cone { .. }
902                    )
903                })
904        });
905        let kernel_attempt = if closed_for_analytics
906            && kernel_eligible
907            && options.try_kernel_analytic_rebuild
908        {
909            try_kernel_analytic(source_positions, source_indices, options)
910        } else {
911            Err("kernel analytic rebuild requires the kernel segmentation to prove complete plane/cylinder/cone coverage".to_owned())
912        };
913
914        let analytic_attempt = match kernel_attempt {
915            Ok(solid) => Ok((
916                solid,
917                ConversionBackend::KernelAnalyticRebuild,
918                "kernel segmentation proved complete plane/cylinder/cone coverage; the kernel region rebuilder then reconstructed and validated the complete analytic shell".to_owned(),
919                None,
920            )),
921            Err(kernel_error) => {
922                let hybrid_attempt = if closed_for_analytics && options.try_hybrid_rebuild {
923                    try_hybrid_analytic(source_positions, source_indices, options)
924                } else if !options.try_hybrid_rebuild {
925                    Err("mixed analytic/faceted rebuild was disabled".to_owned())
926                } else {
927                    Err("mixed analytic/faceted rebuild requires a closed two-manifold".to_owned())
928                };
929                match hybrid_attempt {
930                    Ok(output) => {
931                        let hybrid_report = hybrid_report(output.stats);
932                        if options.policy == ConversionPolicy::RequireFullyAnalytic
933                            && hybrid_report.faceted_faces != 0
934                        {
935                            Err(format!(
936                                "{kernel_error}; mixed rebuild retained {} faceted face(s) for {} source triangle(s)",
937                                hybrid_report.faceted_faces, hybrid_report.faceted_triangles
938                            ))
939                        } else {
940                            let reason = if hybrid_report.faceted_faces == 0 {
941                                "independent segmentation reconstructed and validated a fully analytic plane/cylinder/cone/sphere shell"
942                            } else {
943                                "independent segmentation reconstructed exact plane/cylinder/cone/sphere regions and retained unsupported or unsafe regions as facets"
944                            };
945                            Ok((
946                                output.solid,
947                                ConversionBackend::HybridAnalyticRebuild,
948                                reason.to_owned(),
949                                Some(hybrid_report),
950                            ))
951                        }
952                    }
953                    Err(hybrid_error) => Err(format!("{kernel_error}; {hybrid_error}")),
954                }
955            }
956        };
957
958        match analytic_attempt {
959            Ok((solid, backend, reason, hybrid)) => {
960                (solid, backend, reason, None, hybrid, None, None)
961            }
962            Err(analytic_error) => {
963                let cause = format!("{direct_cause}; {analytic_error}");
964                if options.policy == ConversionPolicy::RequireFullyAnalytic {
965                    return Err(StlConversionError(format!(
966                        "strict analytic conversion refused faceted fallback: {cause}"
967                    )));
968                }
969                fallback_cause = Some(cause.clone());
970                messages.push(format!("analytic rebuild unavailable: {cause}"));
971                let faceted =
972                    mesh_to_faceted_brep(source_positions, source_indices, options.weld_tolerance)
973                        .map_err(|error| {
974                            StlConversionError(format!("faceted mesh repair failed: {error}"))
975                        })?;
976                let faces_before = solid_face_count(&faceted);
977                let merge_tolerance = coplanar_merge_tolerance(mesh);
978                match merge_same_surface_faces(&faceted, merge_tolerance) {
979                    Ok(merged) => {
980                        let faces_after = solid_face_count(&merged);
981                        if faces_after < faces_before {
982                            messages.push(format!(
983                                "coplanar faceted merge reduced faces from {faces_before} to {faces_after} (tolerance {merge_tolerance:.3e})"
984                            ));
985                            (
986                                merged,
987                                ConversionBackend::FacetedRepairCoplanarMerged,
988                                "the complete source triangle set was repaired as a faceted BREP, then adjacent coplanar facets were merged without introducing curved replacement topology".to_owned(),
989                                None,
990                                None,
991                                Some(faces_before),
992                                Some(faces_after),
993                            )
994                        } else {
995                            messages.push(format!(
996                                "coplanar faceted merge found no mergeable faces (tolerance {merge_tolerance:.3e})"
997                            ));
998                            (
999                                faceted,
1000                                ConversionBackend::FacetedRepair,
1001                                "the complete source triangle set was repaired and preserved as a faceted BREP; no adjacent coplanar facets could be merged".to_owned(),
1002                                None,
1003                                None,
1004                                Some(faces_before),
1005                                Some(faces_before),
1006                            )
1007                        }
1008                    }
1009                    Err(error) => {
1010                        messages.push(format!(
1011                            "coplanar faceted merge was unavailable; retained the validated repaired mesh: {error}"
1012                        ));
1013                        (
1014                            faceted,
1015                            ConversionBackend::FacetedRepair,
1016                            "the complete source triangle set was repaired and preserved as a faceted BREP; the optional coplanar merge was unavailable".to_owned(),
1017                            None,
1018                            None,
1019                            Some(faces_before),
1020                            Some(faces_before),
1021                        )
1022                    }
1023                }
1024            }
1025        }
1026    };
1027    let topology_build_seconds = topology_started.elapsed().as_secs_f64();
1028
1029    let topology_issues = solid.validate();
1030    if !topology_issues.is_empty() {
1031        return Err(StlConversionError(format!(
1032            "constructed BREP failed topology validation: {topology_issues:?}"
1033        )));
1034    }
1035
1036    let faces = solid_face_count(&solid);
1037    let detail = ComponentDetail {
1038        backend,
1039        backend_reason,
1040        fallback_cause,
1041        input_vertices,
1042        input_triangles,
1043        recognized_regions: recognition.regions.len(),
1044        recognized_triangles,
1045        unresolved_triangles: recognition.unresolved_triangles.len(),
1046        recognition_skipped: recognition_gate.err(),
1047        source_closed_manifold,
1048        analytic_used_repaired_mesh: repaired.is_some(),
1049        requested_distance_tolerance,
1050        effective_distance_tolerance,
1051        region_type_counts,
1052        regions,
1053        topology_refit,
1054        hybrid_rebuild,
1055        faceted_faces_before_merge,
1056        faceted_faces_after_merge,
1057        faces,
1058        segmentation_seconds,
1059        recognition_seconds,
1060        topology_build_seconds,
1061        messages,
1062        refusal: None,
1063    };
1064    Ok((solid, detail))
1065}
1066
1067/// Fold every body's detail into the document's single report. Counts are
1068/// TOTALS and `regions` is the concatenation; the fields that can only
1069/// describe one body (`backend`, `topology_refit`) are the first body's, and
1070/// `components` is the authoritative per-body record.
1071fn aggregate_report(
1072    components: &[Vec<usize>],
1073    details: Vec<ComponentDetail>,
1074    count: usize,
1075    options: &StlConversionOptions,
1076) -> StlConversionReport {
1077    let mut messages = Vec::new();
1078    if count > 1 {
1079        messages.push(format!(
1080            "the triangle soup holds {count} vertex-connected components; each shell was reconstructed independently before cavity assembly"
1081        ));
1082    }
1083    let refused = details
1084        .iter()
1085        .enumerate()
1086        .filter_map(|(index, detail)| detail.refusal.as_ref().map(|reason| (index, reason)))
1087        .collect::<Vec<_>>();
1088    if !refused.is_empty() {
1089        messages.push(format!(
1090            "{} of {count} bodies could not be reconstructed and are NOT in the model: {}",
1091            refused.len(),
1092            refused
1093                .iter()
1094                .map(|(index, reason)| format!(
1095                    "body {} (from source triangle {}): {reason}",
1096                    index + 1,
1097                    components[*index][0]
1098                ))
1099                .collect::<Vec<_>>()
1100                .join("; ")
1101        ));
1102    }
1103    for (index, detail) in details.iter().enumerate() {
1104        for message in &detail.messages {
1105            messages.push(if count > 1 {
1106                format!("body {}: {message}", index + 1)
1107            } else {
1108                message.clone()
1109            });
1110        }
1111    }
1112
1113    // The document's backend is its first BUILT body's; the caller refuses
1114    // the import before this when no body was built.
1115    let first = details
1116        .iter()
1117        .find(|detail| detail.refusal.is_none())
1118        .unwrap_or(&details[0]);
1119    let backend_reason = if count == 1 {
1120        first.backend_reason.clone()
1121    } else if details
1122        .iter()
1123        .all(|detail| detail.backend == first.backend)
1124    {
1125        format!(
1126            "{count} components were reconstructed separately; each: {}",
1127            first.backend_reason
1128        )
1129    } else {
1130        format!(
1131            "{count} components were reconstructed separately: {}",
1132            details
1133                .iter()
1134                .enumerate()
1135                .map(|(index, detail)| format!("body {} — {}", index + 1, detail.backend_reason))
1136                .collect::<Vec<_>>()
1137                .join("; ")
1138        )
1139    };
1140    let fallback_cause = details
1141        .iter()
1142        .enumerate()
1143        .find_map(|(index, detail)| {
1144            detail.fallback_cause.as_ref().map(|cause| {
1145                if count > 1 {
1146                    format!("body {}: {cause}", index + 1)
1147                } else {
1148                    cause.clone()
1149                }
1150            })
1151        });
1152    let recognition_skipped = details
1153        .iter()
1154        .enumerate()
1155        .map(|(index, detail)| {
1156            detail
1157                .recognition_skipped
1158                .as_ref()
1159                .map(|reason| (index, reason))
1160        })
1161        .collect::<Option<Vec<_>>>()
1162        .map(|skipped| {
1163            if count == 1 || skipped.iter().all(|(_, reason)| *reason == skipped[0].1) {
1164                skipped[0].1.clone()
1165            } else {
1166                skipped
1167                    .iter()
1168                    .map(|(index, reason)| format!("body {}: {reason}", index + 1))
1169                    .collect::<Vec<_>>()
1170                    .join("; ")
1171            }
1172        });
1173
1174    let mut region_type_counts = BTreeMap::new();
1175    for detail in &details {
1176        for (name, count) in &detail.region_type_counts {
1177            *region_type_counts.entry(name.clone()).or_insert(0) += count;
1178        }
1179    }
1180    let hybrid_rebuild = details
1181        .iter()
1182        .any(|detail| detail.hybrid_rebuild.is_some())
1183        .then(|| {
1184            details
1185                .iter()
1186                .filter_map(|detail| detail.hybrid_rebuild)
1187                .fold(HybridConversionReport::default(), add_hybrid_reports)
1188        });
1189    let faceted_faces_before_merge = sum_optional(&details, |detail| detail.faceted_faces_before_merge);
1190    let faceted_faces_after_merge = sum_optional(&details, |detail| detail.faceted_faces_after_merge);
1191
1192    let component_reports = components
1193        .iter()
1194        .zip(&details)
1195        .enumerate()
1196        .map(|(index, (triangles, detail))| ComponentConversionReport {
1197            index,
1198            first_triangle: triangles[0],
1199            input_vertices: detail.input_vertices,
1200            input_triangles: detail.input_triangles,
1201            backend: detail.backend,
1202            backend_reason: detail.backend_reason.clone(),
1203            fallback_cause: detail.fallback_cause.clone(),
1204            recognition_skipped: detail.recognition_skipped.clone(),
1205            recognized_regions: detail.recognized_regions,
1206            recognized_triangles: detail.recognized_triangles,
1207            unresolved_triangles: detail.unresolved_triangles,
1208            source_closed_manifold: detail.source_closed_manifold,
1209            analytic_used_repaired_mesh: detail.analytic_used_repaired_mesh,
1210            hybrid_rebuild: detail.hybrid_rebuild,
1211            faceted_faces_before_merge: detail.faceted_faces_before_merge,
1212            faceted_faces_after_merge: detail.faceted_faces_after_merge,
1213            faces: detail.faces,
1214            refusal: detail.refusal.clone(),
1215            source_name: None,
1216        })
1217        .collect();
1218
1219    StlConversionReport {
1220        backend: first.backend,
1221        backend_reason,
1222        fallback_cause,
1223        input_vertices: details.iter().map(|detail| detail.input_vertices).sum(),
1224        input_triangles: details.iter().map(|detail| detail.input_triangles).sum(),
1225        recognized_regions: details.iter().map(|detail| detail.recognized_regions).sum(),
1226        recognized_triangles: details
1227            .iter()
1228            .map(|detail| detail.recognized_triangles)
1229            .sum(),
1230        unresolved_triangles: details
1231            .iter()
1232            .map(|detail| detail.unresolved_triangles)
1233            .sum(),
1234        recognition_skipped,
1235        source_closed_manifold: details.iter().all(|detail| detail.source_closed_manifold),
1236        analytic_used_repaired_mesh: details
1237            .iter()
1238            .any(|detail| detail.analytic_used_repaired_mesh),
1239        requested_distance_tolerance: first.requested_distance_tolerance,
1240        coordinate_precision_tolerance: options.coordinate_precision_tolerance,
1241        effective_distance_tolerance: first.effective_distance_tolerance,
1242        region_type_counts,
1243        regions: details
1244            .iter()
1245            .flat_map(|detail| detail.regions.iter().cloned())
1246            .collect(),
1247        topology_refit: details
1248            .iter()
1249            .find_map(|detail| detail.topology_refit.clone()),
1250        hybrid_rebuild,
1251        components: component_reports,
1252        exported_step_bytes: 0,
1253        exported_advanced_faces: 0,
1254        faceted_faces_before_merge,
1255        faceted_faces_after_merge,
1256        roundtrip_solids: 0,
1257        manifold_audit_issues: Vec::new(),
1258        timings: ConversionTimings {
1259            segmentation_seconds: details
1260                .iter()
1261                .map(|detail| detail.segmentation_seconds)
1262                .sum(),
1263            recognition_seconds: details
1264                .iter()
1265                .map(|detail| detail.recognition_seconds)
1266                .sum(),
1267            topology_build_seconds: details
1268                .iter()
1269                .map(|detail| detail.topology_build_seconds)
1270                .sum(),
1271            step_export_seconds: 0.0,
1272            step_validation_seconds: 0.0,
1273            total_seconds: 0.0,
1274        },
1275        messages,
1276    }
1277}
1278
1279fn sum_optional(
1280    details: &[ComponentDetail],
1281    read: impl Fn(&ComponentDetail) -> Option<usize>,
1282) -> Option<usize> {
1283    details
1284        .iter()
1285        .any(|detail| read(detail).is_some())
1286        .then(|| details.iter().filter_map(read).sum())
1287}
1288
1289fn add_hybrid_reports(
1290    total: HybridConversionReport,
1291    body: HybridConversionReport,
1292) -> HybridConversionReport {
1293    HybridConversionReport {
1294        total_faces: total.total_faces + body.total_faces,
1295        analytic_plane_faces: total.analytic_plane_faces + body.analytic_plane_faces,
1296        analytic_plane_triangles: total.analytic_plane_triangles + body.analytic_plane_triangles,
1297        analytic_cylinder_faces: total.analytic_cylinder_faces + body.analytic_cylinder_faces,
1298        analytic_cylinder_triangles: total.analytic_cylinder_triangles
1299            + body.analytic_cylinder_triangles,
1300        analytic_cone_faces: total.analytic_cone_faces + body.analytic_cone_faces,
1301        analytic_cone_triangles: total.analytic_cone_triangles + body.analytic_cone_triangles,
1302        analytic_sphere_faces: total.analytic_sphere_faces + body.analytic_sphere_faces,
1303        analytic_sphere_triangles: total.analytic_sphere_triangles + body.analytic_sphere_triangles,
1304        analytic_torus_faces: total.analytic_torus_faces + body.analytic_torus_faces,
1305        analytic_torus_triangles: total.analytic_torus_triangles + body.analytic_torus_triangles,
1306        faceted_faces: total.faceted_faces + body.faceted_faces,
1307        faceted_triangles: total.faceted_triangles + body.faceted_triangles,
1308        demoted_regions: total.demoted_regions + body.demoted_regions,
1309        demoted_triangles: total.demoted_triangles + body.demoted_triangles,
1310        segmentation_plane_regions: total.segmentation_plane_regions
1311            + body.segmentation_plane_regions,
1312        segmentation_cylinder_regions: total.segmentation_cylinder_regions
1313            + body.segmentation_cylinder_regions,
1314        segmentation_cone_regions: total.segmentation_cone_regions + body.segmentation_cone_regions,
1315        segmentation_sphere_regions: total.segmentation_sphere_regions
1316            + body.segmentation_sphere_regions,
1317        segmentation_torus_regions: total.segmentation_torus_regions
1318            + body.segmentation_torus_regions,
1319        segmentation_unsupported_regions: total.segmentation_unsupported_regions
1320            + body.segmentation_unsupported_regions,
1321    }
1322}
1323
1324/// The triangle indices of each vertex-connected component of `mesh`, each
1325/// component's own triangles in source order and the components themselves
1326/// ordered by the lowest source triangle they hold — so the bodies of one file
1327/// always come out in the same order, and so do their names.
1328///
1329/// Vertices are welded by EXACT position first, the canonical-bits weld the
1330/// STL reader's zero-tolerance path uses (`-0.0` and `0.0` are one point), so
1331/// two triangles that meet at the same coordinate join even when the file gave
1332/// that point two indices — a 3MF build gives every instance its own vertex
1333/// range, and an OBJ may repeat a corner per object.
1334///
1335/// Connectivity is by shared VERTEX, not by shared edge. Two closed shells
1336/// that touch at a single point are therefore ONE component, and the shell
1337/// validator refuses them exactly as it does today.
1338fn triangle_components(mesh: &Mesh) -> Vec<Vec<usize>> {
1339    fn find(parent: &mut [usize], mut node: usize) -> usize {
1340        while parent[node] != node {
1341            parent[node] = parent[parent[node]];
1342            node = parent[node];
1343        }
1344        node
1345    }
1346
1347    let bits = |value: f64| if value == 0.0 { 0_u64 } else { value.to_bits() };
1348    let mut position_ids = HashMap::<[u64; 3], usize>::new();
1349    let mut vertex_position = Vec::with_capacity(mesh.vertices.len());
1350    for point in &mesh.vertices {
1351        let key = [bits(point.x), bits(point.y), bits(point.z)];
1352        let next = position_ids.len();
1353        vertex_position.push(*position_ids.entry(key).or_insert(next));
1354    }
1355
1356    let mut parent = (0..position_ids.len()).collect::<Vec<_>>();
1357    for triangle in &mesh.triangles {
1358        let first = vertex_position[triangle[0] as usize];
1359        for &corner in &triangle[1..] {
1360            let (left, right) = (
1361                find(&mut parent, first),
1362                find(&mut parent, vertex_position[corner as usize]),
1363            );
1364            if left != right {
1365                parent[left] = right;
1366            }
1367        }
1368    }
1369
1370    let mut slots = HashMap::<usize, usize>::new();
1371    let mut components = Vec::<Vec<usize>>::new();
1372    for (index, triangle) in mesh.triangles.iter().enumerate() {
1373        let root = find(&mut parent, vertex_position[triangle[0] as usize]);
1374        let slot = match slots.get(&root) {
1375            Some(&slot) => slot,
1376            None => {
1377                components.push(Vec::new());
1378                slots.insert(root, components.len() - 1);
1379                components.len() - 1
1380            }
1381        };
1382        components[slot].push(index);
1383    }
1384    components
1385}
1386
1387/// One component's buffers, rebased onto its own vertices.
1388struct ComponentMesh {
1389    mesh: Mesh,
1390    positions: Vec<f64>,
1391    indices: Option<Vec<u32>>,
1392}
1393
1394/// Cut `triangles` out of the whole input as a standalone mesh and matching
1395/// source buffers. The recognition mesh and the source buffers describe the
1396/// same triangles in the same order (`validate_source_buffers` proves it), so
1397/// one triangle-index list rebases both. An unindexed source stays a soup.
1398fn component_mesh(
1399    mesh: &Mesh,
1400    positions: &[f64],
1401    indices: Option<&[u32]>,
1402    triangles: &[usize],
1403) -> ComponentMesh {
1404    let mut vertex_map = HashMap::<u32, u32>::new();
1405    let mut vertices = Vec::new();
1406    let mut component_triangles = Vec::with_capacity(triangles.len());
1407    for &triangle in triangles {
1408        let mut corners = [0_u32; 3];
1409        for (corner, &index) in mesh.triangles[triangle].iter().enumerate() {
1410            corners[corner] = *vertex_map.entry(index).or_insert_with(|| {
1411                vertices.push(mesh.vertices[index as usize]);
1412                vertices.len() as u32 - 1
1413            });
1414        }
1415        component_triangles.push(corners);
1416    }
1417    let mut component = Mesh::new(vertices, component_triangles);
1418    if let Some(normals) = &mesh.vertex_normals {
1419        if normals.len() == mesh.vertices.len() {
1420            let mut component_normals = vec![Vec3::default(); component.vertices.len()];
1421            for (&source, &target) in &vertex_map {
1422                component_normals[target as usize] = normals[source as usize];
1423            }
1424            component.vertex_normals = Some(component_normals);
1425        }
1426    }
1427    if !mesh.source_metadata.is_empty() {
1428        let local = triangles
1429            .iter()
1430            .enumerate()
1431            .map(|(local, &source)| (source, local))
1432            .collect::<HashMap<_, _>>();
1433        component.source_metadata = mesh
1434            .source_metadata
1435            .iter()
1436            .filter_map(|metadata| {
1437                let triangle_indices = metadata
1438                    .triangle_indices
1439                    .iter()
1440                    .filter_map(|source| local.get(source).copied())
1441                    .collect::<Vec<_>>();
1442                (!triangle_indices.is_empty()).then(|| crate::SourceMetadata {
1443                    triangle_indices,
1444                    ..metadata.clone()
1445                })
1446            })
1447            .collect();
1448    }
1449
1450    let (component_positions, component_indices) = match indices {
1451        Some(indices) => {
1452            let mut source_map = HashMap::<u32, u32>::new();
1453            let mut component_positions = Vec::new();
1454            let mut component_indices = Vec::with_capacity(triangles.len() * 3);
1455            for &triangle in triangles {
1456                for &index in &indices[triangle * 3..triangle * 3 + 3] {
1457                    let mapped = *source_map.entry(index).or_insert_with(|| {
1458                        let base = index as usize * 3;
1459                        component_positions.extend_from_slice(&positions[base..base + 3]);
1460                        component_positions.len() as u32 / 3 - 1
1461                    });
1462                    component_indices.push(mapped);
1463                }
1464            }
1465            (component_positions, Some(component_indices))
1466        }
1467        None => {
1468            let mut component_positions = Vec::with_capacity(triangles.len() * 9);
1469            for &triangle in triangles {
1470                component_positions.extend_from_slice(&positions[triangle * 9..triangle * 9 + 9]);
1471            }
1472            (component_positions, None)
1473        }
1474    };
1475    ComponentMesh {
1476        mesh: component,
1477        positions: component_positions,
1478        indices: component_indices,
1479    }
1480}
1481
1482fn validate_source_buffers(
1483    mesh: &Mesh,
1484    positions: &[f64],
1485    indices: Option<&[u32]>,
1486) -> Result<(), StlConversionError> {
1487    if positions.is_empty() || !positions.len().is_multiple_of(3) {
1488        return Err(StlConversionError(
1489            "source position buffer must contain complete non-empty xyz triples".to_owned(),
1490        ));
1491    }
1492    if positions.iter().any(|value| !value.is_finite()) {
1493        return Err(StlConversionError(
1494            "source position buffer contains a non-finite coordinate".to_owned(),
1495        ));
1496    }
1497    let source_triangles = match indices {
1498        Some(indices) => {
1499            if !indices.len().is_multiple_of(3) {
1500                return Err(StlConversionError(
1501                    "source index buffer must contain complete triangles".to_owned(),
1502                ));
1503            }
1504            if indices
1505                .iter()
1506                .any(|&index| index as usize >= positions.len() / 3)
1507            {
1508                return Err(StlConversionError(
1509                    "source index buffer references a missing position".to_owned(),
1510                ));
1511            }
1512            indices.len() / 3
1513        }
1514        None => {
1515            if !positions.len().is_multiple_of(9) {
1516                return Err(StlConversionError(
1517                    "unindexed source positions must be a triangle soup".to_owned(),
1518                ));
1519            }
1520            positions.len() / 9
1521        }
1522    };
1523    if source_triangles != mesh.triangles.len() {
1524        return Err(StlConversionError(format!(
1525            "source buffers contain {source_triangles} triangles but recognition mesh contains {}",
1526            mesh.triangles.len()
1527        )));
1528    }
1529    Ok(())
1530}
1531
1532/// Recover only small coplanar feature-bounded components that the generic
1533/// pass skipped because its minimum support deliberately protects curved
1534/// model selection. A two-triangle plane is fully determined without giving
1535/// tiny cylinder/torus hypotheses authority over output topology.
1536fn complete_small_planar_regions(
1537    mesh: &Mesh,
1538    options: &RecognitionOptions,
1539    regions: &mut Vec<SurfaceRegion>,
1540    unresolved: &mut Vec<usize>,
1541) -> Result<usize, StlConversionError> {
1542    if unresolved.len() < 2 {
1543        return Ok(0);
1544    }
1545    let analyzed = mesh
1546        .analyze(&MeshAnalysisOptions {
1547            feature_angle: options.feature_angle,
1548            ..MeshAnalysisOptions::default()
1549        })
1550        .map_err(|error| {
1551            StlConversionError(format!("plane completion analysis failed: {error}"))
1552        })?;
1553    let allowed = unresolved.iter().copied().collect::<BTreeSet<_>>();
1554    let mut visited = BTreeSet::new();
1555    let mut accepted = BTreeSet::new();
1556    let mut completed = 0;
1557    let mut plane_options = options.clone();
1558    plane_options.minimum_support = 2;
1559
1560    for &seed in unresolved.iter() {
1561        if !visited.insert(seed) {
1562            continue;
1563        }
1564        let mut component = Vec::new();
1565        let mut queue = VecDeque::from([seed]);
1566        while let Some(triangle) = queue.pop_front() {
1567            component.push(triangle);
1568            let data = &analyzed.triangles[triangle];
1569            for edge in 0..3 {
1570                if data.feature_edges[edge] {
1571                    continue;
1572                }
1573                if let Some(neighbor) = data.neighbors[edge] {
1574                    if allowed.contains(&neighbor) && visited.insert(neighbor) {
1575                        queue.push_back(neighbor);
1576                    }
1577                }
1578            }
1579        }
1580        component.sort_unstable();
1581        if component.len() < 2 {
1582            continue;
1583        }
1584        let Ok(fit) = reconstruct_surface(
1585            mesh,
1586            &component,
1587            &SurfaceHint::KnownType {
1588                surface_type: SurfaceType::Plane,
1589            },
1590            &plane_options,
1591        ) else {
1592            continue;
1593        };
1594        if fit.metrics.support_triangles != component.len()
1595            || !matches!(fit.surface, AnalyticSurface::Plane(_))
1596        {
1597            continue;
1598        }
1599        accepted.extend(component.iter().copied());
1600        regions.push(SurfaceRegion {
1601            surface: fit.surface,
1602            orientation: fit.orientation,
1603            triangle_indices: component,
1604            metrics: fit.metrics,
1605            confidence: fit.confidence,
1606            diagnostics: fit.diagnostics,
1607        });
1608        completed += 1;
1609    }
1610    unresolved.retain(|triangle| !accepted.contains(triangle));
1611    Ok(completed)
1612}
1613
1614fn region_report(region: &SurfaceRegion) -> ConversionRegionReport {
1615    fit_fields(
1616        region.surface,
1617        region.orientation,
1618        &region.metrics,
1619        region.confidence,
1620        &region.diagnostics,
1621    )
1622}
1623
1624fn refit_report(fit: &SurfaceFitResult) -> ConversionRegionReport {
1625    fit_fields(
1626        fit.surface,
1627        fit.orientation,
1628        &fit.metrics,
1629        fit.confidence,
1630        &fit.diagnostics,
1631    )
1632}
1633
1634fn fit_fields(
1635    surface: AnalyticSurface,
1636    orientation: i8,
1637    metrics: &crate::FitMetrics,
1638    confidence: f64,
1639    diagnostics: &crate::FitDiagnostics,
1640) -> ConversionRegionReport {
1641    ConversionRegionReport {
1642        surface_type: surface.surface_type(),
1643        surface,
1644        support_triangles: metrics.support_triangles,
1645        supported_area: metrics.supported_area,
1646        rms_error: metrics.rms_error,
1647        max_error: metrics.max_error,
1648        rms_normal_error_radians: metrics.rms_normal_error,
1649        max_normal_error_radians: metrics.max_normal_error,
1650        confidence,
1651        orientation,
1652        reason: diagnostics.reason.clone(),
1653        phase_timings: diagnostics.phase_timings,
1654    }
1655}
1656
1657fn disjoint_complete_partition(regions: &[SurfaceRegion], triangle_count: usize) -> bool {
1658    let mut seen = BTreeSet::new();
1659    regions.iter().all(|region| {
1660        region
1661            .triangle_indices
1662            .iter()
1663            .all(|&triangle| triangle < triangle_count && seen.insert(triangle))
1664    }) && seen.len() == triangle_count
1665}
1666
1667/// A source mesh the repair closed, with the buffers every downstream lane
1668/// reads bound to it.
1669struct RepairedMesh {
1670    mesh: Mesh,
1671    positions: Vec<f64>,
1672    indices: Vec<u32>,
1673    message: String,
1674}
1675
1676/// Repair a mesh that is not a closed two-manifold, and hand it back only
1677/// when the repair actually closed it — a mesh too broken to close keeps the
1678/// refusal it has always had, and a file whose repair would CHANGE a closed
1679/// mesh never reaches here.
1680fn repaired_closed_mesh(
1681    source_positions: &[f64],
1682    source_indices: Option<&[u32]>,
1683    options: &StlConversionOptions,
1684) -> Option<RepairedMesh> {
1685    let (positions, indices, report) =
1686        repair_triangle_soup(source_positions, source_indices, options.weld_tolerance).ok()?;
1687    let vertices = positions
1688        .chunks_exact(3)
1689        .map(|point| Vec3::new(point[0], point[1], point[2]))
1690        .collect::<Vec<_>>();
1691    let triangles = indices
1692        .chunks_exact(3)
1693        .map(|triangle| [triangle[0], triangle[1], triangle[2]])
1694        .collect::<Vec<_>>();
1695    let mesh = Mesh::new(vertices, triangles);
1696    if !closed_manifold(&mesh) {
1697        return None;
1698    }
1699    let message = format!(
1700        "the source mesh is not a closed two-manifold; the analytic lanes ran on the repaired mesh \
1701         ({} welded vertices, {} triangles: {} dropped as degenerate or duplicate, {} surplus \
1702         sheet(s) pruned, {} reoriented to agree with their neighbours, {} boundary loop(s) \
1703         capped with {} triangle(s))",
1704        report.welded_vertices,
1705        report.triangles,
1706        report.dropped_triangles,
1707        report.pruned_triangles,
1708        report.reoriented_triangles,
1709        report.capped_loops,
1710        report.cap_triangles,
1711    );
1712    Some(RepairedMesh {
1713        mesh,
1714        positions,
1715        indices,
1716        message,
1717    })
1718}
1719
1720fn closed_manifold(mesh: &Mesh) -> bool {
1721    closed_triangles(mesh.triangles.iter().copied())
1722}
1723
1724fn closed_triangles(triangles: impl Iterator<Item = [u32; 3]>) -> bool {
1725    let mut uses = BTreeMap::<(u32, u32), (usize, i32)>::new();
1726    for triangle in triangles {
1727        if triangle[0] == triangle[1] || triangle[1] == triangle[2] || triangle[2] == triangle[0] {
1728            return false;
1729        }
1730        for edge in [
1731            (triangle[0], triangle[1]),
1732            (triangle[1], triangle[2]),
1733            (triangle[2], triangle[0]),
1734        ] {
1735            let key = if edge.0 < edge.1 {
1736                (edge.0, edge.1)
1737            } else {
1738                (edge.1, edge.0)
1739            };
1740            let entry = uses.entry(key).or_default();
1741            entry.0 += 1;
1742            entry.1 += if edge == key { 1 } else { -1 };
1743        }
1744    }
1745    !uses.is_empty()
1746        && uses
1747            .values()
1748            .all(|&(count, sense)| count == 2 && sense == 0)
1749}
1750
1751struct DirectAnalyticBuild {
1752    solid: Result<brep_kernel::BrepSolid, StlConversionError>,
1753    backend: ConversionBackend,
1754    reason: String,
1755    topology_refit: Option<ConversionRegionReport>,
1756}
1757
1758fn direct_analytic_solid(
1759    mesh: &Mesh,
1760    regions: &[SurfaceRegion],
1761    options: &RecognitionOptions,
1762) -> Option<DirectAnalyticBuild> {
1763    if regions.len() == 1 {
1764        return match regions[0].surface {
1765            AnalyticSurface::Sphere(sphere) => Some(DirectAnalyticBuild {
1766                solid: make_sphere_brep(
1767                    kernel_vec(sphere.center),
1768                    sphere.radius,
1769                    kernel_vec(Vec3::new(0.0, 0.0, 1.0)),
1770                )
1771                .map_err(StlConversionError),
1772                backend: ConversionBackend::RansacSphere,
1773                reason: "RANSAC proved complete closed spherical coverage; exported an exact analytic sphere".to_owned(),
1774                topology_refit: None,
1775            }),
1776            AnalyticSurface::Torus(torus) if torus.major_radius > torus.minor_radius => Some(DirectAnalyticBuild {
1777                solid: make_torus_brep(
1778                    kernel_vec(torus.center),
1779                    kernel_vec(torus.axis),
1780                    torus.major_radius,
1781                    torus.minor_radius,
1782                )
1783                .map_err(StlConversionError),
1784                backend: ConversionBackend::RansacTorus,
1785                reason: "RANSAC proved complete closed ring-torus coverage; exported an exact analytic torus".to_owned(),
1786                topology_refit: None,
1787            }),
1788            _ => None,
1789        };
1790    }
1791
1792    let curved = regions
1793        .iter()
1794        .filter(|region| !matches!(region.surface, AnalyticSurface::Plane(_)))
1795        .collect::<Vec<_>>();
1796    let planes = regions
1797        .iter()
1798        .filter_map(|region| match region.surface {
1799            AnalyticSurface::Plane(plane) => Some(plane),
1800            _ => None,
1801        })
1802        .collect::<Vec<_>>();
1803    if curved.len() != 1 {
1804        return None;
1805    }
1806    // A finite cylinder/cone strip sampled at only a handful of axial levels
1807    // can be represented to roundoff by the very-large-radius limit of a
1808    // torus. Planar cap topology removes that ambiguity. In that narrowly
1809    // gated case, ask the production recognizer for a topology-informed
1810    // known-type refit and retain all of its ordinary residual/normal gates.
1811    let topology_refit;
1812    let mut topology_refit_report = None;
1813    let curved = if let AnalyticSurface::Torus(torus) = curved[0].surface {
1814        let scale = mesh_diagonal(mesh);
1815        let collapsed = torus.major_radius.min(torus.minor_radius) > 20.0 * scale;
1816        let forced_type = match planes.len() {
1817            2 if collapsed => Some(SurfaceType::Cylinder),
1818            1 if collapsed => Some(SurfaceType::Cone),
1819            _ => None,
1820        };
1821        if let Some(surface_type) = forced_type {
1822            topology_refit = reconstruct_surface(
1823                mesh,
1824                &curved[0].triangle_indices,
1825                &SurfaceHint::KnownType { surface_type },
1826                options,
1827            )
1828            .ok()?;
1829            topology_refit_report = Some(refit_report(&topology_refit));
1830            &topology_refit.surface
1831        } else {
1832            &curved[0].surface
1833        }
1834    } else {
1835        &curved[0].surface
1836    };
1837    match *curved {
1838        AnalyticSurface::Cylinder(cylinder) if planes.len() == 2 => {
1839            let (low, high) = axial_range(mesh, cylinder.axis_origin, cylinder.axis)?;
1840            let tolerance = conversion_tolerance(mesh, options);
1841            if high - low <= tolerance
1842                || !caps_match(
1843                    &planes,
1844                    cylinder.axis_origin,
1845                    cylinder.axis,
1846                    &[low, high],
1847                    tolerance,
1848                )
1849            {
1850                return None;
1851            }
1852            let base = cylinder.axis_origin + cylinder.axis * low;
1853            Some(DirectAnalyticBuild {
1854                solid: make_cylinder_brep(
1855                    kernel_vec(base),
1856                    kernel_vec(cylinder.axis),
1857                    cylinder.radius,
1858                    high - low,
1859                )
1860                .map_err(StlConversionError),
1861                backend: ConversionBackend::RansacCappedCylinder,
1862                reason: if topology_refit_report.is_some() {
1863                    "two-cap topology disambiguated a large-radius torus limit; the production known-type cylinder refit passed all gates and supplied the exact capped-cylinder parameters".to_owned()
1864                } else {
1865                    "RANSAC proved one cylindrical wall plus two matching planar caps; exported an exact capped cylinder".to_owned()
1866                },
1867                topology_refit: topology_refit_report,
1868            })
1869        }
1870        AnalyticSurface::Cone(cone) if planes.len() == 1 || planes.len() == 2 => {
1871            let (low, high) = axial_range(mesh, cone.apex, cone.axis)?;
1872            let tolerance = conversion_tolerance(mesh, options);
1873            if low < -tolerance || high - low <= tolerance {
1874                return None;
1875            }
1876            let expected_caps = if low <= tolerance {
1877                vec![high]
1878            } else {
1879                vec![low, high]
1880            };
1881            if planes.len() != expected_caps.len()
1882                || !caps_match(&planes, cone.apex, cone.axis, &expected_caps, tolerance)
1883            {
1884                return None;
1885            }
1886            let radius_bottom = low.max(0.0) * cone.half_angle.tan();
1887            let radius_top = high * cone.half_angle.tan();
1888            if radius_top <= tolerance || (low > tolerance && radius_bottom <= tolerance) {
1889                return None;
1890            }
1891            let (base, build_axis, build_bottom, build_top) = if low <= tolerance {
1892                // The kernel's capped-cone constructor requires a positive
1893                // bottom radius. Build a pointed cone from its wide end back
1894                // toward the apex.
1895                (
1896                    cone.apex + cone.axis * high,
1897                    cone.axis * -1.0,
1898                    radius_top,
1899                    0.0,
1900                )
1901            } else {
1902                (
1903                    cone.apex + cone.axis * low,
1904                    cone.axis,
1905                    radius_bottom,
1906                    radius_top,
1907                )
1908            };
1909            Some(DirectAnalyticBuild {
1910                solid: make_cone_brep(
1911                    kernel_vec(base),
1912                    kernel_vec(build_axis),
1913                    build_bottom,
1914                    build_top,
1915                    high - low.max(0.0),
1916                )
1917                .map_err(StlConversionError),
1918                backend: ConversionBackend::RansacCappedCone,
1919                reason: if topology_refit_report.is_some() {
1920                    "cone-cap topology disambiguated a large-radius torus limit; the production known-type cone refit passed all gates and supplied the exact capped-cone parameters".to_owned()
1921                } else {
1922                    "RANSAC proved one conical wall and matching planar cap topology; exported an exact capped cone".to_owned()
1923                },
1924                topology_refit: topology_refit_report,
1925            })
1926        }
1927        _ => None,
1928    }
1929}
1930
1931fn axial_range(mesh: &Mesh, origin: Vec3, axis: Vec3) -> Option<(f64, f64)> {
1932    let mut low = f64::INFINITY;
1933    let mut high = f64::NEG_INFINITY;
1934    for &point in &mesh.vertices {
1935        let height = (point - origin).dot(axis);
1936        low = low.min(height);
1937        high = high.max(height);
1938    }
1939    (low.is_finite() && high.is_finite()).then_some((low, high))
1940}
1941
1942fn caps_match(
1943    planes: &[crate::PlaneSurface],
1944    origin: Vec3,
1945    axis: Vec3,
1946    expected_heights: &[f64],
1947    tolerance: f64,
1948) -> bool {
1949    let mut actual = Vec::with_capacity(planes.len());
1950    for plane in planes {
1951        if plane.normal.dot(axis).abs() < 1.0 - 1.0e-6 {
1952            return false;
1953        }
1954        actual.push((plane.origin - origin).dot(axis));
1955    }
1956    actual.sort_by(f64::total_cmp);
1957    let mut expected = expected_heights.to_vec();
1958    expected.sort_by(f64::total_cmp);
1959    actual.len() == expected.len()
1960        && actual
1961            .iter()
1962            .zip(expected)
1963            .all(|(actual, expected)| (*actual - expected).abs() <= tolerance)
1964}
1965
1966fn mesh_tolerance(mesh: &Mesh) -> f64 {
1967    let mut low = mesh.vertices[0];
1968    let mut high = mesh.vertices[0];
1969    for &point in &mesh.vertices[1..] {
1970        low.x = low.x.min(point.x);
1971        low.y = low.y.min(point.y);
1972        low.z = low.z.min(point.z);
1973        high.x = high.x.max(point.x);
1974        high.y = high.y.max(point.y);
1975        high.z = high.z.max(point.z);
1976    }
1977    (high - low).length().max(1.0) * 1.0e-5
1978}
1979
1980fn conversion_tolerance(mesh: &Mesh, options: &RecognitionOptions) -> f64 {
1981    mesh_tolerance(mesh)
1982        .max(options.distance_tolerance + options.relative_tolerance * mesh_diagonal(mesh))
1983}
1984
1985fn mesh_diagonal(mesh: &Mesh) -> f64 {
1986    let mut low = mesh.vertices[0];
1987    let mut high = mesh.vertices[0];
1988    for &point in &mesh.vertices[1..] {
1989        low.x = low.x.min(point.x);
1990        low.y = low.y.min(point.y);
1991        low.z = low.z.min(point.z);
1992        high.x = high.x.max(point.x);
1993        high.y = high.y.max(point.y);
1994        high.z = high.z.max(point.z);
1995    }
1996    (high - low).length()
1997}
1998
1999fn coplanar_merge_tolerance(mesh: &Mesh) -> f64 {
2000    (mesh_diagonal(mesh) * 1.0e-9).max(1.0e-12)
2001}
2002
2003fn solid_face_count(solid: &brep_kernel::BrepSolid) -> usize {
2004    solid.shells.iter().map(|shell| shell.faces.len()).sum()
2005}
2006
2007/// Most analytic regions a whole primitive can have: a capped cylinder is a
2008/// wall and two caps; a capped cone a wall and one or two caps; a sphere or
2009/// ring torus is one region.
2010const MAX_WHOLE_PRIMITIVE_REGIONS: usize = 3;
2011
2012fn segmentation_options(positions: &[f64], options: &StlConversionOptions) -> SegmentOptions {
2013    SegmentOptions {
2014        deflection_angle_deg: options.kernel_deflection_angle_degrees,
2015        fit_tolerance: options.kernel_fit_tolerance,
2016        normal_tolerance_deg: options.kernel_normal_tolerance_degrees,
2017        // The conversion buffers have already been welded by the STL reader
2018        // or mesh repair. Preserve that indexed topology: the kernel's default
2019        // weld (diagonal * 1e-6) is wider than the STL reader's (1e-9), and can
2020        // merge distinct vertices before the hybrid builder consumes the IDs.
2021        weld_tolerance: if options.weld_tolerance > 0.0 {
2022            options.weld_tolerance
2023        } else {
2024            let mut low = [f64::INFINITY; 3];
2025            let mut high = [f64::NEG_INFINITY; 3];
2026            for point in positions.chunks_exact(3) {
2027                for axis in 0..3 {
2028                    low[axis] = low[axis].min(point[axis]);
2029                    high[axis] = high[axis].max(point[axis]);
2030                }
2031            }
2032            let diagonal = (0..3)
2033                .map(|axis| (high[axis] - low[axis]).powi(2))
2034                .sum::<f64>()
2035                .sqrt();
2036            (diagonal * crate::stl::DEFAULT_RELATIVE_WELD_TOLERANCE).max(f64::MIN_POSITIVE)
2037        },
2038        ..SegmentOptions::default()
2039    }
2040}
2041
2042/// The kernel segmentation that decides whether RANSAC recognition and the
2043/// kernel region rebuilder can pay for themselves.
2044fn gate_segmentation(
2045    positions: &[f64],
2046    indices: Option<&[u32]>,
2047    options: &StlConversionOptions,
2048) -> Result<MeshSegmentation, String> {
2049    let owned_indices;
2050    let indices = match indices {
2051        Some(indices) => indices,
2052        None => {
2053            owned_indices = (0..positions.len() as u32 / 3).collect::<Vec<_>>();
2054            &owned_indices
2055        }
2056    };
2057    segment_mesh_faces(positions, indices, &segmentation_options(positions, options))
2058        .map_err(|error| format!("kernel segmentation failed: {error}"))
2059}
2060
2061/// Every triangle assigned, every region on an analytic carrier.
2062fn segmentation_is_complete(segmentation: &MeshSegmentation) -> bool {
2063    !segmentation
2064        .triangle_region_ids
2065        .contains(&UNASSIGNED_REGION)
2066        && segmentation
2067            .regions
2068            .iter()
2069            .all(|region| !matches!(region.carrier, RegionCarrier::Freeform))
2070}
2071
2072/// Whether RANSAC recognition can feed a conversion lane.
2073///
2074/// Its only consumers are the exact sphere / torus / capped cylinder /
2075/// capped cone lanes, which need a closed mesh whose every triangle lies on
2076/// one of at most [`MAX_WHOLE_PRIMITIVE_REGIONS`] analytic regions. The
2077/// kernel segmentation provides the initial eligibility test. A separate
2078/// geometric check can rescue a fragmented whole primitive. `Err` here records
2079/// why the region-count test alone did not establish eligibility.
2080fn recognition_gate(segmentation: &Result<MeshSegmentation, String>) -> Result<(), String> {
2081    let segmentation = segmentation.as_ref().map_err(Clone::clone)?;
2082    let unassigned = segmentation
2083        .triangle_region_ids
2084        .iter()
2085        .filter(|id| **id == UNASSIGNED_REGION)
2086        .count();
2087    let unsupported = segmentation
2088        .regions
2089        .iter()
2090        .filter(|region| matches!(region.carrier, RegionCarrier::Freeform))
2091        .count();
2092    if unassigned > 0 || unsupported > 0 {
2093        return Err(format!(
2094            "kernel segmentation left {unassigned} triangle(s) unassigned and {unsupported} region(s) without an analytic carrier, so no whole-primitive lane can fire"
2095        ));
2096    }
2097    if segmentation.regions.len() > MAX_WHOLE_PRIMITIVE_REGIONS {
2098        return Err(format!(
2099            "kernel segmentation found {} analytic regions; a whole primitive has at most {MAX_WHOLE_PRIMITIVE_REGIONS}",
2100            segmentation.regions.len()
2101        ));
2102    }
2103    Ok(())
2104}
2105
2106fn try_kernel_analytic(
2107    positions: &[f64],
2108    indices: Option<&[u32]>,
2109    options: &StlConversionOptions,
2110) -> Result<brep_kernel::BrepSolid, String> {
2111    let owned_indices;
2112    let indices = match indices {
2113        Some(indices) => indices,
2114        None => {
2115            owned_indices = (0..positions.len() as u32 / 3).collect::<Vec<_>>();
2116            &owned_indices
2117        }
2118    };
2119    let segmentation_options = segmentation_options(positions, options);
2120    let segmentation = segment_mesh_faces(positions, indices, &segmentation_options)?;
2121    if segmentation
2122        .triangle_region_ids
2123        .contains(&UNASSIGNED_REGION)
2124    {
2125        return Err("independent kernel segmentation left triangles unassigned".to_owned());
2126    }
2127    if let Some(region) = segmentation.regions.iter().find(|region| {
2128        matches!(
2129            region.carrier,
2130            RegionCarrier::Freeform | RegionCarrier::Sphere { .. } | RegionCarrier::Torus { .. }
2131        )
2132    }) {
2133        return Err(format!(
2134            "independent kernel segmentation produced unsupported {} region {}",
2135            region.carrier.kind(),
2136            region.id
2137        ));
2138    }
2139    mesh_regions_to_brep(positions, indices, &segmentation_options)
2140}
2141
2142fn try_hybrid_analytic(
2143    positions: &[f64],
2144    indices: Option<&[u32]>,
2145    options: &StlConversionOptions,
2146) -> Result<crate::hybrid_region_brep::HybridBrepOutput, String> {
2147    crate::hybrid_region_brep::hybrid_plane_cylinder_brep(
2148        positions,
2149        indices,
2150        &segmentation_options(positions, options),
2151    )
2152}
2153
2154fn hybrid_report(stats: crate::hybrid_region_brep::HybridBrepStats) -> HybridConversionReport {
2155    HybridConversionReport {
2156        total_faces: stats.total_faces,
2157        analytic_plane_faces: stats.analytic_plane_faces,
2158        analytic_plane_triangles: stats.analytic_plane_triangles,
2159        analytic_cylinder_faces: stats.analytic_cylinder_faces,
2160        analytic_cylinder_triangles: stats.analytic_cylinder_triangles,
2161        analytic_cone_faces: stats.analytic_cone_faces,
2162        analytic_cone_triangles: stats.analytic_cone_triangles,
2163        analytic_sphere_faces: stats.analytic_sphere_faces,
2164        analytic_sphere_triangles: stats.analytic_sphere_triangles,
2165        analytic_torus_faces: stats.analytic_torus_faces,
2166        analytic_torus_triangles: stats.analytic_torus_triangles,
2167        faceted_faces: stats.faceted_faces,
2168        faceted_triangles: stats.faceted_triangles,
2169        demoted_regions: stats.demoted_regions,
2170        demoted_triangles: stats.demoted_triangles,
2171        segmentation_plane_regions: stats.segmentation_plane_regions,
2172        segmentation_cylinder_regions: stats.segmentation_cylinder_regions,
2173        segmentation_cone_regions: stats.segmentation_cone_regions,
2174        segmentation_sphere_regions: stats.segmentation_sphere_regions,
2175        segmentation_torus_regions: stats.segmentation_torus_regions,
2176        segmentation_unsupported_regions: stats.segmentation_unsupported_regions,
2177    }
2178}
2179
2180fn kernel_vec(value: Vec3) -> brep_kernel::Vec3 {
2181    brep_kernel::Vec3::new(value.x, value.y, value.z)
2182}
2183