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 /// Open surface-model sheets skipped (not solids, so not in `failed`);
273 /// also the counter `SHEETS_SKIPPED` in `diagnostics`.
274 pub sheets_skipped: usize,
275 /// `(index into solids, approximation)` for every imported body whose
276 /// shell closes over its bar (`import.shell_closure`); see
277 /// [`ImportedStep::approximations`].
278 pub approximations: Vec<(usize, crate::Approximation)>,
279 /// Every precision the file states, in millimetres, ascending — see
280 /// `parse::stated_precisions_mm`. Empty when it states none.
281 pub stated_precisions_mm: Vec<f64>,
282 /// The import's diagnostics: measurements always, plus one
283 /// `import.stated_precision_inconsistent` warning when the file's stated
284 /// precision is far under the deviation its own geometry shows
285 /// (`precision::stated_precision_diagnostics`). Informational only —
286 /// acceptance never depends on it.
287 pub diagnostics: crate::KernelDiagnostics,
288}
289
290/// The event code for a body that carries a derived trim off the fit bar (the
291/// 1e-7 floor plus its stations' standoff) after the refit at the floor, which
292/// the importer accepts as it did every fit before the bar, and the counters
293/// and measure beside it. The residual
294/// is the band `EntityTolerances` measures for that edge; the event names how
295/// many are the file's residual and how many the fitter's miss, and the worst
296/// with its numbers, so what the importer accepted before the bar silently is
297/// visible (2026-09-26). Since 2026-10-03 both numbers are read BETWEEN the
298/// fit's stations too (`geometry::trim_floor_reading`) and the class is the
299/// corrected bar's: a trim within the floor of the file curve's standoff is
300/// the file's residual whatever its image does along the carrier.
301pub const TRIM_BOUNDED: &str = "import.trim_bounded";
302pub const TRIMS_BOUNDED: &str = "import.trims_bounded";
303pub const TRIMS_FITTER_MISS: &str = "import.trims_fitter_miss";
304pub const TRIM_BOUND_MM: &str = "import.trim_bound_mm";
305/// Bounded trims whose fit was CLAMPED into the carrier's chart, and the worst
306/// 3D distance that clamp corresponds to (2026-09-30; a report, not a bar).
307pub const TRIMS_CLAMPED: &str = "import.trims_clamped";
308pub const TRIM_CLAMP_MM: &str = "import.trim_clamp_mm";
309
310/// The shell-closure gate's readings on the bodies it ACCEPTED
311/// (`builder/closure.rs`): the worst residual-to-bar ratio over the accepted
312/// bodies, how many bodies the scan could not judge (span budget or an
313/// unreadable face — accepted unjudged, one Degraded event each), and how
314/// many bodies it refused (also in [`StepImportReport::refused`]).
315pub const CLOSURE_RATIO_MAX: &str = "import.closure_ratio_max";
316pub const CLOSURE_APPROXIMATE: &str = "import.closure_approximate";
317pub const CLOSURE_UNJUDGED: &str = "import.closure_unjudged";
318pub const CLOSURE_REFUSED: &str = "import.closure_refused";
319
320/// Open `SHELL_BASED_SURFACE_MODEL` sheets the importer SKIPPED, counted
321/// where it drops them (the flat lane, the occurrence lane once per part, and
322/// [`import_step_assembly`] once per product body). A sheet is not a solid,
323/// so it is neither `failed` nor `refused`; before 2026-10-12 it vanished
324/// uncounted (table 3: ten in `BIMExample-Site.step`).
325pub const SHEETS_SKIPPED: &str = "import.sheets_skipped";
326
327fn closure_diagnostics(
328 notes: &[builder::ClosureNote],
329 refused: &[crate::KernelRefusal],
330 diagnostics: &mut crate::KernelDiagnostics,
331) {
332 for (index, note) in notes.iter().enumerate() {
333 match note {
334 builder::ClosureNote::Closed { residual, bar } => {
335 diagnostics.measure_max(CLOSURE_RATIO_MAX, residual / bar);
336 }
337 builder::ClosureNote::Approximate(approximation) => {
338 diagnostics.measure_max(CLOSURE_RATIO_MAX, approximation.measured / approximation.bar);
339 diagnostics.count(CLOSURE_APPROXIMATE);
340 diagnostics.event(
341 crate::DiagnosticSeverity::Degraded,
342 crate::KernelStage::Validate,
343 CLOSURE_APPROXIMATE,
344 format!("body {index}: {}", approximation.message),
345 );
346 }
347 builder::ClosureNote::Unmeasured { reason, spans } => {
348 diagnostics.count(CLOSURE_UNJUDGED);
349 diagnostics.event(
350 crate::DiagnosticSeverity::Degraded,
351 crate::KernelStage::Validate,
352 CLOSURE_UNJUDGED,
353 format!("body {index}: shell closure not judged — {reason} ({spans} spans); the body is accepted unjudged, not certified closed."),
354 );
355 }
356 builder::ClosureNote::Unreadable { first, count } => {
357 diagnostics.count(CLOSURE_UNJUDGED);
358 diagnostics.event(
359 crate::DiagnosticSeverity::Degraded,
360 crate::KernelStage::Validate,
361 CLOSURE_UNJUDGED,
362 format!("body {index}: shell closure not judged — {count} face(s) unreadable by the scan, first: {first}; the body is accepted unjudged, not certified closed."),
363 );
364 }
365 builder::ClosureNote::Unjudged => {
366 diagnostics.count(CLOSURE_UNJUDGED);
367 }
368 builder::ClosureNote::Overridden(refusal) => {
369 diagnostics.count(CLOSURE_UNJUDGED);
370 diagnostics.event(
371 crate::DiagnosticSeverity::Degraded,
372 crate::KernelStage::Validate,
373 CLOSURE_UNJUDGED,
374 format!("body {index}: the closure guard is OFF (BREP_IMPORT_CLOSURE_GUARD=0); it would have refused: {}", refusal.message),
375 );
376 }
377 }
378 }
379 diagnostics.count_n(CLOSURE_REFUSED, refused.len() as u64);
380}
381
382fn bounded_trim_diagnostics(
383 bounded: &[Vec<builder::readings::BoundedTrim>],
384 diagnostics: &mut crate::KernelDiagnostics,
385) {
386 for (index, trims) in bounded.iter().enumerate() {
387 let Some(worst) = trims
388 .iter()
389 .max_by(|a, b| a.residual.partial_cmp(&b.residual).unwrap_or(std::cmp::Ordering::Equal))
390 else {
391 continue;
392 };
393 let fitters = trims.iter().filter(|trim| trim.fitter).count();
394 let clamped = trims.iter().filter(|trim| trim.clamped_excursion > 0.0).count();
395 let worst_clamp = trims.iter().map(|trim| trim.clamped_distance).fold(0.0f64, f64::max);
396 diagnostics.count_n(TRIMS_BOUNDED, trims.len() as u64);
397 diagnostics.count_n(TRIMS_FITTER_MISS, fitters as u64);
398 diagnostics.measure_max(TRIM_BOUND_MM, worst.residual);
399 diagnostics.count_n(TRIMS_CLAMPED, clamped as u64);
400 diagnostics.measure_max(TRIM_CLAMP_MM, worst_clamp);
401 diagnostics.event(
402 crate::DiagnosticSeverity::Degraded,
403 crate::KernelStage::Collect,
404 TRIM_BOUNDED,
405 format!(
406 "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).{}",
407 trims.len(),
408 crate::PCURVE_REFINEMENT_TOLERANCE,
409 trims.len() - fitters,
410 fitters,
411 worst.face_ref,
412 worst.surface_ref,
413 worst.edge_id,
414 worst.residual,
415 worst.standoff,
416 worst.image_to_foot,
417 if worst.fitter { "the fitter's miss" } else { "the file's residual" },
418 worst.exit,
419 worst.samples,
420 if clamped > 0 {
421 format!(" {clamped} of them were clamped into their chart, by up to {worst_clamp:.3e} mm.")
422 } else {
423 String::new()
424 }
425 ),
426 );
427 }
428}
429
430/// Like [`import_step`] but reports how many bodies failed and the first error,
431/// so callers can distinguish a fully- from a partially-imported assembly —
432/// and runs the stated-precision consistency check over the imported bodies,
433/// whose result rides in [`StepImportReport::diagnostics`].
434///
435/// The check measures every edge and vertex of every body (the same
436/// `EntityTolerances` reading the per-entity-tolerance census took), which is
437/// why it lives on this lane and not on [`import_step_with_appearance`]: the
438/// feature pipeline has no diagnostics channel to carry the answer, and a
439/// measurement nobody reads is not worth its cost on every IMPORT3D.
440pub fn import_step_report(text: &str) -> Result<StepImportReport, String> {
441 let imported = collect_step_solids(text)?;
442 let mut diagnostics =
443 precision::stated_precision_diagnostics(&imported.stated_precisions_mm, &imported.solids);
444 bounded_trim_diagnostics(&imported.bounded_trims, &mut diagnostics);
445 closure_diagnostics(&imported.closure_notes, &imported.refused_bodies, &mut diagnostics);
446 diagnostics.count_n(SHEETS_SKIPPED, imported.sheets_skipped as u64);
447 Ok(StepImportReport {
448 solids: imported.solids,
449 failed: imported.failed,
450 first_error: imported.first_error,
451 refused: imported.refused_bodies,
452 failed_refusals: imported.failed_refusals,
453 sheets_skipped: imported.sheets_skipped,
454 approximations: imported
455 .closure_notes
456 .iter()
457 .enumerate()
458 .filter_map(|(index, note)| note.approximation().map(|a| (index, a.clone())))
459 .collect(),
460 stated_precisions_mm: imported.stated_precisions_mm,
461 diagnostics,
462 })
463}