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