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