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}