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 [`StepAssemblyExport::occurrence_paths`]
51//! walks the graph and registers every entity under EVERY chained occurrence
52//! path that reaches it (`ACOMP3:`, `ACOMP5:ACOMP1:`), with the vertex points
53//! moved into root space so a `{body}@x,y,z` reference still matches. Two
54//! instances of one part share the part's entities, so `ACOMP3:top` and
55//! `ACOMP4:top` resolve to the SAME `ADVANCED_FACE`.
56//!
57//! They are not the same ANNOTATION, though, and PMI is written accordingly:
58//!
59//! - a PART's own annotations ([`StepExportProduct::pmi`], from the part
60//!   document's `pmi` block) are written into that part's product, with the
61//!   part's own names and its own draughting model — once, however many
62//!   instances place it;
63//! - the ROOT document's annotations are written through the occurrence paths,
64//!   and a reference that reaches a part through an occurrence hangs its
65//!   `SHAPE_ASPECT` off THAT occurrence's `PRODUCT_DEFINITION_SHAPE` (the
66//!   `'NAUO PRDDFN'` one), not the part's. The `GEOMETRIC_ITEM_SPECIFIC_USAGE`
67//!   still points at the part's face; only the owner says which instance is
68//!   meant, and it is the only place that can say it.
69//!
70//! `io/step_import/assembly_pmi.rs` reads exactly that distinction back, which
71//! is what makes an assembly's PMI survive export → import unchanged.
72
73use super::{
74    finish_step_file, id_list, mat4_mul, pmi, step_string, styles, write_file_contexts,
75    write_product, write_product_geometry, Mat4, StepColors, StepExportReport, StepItemOwner,
76    StepNameMaps, StepPmi, StepWriter, MAT4_IDENTITY,
77};
78use crate::feature_pipeline::pmi::{PmiReport, PmiState};
79use crate::{BrepSolid, Vec3};
80
81/// One product of an exported structure: a part, a sub-assembly, or the root
82/// document. `bodies` are in the product's OWN local frame — never posed.
83#[derive(Clone, Debug, Default)]
84pub struct StepExportProduct {
85    /// `PRODUCT.name` — the parts-library entry name, or the document name.
86    pub name: String,
87    /// `PRODUCT.id` — the vendor part number: a library part's BOM
88    /// `Part_Number` attribute, else its `sourceKey` (`export_tree.rs`). The
89    /// name is used when empty.
90    pub id: String,
91    /// This product's own solids and their part-local scene names. Empty for a
92    /// pure assembly node, which is then written as a bare `SHAPE_REPRESENTATION`.
93    pub bodies: Vec<(String, BrepSolid)>,
94    /// This product's OWN PMI — the part document's `pmi` block, resolved
95    /// against the part's own geometry (`export_tree.rs` runs it). Written
96    /// into THIS product, with the part's own names and its own draughting
97    /// model, which is where a structured import reads it back from.
98    pub pmi: Option<(PmiState, PmiReport)>,
99}
100
101/// One placement of `child` inside `parent` — a `NEXT_ASSEMBLY_USAGE_OCCURRENCE`.
102#[derive(Clone, Debug)]
103pub struct StepExportOccurrence {
104    /// The instance label: the placing component's id (`ACOMP3`). Written into
105    /// the NAUO's `reference_designator`, which is what the importer reads back
106    /// first, so an instance keeps its identity through the round trip.
107    pub designator: String,
108    /// Index into [`StepAssemblyExport::products`].
109    pub parent: usize,
110    /// Index into [`StepAssemblyExport::products`].
111    pub child: usize,
112    /// child-local -> parent-local, row-major affine. Must be RIGID: a mirrored
113    /// or scaled instance has no `AXIS2_PLACEMENT_3D` and is rejected rather
114    /// than silently written as its rotation part.
115    pub placement: Mat4,
116}
117
118/// A document's product structure, ready to write. Exactly one product must be
119/// a root (never an occurrence child) — the document itself.
120#[derive(Clone, Debug, Default)]
121pub struct StepAssemblyExport {
122    pub products: Vec<StepExportProduct>,
123    pub occurrences: Vec<StepExportOccurrence>,
124}
125
126/// How many occurrence paths the PMI alias walk will register before it stops.
127/// One path per instance is the honest count, and a real assembly has
128/// thousands at most; the cap only bounds a pathological diamond graph, whose
129/// path count is exponential in its depth.
130const MAX_OCCURRENCE_PATHS: usize = 100_000;
131
132impl StepAssemblyExport {
133    /// The single root product's index, with the graph checked on the way: every
134    /// occurrence in range, no self-placement, exactly one root, no cycle, every
135    /// product reachable, every placement rigid.
136    pub fn root(&self) -> Result<usize, String> {
137        if self.products.is_empty() {
138            return Err("export_step: an assembly needs at least one product".into());
139        }
140        let count = self.products.len();
141        let mut is_child = vec![false; count];
142        for occurrence in &self.occurrences {
143            if occurrence.parent >= count || occurrence.child >= count {
144                return Err(format!(
145                    "export_step: occurrence '{}' names a product outside the structure",
146                    occurrence.designator
147                ));
148            }
149            if occurrence.parent == occurrence.child {
150                return Err(format!(
151                    "export_step: occurrence '{}' places a product inside itself",
152                    occurrence.designator
153                ));
154            }
155            rigid(&occurrence.placement).map_err(|error| {
156                format!(
157                    "export_step: occurrence '{}' placement: {error}",
158                    occurrence.designator
159                )
160            })?;
161            is_child[occurrence.child] = true;
162        }
163        let roots: Vec<usize> = (0..count).filter(|index| !is_child[*index]).collect();
164        let [root] = roots.as_slice() else {
165            return Err(format!(
166                "export_step: an assembly needs exactly one root product, found {}",
167                roots.len()
168            ));
169        };
170        // Reachability doubles as the cycle check: a cycle makes every product
171        // on it a child, so an unreached product is either orphaned or looping.
172        let mut reached = vec![false; count];
173        let mut stack = vec![*root];
174        reached[*root] = true;
175        while let Some(product) = stack.pop() {
176            for occurrence in &self.occurrences {
177                if occurrence.parent == product && !reached[occurrence.child] {
178                    reached[occurrence.child] = true;
179                    stack.push(occurrence.child);
180                }
181            }
182        }
183        if let Some(lost) = reached.iter().position(|hit| !hit) {
184            return Err(format!(
185                "export_step: product '{}' is not reachable from the root",
186                self.products[lost].name
187            ));
188        }
189        Ok(*root)
190    }
191
192    /// Every chained occurrence path that reaches each product: the component
193    /// namespace prefix the DOCUMENT knows its entities by, and the transform
194    /// into root space. The root itself gets one empty path at identity.
195    ///
196    /// Depth-first from the root, guarding the path's own ancestors, so a
197    /// structure that somehow still carries a cycle terminates.
198    fn occurrence_paths(&self, root: usize) -> Vec<Vec<OccurrencePath>> {
199        let mut paths: Vec<Vec<OccurrencePath>> = vec![Vec::new(); self.products.len()];
200        let mut total = 0usize;
201        let mut stack: Vec<(usize, OccurrencePath, Vec<usize>)> = vec![(
202            root,
203            OccurrencePath {
204                prefix: String::new(),
205                world: MAT4_IDENTITY,
206                last: None,
207            },
208            vec![root],
209        )];
210        while let Some((product, path, ancestors)) = stack.pop() {
211            if total >= MAX_OCCURRENCE_PATHS {
212                break;
213            }
214            total += 1;
215            for (slot, occurrence) in self.occurrences.iter().enumerate() {
216                if occurrence.parent != product || ancestors.contains(&occurrence.child) {
217                    continue;
218                }
219                let mut ancestors = ancestors.clone();
220                ancestors.push(occurrence.child);
221                stack.push((
222                    occurrence.child,
223                    OccurrencePath {
224                        prefix: format!("{}{}:", path.prefix, occurrence.designator),
225                        world: mat4_mul(&path.world, &occurrence.placement),
226                        last: Some(slot),
227                    },
228                    ancestors,
229                ));
230            }
231            paths[product].push(path);
232        }
233        paths
234    }
235}
236
237/// One way the document's geometry reaches a product: the component namespace
238/// that names it (`ACOMP5:ACOMP1:`, `""` at the root), the transform into root
239/// space, and the LAST occurrence on the way — the instance an annotation
240/// written against this namespace is an annotation on.
241#[derive(Clone)]
242struct OccurrencePath {
243    prefix: String,
244    world: Mat4,
245    /// Index into [`StepAssemblyExport::occurrences`]; `None` at the root.
246    last: Option<usize>,
247}
248
249/// A placement is rigid when its linear block is orthonormal with a positive
250/// determinant — the only kind an `AXIS2_PLACEMENT_3D` can express.
251fn rigid(matrix: &Mat4) -> Result<(), String> {
252    if matrix.iter().any(|value| !value.is_finite()) {
253        return Err("matrix is not finite".into());
254    }
255    let column = |index: usize| {
256        [
257            matrix[index],
258            matrix[4 + index],
259            matrix[8 + index],
260        ]
261    };
262    let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
263    let (x, y, z) = (column(0), column(1), column(2));
264    const TOLERANCE: f64 = 1e-9;
265    for (a, b, expected) in [
266        (x, x, 1.0),
267        (y, y, 1.0),
268        (z, z, 1.0),
269        (x, y, 0.0),
270        (x, z, 0.0),
271        (y, z, 0.0),
272    ] {
273        if (dot(a, b) - expected).abs() > TOLERANCE {
274            return Err("matrix is not a rotation (mirrored or scaled)".into());
275        }
276    }
277    let cross = [
278        x[1] * y[2] - x[2] * y[1],
279        x[2] * y[0] - x[0] * y[2],
280        x[0] * y[1] - x[1] * y[0],
281    ];
282    if (dot(cross, z) - 1.0).abs() > TOLERANCE {
283        return Err("matrix is left-handed (a mirrored instance)".into());
284    }
285    Ok(())
286}
287
288/// The `AXIS2_PLACEMENT_3D` expressing a rigid placement's frame: the origin is
289/// its translation, the axis its third linear COLUMN and the reference direction
290/// its first — the exact inverse of the importer's `frame_matrix`.
291fn write_placement_axis(writer: &mut StepWriter, matrix: &Mat4) -> Result<usize, String> {
292    super::write_placement(
293        writer,
294        Vec3::new(matrix[3], matrix[7], matrix[11]),
295        Vec3::new(matrix[2], matrix[6], matrix[10]),
296        Vec3::new(matrix[0], matrix[4], matrix[8]),
297    )
298}
299
300/// [`export_step_assembly_report`] returning just the Part 21 text.
301pub fn export_step_assembly(
302    assembly: &StepAssemblyExport,
303    unit: &str,
304    timestamp: &str,
305) -> Result<String, String> {
306    export_step_assembly_report(assembly, unit, timestamp, None, None).map(|report| report.text)
307}
308
309/// Write a product structure as an AP242 Part 21 document: one product per
310/// distinct part, one `NEXT_ASSEMBLY_USAGE_OCCURRENCE` per placement, exact
311/// B-rep geometry in each part's own frame.
312///
313/// The file is named after the ROOT product, and the optional PMI block rides
314/// on the root exactly as it does in the flat lane — with each shape aspect
315/// attached to the product that actually OWNS the geometry it names.
316pub fn export_step_assembly_report(
317    assembly: &StepAssemblyExport,
318    unit: &str,
319    timestamp: &str,
320    pmi: Option<&StepPmi<'_>>,
321    colors: Option<&StepColors>,
322) -> Result<StepExportReport, String> {
323    let root = assembly.root()?;
324    if assembly
325        .products
326        .iter()
327        .all(|product| product.bodies.is_empty())
328    {
329        return Err("export_step: at least one solid is required".into());
330    }
331    let mut report = StepExportReport {
332        products: assembly.products.len(),
333        occurrences: assembly.occurrences.len(),
334        ..StepExportReport::default()
335    };
336    let mut writer = StepWriter::default();
337    let contexts = write_file_contexts(&mut writer, unit)?;
338
339    // 1 — every product's geometry, each in its own local frame. Written first
340    // because a representation's item list needs the solids it carries.
341    let mut geometries = Vec::with_capacity(assembly.products.len());
342    for product in &assembly.products {
343        let bodies: Vec<(String, &BrepSolid)> = product
344            .bodies
345            .iter()
346            .map(|(name, solid)| (name.clone(), solid))
347            .collect();
348        geometries.push(write_product_geometry(
349            &mut writer,
350            &contexts,
351            &bodies,
352            &mut report,
353        )?);
354    }
355
356    // 2 — each occurrence's placement frame. It is an ITEM of the parent
357    // representation, so it has to exist before that representation is written.
358    let mut placement_axes = Vec::with_capacity(assembly.occurrences.len());
359    for occurrence in &assembly.occurrences {
360        placement_axes.push(write_placement_axis(&mut writer, &occurrence.placement)?);
361    }
362
363    // 3 — the representations: identity axis, own solids, child placements.
364    let geometry_context = contexts.geometry_context;
365    let mut representations = Vec::with_capacity(assembly.products.len());
366    for (index, product) in assembly.products.iter().enumerate() {
367        let mut items = vec![contexts.axis];
368        items.extend(geometries[index].solids.iter().map(|(_, id)| *id));
369        for (slot, occurrence) in assembly.occurrences.iter().enumerate() {
370            if occurrence.parent == index {
371                items.push(placement_axes[slot]);
372            }
373        }
374        // A pure assembly node carries no bodies, and an ADVANCED_BREP shape
375        // representation with no solid in it is not one — say SHAPE_REPRESENTATION,
376        // which is what a vendor writes and what our importer reads for the
377        // structure-only levels of a tree.
378        let keyword = if geometries[index].solids.is_empty() {
379            "SHAPE_REPRESENTATION"
380        } else {
381            "ADVANCED_BREP_SHAPE_REPRESENTATION"
382        };
383        representations.push(writer.add(format!(
384            "{keyword}('{}',{},#{geometry_context})",
385            step_string(&product.name),
386            id_list(&items)
387        )));
388    }
389
390    // 4 — the product definition chains, bound to those representations.
391    let mut definitions = Vec::with_capacity(assembly.products.len());
392    for (index, product) in assembly.products.iter().enumerate() {
393        // 'assembly' vs 'part' is the distinction a receiving system builds its
394        // own structure tree from, so it follows the graph, not the geometry: a
395        // product that places others is an assembly even when it also carries
396        // bodies of its own.
397        let places_others = assembly
398            .occurrences
399            .iter()
400            .any(|occurrence| occurrence.parent == index);
401        let category = if places_others { "assembly" } else { "part" };
402        let ids = write_product(&mut writer, &contexts, &product.name, &product.id, category);
403        let (product_shape, representation) = (ids.product_shape, representations[index]);
404        writer.add(format!(
405            "SHAPE_DEFINITION_REPRESENTATION(#{product_shape},#{representation})"
406        ));
407        definitions.push(ids);
408    }
409
410    // 5 — the occurrences themselves. Each one's `PRODUCT_DEFINITION_SHAPE` is
411    // kept: it is what an OCCURRENCE-scoped annotation's shape aspect hangs
412    // off (step 7), and the entity a reader tells "this instance" from "this
413    // part" by.
414    let mut nauo_shapes: Vec<usize> = Vec::with_capacity(assembly.occurrences.len());
415    for (slot, occurrence) in assembly.occurrences.iter().enumerate() {
416        let parent = definitions[occurrence.parent].definition;
417        let child = definitions[occurrence.child].definition;
418        let label = step_string(&occurrence.designator);
419        let nauo = writer.add(format!(
420            "NEXT_ASSEMBLY_USAGE_OCCURRENCE('{label}','{label}','',#{parent},#{child},'{label}')"
421        ));
422        let nauo_shape =
423            writer.add(format!("PRODUCT_DEFINITION_SHAPE('','NAUO PRDDFN',#{nauo})"));
424        nauo_shapes.push(nauo_shape);
425        let identity = contexts.axis;
426        let placement = placement_axes[slot];
427        let transformation = writer.add(format!(
428            "ITEM_DEFINED_TRANSFORMATION('','',#{identity},#{placement})"
429        ));
430        let (child_rep, parent_rep) = (
431            representations[occurrence.child],
432            representations[occurrence.parent],
433        );
434        let relationship = writer.add(format!(
435            "(REPRESENTATION_RELATIONSHIP('','',#{child_rep},#{parent_rep})\
436             REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#{transformation})\
437             SHAPE_REPRESENTATION_RELATIONSHIP())"
438        ));
439        writer.add(format!(
440            "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#{relationship},#{nauo_shape})"
441        ));
442    }
443
444    // 6 — geometry colour. A product's entities are known to the DOCUMENT under
445    // every occurrence namespace that reaches it, so the colour lookup resolves
446    // through the same paths the PMI references do — sorted, so which of a
447    // repeated part's namespaces supplies the colour does not depend on the
448    // walk's stack order.
449    // ONE walk for both consumers below — each product's occurrence namespaces —
450    // and only when one of them needs it: the walk is capped at
451    // `MAX_OCCURRENCE_PATHS`, so an export that carries neither colour nor PMI
452    // must not pay for it.
453    let colors = colors.filter(|colors| !colors.is_empty());
454    let paths = (pmi.is_some() || colors.is_some())
455        .then(|| assembly.occurrence_paths(root))
456        .unwrap_or_default();
457    if let Some(colors) = colors {
458        let mut items = Vec::new();
459        for (index, geometry) in geometries.iter().enumerate() {
460            let mut prefixes: Vec<String> =
461                paths[index].iter().map(|path| path.prefix.clone()).collect();
462            prefixes.sort();
463            items.extend(styles::styled_items(geometry, colors, &prefixes));
464        }
465        report.styled_items = styles::write_geometry_styles(&mut writer, geometry_context, &items)?;
466    }
467
468    // 7a — each PART's own PMI, written into that part's product with the
469    // part's own names. A part shared by six instances carries its annotations
470    // ONCE, which is the same rule the importer reads them back under.
471    for (index, geometry) in geometries.iter().enumerate() {
472        let Some((state, part_report)) = &assembly.products[index].pmi else {
473            continue;
474        };
475        let mut names = StepNameMaps::default();
476        names.register(
477            geometry,
478            StepItemOwner {
479                product_shape: definitions[index].product_shape,
480                representation: representations[index],
481            },
482            "",
483            &MAT4_IDENTITY,
484        );
485        let context = pmi::StepContext {
486            product_shape: definitions[index].product_shape,
487            representation: representations[index],
488            geometry_context,
489            length_unit: contexts.length_unit,
490            angle_unit: contexts.angle_unit,
491            faces: &names.faces,
492            edges: &names.edges,
493            vertices: &names.vertices,
494        };
495        report.pmi_unresolved_references += pmi::write_pmi(
496            &mut writer,
497            &context,
498            &StepPmi {
499                state,
500                report: part_report,
501            },
502        )?;
503    }
504
505    // 7b — PMI on the ROOT document, resolving through every occurrence path.
506    //
507    // A reference that reaches a part THROUGH an occurrence hangs its shape
508    // aspect off that occurrence's `PRODUCT_DEFINITION_SHAPE`, not the part's:
509    // `ACOMP3:top` and `ACOMP4:top` name the SAME `ADVANCED_FACE` (two
510    // instances share the part's geometry, and the `GEOMETRIC_ITEM_SPECIFIC_-
511    // USAGE` still points at it), but they are annotations on two different
512    // INSTANCES, and the owner is the only place that distinction can live.
513    // It is also what the importer reads the distinction back out of
514    // (`io/step_import/assembly_pmi.rs`), which is what closes the round trip.
515    if let Some(pmi) = pmi {
516        let mut names = StepNameMaps::default();
517        for (index, geometry) in geometries.iter().enumerate() {
518            for path in &paths[index] {
519                let owner = StepItemOwner {
520                    product_shape: match path.last {
521                        Some(slot) => nauo_shapes[slot],
522                        None => definitions[index].product_shape,
523                    },
524                    representation: representations[index],
525                };
526                names.register(geometry, owner, &path.prefix, &path.world);
527            }
528        }
529        let context = pmi::StepContext {
530            product_shape: definitions[root].product_shape,
531            representation: representations[root],
532            geometry_context,
533            length_unit: contexts.length_unit,
534            angle_unit: contexts.angle_unit,
535            faces: &names.faces,
536            edges: &names.edges,
537            vertices: &names.vertices,
538        };
539        report.pmi_unresolved_references += pmi::write_pmi(&mut writer, &context, pmi)?;
540    }
541
542    finish_step_file(
543        writer,
544        &assembly.products[root].name,
545        timestamp,
546        &mut report,
547    )?;
548    Ok(report)
549}
550