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