Skip to main content

brep_kernel/io/step/
assembly.rs

1//! STRUCTURED STEP export: write a product structure, not just bodies.
2//!
3//! The flat writer ([`super::export_step_report_named`]) puts every body of the
4//! document into ONE `PRODUCT`. That is the right answer for a part, and the
5//! wrong one for an assembly: a nested assembly came out as a bag of bodies at
6//! their world positions, with the parts library, the instance count and the
7//! hierarchy all discarded.
8//!
9//! This module is the other answer. It takes a [`StepAssemblyExport`] — one
10//! [`StepExportProduct`] per distinct part holding that part's bodies IN ITS OWN
11//! LOCAL FRAME, plus one [`StepExportOccurrence`] per placement — and writes the
12//! AP242 graph the importer (`io/step_import/`) reads back:
13//!
14//! ```text
15//!   PRODUCT -> PRODUCT_DEFINITION_FORMATION -> PRODUCT_DEFINITION
16//!       -> PRODUCT_DEFINITION_SHAPE -> SHAPE_DEFINITION_REPRESENTATION
17//!       -> ADVANCED_BREP_SHAPE_REPRESENTATION (the part's own bodies)
18//!
19//!   NEXT_ASSEMBLY_USAGE_OCCURRENCE(parent_pd, child_pd)
20//!       <- PRODUCT_DEFINITION_SHAPE('NAUO PRDDFN')
21//!       <- CONTEXT_DEPENDENT_SHAPE_REPRESENTATION
22//!       -> ( REPRESENTATION_RELATIONSHIP(child_rep, parent_rep)
23//!            REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(
24//!              ITEM_DEFINED_TRANSFORMATION(identity_axis, placement_axis) )
25//!            SHAPE_REPRESENTATION_RELATIONSHIP() )
26//! ```
27//!
28//! # The placement convention, and why it is copied rather than chosen
29//!
30//! A reader composes `T = M(item_2) · M(item_1)⁻¹` to map `rep_1` into `rep_2`.
31//! With `rep_1` the CHILD and `item_1` the identity axis, `T` is simply
32//! `M(item_2)` — the child's frame expressed in the parent. That ordering is
33//! taken from the `as1-ug-214` vendor fixture
34//! (`REPRESENTATION_RELATIONSHIP(' ',' ',#213 /* rod */,#219 /* rod_assem */)`
35//! with `ITEM_DEFINED_TRANSFORMATION(' ',' ',#1041 /* identity */,#1109)`), NOT
36//! from our own importer: `resolve_rep_rel_transform` reorients using the
37//! child's known root representation, so a self round-trip would have passed
38//! with the ordering reversed and told us nothing.
39//!
40//! For the same reason `item_1`/`item_2` are ITEMS of the reps they belong to —
41//! the identity axis is the first item of every representation this writer
42//! emits, and each occurrence's placement axis is pushed into its PARENT's item
43//! list. A conformance checker requires exactly that.
44//!
45//! # Naming, and what PMI resolves against
46//!
47//! A part's bodies, faces and edges keep the part's OWN names — `Extrude1_top`,
48//! not `ACOMP3:Extrude1_top` — because they are written into the part's product,
49//! where the component namespace does not apply. The document still refers to
50//! them by the namespaced name, so [`occurrence_paths`] walks the graph and
51//! registers every entity under EVERY chained occurrence path that reaches it
52//! (`ACOMP3:`, `ACOMP5:ACOMP1:`), with the vertex points moved into root space
53//! so a `{body}@x,y,z` reference still matches. Two instances of one part share
54//! the part's entities, so `ACOMP3:top` and `ACOMP4:top` resolve to the SAME
55//! `ADVANCED_FACE`: an annotation on one instance is an annotation on the part.
56
57use super::{
58    finish_step_file, id_list, mat4_mul, pmi, step_string, write_file_contexts, write_product,
59    write_product_geometry, Mat4, StepExportReport, StepItemOwner, StepNameMaps, StepPmi,
60    StepWriter, MAT4_IDENTITY,
61};
62use crate::{BrepSolid, Vec3};
63
64/// One product of an exported structure: a part, a sub-assembly, or the root
65/// document. `bodies` are in the product's OWN local frame — never posed.
66#[derive(Clone, Debug, Default)]
67pub struct StepExportProduct {
68    /// `PRODUCT.name` — the parts-library entry name, or the document name.
69    pub name: String,
70    /// `PRODUCT.id` — the vendor part number. The name is used when empty.
71    pub id: String,
72    /// This product's own solids and their part-local scene names. Empty for a
73    /// pure assembly node, which is then written as a bare `SHAPE_REPRESENTATION`.
74    pub bodies: Vec<(String, BrepSolid)>,
75}
76
77/// One placement of `child` inside `parent` — a `NEXT_ASSEMBLY_USAGE_OCCURRENCE`.
78#[derive(Clone, Debug)]
79pub struct StepExportOccurrence {
80    /// The instance label: the placing component's id (`ACOMP3`). Written into
81    /// the NAUO's `reference_designator`, which is what the importer reads back
82    /// first, so an instance keeps its identity through the round trip.
83    pub designator: String,
84    /// Index into [`StepAssemblyExport::products`].
85    pub parent: usize,
86    /// Index into [`StepAssemblyExport::products`].
87    pub child: usize,
88    /// child-local -> parent-local, row-major affine. Must be RIGID: a mirrored
89    /// or scaled instance has no `AXIS2_PLACEMENT_3D` and is rejected rather
90    /// than silently written as its rotation part.
91    pub placement: Mat4,
92}
93
94/// A document's product structure, ready to write. Exactly one product must be
95/// a root (never an occurrence child) — the document itself.
96#[derive(Clone, Debug, Default)]
97pub struct StepAssemblyExport {
98    pub products: Vec<StepExportProduct>,
99    pub occurrences: Vec<StepExportOccurrence>,
100}
101
102/// How many occurrence paths the PMI alias walk will register before it stops.
103/// One path per instance is the honest count, and a real assembly has
104/// thousands at most; the cap only bounds a pathological diamond graph, whose
105/// path count is exponential in its depth.
106const MAX_OCCURRENCE_PATHS: usize = 100_000;
107
108impl StepAssemblyExport {
109    /// The single root product's index, with the graph checked on the way: every
110    /// occurrence in range, no self-placement, exactly one root, no cycle, every
111    /// product reachable, every placement rigid.
112    pub fn root(&self) -> Result<usize, String> {
113        if self.products.is_empty() {
114            return Err("export_step: an assembly needs at least one product".into());
115        }
116        let count = self.products.len();
117        let mut is_child = vec![false; count];
118        for occurrence in &self.occurrences {
119            if occurrence.parent >= count || occurrence.child >= count {
120                return Err(format!(
121                    "export_step: occurrence '{}' names a product outside the structure",
122                    occurrence.designator
123                ));
124            }
125            if occurrence.parent == occurrence.child {
126                return Err(format!(
127                    "export_step: occurrence '{}' places a product inside itself",
128                    occurrence.designator
129                ));
130            }
131            rigid(&occurrence.placement).map_err(|error| {
132                format!(
133                    "export_step: occurrence '{}' placement: {error}",
134                    occurrence.designator
135                )
136            })?;
137            is_child[occurrence.child] = true;
138        }
139        let roots: Vec<usize> = (0..count).filter(|index| !is_child[*index]).collect();
140        let [root] = roots.as_slice() else {
141            return Err(format!(
142                "export_step: an assembly needs exactly one root product, found {}",
143                roots.len()
144            ));
145        };
146        // Reachability doubles as the cycle check: a cycle makes every product
147        // on it a child, so an unreached product is either orphaned or looping.
148        let mut reached = vec![false; count];
149        let mut stack = vec![*root];
150        reached[*root] = true;
151        while let Some(product) = stack.pop() {
152            for occurrence in &self.occurrences {
153                if occurrence.parent == product && !reached[occurrence.child] {
154                    reached[occurrence.child] = true;
155                    stack.push(occurrence.child);
156                }
157            }
158        }
159        if let Some(lost) = reached.iter().position(|hit| !hit) {
160            return Err(format!(
161                "export_step: product '{}' is not reachable from the root",
162                self.products[lost].name
163            ));
164        }
165        Ok(*root)
166    }
167
168    /// Every chained occurrence path that reaches each product: the component
169    /// namespace prefix the DOCUMENT knows its entities by, and the transform
170    /// into root space. The root itself gets one empty path at identity.
171    ///
172    /// Depth-first from the root, guarding the path's own ancestors, so a
173    /// structure that somehow still carries a cycle terminates.
174    fn occurrence_paths(&self, root: usize) -> Vec<Vec<(String, Mat4)>> {
175        let mut paths: Vec<Vec<(String, Mat4)>> = vec![Vec::new(); self.products.len()];
176        let mut total = 0usize;
177        let mut stack: Vec<(usize, String, Mat4, Vec<usize>)> =
178            vec![(root, String::new(), MAT4_IDENTITY, vec![root])];
179        while let Some((product, prefix, world, ancestors)) = stack.pop() {
180            if total >= MAX_OCCURRENCE_PATHS {
181                break;
182            }
183            total += 1;
184            paths[product].push((prefix.clone(), world));
185            for occurrence in &self.occurrences {
186                if occurrence.parent != product || ancestors.contains(&occurrence.child) {
187                    continue;
188                }
189                let mut ancestors = ancestors.clone();
190                ancestors.push(occurrence.child);
191                stack.push((
192                    occurrence.child,
193                    format!("{prefix}{}:", occurrence.designator),
194                    mat4_mul(&world, &occurrence.placement),
195                    ancestors,
196                ));
197            }
198        }
199        paths
200    }
201}
202
203/// A placement is rigid when its linear block is orthonormal with a positive
204/// determinant — the only kind an `AXIS2_PLACEMENT_3D` can express.
205fn rigid(matrix: &Mat4) -> Result<(), String> {
206    if matrix.iter().any(|value| !value.is_finite()) {
207        return Err("matrix is not finite".into());
208    }
209    let column = |index: usize| {
210        [
211            matrix[index],
212            matrix[4 + index],
213            matrix[8 + index],
214        ]
215    };
216    let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
217    let (x, y, z) = (column(0), column(1), column(2));
218    const TOLERANCE: f64 = 1e-9;
219    for (a, b, expected) in [
220        (x, x, 1.0),
221        (y, y, 1.0),
222        (z, z, 1.0),
223        (x, y, 0.0),
224        (x, z, 0.0),
225        (y, z, 0.0),
226    ] {
227        if (dot(a, b) - expected).abs() > TOLERANCE {
228            return Err("matrix is not a rotation (mirrored or scaled)".into());
229        }
230    }
231    let cross = [
232        x[1] * y[2] - x[2] * y[1],
233        x[2] * y[0] - x[0] * y[2],
234        x[0] * y[1] - x[1] * y[0],
235    ];
236    if (dot(cross, z) - 1.0).abs() > TOLERANCE {
237        return Err("matrix is left-handed (a mirrored instance)".into());
238    }
239    Ok(())
240}
241
242/// The `AXIS2_PLACEMENT_3D` expressing a rigid placement's frame: the origin is
243/// its translation, the axis its third linear COLUMN and the reference direction
244/// its first — the exact inverse of the importer's `frame_matrix`.
245fn write_placement_axis(writer: &mut StepWriter, matrix: &Mat4) -> Result<usize, String> {
246    super::write_placement(
247        writer,
248        Vec3::new(matrix[3], matrix[7], matrix[11]),
249        Vec3::new(matrix[2], matrix[6], matrix[10]),
250        Vec3::new(matrix[0], matrix[4], matrix[8]),
251    )
252}
253
254/// [`export_step_assembly_report`] returning just the Part 21 text.
255pub fn export_step_assembly(
256    assembly: &StepAssemblyExport,
257    unit: &str,
258    timestamp: &str,
259) -> Result<String, String> {
260    export_step_assembly_report(assembly, unit, timestamp, None).map(|report| report.text)
261}
262
263/// Write a product structure as an AP242 Part 21 document: one product per
264/// distinct part, one `NEXT_ASSEMBLY_USAGE_OCCURRENCE` per placement, exact
265/// B-rep geometry in each part's own frame.
266///
267/// The file is named after the ROOT product, and the optional PMI block rides
268/// on the root exactly as it does in the flat lane — with each shape aspect
269/// attached to the product that actually OWNS the geometry it names.
270pub fn export_step_assembly_report(
271    assembly: &StepAssemblyExport,
272    unit: &str,
273    timestamp: &str,
274    pmi: Option<&StepPmi<'_>>,
275) -> Result<StepExportReport, String> {
276    let root = assembly.root()?;
277    if assembly
278        .products
279        .iter()
280        .all(|product| product.bodies.is_empty())
281    {
282        return Err("export_step: at least one solid is required".into());
283    }
284    let mut report = StepExportReport {
285        products: assembly.products.len(),
286        occurrences: assembly.occurrences.len(),
287        ..StepExportReport::default()
288    };
289    let mut writer = StepWriter::default();
290    let contexts = write_file_contexts(&mut writer, unit)?;
291
292    // 1 — every product's geometry, each in its own local frame. Written first
293    // because a representation's item list needs the solids it carries.
294    let mut geometries = Vec::with_capacity(assembly.products.len());
295    for product in &assembly.products {
296        let bodies: Vec<(String, &BrepSolid)> = product
297            .bodies
298            .iter()
299            .map(|(name, solid)| (name.clone(), solid))
300            .collect();
301        geometries.push(write_product_geometry(
302            &mut writer,
303            &contexts,
304            &bodies,
305            &mut report,
306        )?);
307    }
308
309    // 2 — each occurrence's placement frame. It is an ITEM of the parent
310    // representation, so it has to exist before that representation is written.
311    let mut placement_axes = Vec::with_capacity(assembly.occurrences.len());
312    for occurrence in &assembly.occurrences {
313        placement_axes.push(write_placement_axis(&mut writer, &occurrence.placement)?);
314    }
315
316    // 3 — the representations: identity axis, own solids, child placements.
317    let geometry_context = contexts.geometry_context;
318    let mut representations = Vec::with_capacity(assembly.products.len());
319    for (index, product) in assembly.products.iter().enumerate() {
320        let mut items = vec![contexts.axis];
321        items.extend(&geometries[index].solids);
322        for (slot, occurrence) in assembly.occurrences.iter().enumerate() {
323            if occurrence.parent == index {
324                items.push(placement_axes[slot]);
325            }
326        }
327        // A pure assembly node carries no bodies, and an ADVANCED_BREP shape
328        // representation with no solid in it is not one — say SHAPE_REPRESENTATION,
329        // which is what a vendor writes and what our importer reads for the
330        // structure-only levels of a tree.
331        let keyword = if geometries[index].solids.is_empty() {
332            "SHAPE_REPRESENTATION"
333        } else {
334            "ADVANCED_BREP_SHAPE_REPRESENTATION"
335        };
336        representations.push(writer.add(format!(
337            "{keyword}('{}',{},#{geometry_context})",
338            step_string(&product.name),
339            id_list(&items)
340        )));
341    }
342
343    // 4 — the product definition chains, bound to those representations.
344    let mut definitions = Vec::with_capacity(assembly.products.len());
345    for (index, product) in assembly.products.iter().enumerate() {
346        // 'assembly' vs 'part' is the distinction a receiving system builds its
347        // own structure tree from, so it follows the graph, not the geometry: a
348        // product that places others is an assembly even when it also carries
349        // bodies of its own.
350        let places_others = assembly
351            .occurrences
352            .iter()
353            .any(|occurrence| occurrence.parent == index);
354        let category = if places_others { "assembly" } else { "part" };
355        let ids = write_product(&mut writer, &contexts, &product.name, &product.id, category);
356        let (product_shape, representation) = (ids.product_shape, representations[index]);
357        writer.add(format!(
358            "SHAPE_DEFINITION_REPRESENTATION(#{product_shape},#{representation})"
359        ));
360        definitions.push(ids);
361    }
362
363    // 5 — the occurrences themselves.
364    for (slot, occurrence) in assembly.occurrences.iter().enumerate() {
365        let parent = definitions[occurrence.parent].definition;
366        let child = definitions[occurrence.child].definition;
367        let label = step_string(&occurrence.designator);
368        let nauo = writer.add(format!(
369            "NEXT_ASSEMBLY_USAGE_OCCURRENCE('{label}','{label}','',#{parent},#{child},'{label}')"
370        ));
371        let nauo_shape =
372            writer.add(format!("PRODUCT_DEFINITION_SHAPE('','NAUO PRDDFN',#{nauo})"));
373        let identity = contexts.axis;
374        let placement = placement_axes[slot];
375        let transformation = writer.add(format!(
376            "ITEM_DEFINED_TRANSFORMATION('','',#{identity},#{placement})"
377        ));
378        let (child_rep, parent_rep) = (
379            representations[occurrence.child],
380            representations[occurrence.parent],
381        );
382        let relationship = writer.add(format!(
383            "(REPRESENTATION_RELATIONSHIP('','',#{child_rep},#{parent_rep})\
384             REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#{transformation})\
385             SHAPE_REPRESENTATION_RELATIONSHIP())"
386        ));
387        writer.add(format!(
388            "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#{relationship},#{nauo_shape})"
389        ));
390    }
391
392    // 6 — PMI on the root, resolving through every occurrence path.
393    if let Some(pmi) = pmi {
394        let paths = assembly.occurrence_paths(root);
395        let mut names = StepNameMaps::default();
396        for (index, geometry) in geometries.iter().enumerate() {
397            let owner = StepItemOwner {
398                product_shape: definitions[index].product_shape,
399                representation: representations[index],
400            };
401            for (prefix, world) in &paths[index] {
402                names.register(geometry, owner, prefix, world);
403            }
404        }
405        let context = pmi::StepContext {
406            product_shape: definitions[root].product_shape,
407            representation: representations[root],
408            geometry_context,
409            length_unit: contexts.length_unit,
410            angle_unit: contexts.angle_unit,
411            faces: &names.faces,
412            edges: &names.edges,
413            vertices: &names.vertices,
414        };
415        report.pmi_unresolved_references = pmi::write_pmi(&mut writer, &context, pmi)?;
416    }
417
418    finish_step_file(
419        writer,
420        &assembly.products[root].name,
421        timestamp,
422        &mut report,
423    )?;
424    Ok(report)
425}
426
427// BREP private tests: f9fc1e069685c113