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 structure node holding that
10//! node's own bodies IN ITS OWN LOCAL FRAME, and one [`StepOccurrence`] per
11//! structure EDGE — a NEXT_ASSEMBLY_USAGE_OCCURRENCE or a MAPPED_ITEM
12//! (`mapped.rs`) — carrying the child→parent placement.
13//! **Nothing here is transformed.** Composing the tree — and so choosing what a
14//! "component" is — belongs to the caller, which maps products onto parts-library
15//! entries and occurrences onto assembly-component instances.
16//!
17//! Two invariants make that safe:
18//!
19//! - **ONE parse, each body built ONCE.** The parse is shared with nothing (the
20//! caller passes the text once), and inside it every distinct [`StepBody`] is
21//! reconstructed a single time and cloned into its product, exactly as
22//! `resolve_assembly` caches per body.
23//! - **The same walk.** Tree shape, visit order, transform composition and the
24//! cycle guard all come from [`walk_occurrences`], which the flat lane also
25//! uses, so the two lanes cannot drift apart. The kernel test suite asserts
26//! the strong form of this: replaying this module's output through the flat
27//! lane's own `transform_brep` call reproduces `resolve_assembly`'s solids
28//! BIT-for-bit on every single-context assembly fixture.
29//!
30//! **Units are resolved PER PRODUCT here**, not once per file.
31//! `derive_length_scale_mm` picks one global scale out of `HashMap` iteration
32//! order, which a mixed-unit assembly — each product representation carrying
33//! its own `GLOBAL_UNIT_ASSIGNED_CONTEXT` — makes both wrong and
34//! nondeterministic. See [`ProductScales`].
35
36use super::*;
37use crate::feature_pipeline::pmi::PmiState;
38
39/// One node of the file's product structure: a PRODUCT_DEFINITION, or a
40/// representation that a MAPPED_ITEM instantiates and no product definition owns
41/// (`bodies::representation_owner_index`).
42#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
43pub struct StepProduct {
44 /// The node's entity id — a PRODUCT_DEFINITION, or the representation that
45 /// stands in for one. The stable identity and dedup key either way, because
46 /// Part 21 ids are unique within a file.
47 pub pd_ref: usize,
48 /// `PRODUCT.name`, the representation's name for a node that is one, or
49 /// `pd#<id>` when the file carries neither.
50 pub name: String,
51 /// `PRODUCT.id` — the vendor part number, for the BOM. May be empty.
52 ///
53 /// The CONSUMER is what makes this durable: the app's assembly intake
54 /// records it as `partAttributes.Part_Number` on the part document it mints
55 /// (`BREP_render`'s `model_io.rs::part_attributes`), which is the record
56 /// `io/step/export_tree.rs` reads back out as `PRODUCT.id`. An intake that
57 /// dropped it would silently replace a vendor's part numbers with our own
58 /// file names on the first export -> import -> export.
59 pub id: String,
60 /// This product's OWN solids, in its OWN local frame and at its OWN unit
61 /// scale. Empty ⇒ a pure assembly node (structure, no geometry).
62 pub bodies: Vec<BrepSolid>,
63 /// The styled colour of each entry of `bodies`, PARALLEL to it (index `i`
64 /// is body `i`). Default-filled when the file carries no presentation
65 /// entities, so it is always the same length as `bodies` and a caller can
66 /// `zip` without a length check.
67 pub appearances: Vec<BodyAppearance>,
68 /// Bodies of this product that did not reconstruct. Graceful degradation:
69 /// counted, never fatal, and never a reason to reject the file.
70 pub failed_bodies: usize,
71 /// The file's PMI that annotates THIS product's own geometry, with
72 /// references naming the faces / edges / vertices the part document's
73 /// `IMPORT3D1` feature will stamp (`assembly_pmi.rs`).
74 ///
75 /// ONE lift per PART, however many occurrences place it — an annotation on
76 /// a part's face IS an annotation on every instance of that part, which is
77 /// the exporter's rule read backwards (`io/step/assembly.rs`: two
78 /// instances of one part share the part's `ADVANCED_FACE`s). PMI the file
79 /// pinned to a particular INSTANCE is not here; it rides
80 /// [`StepAssembly::pmi`].
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub pmi: Option<PmiState>,
83}
84
85/// One occurrence: a placement of `child` inside `parent`, from a NAUO edge or a
86/// mapped item.
87#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
88pub struct StepOccurrence {
89 /// The entity that IS this occurrence: a NEXT_ASSEMBLY_USAGE_OCCURRENCE, or
90 /// the MAPPED_ITEM that placed a shared representation (`mapped.rs`). Part 21
91 /// ids are unique per file, so this identifies the occurrence either way, and
92 /// consumers order sibling occurrences by it for determinism.
93 pub edge_ref: usize,
94 /// Index into [`StepAssembly::products`].
95 pub parent: usize,
96 /// Index into [`StepAssembly::products`].
97 pub child: usize,
98 /// The instance label ("bolt-3") — see [`edge_designator`] for which of a
99 /// NAUO's several label attributes wins, and what a mapped item contributes.
100 pub designator: String,
101 /// child-local → parent-local, row-major affine, translations in MILLIMETRES
102 /// (scaled by the PARENT representation's unit, which is the frame the
103 /// placement is expressed in). NOT a world transform: the caller composes
104 /// these down from [`StepAssembly::roots`].
105 pub placement: [f64; 16],
106 /// RᵀR = I and det R = +1 within 1e-9 — i.e. the placement is a pure
107 /// rotation + translation, representable as a translate/rotate component
108 /// pose. A mirrored or scaled occurrence has no such representation and its
109 /// non-rigid factor must be baked into a distinct part by the caller, which
110 /// is what `BREP_render`'s assembly intake does. A MAPPED_ITEM through a
111 /// CARTESIAN_TRANSFORMATION_OPERATOR_3D is the placement that makes this
112 /// `false` today (`mapped.rs`).
113 pub rigid: bool,
114}
115
116/// A STEP file's product structure, with geometry, and without a single
117/// transform applied to it.
118#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
119pub struct StepAssembly {
120 /// Every product the structure mentions, ordered by ascending `pd_ref`.
121 pub products: Vec<StepProduct>,
122 /// One entry per structure edge, ordered by ascending `edge_ref`.
123 pub occurrences: Vec<StepOccurrence>,
124 /// Indices of the products that are never an occurrence child — where a
125 /// consumer starts composing world transforms.
126 pub roots: Vec<usize>,
127 /// The first thing that did not come in: a body that failed to reconstruct,
128 /// or a mapping that was refused ([`StepMappedItemError`]). A refused mapping
129 /// is NOT counted in any product's `failed_bodies`, which counts bodies that
130 /// failed to BUILD — a placement we cannot express is a different thing, and
131 /// the geometry under it built perfectly well.
132 pub first_error: Option<String>,
133 /// The file's ASSEMBLY-level PMI: annotations the file pinned to an
134 /// OCCURRENCE rather than a part, annotations spanning two parts, and
135 /// annotations with no geometry of their own (a free note, a saved view).
136 ///
137 /// References to a component's geometry carry the STEP occurrence chain
138 /// they resolve through (`OCCURRENCE_REF_PREFIX`): the kernel cannot know
139 /// what the consumer will call its components, so the consumer rewrites
140 /// them with `rewrite_occurrence_refs`.
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub pmi: Option<PmiState>,
143 /// What the PMI lift could not do cleanly — a reference naming geometry
144 /// this import did not build, an occurrence chain nothing placed. Every
145 /// one of these describes a KEPT annotation: PMI is never dropped for want
146 /// of a reference, it arrives as a row that reports itself.
147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
148 pub pmi_diagnostics: Vec<String>,
149}
150
151/// Read a STEP file's product structure AND build each product's own bodies, in
152/// ONE parse, with each distinct body built ONCE.
153///
154/// `Ok(None)` when the file carries no assembly structure worth using — no
155/// structure edges at all (neither NAUO nor mapped item), or none that the walk
156/// reaches a built body through. That is the same condition on which
157/// [`collect_step_solids`] abandons the assembly branch, so a caller can treat
158/// `None` as "fall back to the flat lane" and get exactly today's behaviour.
159///
160/// `Err` only for a file that is not Part 21 or does not parse — a broken
161/// *assembly* degrades, it does not fail.
162pub fn read_step_assembly(text: &str) -> Result<Option<StepAssembly>, String> {
163 if !text.contains("ISO-10303-21") {
164 return Err("step_import: not an ISO-10303-21 Part 21 file".into());
165 }
166 let entities = parse_data_section(text)?;
167 let global_scale = derive_length_scale_mm(&entities);
168 let edges = structure_edges(&entities, &Resolver::new(&entities, global_scale));
169 if edges.is_empty() {
170 return Ok(None); // no product structure: the flat lane is the answer
171 }
172
173 let mut scales = ProductScales::new(&entities, global_scale);
174
175 // Each edge's placement, read at the scale of the representation each of its
176 // frames is an item of: an ITEM_DEFINED_TRANSFORMATION's AXIS2_PLACEMENT_3D
177 // origins are lengths in the PARENT representation's context (§3.6.2), so
178 // the parent's unit — not the file-global one — converts them to
179 // millimetres, and a mapped item's `mapping_origin` is an item of the CHILD
180 // representation and takes the child's (`mapped.rs`). The rotation blocks
181 // are dimensionless and unaffected.
182 //
183 // (Strictly, a NAUO's `item_1` lives in the CHILD representation too, so a
184 // file that BOTH mixes units AND gives item_1 a non-identity origin would
185 // want its two frames scaled separately. Every producer we have seen writes
186 // item_1 as the child rep's origin placement, where the two agree; splitting
187 // them would mean reimplementing `nauo_edge_transform` rather than handing
188 // it a Resolver, which is a worse trade for a case no fixture exhibits. A
189 // MAPPED_ITEM is not in that position: its two frames are REQUIRED to be
190 // items of the two different representations, so the split is the only
191 // correct reading and `mapped-item-nested-nauo.step` exhibits it.)
192 //
193 // A mapping we cannot express as a rigid placement is REFUSED here: its edge
194 // is dropped, so nothing under it is instantiated, and the refusal is
195 // reported through `first_error`.
196 let ResolvedEdges {
197 edges,
198 transforms,
199 refusals,
200 } = resolve_structure_edges(&entities, &edges, |node| scales.get(node));
201 let mut first_error: Option<String> = refusals.first().map(ToString::to_string);
202 if edges.is_empty() {
203 return Ok(None); // every edge refused: nothing to build a structure from
204 }
205
206 // Every structure node the surviving edges mention, by ascending entity id.
207 let mut pd_refs: Vec<usize> = edges
208 .iter()
209 .flat_map(|edge| [edge.parent_pd, edge.child_pd])
210 .collect();
211 pd_refs.sort_unstable();
212 pd_refs.dedup();
213 let product_index: HashMap<usize, usize> = pd_refs
214 .iter()
215 .enumerate()
216 .map(|(index, &pd)| (pd, index))
217 .collect();
218
219 // Each product's own bodies, built at that product's own unit scale. The
220 // cache is keyed by (body, scale) rather than by body alone: two products
221 // can only share a body entity by sharing a representation, in which case
222 // they share a context and the scale key is redundant — but if they ever do
223 // not, the second product gets its own build instead of the first's.
224 // The file's presentation entities, read ONCE for the whole document.
225 let styles = StyleTable::read(&entities);
226 let mut solid_cache: HashMap<(StepBody, u64), Result<(BrepSolid, BodyAppearance), String>> =
227 HashMap::default();
228 let mut products: Vec<StepProduct> = Vec::with_capacity(pd_refs.len());
229 // The face refs of every body that built, in `bodies` order — the pairing
230 // an annotation's `ADVANCED_FACE` is mapped to `IMPORT3D1_Face_i` through
231 // (`assembly_pmi.rs`). Collected only when the file mentions PMI at all,
232 // so a plain assembly pays nothing for it.
233 let wants_pmi = super::pmi::file_mentions_pmi(text);
234 let mut face_refs: super::assembly_pmi::ProductFaceRefs = Vec::with_capacity(pd_refs.len());
235 for &pd in &pd_refs {
236 let scale = scales.get(pd);
237 let resolver = Resolver::new(&entities, scale);
238 let scale_key = scale.to_bits();
239 let mut bodies = Vec::new();
240 let mut appearances = Vec::new();
241 let mut product_face_refs = Vec::new();
242 let mut failed_bodies = 0usize;
243 for body in pd_step_bodies(&entities, &resolver, pd) {
244 let built = solid_cache.entry((body, scale_key)).or_insert_with(|| {
245 build_step_body(&resolver, body)
246 .map(|(solid, _readings)| {
247 let appearance = step_body_appearance(&resolver, &styles, body, &solid);
248 (solid, appearance)
249 })
250 .map_err(|error| {
251 format!("step_import: part body {body:?} failed to build: {error}")
252 })
253 });
254 match built {
255 // Pushed together, so `bodies` and `appearances` stay parallel
256 // through every skip arm below.
257 Ok((solid, appearance)) => {
258 if wants_pmi {
259 product_face_refs.push(super::pmi::body_face_refs(&resolver, body, solid));
260 }
261 bodies.push(solid.clone());
262 appearances.push(appearance.clone());
263 }
264 // An OPEN SHELL_BASED_SURFACE_MODEL sheet is not a solid body:
265 // skipped, not counted, exactly as both existing lanes do.
266 Err(_) if matches!(body, StepBody::SurfaceModelShell { .. }) => {}
267 Err(error) => {
268 failed_bodies += 1;
269 if first_error.is_none() {
270 first_error = Some(error.clone());
271 }
272 }
273 }
274 }
275 face_refs.push(product_face_refs);
276 products.push(StepProduct {
277 pd_ref: pd,
278 name: product_name(&resolver, pd),
279 id: product_id(&resolver, pd),
280 bodies,
281 appearances,
282 failed_bodies,
283 pmi: None,
284 });
285 }
286
287 // Which products the structure actually REACHES — through the flat lane's
288 // own walk, so the cycle guard and root set are identical. A graph that
289 // reaches no geometry is not a structure we can import, so the caller falls
290 // back to the flat lane, exactly as `collect_step_solids` does when
291 // `resolve_assembly` yields no solid.
292 //
293 // (One corner differs harmlessly: a body that builds but fails
294 // `transform_brep` yields no flat solid, yet counts as reached here.)
295 let mut reached: HashSet<usize> = HashSet::default();
296 {
297 let resolver = Resolver::new(&entities, global_scale);
298 walk_occurrences(&resolver, &edges, &transforms, |node| {
299 reached.insert(node.pd);
300 });
301 }
302 let reaches_geometry = products
303 .iter()
304 .any(|product| !product.bodies.is_empty() && reached.contains(&product.pd_ref));
305 if !reaches_geometry {
306 return Ok(None);
307 }
308
309 let label_resolver = Resolver::new(&entities, global_scale);
310 let occurrences: Vec<StepOccurrence> = edges
311 .iter()
312 .map(|edge| {
313 let placement = transforms
314 .get(&edge.edge_id)
315 .copied()
316 .unwrap_or_else(mat4_identity);
317 StepOccurrence {
318 edge_ref: edge.edge_id,
319 parent: product_index[&edge.parent_pd],
320 child: product_index[&edge.child_pd],
321 designator: edge_designator(&label_resolver, edge),
322 placement,
323 rigid: transform_is_rigid(&placement),
324 }
325 })
326 .collect();
327
328 let roots: Vec<usize> = assembly_roots(&edges)
329 .into_iter()
330 .map(|pd| product_index[&pd])
331 .collect();
332
333 // The file's PMI, split between the part documents this import will mint
334 // and the assembly document that places them (`assembly_pmi.rs`). Read
335 // here rather than by the consumer because this is where the file text,
336 // the parse and each product's own face pairing are all in hand at once —
337 // the same reason the geometry is built here.
338 let mut pmi = super::assembly_pmi::AssemblyPmi::default();
339 if wants_pmi {
340 pmi = super::assembly_pmi::lift(
341 text,
342 &entities,
343 &products,
344 &face_refs,
345 &occurrences,
346 &roots,
347 global_scale,
348 |pd| scales.get(pd),
349 );
350 for (product, state) in products.iter_mut().zip(pmi.per_product.drain(..)) {
351 product.pmi = state;
352 }
353 }
354
355 Ok(Some(StepAssembly {
356 products,
357 occurrences,
358 roots,
359 first_error,
360 pmi: pmi.assembly,
361 pmi_diagnostics: pmi.diagnostics,
362 }))
363}
364
365/// Per-product length units, memoised.
366///
367/// A product's geometry is expressed in the REPRESENTATION_CONTEXT of the shape
368/// representation that defines it, and that context carries its own unit
369/// assignment. `Resolver` is only `{entities, length_scale}`, so building a
370/// product at its own scale costs one struct rebuild — which is the whole
371/// implementation of "per-product units".
372///
373/// Falls back to the file-global scale for a product with no resolvable context
374/// (a pure assembly node with no shape representation, typically), so a
375/// single-context file resolves to the identical scale everywhere and the
376/// structured lane's geometry stays byte-identical to the flat lane's.
377pub(super) struct ProductScales<'a> {
378 entities: &'a HashMap<usize, Entity>,
379 global: f64,
380 cache: HashMap<usize, f64>,
381}
382
383impl<'a> ProductScales<'a> {
384 pub(super) fn new(entities: &'a HashMap<usize, Entity>, global: f64) -> Self {
385 Self {
386 entities,
387 global,
388 cache: HashMap::default(),
389 }
390 }
391
392 /// Millimetres per native length unit for `pd`'s own representation. The
393 /// candidate SRs come from `pd_root_srs`, which sorts and dedups, so a
394 /// product with several representations resolves deterministically (unlike
395 /// `derive_length_scale_mm`, which reads whatever the `HashMap` yields).
396 pub(super) fn get(&mut self, pd: usize) -> f64 {
397 if let Some(&scale) = self.cache.get(&pd) {
398 return scale;
399 }
400 let scale = pd_root_srs(self.entities, pd)
401 .into_iter()
402 .filter_map(|sr| self.entities.get(&sr).and_then(representation_context))
403 .find_map(|context| context_length_scale_mm(self.entities, context))
404 .unwrap_or(self.global);
405 self.cache.insert(pd, scale);
406 scale
407 }
408}
409
410/// Is this affine's linear block a pure rotation — RᵀR = I and det R = +1 to
411/// 1e-9? A mirrored (det = −1) or scaled occurrence is NOT representable as a
412/// component pose, and its caller must bake the non-rigid factor into a
413/// distinct part rather than silently reuse the unmirrored twin.
414///
415/// A NAUO placement cannot fail this: it is built from two AXIS2_PLACEMENT_3Ds,
416/// and `Resolver::placement` always returns an orthonormal right-handed frame. A
417/// MAPPED_ITEM's can, and does — its target may be a
418/// CARTESIAN_TRANSFORMATION_OPERATOR_3D with a left-handed triad or a `scale`
419/// (`mapped.rs`) — so this flag is now LIVE rather than carried for a future
420/// lane, and `mapped-item-mirrored.step` is the fixture that makes it false.
421///
422/// `false` is not a refusal. It is the statement that this placement is not a
423/// component pose, which the caller answers by splitting `world = rigid · factor`
424/// and baking `factor` into a distinct part — `BREP_render`'s `split_rigid` /
425/// `build_library_entry`, whose mirrored part is named `… (mirrored)` and counted
426/// in the import notice's "N mirrored instances baked". The flat import lane bakes
427/// the same reflection directly into the posed geometry it returns
428/// (`resolve_assembly`, through `transform_brep`'s orientation reversal), so the
429/// two lanes agree about what the file means.
430pub(super) fn transform_is_rigid(matrix: &Mat4) -> bool {
431 const TOLERANCE: f64 = 1e-9;
432 let column = |index: usize| [matrix[index], matrix[4 + index], matrix[8 + index]];
433 let (x, y, z) = (column(0), column(1), column(2));
434 let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
435 for (a, b, expected) in [
436 (x, x, 1.0),
437 (y, y, 1.0),
438 (z, z, 1.0),
439 (x, y, 0.0),
440 (x, z, 0.0),
441 (y, z, 0.0),
442 ] {
443 if (dot(a, b) - expected).abs() > TOLERANCE {
444 return false;
445 }
446 }
447 // det R, as the scalar triple product of the columns.
448 let cross = [
449 y[1] * z[2] - y[2] * z[1],
450 y[2] * z[0] - y[0] * z[2],
451 y[0] * z[1] - y[1] * z[0],
452 ];
453 (dot(x, cross) - 1.0).abs() <= TOLERANCE
454}
455
456/// The instance label of one structure edge: a mapped item's own name
457/// (`mapped::mapped_item_designator`), or a NAUO's designator below.
458fn edge_designator(resolver: &Resolver, edge: &AssemblyEdge) -> String {
459 match edge.kind {
460 StructureEdgeKind::Nauo => nauo_designator(resolver, edge.edge_id),
461 StructureEdgeKind::Mapped => mapped_item_designator(resolver, edge.edge_id),
462 }
463}
464
465/// The instance label of a NAUO —
466/// `NEXT_ASSEMBLY_USAGE_OCCURRENCE(id, name, description, relating, related,
467/// reference_designator)`.
468///
469/// Producers disagree about which attribute carries it, so take the first
470/// NON-BLANK of `reference_designator` → `name` → `description` → `id`. The
471/// three assembly fixtures each land on a different one: AssemblyExample and
472/// io1-ac put it in `name`; as1-ug writes `name` as a single space and the real
473/// designator ('ROD', 'NUT1', …) in `description`; a producer that fills
474/// `reference_designator` is the standard-conforming case and wins outright.
475fn nauo_designator(resolver: &Resolver, nauo: usize) -> String {
476 let field = |args: &[Value], index: usize| match args.get(index) {
477 Some(Value::Str(text)) if !text.trim().is_empty() => Some(text.trim().to_string()),
478 _ => None,
479 };
480 (|| {
481 let args = resolver
482 .get(nauo)
483 .ok()?
484 .find("NEXT_ASSEMBLY_USAGE_OCCURRENCE")?;
485 field(args, 5)
486 .or_else(|| field(args, 1))
487 .or_else(|| field(args, 2))
488 .or_else(|| field(args, 0))
489 })()
490 .unwrap_or_else(|| format!("nauo#{nauo}"))
491}