STEP: reader

Own ISO 10303-21 parser and AP203/AP214/AP242 geometry reader in the kernel
(step_import): planes, cylinders, cones, spheres, tori, B-spline and rational
surfaces, surfaces of revolution/extrusion; lines, conics, B-spline curves;
breps with voids, faceted breps, shell models; units (SI prefixes, conversion
based), product names, colours, assemblies (NAUO + item-defined
transformations, mapped items). Hostile input is capped and guarded; faces
that cannot be read are left out and reported. Faces that wrap around a
closed non-periodic direction (pipe tori, revolved B-spline bands) are split
for truck's mesher. Bodies are meshed per shell so open imports never panic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 21:01:04 +00:00
1 parent 6c819a7b06
commit ed611ed7ba
8 files changed
+3109 -6

No files matched your search

+638
View File
@@ -0,0 +1,638 @@
//! STEP import (ISO 10303-21 files, AP203/AP214/AP242 geometry).
//!
//! Our own reader, written from the public standards: [`p21`] parses the exchange structure,
//! [`geom`] and [`brep`] turn geometry and topology into kernel solids, and this module walks
//! the product structure (products, shape representations, assemblies with their placements,
//! units, colours). STEP files are untrusted: every count and nesting depth is capped, every
//! truck call runs under [`guard`], and what cannot be read becomes a warning rather than a
//! failure wherever the rest of the file is still usable.
mod brep;
mod geom;
pub(crate) mod p21;
mod split;
use std::collections::{BTreeMap, HashMap, HashSet};
use serde::{Deserialize, Serialize};
use truck_modeling as mt;
use crate::body::Body;
use crate::{KernelError, Result, guard};
use geom::R;
use p21::{Entity, Exchange, Param};
/// Deepest assembly nesting followed.
const MAX_ASSEMBLY_DEPTH: usize = 32;
/// Most body instances in one import.
const MAX_BODIES: usize = 10_000;
/// A body read from a STEP file.
#[derive(Clone, Debug)]
pub struct ImportedBody {
/// Solid name (the brep's label), else the product name.
pub name: String,
pub body: Body,
/// Surface colour (linear-ish RGB 0..1) from the file's presentation styles.
pub color: Option<[f32; 3]>,
/// Product names from the root to the part that holds the body.
pub path: Vec<String>,
/// The body is a closed solid (false: faces were left out, or the file had a surface model).
pub closed: bool,
/// Faces read from the file. The body can have more: a face that wraps all the way around
/// a closed surface is split in two for the kernel.
pub file_faces: usize,
}
/// One product occurrence in the file's assembly tree.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StepNode {
pub name: String,
/// Placement in the parent (column-major 4×4, millimetres).
pub transform: [[f64; 4]; 4],
/// Indices into [`StepImport::bodies`] of this product's own bodies.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bodies: Vec<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<StepNode>,
}
/// Result of [`step_import`].
#[derive(Clone, Debug)]
pub struct StepImport {
pub bodies: Vec<ImportedBody>,
/// Root products (an assembly tree; a plain part is a single root).
pub tree: Vec<StepNode>,
/// What could not be read (unsupported entities, faces left out…).
pub warnings: Vec<String>,
/// FILE_SCHEMA (e.g. `AUTOMOTIVE_DESIGN`, `CONFIG_CONTROL_DESIGN`, `AP242_…`).
pub schema: String,
/// FILE_NAME originating system.
pub system: String,
}
/// Reading context: the file and the units of the representation being read.
pub(crate) struct Ctx<'a> {
pub ex: &'a Exchange,
/// Millimetres per file length unit.
pub len: f64,
/// Radians per file angle unit.
pub ang: f64,
/// Vertex-on-curve tolerance (mm).
pub tol: f64,
}
#[derive(Clone, Copy)]
struct Units {
len: f64,
ang: f64,
tol: f64,
}
const DEFAULT_UNITS: Units = Units { len: 1.0, ang: 1.0, tol: 0.01 };
fn si_prefix(p: Option<&Param>) -> f64 {
match p.and_then(Param::as_enum) {
Some("EXA") => 1e18,
Some("PETA") => 1e15,
Some("TERA") => 1e12,
Some("GIGA") => 1e9,
Some("MEGA") => 1e6,
Some("KILO") => 1e3,
Some("HECTO") => 1e2,
Some("DECA") => 1e1,
Some("DECI") => 1e-1,
Some("CENTI") => 1e-2,
Some("MILLI") => 1e-3,
Some("MICRO") => 1e-6,
Some("NANO") => 1e-9,
Some("PICO") => 1e-12,
Some("FEMTO") => 1e-15,
Some("ATTO") => 1e-18,
_ => 1.0,
}
}
/// A unit's size: millimetres for lengths, radians for plane angles.
fn unit_factor(ex: &Exchange, id: u64, depth: usize) -> Option<(&'static str, f64)> {
if depth > 4 {
return None;
}
let e = ex.get(id)?;
let kind = if e.has("LENGTH_UNIT") {
"length"
} else if e.has("PLANE_ANGLE_UNIT") {
"angle"
} else {
"other"
};
if let Some(si) = e.record("SI_UNIT") {
let base = match si.get(1).and_then(Param::as_enum) {
Some("METRE") => 1000.0,
Some("RADIAN") => 1.0,
_ => return Some((kind, 1.0)),
};
return Some((kind, si_prefix(si.first()) * base));
}
if let Some(cb) = e.record("CONVERSION_BASED_UNIT") {
let m = ex.get(cb.get(1)?.as_ref_id()?)?;
let mp = m.params();
let v = mp.first()?.as_f64()?;
let (_, f) = unit_factor(ex, mp.get(1)?.as_ref_id()?, depth + 1)?;
let x = v * f;
return (x.is_finite() && x > 0.0).then_some((kind, x));
}
Some((kind, 1.0))
}
/// Units of a representation context (GLOBAL_UNIT_ASSIGNED_CONTEXT, uncertainty).
fn context_units(ex: &Exchange, ctx: Option<u64>, cache: &mut HashMap<u64, Units>) -> Units {
let Some(id) = ctx else { return DEFAULT_UNITS };
if let Some(u) = cache.get(&id) {
return *u;
}
let mut u = DEFAULT_UNITS;
let mut tol_file = None;
if let Some(e) = ex.get(id) {
if let Some(units) = e.record("GLOBAL_UNIT_ASSIGNED_CONTEXT").and_then(|r| r.first()).and_then(Param::as_list) {
for uid in units.iter().filter_map(Param::as_ref_id) {
match unit_factor(ex, uid, 0) {
Some(("length", f)) if f.is_finite() && f > 0.0 => u.len = f,
Some(("angle", f)) if f.is_finite() && f > 0.0 => u.ang = f,
_ => {}
}
}
}
if let Some(unc) = e.record("GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT").and_then(|r| r.first()).and_then(Param::as_list) {
for uid in unc.iter().filter_map(Param::as_ref_id) {
if let Some(m) = ex.get(uid)
&& let Some(v) = m.params().first().and_then(Param::as_f64)
{
let f = m.params().get(1).and_then(Param::as_ref_id).and_then(|x| unit_factor(ex, x, 0)).map(|x| x.1);
tol_file = Some(v * f.unwrap_or(u.len));
}
}
}
}
u.tol = tol_file.filter(|t| t.is_finite() && *t > 0.0).map(|t| (t * 10.0).max(1e-3)).unwrap_or(u.len * 1e-2).clamp(1e-4, 1.0);
cache.insert(id, u);
u
}
/// Items and context of a representation entity.
fn representation(e: &Entity) -> Option<(Vec<u64>, Option<u64>)> {
let p = if e.is_complex() {
e.record("REPRESENTATION")
.or_else(|| e.records.iter().find(|r| r.name.ends_with("REPRESENTATION") && r.params.len() >= 3).map(|r| r.params.as_slice()))?
} else if e.name().ends_with("REPRESENTATION") {
e.params()
} else {
return None;
};
let items = p.get(1)?.as_list()?.iter().filter_map(Param::as_ref_id).collect();
Some((items, p.get(2).and_then(Param::as_ref_id)))
}
fn mat_mul(a: &mt::Matrix4, b: &mt::Matrix4) -> mt::Matrix4 {
a * b
}
fn to_array(m: &mt::Matrix4) -> [[f64; 4]; 4] {
[[m.x.x, m.x.y, m.x.z, m.x.w], [m.y.x, m.y.y, m.y.z, m.y.w], [m.z.x, m.z.y, m.z.z, m.z.w], [m.w.x, m.w.y, m.w.z, m.w.w]]
}
/// Inverse of a rigid placement matrix (orthonormal rotation + translation).
fn rigid_inverse(m: &mt::Matrix4) -> mt::Matrix4 {
use mt::SquareMatrix;
m.invert().unwrap_or_else(mt::Matrix4::identity)
}
struct Reader<'a> {
ex: &'a Exchange,
units: HashMap<u64, Units>,
warnings: BTreeMap<String, usize>,
/// Representation → its items and context.
reps: HashMap<u64, (Vec<u64>, Option<u64>)>,
/// Plain representation relationships (same product, no placement).
rep_links: HashMap<u64, Vec<u64>>,
/// Product definition → its shape representations.
pd_reps: HashMap<u64, Vec<u64>>,
/// Product definition → child occurrences (NAUO ids).
children: HashMap<u64, Vec<u64>>,
/// NAUO → placement relationship entity.
placements: HashMap<u64, u64>,
/// Styled item → colour.
colors: HashMap<u64, [f32; 3]>,
/// Converted items (brep id → solid, or the error).
solids: HashMap<u64, std::result::Result<(mt::Solid, bool, usize), String>>,
used_items: HashSet<u64>,
/// Items already instanced for the product being read.
node_items: HashSet<u64>,
bodies: Vec<ImportedBody>,
}
impl<'a> Reader<'a> {
fn warn(&mut self, msg: impl Into<String>) {
*self.warnings.entry(msg.into()).or_default() += 1;
}
fn index(&mut self) {
let ex = self.ex;
let mut pds: HashMap<u64, u64> = HashMap::new(); // PDS → definition
let mut sdrs: Vec<(u64, u64)> = Vec::new(); // (PDS, rep)
let mut cdsrs: Vec<(u64, u64)> = Vec::new(); // (relationship, PDS)
let mut nauos: Vec<(u64, u64, u64)> = Vec::new(); // (id, parent, child)
let mut ids: Vec<&u64> = ex.entities.keys().collect();
ids.sort();
for &id in ids {
let Some(e) = ex.get(id) else { continue };
if let Some(r) = representation(e) {
self.reps.insert(id, r);
}
let p = e.params();
let rf = |i: usize| p.get(i).and_then(Param::as_ref_id);
match e.name() {
"PRODUCT_DEFINITION_SHAPE" | "PROPERTY_DEFINITION" => {
if let Some(d) = rf(2) {
pds.insert(id, d);
}
}
"SHAPE_DEFINITION_REPRESENTATION" => {
if let (Some(d), Some(r)) = (rf(0), rf(1)) {
sdrs.push((d, r));
}
}
"CONTEXT_DEPENDENT_SHAPE_REPRESENTATION" => {
if let (Some(r), Some(d)) = (rf(0), rf(1)) {
cdsrs.push((r, d));
}
}
"NEXT_ASSEMBLY_USAGE_OCCURRENCE" | "ASSEMBLY_COMPONENT_USAGE" | "SPECIFIED_HIGHER_USAGE_OCCURRENCE" => {
if let (Some(a), Some(b)) = (rf(3), rf(4)) {
nauos.push((id, a, b));
}
}
"SHAPE_REPRESENTATION_RELATIONSHIP" | "REPRESENTATION_RELATIONSHIP" if !e.is_complex() => {
if let (Some(a), Some(b)) = (rf(2), rf(3)) {
self.rep_links.entry(a).or_default().push(b);
self.rep_links.entry(b).or_default().push(a);
}
}
"STYLED_ITEM" | "OVER_RIDING_STYLED_ITEM" => {
if let Some(item) = rf(2)
&& let Some(c) = self.find_colour(p.get(1))
{
self.colors.entry(item).or_insert(c);
}
}
_ => {}
}
}
for (d, r) in sdrs {
if let Some(&pd) = pds.get(&d) {
self.pd_reps.entry(pd).or_default().push(r);
}
}
for (r, d) in cdsrs {
if let Some(&nauo) = pds.get(&d) {
self.placements.insert(nauo, r);
}
}
for (id, parent, child) in nauos {
if parent != child {
self.children.entry(parent).or_default().push(id);
}
}
}
/// First colour reachable from a style list (bounded search).
fn find_colour(&self, styles: Option<&Param>) -> Option<[f32; 3]> {
let mut queue: Vec<u64> = styles?.as_list()?.iter().filter_map(Param::as_ref_id).collect();
let mut seen = HashSet::new();
while let Some(id) = queue.pop() {
if seen.len() > 64 || !seen.insert(id) {
continue;
}
let e = self.ex.get(id)?;
if e.name() == "COLOUR_RGB" {
let p = e.params();
let c = |i: usize| p.get(i).and_then(Param::as_f64).map(|x| x.clamp(0.0, 1.0) as f32);
return Some([c(1)?, c(2)?, c(3)?]);
}
if e.name() == "DRAUGHTING_PRE_DEFINED_COLOUR" {
return Some(match e.params().first().and_then(Param::as_str).unwrap_or("") {
"red" => [1.0, 0.0, 0.0],
"green" => [0.0, 1.0, 0.0],
"blue" => [0.0, 0.0, 1.0],
"yellow" => [1.0, 1.0, 0.0],
"magenta" => [1.0, 0.0, 1.0],
"cyan" => [0.0, 1.0, 1.0],
"black" => [0.0, 0.0, 0.0],
_ => [1.0, 1.0, 1.0],
});
}
fn walk(p: &Param, out: &mut Vec<u64>, depth: usize) {
match p {
Param::Ref(r) => out.push(*r),
Param::List(v) | Param::Typed(_, v) if depth < 4 => v.iter().for_each(|x| walk(x, out, depth + 1)),
_ => {}
}
}
for r in &e.records {
for p in &r.params {
walk(p, &mut queue, 0);
}
}
}
None
}
fn units_of(&mut self, rep: u64) -> Units {
let ctx = self.reps.get(&rep).and_then(|r| r.1);
context_units(self.ex, ctx, &mut self.units)
}
fn placement_matrix(&mut self, item: u64, u: Units) -> R<mt::Matrix4> {
let cx = Ctx { ex: self.ex, len: u.len, ang: u.ang, tol: u.tol };
let e = geom::entity(&cx, item)?;
if e.name() == "CARTESIAN_TRANSFORMATION_OPERATOR_3D" {
let p = e.params();
let o = geom::point(&cx, p.get(4).and_then(Param::as_ref_id).ok_or("transformation without origin")?)?;
return Ok(mt::Matrix4::from_translation(o.to_homogeneous().truncate()));
}
Ok(geom::frame(&cx, item)?.matrix())
}
/// Representations of a product (its own shape representations and those linked to them).
fn rep_closure(&self, roots: &[u64]) -> Vec<u64> {
let mut seen: Vec<u64> = Vec::new();
let mut stack: Vec<u64> = roots.to_vec();
while let Some(r) = stack.pop() {
if seen.contains(&r) || seen.len() > 1024 {
continue;
}
seen.push(r);
if let Some(l) = self.rep_links.get(&r) {
stack.extend(l.iter().copied());
}
}
seen
}
fn item_solid(&mut self, item: u64, u: Units) -> Option<(mt::Solid, bool, usize)> {
if let Some(r) = self.solids.get(&item) {
return r.as_ref().ok().cloned();
}
let cx = Ctx { ex: self.ex, len: u.len, ang: u.ang, tol: u.tol };
let r = match guard("step brep", || brep::read_item(&cx, item).map_err(KernelError::Failed)) {
Ok(out) => {
let label = self.label(item);
for f in &out.failed {
self.warn(format!("{label}: {f}"));
}
if !out.closed {
self.warn(format!("{label}: imported as an open body (the shell is not closed)"));
}
Ok((out.solid, out.closed, out.file_faces))
}
Err(e) => {
let label = self.label(item);
let msg = e.to_string().trim_start_matches("the operation failed: ").to_string();
self.warn(format!("{label}: not imported: {msg}"));
Err(msg)
}
};
self.solids.insert(item, r.clone());
r.ok()
}
fn label(&self, item: u64) -> String {
let name = self.ex.get(item).and_then(|e| e.params().first()).and_then(Param::as_str).unwrap_or("").trim().to_string();
if name.is_empty() { format!("solid #{item}") } else { format!("solid '{name}'") }
}
/// Bodies of a representation's items (following mapped items), placed by `m`.
fn rep_bodies(&mut self, rep: u64, m: &mt::Matrix4, path: &[String], product: &str, out: &mut Vec<usize>, depth: usize) {
if depth > MAX_ASSEMBLY_DEPTH {
self.warn("mapped items nested too deeply");
return;
}
let u = self.units_of(rep);
let items = self.reps.get(&rep).map(|r| r.0.clone()).unwrap_or_default();
for item in items {
let Some(e) = self.ex.get(item) else { continue };
match e.name() {
"MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL" => {
self.used_items.insert(item);
if !self.node_items.insert(item) {
continue;
}
if self.bodies.len() >= MAX_BODIES {
self.warn(format!("more than {MAX_BODIES} bodies: the rest are left out"));
return;
}
let Some((solid, closed, file_faces)) = self.item_solid(item, u) else { continue };
let identity = *m == mt::Matrix4::from_scale(1.0);
let placed = if identity {
Ok(solid)
} else {
let mm = *m;
guard("step placement", || Ok(mt::builder::transformed(&solid, mm)))
};
let body = placed.and_then(Body::new);
match body {
Ok(body) => {
let own = e.params().first().and_then(Param::as_str).unwrap_or("").trim().to_string();
let name = if own.is_empty() { product.to_string() } else { own };
out.push(self.bodies.len());
self.bodies.push(ImportedBody {
name,
body,
color: self.colors.get(&item).copied(),
path: path.to_vec(),
closed,
file_faces,
});
}
Err(err) => {
let label = self.label(item);
self.warn(format!("{label}: not imported: {err}"));
}
}
}
"MAPPED_ITEM" => {
let p = e.params();
let (Some(map), Some(target)) = (p.get(1).and_then(Param::as_ref_id), p.get(2).and_then(Param::as_ref_id)) else { continue };
let Some(me) = self.ex.get(map) else { continue };
let (Some(origin), Some(mapped)) =
(me.params().first().and_then(Param::as_ref_id), me.params().get(1).and_then(Param::as_ref_id))
else {
continue;
};
let mu = self.units_of(mapped);
match (self.placement_matrix(target, u), self.placement_matrix(origin, mu)) {
(Ok(t), Ok(o)) => {
let mm = mat_mul(m, &mat_mul(&t, &rigid_inverse(&o)));
for r in self.rep_closure(&[mapped]) {
self.rep_bodies(r, &mm, path, product, out, depth + 1);
}
}
(Err(e), _) | (_, Err(e)) => self.warn(format!("mapped item #{item}: {e}")),
}
}
_ => {}
}
}
}
fn product_name(&self, pd: u64) -> String {
let name = (|| {
let pdf = self.ex.get(self.ex.get(pd)?.params().get(2)?.as_ref_id()?)?;
let prod = self.ex.get(pdf.params().get(2)?.as_ref_id()?)?;
let p = prod.params();
let n = p.get(1).and_then(Param::as_str).filter(|s| !s.trim().is_empty()).or_else(|| p.first().and_then(Param::as_str))?;
Some(n.trim().to_string())
})();
name.filter(|n| !n.is_empty()).unwrap_or_else(|| "Import".into())
}
/// Placement of a child occurrence in its parent.
fn occurrence_matrix(&mut self, nauo: u64, child_reps: &[u64]) -> mt::Matrix4 {
let Some(&rel) = self.placements.get(&nauo) else { return mt::Matrix4::from_scale(1.0) };
let r = (|| -> R<mt::Matrix4> {
let e = self.ex.get(rel).ok_or("missing placement")?;
let rr = e.record("REPRESENTATION_RELATIONSHIP").ok_or("placement without representations")?;
let (r1, r2) =
(rr.get(2).and_then(Param::as_ref_id).ok_or("bad placement")?, rr.get(3).and_then(Param::as_ref_id).ok_or("bad placement")?);
let op = e
.record("REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION")
.and_then(|x| x.first())
.and_then(Param::as_ref_id)
.ok_or("placement without transformation")?;
let ope = self.ex.get(op).ok_or("missing transformation")?;
let (u1, u2) = (self.units_of(r1), self.units_of(r2));
if ope.name() == "ITEM_DEFINED_TRANSFORMATION" {
let p = ope.params();
let (i1, i2) = (
p.get(2).and_then(Param::as_ref_id).ok_or("bad transformation")?,
p.get(3).and_then(Param::as_ref_id).ok_or("bad transformation")?,
);
let m1 = self.placement_matrix(i1, u1)?;
let m2 = self.placement_matrix(i2, u2)?;
// rep_1 is normally the component: map its item onto the assembly's item.
let child_is_1 = child_reps.contains(&r1) || !child_reps.contains(&r2);
Ok(if child_is_1 { mat_mul(&m2, &rigid_inverse(&m1)) } else { mat_mul(&m1, &rigid_inverse(&m2)) })
} else {
self.placement_matrix(op, u2)
}
})();
match r {
Ok(m) => m,
Err(e) => {
self.warn(format!("component placement #{rel}: {e} (placed at the origin)"));
mt::Matrix4::from_scale(1.0)
}
}
}
fn node(&mut self, pd: u64, local: mt::Matrix4, world: mt::Matrix4, path: &[String], depth: usize, stack: &mut Vec<u64>) -> StepNode {
let name = self.product_name(pd);
let mut node = StepNode { name: name.clone(), transform: to_array(&local), bodies: Vec::new(), children: Vec::new() };
if depth > MAX_ASSEMBLY_DEPTH || stack.contains(&pd) {
self.warn("assembly nested too deeply or recursive: some components were left out");
return node;
}
stack.push(pd);
let mut path = path.to_vec();
path.push(name.clone());
let own = self.pd_reps.get(&pd).cloned().unwrap_or_default();
let reps = self.rep_closure(&own);
let mut bodies = Vec::new();
self.node_items.clear();
for r in &reps {
self.rep_bodies(*r, &world, &path, &name, &mut bodies, 0);
}
node.bodies = bodies;
for nauo in self.children.get(&pd).cloned().unwrap_or_default() {
let Some(child) = self.ex.get(nauo).and_then(|e| e.params().get(4)).and_then(Param::as_ref_id) else { continue };
let child_own = self.pd_reps.get(&child).cloned().unwrap_or_default();
let child_reps = self.rep_closure(&child_own);
let m = self.occurrence_matrix(nauo, &child_reps);
let w = mat_mul(&world, &m);
let c = self.node(child, m, w, &path, depth + 1, stack);
node.children.push(c);
}
stack.pop();
node
}
}
/// Read a STEP file: its solids (as bodies, placed by the assembly structure) and the tree.
pub fn step_import(text: &str) -> Result<StepImport> {
let ex = p21::parse(text).map_err(KernelError::Invalid)?;
let mut rd = Reader {
ex: &ex,
units: HashMap::new(),
warnings: BTreeMap::new(),
reps: HashMap::new(),
rep_links: HashMap::new(),
pd_reps: HashMap::new(),
children: HashMap::new(),
placements: HashMap::new(),
colors: HashMap::new(),
solids: HashMap::new(),
used_items: HashSet::new(),
node_items: HashSet::new(),
bodies: Vec::new(),
};
rd.index();
// Roots: product definitions that are nobody's component.
let child_pds: HashSet<u64> =
rd.children.values().flatten().filter_map(|n| ex.get(*n).and_then(|e| e.params().get(4)).and_then(Param::as_ref_id)).collect();
let mut roots: Vec<u64> =
ex.entities.iter().filter(|(id, e)| e.name() == "PRODUCT_DEFINITION" && !child_pds.contains(id)).map(|(id, _)| *id).collect();
roots.sort();
let identity = mt::Matrix4::from_scale(1.0);
let mut tree = Vec::new();
for pd in roots {
let n = rd.node(pd, identity, identity, &[], 0, &mut Vec::new());
if !n.bodies.is_empty() || !n.children.is_empty() {
tree.push(n);
}
}
// Solids outside any product structure (bare geometry files).
let mut loose: Vec<u64> = rd
.reps
.iter()
.flat_map(|(r, (items, _))| items.iter().map(move |i| (*r, *i)))
.filter(|(_, i)| !rd.used_items.contains(i))
.filter(|(_, i)| {
ex.get(*i).is_some_and(|e| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL"))
})
.map(|(r, _)| r)
.collect();
loose.sort();
loose.dedup();
if !loose.is_empty() {
let mut bodies = Vec::new();
rd.node_items = rd.used_items.clone();
for r in loose {
rd.rep_bodies(r, &identity, &[], "Import", &mut bodies, 0);
}
if !bodies.is_empty() {
tree.push(StepNode { name: "Import".into(), transform: to_array(&identity), bodies, children: Vec::new() });
}
}
if let Some(first) = ex.duplicates.first() {
rd.warn(format!("{} entity ids are defined twice (first #{first}); the first definitions were used", ex.duplicates.len()));
}
let warnings: Vec<String> = rd.warnings.iter().map(|(m, n)| if *n > 1 { format!("{m} (×{n})") } else { m.clone() }).collect();
if rd.bodies.is_empty() {
let why = warnings.first().cloned().unwrap_or_else(|| "the file has no solids".into());
return Err(KernelError::Failed(format!("STEP import: no bodies ({why})")));
}
Ok(StepImport { bodies: rd.bodies, tree, warnings, schema: ex.schemas.join(", "), system: ex.originating_system.clone() })
}
#[cfg(test)]
mod tests;