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