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
40use std::collections::BTreeMap;
41
42use super::assembly::{StepAssemblyExport, StepExportOccurrence, StepExportProduct};
43use super::Mat4;
44use crate::feature_pipeline::features::common::{compose_trs_matrix, vec3_from_value};
45use crate::feature_pipeline::parts_library::{self, PartsLibraryEntry, PartsLibraryMap};
46use crate::feature_pipeline::{Env, HistoryRequest};
47use crate::{is_component_reference, restore_solids, BrepSolid};
48
49/// How deep the recursion will go before it refuses. Matches the import side's
50/// own cap (`model_io.rs::MAX_NESTED_DEPTH`): sixty-four levels of embedded
51/// documents is far past anything a real assembly carries, and the guard is
52/// what keeps a malformed (but acyclic) document off the native stack.
53const MAX_DEPTH: usize = 64;
54
55/// The component namespace prefix a scene name opens with, if any:
56/// `ACOMP3:Extrude1` -> `Some("ACOMP3")`, `Extrude1` -> `None`.
57fn component_prefix(name: &str) -> Option<&str> {
58    let (head, _) = name.split_once(':')?;
59    is_component_reference(head).then_some(head)
60}
61
62/// Build the export tree for a document with components.
63///
64/// * `document_name` names the ROOT product.
65/// * `root_bodies` is every resident solid of the document with its scene name;
66///   the component-owned ones are filtered out here, so the caller passes the
67///   scene exactly as it has it.
68/// * `root_components` is one entry per ACOMP instance of the ROOT document:
69///   `(component id, parts-library entry name, instance pose)`. The pose is the
70///   live one, so a solved or gizmo-moved instance exports where it sits.
71pub fn assembly_export_tree(
72    document_name: &str,
73    root_bodies: Vec<(String, BrepSolid)>,
74    root_components: &[(String, String, Mat4)],
75) -> Result<StepAssemblyExport, String> {
76    let own: Vec<(String, BrepSolid)> = root_bodies
77        .into_iter()
78        .filter(|(name, _)| component_prefix(name).is_none())
79        .collect();
80    let mut assembly = StepAssemblyExport {
81        products: vec![StepExportProduct {
82            name: document_name.to_string(),
83            id: String::new(),
84            bodies: own,
85        }],
86        occurrences: Vec::new(),
87    };
88    let library = parts_library::parts_library_map();
89    place_children(&mut assembly, 0, &library, root_components, 0)?;
90    Ok(assembly)
91}
92
93/// Add one occurrence per instance, building each distinct part ONCE at this
94/// level (`seen`) so repeated placements share a product.
95fn place_children(
96    assembly: &mut StepAssemblyExport,
97    parent: usize,
98    library: &PartsLibraryMap,
99    instances: &[(String, String, Mat4)],
100    depth: usize,
101) -> Result<(), String> {
102    let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
103    for (designator, part_name, placement) in instances {
104        let child = match seen.get(part_name.as_str()) {
105            Some(index) => *index,
106            None => {
107                let entry = library.get(part_name).ok_or_else(|| {
108                    format!(
109                        "export_step: part '{part_name}' (placed by {designator}) is not in \
110                         the parts library of '{}'",
111                        assembly.products[parent].name
112                    )
113                })?;
114                let index = add_part(assembly, part_name, entry, depth)?;
115                seen.insert(part_name.as_str(), index);
116                index
117            }
118        };
119        assembly.occurrences.push(StepExportOccurrence {
120            designator: designator.clone(),
121            parent,
122            child,
123            placement: *placement,
124        });
125    }
126    Ok(())
127}
128
129/// Add one parts-library entry as a product, then recurse into the components
130/// ITS document places. Returns the new product's index.
131fn add_part(
132    assembly: &mut StepAssemblyExport,
133    part_name: &str,
134    entry: &PartsLibraryEntry,
135    depth: usize,
136) -> Result<usize, String> {
137    if depth >= MAX_DEPTH {
138        return Err(format!(
139            "export_step: assembly nests deeper than {MAX_DEPTH} levels at part '{part_name}'"
140        ));
141    }
142    // The snapshot is the fast lane the ACOMP feature itself takes; a stale or
143    // unreadable one heals the same way, by re-executing the embedded document.
144    let restored = match restore_solids(&entry.snapshot) {
145        Ok(restored) if !entry.dirty => restored,
146        _ => {
147            let (snapshot, _) = parts_library::rebuild_snapshot(&entry.document)
148                .map_err(|error| format!("export_step: part '{part_name}': {error}"))?;
149            restore_solids(&snapshot)
150                .map_err(|error| format!("export_step: part '{part_name}': {error}"))?
151        }
152    };
153    // This product's OWN bodies: the snapshot solids that no nested component
154    // contributed. The namespaced ones are written in the child's product.
155    let bodies: Vec<(String, BrepSolid)> = restored
156        .solids
157        .into_iter()
158        .filter(|solid| component_prefix(&solid.name).is_none())
159        .map(|solid| (solid.name, solid.solid))
160        .collect();
161    let index = assembly.products.len();
162    assembly.products.push(StepExportProduct {
163        name: part_name.to_string(),
164        id: entry.source_key.clone(),
165        bodies,
166    });
167    let (library, instances) = document_components(&entry.document, part_name)?;
168    place_children(assembly, index, &library, &instances, depth + 1)?;
169    Ok(index)
170}
171
172/// A part document's own parts library and its ACOMP instances, with each pose
173/// evaluated in THAT document's expression environment and composed through the
174/// one feature-transform convention.
175fn document_components(
176    document: &serde_json::Value,
177    part_name: &str,
178) -> Result<(PartsLibraryMap, Vec<(String, String, Mat4)>), String> {
179    let request: HistoryRequest = serde_json::from_value(document.clone()).map_err(|error| {
180        format!("export_step: part '{part_name}' document does not parse: {error}")
181    })?;
182    let env = Env::build(&request.expressions, &request.configurator).unwrap_or_else(Env::poisoned);
183    let mut instances = Vec::new();
184    for feature in &request.features {
185        if !parts_library::is_acomp_type(&feature.feature_type) {
186            continue;
187        }
188        let params = &feature.input_params;
189        let string = |key: &str| {
190            params
191                .get(key)
192                .and_then(serde_json::Value::as_str)
193                .map(str::trim)
194                .filter(|text| !text.is_empty())
195                .map(str::to_string)
196        };
197        let Some(id) = string("id") else {
198            return Err(format!(
199                "export_step: part '{part_name}' has a component with no id"
200            ));
201        };
202        let Some(child) = string("partName") else {
203            return Err(format!(
204                "export_step: part '{part_name}' component {id} names no part"
205            ));
206        };
207        let transform = params.get("transform");
208        let translate = vec3_from_value(
209            &env,
210            transform.and_then(|value| value.get("translate")),
211            "transform.translate",
212            [0.0, 0.0, 0.0],
213        )?;
214        let rotate_deg = vec3_from_value(
215            &env,
216            transform.and_then(|value| value.get("rotateEulerDeg")),
217            "transform.rotateEulerDeg",
218            [0.0, 0.0, 0.0],
219        )?;
220        let placement = compose_trs_matrix(
221            translate,
222            [
223                rotate_deg[0].to_radians(),
224                rotate_deg[1].to_radians(),
225                rotate_deg[2].to_radians(),
226            ],
227            [1.0, 1.0, 1.0],
228            [0.0, 0.0, 0.0],
229        );
230        instances.push((id, child, placement));
231    }
232    Ok((request.parts_library, instances))
233}