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