Skip to main content

brep_kernel/io/step_import/
assembly.rs

1//! STRUCTURED STEP import: read the product structure and keep it.
2//!
3//! [`collect_step_solids`] resolves a STEP assembly graph and then throws the
4//! structure away — it composes each occurrence's world transform, bakes it
5//! into a copy of the part, and returns a flat `Vec<BrepSolid>`. That is the
6//! right answer for "import these bodies", and it stays exactly as it is.
7//!
8//! This module is the other answer. It reads the SAME graph in the SAME single
9//! parse and returns it: one [`StepProduct`] per PRODUCT_DEFINITION holding
10//! that product's own bodies IN ITS OWN LOCAL FRAME, and one [`StepOccurrence`]
11//! per NEXT_ASSEMBLY_USAGE_OCCURRENCE edge carrying the child→parent placement.
12//! **Nothing here is transformed.** Composing the tree — and so choosing what a
13//! "component" is — belongs to the caller, which maps products onto parts-library
14//! entries and occurrences onto assembly-component instances.
15//!
16//! Two invariants make that safe:
17//!
18//! - **ONE parse, each body built ONCE.** The parse is shared with nothing (the
19//!   caller passes the text once), and inside it every distinct [`StepBody`] is
20//!   reconstructed a single time and cloned into its product, exactly as
21//!   `resolve_assembly` caches per body.
22//! - **The same walk.** Tree shape, visit order, transform composition and the
23//!   cycle guard all come from [`walk_occurrences`], which the flat lane also
24//!   uses, so the two lanes cannot drift apart. The kernel test suite asserts
25//!   the strong form of this: replaying this module's output through the flat
26//!   lane's own `transform_brep` call reproduces `resolve_assembly`'s solids
27//!   BIT-for-bit on every single-context assembly fixture.
28//!
29//! **Units are resolved PER PRODUCT here** (`step-assembly-import.md` §3.6),
30//! not once per file. `derive_length_scale_mm` picks one global scale out of
31//! `HashMap` iteration order, which a mixed-unit assembly — each product
32//! representation carrying its own `GLOBAL_UNIT_ASSIGNED_CONTEXT` — makes both
33//! wrong and nondeterministic. See [`ProductScales`].
34
35use super::*;
36
37/// One PRODUCT_DEFINITION in the file's product structure.
38#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
39pub struct StepProduct {
40    /// PRODUCT_DEFINITION entity id — the stable identity and dedup key.
41    pub pd_ref: usize,
42    /// `PRODUCT.name`, or `pd#<id>` when the file carries no PRODUCT chain.
43    pub name: String,
44    /// `PRODUCT.id` — the vendor part number, for the BOM. May be empty.
45    pub id: String,
46    /// This product's OWN solids, in its OWN local frame and at its OWN unit
47    /// scale. Empty ⇒ a pure assembly node (structure, no geometry).
48    pub bodies: Vec<BrepSolid>,
49    /// The styled colour of each entry of `bodies`, PARALLEL to it (index `i`
50    /// is body `i`). Default-filled when the file carries no presentation
51    /// entities, so it is always the same length as `bodies` and a caller can
52    /// `zip` without a length check.
53    pub appearances: Vec<BodyAppearance>,
54    /// Bodies of this product that did not reconstruct. Graceful degradation:
55    /// counted, never fatal, and never a reason to reject the file.
56    pub failed_bodies: usize,
57}
58
59/// One NAUO occurrence: a placement of `child` inside `parent`.
60#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
61pub struct StepOccurrence {
62    /// NEXT_ASSEMBLY_USAGE_OCCURRENCE entity id.
63    pub nauo_ref: usize,
64    /// Index into [`StepAssembly::products`].
65    pub parent: usize,
66    /// Index into [`StepAssembly::products`].
67    pub child: usize,
68    /// The instance label ("bolt-3") — see [`nauo_designator`] for which of the
69    /// NAUO's several label attributes wins.
70    pub designator: String,
71    /// child-local → parent-local, row-major affine, translations in MILLIMETRES
72    /// (scaled by the PARENT representation's unit, which is the frame the
73    /// placement is expressed in). NOT a world transform: the caller composes
74    /// these down from [`StepAssembly::roots`].
75    pub placement: [f64; 16],
76    /// RᵀR = I and det R = +1 within 1e-9 — i.e. the placement is a pure
77    /// rotation + translation, representable as a translate/rotate component
78    /// pose. A mirrored or scaled occurrence has no such representation and its
79    /// non-rigid factor must be baked into a distinct part by the caller.
80    pub rigid: bool,
81}
82
83/// A STEP file's product structure, with geometry, and without a single
84/// transform applied to it.
85#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
86pub struct StepAssembly {
87    /// Every product the structure mentions, ordered by ascending `pd_ref`.
88    pub products: Vec<StepProduct>,
89    /// One entry per NAUO edge, ordered by ascending `nauo_ref`.
90    pub occurrences: Vec<StepOccurrence>,
91    /// Indices of the products that are never an occurrence child — where a
92    /// consumer starts composing world transforms.
93    pub roots: Vec<usize>,
94    /// The first body-reconstruction error, if any body failed.
95    pub first_error: Option<String>,
96}
97
98/// Read a STEP file's product structure AND build each product's own bodies, in
99/// ONE parse, with each distinct body built ONCE.
100///
101/// `Ok(None)` when the file carries no assembly structure worth using — no NAUO
102/// edges at all, or none that the walk reaches a built body through. That is
103/// the same condition on which [`collect_step_solids`] abandons the assembly
104/// branch, so a caller can treat `None` as "fall back to the flat lane" and get
105/// exactly today's behaviour.
106///
107/// `Err` only for a file that is not Part 21 or does not parse — a broken
108/// *assembly* degrades, it does not fail.
109pub fn read_step_assembly(text: &str) -> Result<Option<StepAssembly>, String> {
110    if !text.contains("ISO-10303-21") {
111        return Err("step_import: not an ISO-10303-21 Part 21 file".into());
112    }
113    let entities = parse_data_section(text)?;
114    let edges = assembly_edges(&entities);
115    if edges.is_empty() {
116        return Ok(None); // no product structure: the flat lane is the answer
117    }
118
119    let global_scale = derive_length_scale_mm(&entities);
120    let mut scales = ProductScales::new(&entities, global_scale);
121
122    // Every product the structure mentions, by ascending entity id.
123    let mut pd_refs: Vec<usize> = edges
124        .iter()
125        .flat_map(|edge| [edge.parent_pd, edge.child_pd])
126        .collect();
127    pd_refs.sort_unstable();
128    pd_refs.dedup();
129    let product_index: HashMap<usize, usize> = pd_refs
130        .iter()
131        .enumerate()
132        .map(|(index, &pd)| (pd, index))
133        .collect();
134
135    // Each edge's placement, read through a Resolver at the PARENT product's
136    // scale: an ITEM_DEFINED_TRANSFORMATION's AXIS2_PLACEMENT_3D origins are
137    // lengths in the parent representation's context (§3.6.2), so the parent's
138    // unit — not the file-global one — converts them to millimetres. The
139    // rotation block is dimensionless and unaffected.
140    //
141    // (Strictly, `item_1` lives in the CHILD representation, so a file that
142    // BOTH mixes units AND gives item_1 a non-identity origin would want the
143    // two frames scaled separately. Every producer we have seen writes item_1
144    // as the child rep's origin placement, where the two agree; splitting the
145    // scales would mean reimplementing `edge_transform` rather than handing it
146    // a Resolver, which is a worse trade for a case no fixture exhibits.)
147    let mut transforms: HashMap<usize, Mat4> = HashMap::default();
148    for edge in &edges {
149        let parent_resolver = Resolver {
150            entities: &entities,
151            length_scale: scales.get(edge.parent_pd),
152        };
153        transforms.insert(
154            edge.nauo_id,
155            edge_transform(&entities, &parent_resolver, edge),
156        );
157    }
158
159    // Each product's own bodies, built at that product's own unit scale. The
160    // cache is keyed by (body, scale) rather than by body alone: two products
161    // can only share a body entity by sharing a representation, in which case
162    // they share a context and the scale key is redundant — but if they ever do
163    // not, the second product gets its own build instead of the first's.
164    // The file's presentation entities, read ONCE for the whole document.
165    let styles = StyleTable::read(&entities);
166    let mut solid_cache: HashMap<(StepBody, u64), Result<(BrepSolid, BodyAppearance), String>> =
167        HashMap::default();
168    let mut first_error: Option<String> = None;
169    let mut products: Vec<StepProduct> = Vec::with_capacity(pd_refs.len());
170    for &pd in &pd_refs {
171        let scale = scales.get(pd);
172        let resolver = Resolver {
173            entities: &entities,
174            length_scale: scale,
175        };
176        let scale_key = scale.to_bits();
177        let mut bodies = Vec::new();
178        let mut appearances = Vec::new();
179        let mut failed_bodies = 0usize;
180        for body in pd_step_bodies(&entities, &resolver, pd) {
181            let built = solid_cache.entry((body, scale_key)).or_insert_with(|| {
182                build_step_body(&resolver, body)
183                    .map(|solid| {
184                        let appearance = step_body_appearance(&resolver, &styles, body, &solid);
185                        (solid, appearance)
186                    })
187                    .map_err(|error| {
188                        format!("step_import: part body {body:?} failed to build: {error}")
189                    })
190            });
191            match built {
192                // Pushed together, so `bodies` and `appearances` stay parallel
193                // through every skip arm below.
194                Ok((solid, appearance)) => {
195                    bodies.push(solid.clone());
196                    appearances.push(appearance.clone());
197                }
198                // An OPEN SHELL_BASED_SURFACE_MODEL sheet is not a solid body:
199                // skipped, not counted, exactly as both existing lanes do.
200                Err(_) if matches!(body, StepBody::SurfaceModelShell { .. }) => {}
201                Err(error) => {
202                    failed_bodies += 1;
203                    if first_error.is_none() {
204                        first_error = Some(error.clone());
205                    }
206                }
207            }
208        }
209        products.push(StepProduct {
210            pd_ref: pd,
211            name: product_name(&resolver, pd),
212            id: product_id(&resolver, pd),
213            bodies,
214            appearances,
215            failed_bodies,
216        });
217    }
218
219    // Which products the structure actually REACHES — through the flat lane's
220    // own walk, so the cycle guard and root set are identical. A graph that
221    // reaches no geometry is not a structure we can import, so the caller falls
222    // back to the flat lane, exactly as `collect_step_solids` does when
223    // `resolve_assembly` yields no solid.
224    //
225    // (One corner differs harmlessly: a body that builds but fails
226    // `transform_brep` yields no flat solid, yet counts as reached here.)
227    let mut reached: HashSet<usize> = HashSet::default();
228    {
229        let resolver = Resolver {
230            entities: &entities,
231            length_scale: global_scale,
232        };
233        walk_occurrences(&resolver, &edges, &transforms, |node| {
234            reached.insert(node.pd);
235        });
236    }
237    let reaches_geometry = products
238        .iter()
239        .any(|product| !product.bodies.is_empty() && reached.contains(&product.pd_ref));
240    if !reaches_geometry {
241        return Ok(None);
242    }
243
244    let label_resolver = Resolver {
245        entities: &entities,
246        length_scale: global_scale,
247    };
248    let occurrences = edges
249        .iter()
250        .map(|edge| {
251            let placement = transforms
252                .get(&edge.nauo_id)
253                .copied()
254                .unwrap_or_else(mat4_identity);
255            StepOccurrence {
256                nauo_ref: edge.nauo_id,
257                parent: product_index[&edge.parent_pd],
258                child: product_index[&edge.child_pd],
259                designator: nauo_designator(&label_resolver, edge.nauo_id),
260                placement,
261                rigid: transform_is_rigid(&placement),
262            }
263        })
264        .collect();
265
266    let roots = assembly_roots(&edges)
267        .into_iter()
268        .map(|pd| product_index[&pd])
269        .collect();
270
271    Ok(Some(StepAssembly {
272        products,
273        occurrences,
274        roots,
275        first_error,
276    }))
277}
278
279/// Per-product length units, memoised (`step-assembly-import.md` §3.6).
280///
281/// A product's geometry is expressed in the REPRESENTATION_CONTEXT of the shape
282/// representation that defines it, and that context carries its own unit
283/// assignment. `Resolver` is only `{entities, length_scale}`, so building a
284/// product at its own scale costs one struct rebuild — which is the whole
285/// implementation of "per-product units".
286///
287/// Falls back to the file-global scale for a product with no resolvable context
288/// (a pure assembly node with no shape representation, typically), so a
289/// single-context file resolves to the identical scale everywhere and the
290/// structured lane's geometry stays byte-identical to the flat lane's.
291pub(super) struct ProductScales<'a> {
292    entities: &'a HashMap<usize, Entity>,
293    global: f64,
294    cache: HashMap<usize, f64>,
295}
296
297impl<'a> ProductScales<'a> {
298    pub(super) fn new(entities: &'a HashMap<usize, Entity>, global: f64) -> Self {
299        Self {
300            entities,
301            global,
302            cache: HashMap::default(),
303        }
304    }
305
306    /// Millimetres per native length unit for `pd`'s own representation. The
307    /// candidate SRs come from `pd_root_srs`, which sorts and dedups, so a
308    /// product with several representations resolves deterministically (unlike
309    /// `derive_length_scale_mm`, which reads whatever the `HashMap` yields).
310    pub(super) fn get(&mut self, pd: usize) -> f64 {
311        if let Some(&scale) = self.cache.get(&pd) {
312            return scale;
313        }
314        let scale = pd_root_srs(self.entities, pd)
315            .into_iter()
316            .filter_map(|sr| self.entities.get(&sr).and_then(representation_context))
317            .find_map(|context| context_length_scale_mm(self.entities, context))
318            .unwrap_or(self.global);
319        self.cache.insert(pd, scale);
320        scale
321    }
322}
323
324/// Is this affine's linear block a pure rotation — RᵀR = I and det R = +1 to
325/// 1e-9? A mirrored (det = −1) or scaled occurrence is NOT representable as a
326/// component pose, and its caller must bake the non-rigid factor into a
327/// distinct part rather than silently reuse the unmirrored twin.
328///
329/// Nothing the importer reads TODAY can fail this: an occurrence placement is
330/// built from two AXIS2_PLACEMENT_3Ds, and `Resolver::placement` always returns
331/// an orthonormal right-handed frame. It becomes load-bearing the moment
332/// CARTESIAN_TRANSFORMATION_OPERATOR_3D placements are read (§3.7), which is
333/// why the flag is computed and carried now rather than assumed away.
334pub(super) fn transform_is_rigid(matrix: &Mat4) -> bool {
335    const TOLERANCE: f64 = 1e-9;
336    let column = |index: usize| [matrix[index], matrix[4 + index], matrix[8 + index]];
337    let (x, y, z) = (column(0), column(1), column(2));
338    let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
339    for (a, b, expected) in [
340        (x, x, 1.0),
341        (y, y, 1.0),
342        (z, z, 1.0),
343        (x, y, 0.0),
344        (x, z, 0.0),
345        (y, z, 0.0),
346    ] {
347        if (dot(a, b) - expected).abs() > TOLERANCE {
348            return false;
349        }
350    }
351    // det R, as the scalar triple product of the columns.
352    let cross = [
353        y[1] * z[2] - y[2] * z[1],
354        y[2] * z[0] - y[0] * z[2],
355        y[0] * z[1] - y[1] * z[0],
356    ];
357    (dot(x, cross) - 1.0).abs() <= TOLERANCE
358}
359
360/// The instance label of a NAUO —
361/// `NEXT_ASSEMBLY_USAGE_OCCURRENCE(id, name, description, relating, related,
362/// reference_designator)`.
363///
364/// Producers disagree about which attribute carries it, so take the first
365/// NON-BLANK of `reference_designator` → `name` → `description` → `id`. The
366/// three assembly fixtures each land on a different one: AssemblyExample and
367/// io1-ac put it in `name`; as1-ug writes `name` as a single space and the real
368/// designator ('ROD', 'NUT1', …) in `description`; a producer that fills
369/// `reference_designator` is the standard-conforming case and wins outright.
370fn nauo_designator(resolver: &Resolver, nauo: usize) -> String {
371    let field = |args: &[Value], index: usize| match args.get(index) {
372        Some(Value::Str(text)) if !text.trim().is_empty() => Some(text.trim().to_string()),
373        _ => None,
374    };
375    (|| {
376        let args = resolver
377            .get(nauo)
378            .ok()?
379            .find("NEXT_ASSEMBLY_USAGE_OCCURRENCE")?;
380        field(args, 5)
381            .or_else(|| field(args, 1))
382            .or_else(|| field(args, 2))
383            .or_else(|| field(args, 0))
384    })()
385    .unwrap_or_else(|| format!("nauo#{nauo}"))
386}