Skip to main content

brep_kernel/io/step/
export_tree.rs

1//! Build the STRUCTURED STEP export tree out of the document's parts library.
2//!
3//! [`assembly_export_tree`] is the consumer half of `assembly.rs`: it turns the
4//! app's assembly model — a parts library of unique parts plus ACOMP instances
5//! that place them — into the [`StepAssemblyExport`] the writer emits.
6//!
7//! # Where each half comes from
8//!
9//! * **Structure** is read from the DOCUMENTS. The root's occurrences are passed
10//!   in (the app holds the live, post-solve component poses); every level below
11//!   comes from that part's own embedded document, whose ACOMP features carry
12//!   `partName` and the `{translate, rotateEulerDeg}` pose. Those values are
13//!   expression-capable, so they are evaluated through the part document's OWN
14//!   [`Env`] and composed by [`compose_trs_matrix`] — the same two calls the
15//!   ACOMP feature makes, never a re-derived convention.
16//! * **Geometry** is read from the SNAPSHOTS. A library entry's snapshot is the
17//!   evaluated part in its OWN LOCAL FRAME, which is exactly what a STEP product
18//!   must carry. Nothing is re-executed and nothing is un-posed: taking the
19//!   scene's placed members and multiplying by the inverse pose would reproduce
20//!   the snapshot only to rounding, and would not separate a sub-assembly's own
21//!   bodies from its children's.
22//!
23//! # Splitting a sub-assembly's snapshot
24//!
25//! A sub-assembly's snapshot holds its children's bodies too, because the ACOMP
26//! features inside it ran when the snapshot was built — and those arrive
27//! NAMESPACED (`ACOMP1:Extrude1`, chaining further down). So a product's OWN
28//! bodies are the snapshot solids whose name carries no `ACOMP<digits>:` prefix;
29//! the prefixed ones are the child products' business and are dropped here, to
30//! be written once, unposed, in the child's own product.
31//!
32//! # Dedup
33//!
34//! One product per library entry PER DOCUMENT LEVEL: two instances of `bolt` in
35//! one assembly share a product and get two occurrences, which is the whole
36//! point. A part reached from two different parents gets a product each — the
37//! rigid-nesting model already stores it once per level, so there is no content
38//! identity to dedup across levels.
39//!
40//! # Identity
41//!
42//! `PRODUCT.name` is the parts-library entry name. `PRODUCT.id` is the part's
43//! BOM part number — the `Part_Number` field of the embedded document's
44//! top-level [`PART_ATTRIBUTES`] record, the same record the BOM panel's
45//! `part.` columns and the Part Properties dialog read and write — because that
46//! is the identity a downstream PDM keys on. A part with no part number falls
47//! back to the library entry's `sourceKey` (the file it was imported from), and
48//! a part with neither writes an empty id, which the writer fills with the
49//! name. The ROOT product's id is empty: [`assembly_export_tree`] never sees
50//! the root document, only its name.
51
52use std::collections::BTreeMap;
53
54use super::assembly::{StepAssemblyExport, StepExportOccurrence, StepExportProduct};
55use super::Mat4;
56use crate::feature_pipeline::features::common::{compose_trs_matrix, vec3_from_value};
57use crate::feature_pipeline::parts_library::{self, PartsLibraryEntry, PartsLibraryMap};
58use crate::feature_pipeline::{Env, HistoryRequest};
59use crate::{is_component_reference, restore_solids, BrepSolid};
60
61/// How deep the recursion will go before it refuses. Matches the import side's
62/// own cap (`model_io.rs::MAX_NESTED_DEPTH`): sixty-four levels of embedded
63/// documents is far past anything a real assembly carries, and the guard is
64/// what keeps a malformed (but acyclic) document off the native stack.
65const MAX_DEPTH: usize = 64;
66
67/// The top-level document key holding a part's own BOM attribute record
68/// (`{ "Part_Number": "PN-7", "Material": "6061", ... }`). Owned by the app's
69/// document model; the kernel reads it only to stamp `PRODUCT.id`.
70pub const PART_ATTRIBUTES: &str = "partAttributes";
71/// The BOM part-number field inside [`PART_ATTRIBUTES`].
72pub const PART_NUMBER: &str = "Part_Number";
73
74/// A part document's BOM part number: `partAttributes.Part_Number`, trimmed;
75/// `None` when the record, the field, or its text is absent or blank.
76fn part_number(document: &serde_json::Value) -> Option<String> {
77    document
78        .get(PART_ATTRIBUTES)?
79        .get(PART_NUMBER)?
80        .as_str()
81        .map(str::trim)
82        .filter(|text| !text.is_empty())
83        .map(str::to_string)
84}
85
86/// The component namespace prefix a scene name opens with, if any:
87/// `ACOMP3:Extrude1` -> `Some("ACOMP3")`, `Extrude1` -> `None`.
88fn component_prefix(name: &str) -> Option<&str> {
89    let (head, _) = name.split_once(':')?;
90    is_component_reference(head).then_some(head)
91}
92
93/// Build the export tree for a document with components.
94///
95/// * `document_name` names the ROOT product.
96/// * `root_bodies` is every resident solid of the document with its scene name;
97///   the component-owned ones are filtered out here, so the caller passes the
98///   scene exactly as it has it.
99/// * `root_components` is one entry per ACOMP instance of the ROOT document:
100///   `(component id, parts-library entry name, instance pose)`. The pose is the
101///   live one, so a solved or gizmo-moved instance exports where it sits.
102pub fn assembly_export_tree(
103    document_name: &str,
104    root_bodies: Vec<(String, BrepSolid)>,
105    root_components: &[(String, String, Mat4)],
106) -> Result<StepAssemblyExport, String> {
107    let own: Vec<(String, BrepSolid)> = root_bodies
108        .into_iter()
109        .filter(|(name, _)| component_prefix(name).is_none())
110        .collect();
111    let mut assembly = StepAssemblyExport {
112        products: vec![StepExportProduct {
113            name: document_name.to_string(),
114            id: String::new(),
115            bodies: own,
116            // The ROOT document's own PMI is not a product's: it rides the
117            // export's `pmi` argument, resolved against the live scene, and is
118            // written through the occurrence paths (`assembly.rs` step 7b).
119            pmi: None,
120        }],
121        occurrences: Vec::new(),
122    };
123    let library = parts_library::parts_library_map();
124    place_children(&mut assembly, 0, &library, root_components, 0)?;
125    Ok(assembly)
126}
127
128/// Add one occurrence per instance, building each distinct part ONCE at this
129/// level (`seen`) so repeated placements share a product.
130fn place_children(
131    assembly: &mut StepAssemblyExport,
132    parent: usize,
133    library: &PartsLibraryMap,
134    instances: &[(String, String, Mat4)],
135    depth: usize,
136) -> Result<(), String> {
137    let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
138    for (designator, part_name, placement) in instances {
139        let child = match seen.get(part_name.as_str()) {
140            Some(index) => *index,
141            None => {
142                let entry = library.get(part_name).ok_or_else(|| {
143                    format!(
144                        "export_step: part '{part_name}' (placed by {designator}) is not in \
145                         the parts library of '{}'",
146                        assembly.products[parent].name
147                    )
148                })?;
149                let index = add_part(assembly, part_name, entry, depth)?;
150                seen.insert(part_name.as_str(), index);
151                index
152            }
153        };
154        assembly.occurrences.push(StepExportOccurrence {
155            designator: designator.clone(),
156            parent,
157            child,
158            placement: *placement,
159        });
160    }
161    Ok(())
162}
163
164/// Add one parts-library entry as a product, then recurse into the components
165/// ITS document places. Returns the new product's index.
166fn add_part(
167    assembly: &mut StepAssemblyExport,
168    part_name: &str,
169    entry: &PartsLibraryEntry,
170    depth: usize,
171) -> Result<usize, String> {
172    if depth >= MAX_DEPTH {
173        return Err(format!(
174            "export_step: assembly nests deeper than {MAX_DEPTH} levels at part '{part_name}'"
175        ));
176    }
177    // The snapshot is the fast lane the ACOMP feature itself takes; a stale or
178    // unreadable one heals the same way, by re-executing the embedded document.
179    let restored = match restore_solids(&entry.snapshot) {
180        Ok(restored) if !entry.dirty => restored,
181        _ => {
182            let (snapshot, _) = parts_library::rebuild_snapshot(&entry.document)
183                .map_err(|error| format!("export_step: part '{part_name}': {error}"))?;
184            restore_solids(&snapshot)
185                .map_err(|error| format!("export_step: part '{part_name}': {error}"))?
186        }
187    };
188    // This product's OWN bodies: the snapshot solids that no nested component
189    // contributed. The namespaced ones are written in the child's product.
190    let bodies: Vec<(String, BrepSolid)> = restored
191        .solids
192        .into_iter()
193        .filter(|solid| component_prefix(&solid.name).is_none())
194        .map(|solid| (solid.name, solid.solid))
195        .collect();
196    let index = assembly.products.len();
197    // The BOM part number is the identity a PDM reads; a part that was never
198    // given one is still identified by the file it came from.
199    let id = part_number(&entry.document).unwrap_or_else(|| entry.source_key.clone());
200    // The part's OWN PMI, resolved against the part's own geometry. Read from
201    // the embedded document rather than the snapshot, because an annotation is
202    // a reference plus a value, not geometry — and resolved here, where the
203    // part document is in hand, so the writer can put it in the part's product
204    // instead of the assembly's (`assembly.rs` step 7a).
205    let pmi = parts_library::document_pmi(&entry.document);
206    assembly.products.push(StepExportProduct {
207        name: part_name.to_string(),
208        id,
209        bodies,
210        pmi,
211    });
212    let (library, instances) = document_components(&entry.document, part_name)?;
213    place_children(assembly, index, &library, &instances, depth + 1)?;
214    Ok(index)
215}
216
217/// A part document's own parts library and its ACOMP instances, with each pose
218/// evaluated in THAT document's expression environment and composed through the
219/// one feature-transform convention.
220fn document_components(
221    document: &serde_json::Value,
222    part_name: &str,
223) -> Result<(PartsLibraryMap, Vec<(String, String, Mat4)>), String> {
224    let request: HistoryRequest = serde_json::from_value(document.clone()).map_err(|error| {
225        format!("export_step: part '{part_name}' document does not parse: {error}")
226    })?;
227    let env = Env::build(&request.expressions, &request.configurator).unwrap_or_else(Env::poisoned);
228    let mut instances = Vec::new();
229    for feature in &request.features {
230        if !parts_library::is_acomp_type(&feature.feature_type) {
231            continue;
232        }
233        let params = &feature.input_params;
234        let string = |key: &str| {
235            params
236                .get(key)
237                .and_then(serde_json::Value::as_str)
238                .map(str::trim)
239                .filter(|text| !text.is_empty())
240                .map(str::to_string)
241        };
242        let Some(id) = string("id") else {
243            return Err(format!(
244                "export_step: part '{part_name}' has a component with no id"
245            ));
246        };
247        let Some(child) = string("partName") else {
248            return Err(format!(
249                "export_step: part '{part_name}' component {id} names no part"
250            ));
251        };
252        let transform = params.get("transform");
253        let translate = vec3_from_value(
254            &env,
255            transform.and_then(|value| value.get("translate")),
256            "transform.translate",
257            [0.0, 0.0, 0.0],
258        )?;
259        let rotate_deg = vec3_from_value(
260            &env,
261            transform.and_then(|value| value.get("rotateEulerDeg")),
262            "transform.rotateEulerDeg",
263            [0.0, 0.0, 0.0],
264        )?;
265        let placement = compose_trs_matrix(
266            translate,
267            [
268                rotate_deg[0].to_radians(),
269                rotate_deg[1].to_radians(),
270                rotate_deg[2].to_radians(),
271            ],
272            [1.0, 1.0, 1.0],
273            [0.0, 0.0, 0.0],
274        );
275        instances.push((id, child, placement));
276    }
277    Ok((request.parts_library, instances))
278}
279