Skip to main content

brep_reconstruction/
stl_conversion.rs

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