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 let requires_plugin_pmi = entry.document.get("pmi")
207 .and_then(|v| serde_json::from_value::<crate::feature_pipeline::pmi::PmiState>(v.clone()).ok())
208 .is_some_and(|state| state.views.iter().flat_map(|v| &v.annotations)
209 .any(|a| a.enabled && a.kind.contains('/')));
210 if requires_plugin_pmi && pmi.is_none() {
211 return Err(format!("part '{part_name}': plugin annotation replay unavailable during export"));
212 }
213 assembly.products.push(StepExportProduct {
214 name: part_name.to_string(),
215 id,
216 bodies,
217 pmi,
218 });
219 let (library, instances) = document_components(&entry.document, part_name)?;
220 place_children(assembly, index, &library, &instances, depth + 1)?;
221 Ok(index)
222}
223
224/// A part document's own parts library and its ACOMP instances, with each pose
225/// evaluated in THAT document's expression environment and composed through the
226/// one feature-transform convention.
227fn document_components(
228 document: &serde_json::Value,
229 part_name: &str,
230) -> Result<(PartsLibraryMap, Vec<(String, String, Mat4)>), String> {
231 let request: HistoryRequest = serde_json::from_value(document.clone()).map_err(|error| {
232 format!("export_step: part '{part_name}' document does not parse: {error}")
233 })?;
234 let env = Env::build(&request.expressions, &request.configurator).unwrap_or_else(Env::poisoned);
235 let mut instances = Vec::new();
236 for feature in &request.features {
237 if !parts_library::is_acomp_type(&feature.feature_type) {
238 continue;
239 }
240 let params = &feature.input_params;
241 let string = |key: &str| {
242 params
243 .get(key)
244 .and_then(serde_json::Value::as_str)
245 .map(str::trim)
246 .filter(|text| !text.is_empty())
247 .map(str::to_string)
248 };
249 let Some(id) = string("id") else {
250 return Err(format!(
251 "export_step: part '{part_name}' has a component with no id"
252 ));
253 };
254 let Some(child) = string("partName") else {
255 return Err(format!(
256 "export_step: part '{part_name}' component {id} names no part"
257 ));
258 };
259 let transform = params.get("transform");
260 let translate = vec3_from_value(
261 &env,
262 transform.and_then(|value| value.get("translate")),
263 "transform.translate",
264 [0.0, 0.0, 0.0],
265 )?;
266 let rotate_deg = vec3_from_value(
267 &env,
268 transform.and_then(|value| value.get("rotateEulerDeg")),
269 "transform.rotateEulerDeg",
270 [0.0, 0.0, 0.0],
271 )?;
272 let placement = compose_trs_matrix(
273 translate,
274 [
275 rotate_deg[0].to_radians(),
276 rotate_deg[1].to_radians(),
277 rotate_deg[2].to_radians(),
278 ],
279 [1.0, 1.0, 1.0],
280 [0.0, 0.0, 0.0],
281 );
282 instances.push((id, child, placement));
283 }
284 Ok((request.parts_library, instances))
285}
286