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}