Skip to main content

brep_kernel/io/step_import/
mod.rs

1//! STEP Part 21 import for NURBS and analytic surfaces, assemblies, and PMI.
2//!
3//! The importer reconstructs and validates BREP topology. Analytic carriers are
4//! converted to NURBS sized to the face bounds. Seam edges occupy opposite
5//! parameter boundaries, and collapsed pole bounds become degenerate edges so
6//! faces close and integrate correctly.
7//!
8//! Pcurves are DERIVED, by projecting each edge onto its carrier; a
9//! `SURFACE_CURVE`/`SEAM_CURVE` bundle contributes only its 3D curve.
10//!
11//! [`supplied`] can read the bundle's `PCURVE` associations instead and seat
12//! the vendor's own stated trim, and does so correctly, but it is OFF unless
13//! `BREP_SUPPLIED_PCURVES=1`. Not caution — measurement. Preferring a supplied
14//! pcurve helps only where the edge's 3D curve has drifted off its own carrier,
15//! and nothing distinguishes that case from the ones where it hurts: our own
16//! exporter writes exact analytic 3D curves with fitted pcurves, so seating the
17//! fit cost a round trip through our writer a factor of 4800 in volume
18//! fidelity. See `supplied::supplied_pcurves_enabled` for the full derivation.
19
20use crate::topology::{
21    BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord, ShellRecord, VertexRecord,
22};
23use crate::{
24    build_pcurve_on_surface_range, make_arc, make_extrusion, make_hyperbola, make_line,
25    make_parabola, make_plane, make_revolution, offset_surface, transform_brep, AffineTransform,
26    KernelRefusal, NurbsCurve, NurbsSurface, Vec3, Vec4,
27};
28use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
29use std::collections::VecDeque;
30
31const TAU: f64 = std::f64::consts::TAU;
32
33pub(crate) mod parse;
34mod supplied;
35mod bodies;
36mod assembly;
37mod assembly_pmi;
38mod mapped;
39mod builder;
40mod geometry;
41mod styles;
42mod pmi;
43mod precision;
44mod trim_readings;
45
46use crate::appearance::{BodyAppearance, ImportedColor};
47use parse::*;
48use supplied::*;
49use bodies::*;
50use mapped::*;
51use builder::*;
52use geometry::*;
53use styles::*;
54pub use assembly::{read_step_assembly, StepAssembly, StepOccurrence, StepProduct};
55pub use assembly_pmi::{occurrence_ref_parts, rewrite_occurrence_refs, OCCURRENCE_REF_PREFIX};
56pub use mapped::StepMappedItemError;
57pub use pmi::read_step_pmi;
58pub use precision::STATED_PRECISION_INCONSISTENCY_RATIO;
59pub use trim_readings::{
60    import_step_trim_readings, StepBodyTrimReadings, StepEdgeReading, StepFaceReading,
61    StepSuppliedTrim, StepTrimReadings,
62};
63pub(crate) use pmi::decode_step_text as decode_step_text_for_tests;
64
65
66// ---------------------------------------------------------------------------
67// Topology assembly
68// ---------------------------------------------------------------------------
69
70/// Why one body did not import: the builders' legacy text, or a TYPED refusal
71/// from the one gate that mints one today, the shell-closure gate
72/// (`builder/closure.rs`). The class rides to the feature boundary through
73/// [`import_step_bodies`]; every other producer inside the importer still
74/// returns text and is preserved verbatim (typed-refusal plan, item 1).
75#[derive(Clone, Debug, PartialEq)]
76pub enum StepBodyError {
77    Text(String),
78    Refused(crate::KernelRefusal),
79}
80
81impl StepBodyError {
82    /// The human text either way — the refusal's own message, verbatim.
83    pub fn message(&self) -> &str {
84        match self {
85            Self::Text(text) => text,
86            Self::Refused(refusal) => &refusal.message,
87        }
88    }
89
90    /// Whether this is the shell-closure gate's refusal
91    /// (`RefusalClass::UnsoundResult { defect: SoundnessDefect::VectorArea }`,
92    /// `builder/closure.rs`): the one typed class the body lanes treat as a
93    /// REFUSED body rather than a body that failed to build. Every other
94    /// producer that went typed on 2026-10-03 (the pinched-vertex split, the
95    /// `BREP_WITH_VOIDS` certifications, the post-build validation) keeps the
96    /// outcome its text had: counted in `failed`, skipped on an opportunistic
97    /// surface-model sheet, never a whole-file refusal.
98    pub fn closure_refusal(&self) -> bool {
99        matches!(
100            self,
101            Self::Refused(crate::KernelRefusal {
102                class: crate::RefusalClass::UnsoundResult {
103                    defect: crate::SoundnessDefect::VectorArea,
104                    ..
105                },
106                ..
107            })
108        )
109    }
110
111    /// The same error with its text rewritten by `f`, the class untouched.
112    pub fn with_context(self, f: impl FnOnce(&str) -> String) -> Self {
113        match self {
114            Self::Text(text) => Self::Text(f(&text)),
115            Self::Refused(refusal) => Self::Refused(refusal.with_message(f)),
116        }
117    }
118}
119
120impl From<String> for StepBodyError {
121    fn from(text: String) -> Self {
122        Self::Text(text)
123    }
124}
125
126impl From<&str> for StepBodyError {
127    fn from(text: &str) -> Self {
128        Self::Text(text.to_string())
129    }
130}
131
132impl From<crate::KernelRefusal> for StepBodyError {
133    fn from(refusal: crate::KernelRefusal) -> Self {
134        Self::Refused(refusal)
135    }
136}
137
138impl std::fmt::Display for StepBodyError {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.write_str(self.message())
141    }
142}
143
144/// Public entry: parse a STEP Part 21 document and return one `BrepSolid` per
145/// MANIFOLD_SOLID_BREP, FACETED_BREP, or certified BREP_WITH_VOIDS. Every
146/// returned solid passes `validate()` and closes its shells' vector area to
147/// the import's bar; a file with a body the closure gate refuses is refused
148/// as a whole (see [`import_step_bodies`]).
149pub fn import_step(text: &str) -> Result<Vec<BrepSolid>, String> {
150    Ok(import_step_with_appearance(text)?.0)
151}
152
153/// [`import_step_with_appearance`] with the error TYPED: the lane the IMPORT3D
154/// feature uses, so a refused body reaches the app as a class and not only as
155/// text.
156///
157/// **A refused body refuses the import.** `collect_step_solids` skips a body
158/// that failed to build and returns the rest, counting it in `failed`, and
159/// until 2026-09-30 this entry point returned those bodies as a success —
160/// the count and the first error never left `ImportedBodies`, so a file with
161/// a body the importer could not build imported SILENTLY short. A body the
162/// closure gate refuses is a body the file describes and the kernel declines
163/// to certify, and returning the others as the file's contents would be the
164/// partial result the typed-refusal plan rules out ("partial geometry is not a
165/// successful result"). The feature has no per-body typed slot to say "five
166/// of seven" (`Fulfilment::rejected` is a closed selection-resolution kind),
167/// so the whole import is refused with the first body's class and a message
168/// naming every refused body; the report lane ([`import_step_report`]) keeps
169/// returning the bodies that did build beside the refusals, for tools that
170/// want to read them. Bodies that fail with TEXT keep the pre-2026-09-30
171/// behaviour (skipped, the rest returned) — changing that is the plan's
172/// migration of the builder boundary, not this gate's.
173pub fn import_step_bodies(text: &str) -> Result<ImportedStep, StepBodyError> {
174    let imported = collect_step_solids(text)?;
175    if let Some(first) = imported.refused_bodies.first() {
176        let others = imported.refused_bodies.len() - 1;
177        let refusal = if others == 0 {
178            first.clone()
179        } else {
180            let rest: Vec<String> = imported.refused_bodies[1..]
181                .iter()
182                .map(|refusal| refusal.message.clone())
183                .collect();
184            first.clone().with_message(|message| {
185                format!("{message} And {others} more: {}", rest.join(" "))
186            })
187        };
188        return Err(StepBodyError::Refused(refusal.with_message(|message| {
189            format!(
190                "{} of {} bodies refused as open; {}",
191                imported.refused_bodies.len(),
192                imported.solids.len() + imported.failed,
193                message
194            )
195        })));
196    }
197    if imported.solids.is_empty() {
198        return Err(StepBodyError::Text(imported.first_error.unwrap_or_else(|| {
199            "step_import: no MANIFOLD_SOLID_BREP/FACETED_BREP/BREP_WITH_VOIDS body imported (unsupported or invalid representation)"
200                .into()
201        })));
202    }
203    let approximations = imported
204        .closure_notes
205        .iter()
206        .enumerate()
207        .filter_map(|(index, note)| note.approximation().map(|a| (index, a.clone())))
208        .collect();
209    Ok(ImportedStep {
210        solids: imported.solids,
211        appearances: imported.appearances,
212        approximations,
213    })
214}
215
216/// What [`import_step_bodies`] returns: the bodies, their colours, and the
217/// typed approximations the closure gate attached to some of them.
218#[derive(Clone, Debug)]
219pub struct ImportedStep {
220    /// One solid per body that reconstructed, in file order.
221    pub solids: Vec<BrepSolid>,
222    /// One per solid, in the same order (default when the file has none).
223    pub appearances: Vec<BodyAppearance>,
224    /// `(index into solids, approximation)` for every body the closure gate
225    /// imported over its bar (`builder/closure.rs`): a typed statement that
226    /// the body's volume is determined only to the bound the approximation
227    /// carries. Empty on a file whose every body closes.
228    pub approximations: Vec<(usize, crate::Approximation)>,
229}
230
231/// [`import_step`] plus the file's PRESENTATION colours: one
232/// [`BodyAppearance`] per returned solid, in the same order.
233///
234/// This is the lane the IMPORT3D feature uses, because colour is stamped as
235/// scene metadata on the names IT chooses — see
236/// `feature_pipeline::features::import3d` and `io/appearance.rs` for the record
237/// shape. A file with no presentation entities returns default (empty)
238/// appearances, never a short vector, so callers can `zip` unconditionally.
239pub fn import_step_with_appearance(
240    text: &str,
241) -> Result<(Vec<BrepSolid>, Vec<BodyAppearance>), String> {
242    import_step_bodies(text)
243        .map(|imported| (imported.solids, imported.appearances))
244        .map_err(|error| error.to_string())
245}
246
247/// What [`import_step_report`] returns: the bodies, how gracefully the file
248/// imported, and the import's diagnostics.
249#[derive(Clone, Debug)]
250pub struct StepImportReport {
251    /// One solid per body that reconstructed, in file order.
252    pub solids: Vec<BrepSolid>,
253    /// Things that did not come in (skipped, not fatal): a body that failed to
254    /// reconstruct, an occurrence whose transform failed, or a mapping that was
255    /// refused (`mapped::StepMappedItemError`).
256    pub failed: usize,
257    /// The first such failure's text, when `failed > 0`.
258    pub first_error: Option<String>,
259    /// The bodies the shell-closure gate refused, typed
260    /// (`RefusalClass::UnsoundResult { defect: SoundnessDefect::VectorArea }`),
261    /// in file order. Each is counted in `failed`. [`import_step`] and the
262    /// feature lane refuse the whole file on any of these; this lane returns
263    /// the bodies that did import beside them.
264    pub refused: Vec<crate::KernelRefusal>,
265    /// The bodies that FAILED to build with a typed cause that is not the
266    /// closure gate's, in file order: the pinched-vertex split, a
267    /// `BREP_WITH_VOIDS` certification, the post-build validation
268    /// (2026-10-03). Each is counted in `failed`, and the first is
269    /// `first_error`'s text; none refuses the whole file on any lane, which
270    /// is the outcome their text had.
271    pub failed_refusals: Vec<crate::KernelRefusal>,
272    /// `(index into solids, approximation)` for every imported body whose
273    /// shell closes over its bar (`import.shell_closure`); see
274    /// [`ImportedStep::approximations`].
275    pub approximations: Vec<(usize, crate::Approximation)>,
276    /// Every precision the file states, in millimetres, ascending — see
277    /// `parse::stated_precisions_mm`. Empty when it states none.
278    pub stated_precisions_mm: Vec<f64>,
279    /// The import's diagnostics: measurements always, plus one
280    /// `import.stated_precision_inconsistent` warning when the file's stated
281    /// precision is far under the deviation its own geometry shows
282    /// ([`precision::stated_precision_diagnostics`]). Informational only —
283    /// acceptance never depends on it.
284    pub diagnostics: crate::KernelDiagnostics,
285}
286
287/// The event code for a body that carries a derived trim off the fit bar (the
288/// 1e-7 floor plus its stations' standoff) after the refit at the floor, which
289/// the importer accepts as it did every fit before the bar, and the counters
290/// and measure beside it. The residual
291/// is the band `EntityTolerances` measures for that edge; the event names how
292/// many are the file's residual and how many the fitter's miss, and the worst
293/// with its numbers, so what the importer accepted before the bar silently is
294/// visible (2026-09-26). Since 2026-10-03 both numbers are read BETWEEN the
295/// fit's stations too (`geometry::trim_floor_reading`) and the class is the
296/// corrected bar's: a trim within the floor of the file curve's standoff is
297/// the file's residual whatever its image does along the carrier.
298pub const TRIM_BOUNDED: &str = "import.trim_bounded";
299pub const TRIMS_BOUNDED: &str = "import.trims_bounded";
300pub const TRIMS_FITTER_MISS: &str = "import.trims_fitter_miss";
301pub const TRIM_BOUND_MM: &str = "import.trim_bound_mm";
302/// Bounded trims whose fit was CLAMPED into the carrier's chart, and the worst
303/// 3D distance that clamp corresponds to (2026-09-30; a report, not a bar).
304pub const TRIMS_CLAMPED: &str = "import.trims_clamped";
305pub const TRIM_CLAMP_MM: &str = "import.trim_clamp_mm";
306
307/// The shell-closure gate's readings on the bodies it ACCEPTED
308/// (`builder/closure.rs`): the worst residual-to-bar ratio over the accepted
309/// bodies, how many bodies the scan could not judge (span budget or an
310/// unreadable face — accepted unjudged, one Degraded event each), and how
311/// many bodies it refused (also in [`StepImportReport::refused`]).
312pub const CLOSURE_RATIO_MAX: &str = "import.closure_ratio_max";
313pub const CLOSURE_APPROXIMATE: &str = "import.closure_approximate";
314pub const CLOSURE_UNJUDGED: &str = "import.closure_unjudged";
315pub const CLOSURE_REFUSED: &str = "import.closure_refused";
316
317fn closure_diagnostics(
318    notes: &[builder::ClosureNote],
319    refused: &[crate::KernelRefusal],
320    diagnostics: &mut crate::KernelDiagnostics,
321) {
322    for (index, note) in notes.iter().enumerate() {
323        match note {
324            builder::ClosureNote::Closed { residual, bar } => {
325                diagnostics.measure_max(CLOSURE_RATIO_MAX, residual / bar);
326            }
327            builder::ClosureNote::Approximate(approximation) => {
328                diagnostics.measure_max(CLOSURE_RATIO_MAX, approximation.measured / approximation.bar);
329                diagnostics.count(CLOSURE_APPROXIMATE);
330                diagnostics.event(
331                    crate::DiagnosticSeverity::Degraded,
332                    crate::KernelStage::Validate,
333                    CLOSURE_APPROXIMATE,
334                    format!("body {index}: {}", approximation.message),
335                );
336            }
337            builder::ClosureNote::Unmeasured { reason, spans } => {
338                diagnostics.count(CLOSURE_UNJUDGED);
339                diagnostics.event(
340                    crate::DiagnosticSeverity::Degraded,
341                    crate::KernelStage::Validate,
342                    CLOSURE_UNJUDGED,
343                    format!("body {index}: shell closure not judged — {reason} ({spans} spans); the body is accepted unjudged, not certified closed."),
344                );
345            }
346            builder::ClosureNote::Unreadable { first, count } => {
347                diagnostics.count(CLOSURE_UNJUDGED);
348                diagnostics.event(
349                    crate::DiagnosticSeverity::Degraded,
350                    crate::KernelStage::Validate,
351                    CLOSURE_UNJUDGED,
352                    format!("body {index}: shell closure not judged — {count} face(s) unreadable by the scan, first: {first}; the body is accepted unjudged, not certified closed."),
353                );
354            }
355            builder::ClosureNote::Unjudged => {
356                diagnostics.count(CLOSURE_UNJUDGED);
357            }
358            builder::ClosureNote::Overridden(refusal) => {
359                diagnostics.count(CLOSURE_UNJUDGED);
360                diagnostics.event(
361                    crate::DiagnosticSeverity::Degraded,
362                    crate::KernelStage::Validate,
363                    CLOSURE_UNJUDGED,
364                    format!("body {index}: the closure guard is OFF (BREP_IMPORT_CLOSURE_GUARD=0); it would have refused: {}", refusal.message),
365                );
366            }
367        }
368    }
369    diagnostics.count_n(CLOSURE_REFUSED, refused.len() as u64);
370}
371
372fn bounded_trim_diagnostics(
373    bounded: &[Vec<builder::readings::BoundedTrim>],
374    diagnostics: &mut crate::KernelDiagnostics,
375) {
376    for (index, trims) in bounded.iter().enumerate() {
377        let Some(worst) = trims
378            .iter()
379            .max_by(|a, b| a.residual.partial_cmp(&b.residual).unwrap_or(std::cmp::Ordering::Equal))
380        else {
381            continue;
382        };
383        let fitters = trims.iter().filter(|trim| trim.fitter).count();
384        let clamped = trims.iter().filter(|trim| trim.clamped_excursion > 0.0).count();
385        let worst_clamp = trims.iter().map(|trim| trim.clamped_distance).fold(0.0f64, f64::max);
386        diagnostics.count_n(TRIMS_BOUNDED, trims.len() as u64);
387        diagnostics.count_n(TRIMS_FITTER_MISS, fitters as u64);
388        diagnostics.measure_max(TRIM_BOUND_MM, worst.residual);
389        diagnostics.count_n(TRIMS_CLAMPED, clamped as u64);
390        diagnostics.measure_max(TRIM_CLAMP_MM, worst_clamp);
391        diagnostics.event(
392            crate::DiagnosticSeverity::Degraded,
393            crate::KernelStage::Collect,
394            TRIM_BOUNDED,
395            format!(
396                "body {index}: {} trim(s) sit off the {:.0e} mm fit bar and carry their measured residual as their band ({} of them the file's residual, within the floor of the curve's standoff read between the stations; {} the fitter's miss); worst ADVANCED_FACE #{} (surface #{}) edge {}: {:.3e} mm, its curve {:.3e} mm off the carrier, the image {:.3e} mm off its feet ({}; {} at {} samples).{}",
397                trims.len(),
398                crate::PCURVE_REFINEMENT_TOLERANCE,
399                trims.len() - fitters,
400                fitters,
401                worst.face_ref,
402                worst.surface_ref,
403                worst.edge_id,
404                worst.residual,
405                worst.standoff,
406                worst.image_to_foot,
407                if worst.fitter { "the fitter's miss" } else { "the file's residual" },
408                worst.exit,
409                worst.samples,
410                if clamped > 0 {
411                    format!(" {clamped} of them were clamped into their chart, by up to {worst_clamp:.3e} mm.")
412                } else {
413                    String::new()
414                }
415            ),
416        );
417    }
418}
419
420/// Like [`import_step`] but reports how many bodies failed and the first error,
421/// so callers can distinguish a fully- from a partially-imported assembly —
422/// and runs the stated-precision consistency check over the imported bodies,
423/// whose result rides in [`StepImportReport::diagnostics`].
424///
425/// The check measures every edge and vertex of every body (the same
426/// `EntityTolerances` reading the per-entity-tolerance census took), which is
427/// why it lives on this lane and not on [`import_step_with_appearance`]: the
428/// feature pipeline has no diagnostics channel to carry the answer, and a
429/// measurement nobody reads is not worth its cost on every IMPORT3D.
430pub fn import_step_report(text: &str) -> Result<StepImportReport, String> {
431    let imported = collect_step_solids(text)?;
432    let mut diagnostics =
433        precision::stated_precision_diagnostics(&imported.stated_precisions_mm, &imported.solids);
434    bounded_trim_diagnostics(&imported.bounded_trims, &mut diagnostics);
435    closure_diagnostics(&imported.closure_notes, &imported.refused_bodies, &mut diagnostics);
436    Ok(StepImportReport {
437        solids: imported.solids,
438        failed: imported.failed,
439        first_error: imported.first_error,
440        refused: imported.refused_bodies,
441        failed_refusals: imported.failed_refusals,
442        approximations: imported
443            .closure_notes
444            .iter()
445            .enumerate()
446            .filter_map(|(index, note)| note.approximation().map(|a| (index, a.clone())))
447            .collect(),
448        stated_precisions_mm: imported.stated_precisions_mm,
449        diagnostics,
450    })
451}