brep_kernel/io/step_import/assembly_pmi.rs
1//! PMI on a STRUCTURED import: which PART, or which OCCURRENCE, each
2//! annotation belongs to.
3//!
4//! The flat lane reads a file's PMI once, against one name map covering every
5//! solid in the file ([`super::pmi::read_step_pmi`]). A structured import has
6//! no such thing: each PRODUCT becomes its own part document whose bodies are
7//! named by that document's own `IMPORT3D1` feature, so two parts both call
8//! their first face `IMPORT3D1_Face_0`, and an annotation has to be told which
9//! of them it means.
10//!
11//! # One read, product-qualified names
12//!
13//! So the lift is still ONE read of the file. Every product's faces / edges /
14//! vertices go into one [`NameMap`](super::pmi::NameMap), each entry tagged
15//! with the product it belongs to:
16//!
17//! ```text
18//! 3#IMPORT3D1_Face_7 face 7 of product 3's first body
19//! 3@1042#IMPORT3D1_Face_7 …as seen through occurrence #1042
20//! ```
21//!
22//! The `@` form is the file PINNING an annotation to an instance: a
23//! `SHAPE_ASPECT` whose `of_shape` is an occurrence's
24//! `PRODUCT_DEFINITION_SHAPE` (`PRODUCT_DEFINITION_SHAPE('','NAUO PRDDFN',
25//! #nauo)`) says "this tolerance is on THIS bolt", where an aspect on the
26//! part's own product shape says "this tolerance is on the bolt". That
27//! distinction is the whole reason this module exists, and it is the one the
28//! exporter writes back out (`io/step/assembly.rs`).
29//!
30//! # Where each annotation lands
31//!
32//! | the annotation's references | lands on | named |
33//! |---|---|---|
34//! | one product, unpinned | that PART's document | part-local (`IMPORT3D1_Face_7`) |
35//! | one product, pinned to an occurrence | the ASSEMBLY document | [`OCCURRENCE_REF_PREFIX`] + the occurrence chain |
36//! | two or more products | the ASSEMBLY document | ditto, per reference |
37//! | none that resolved | the ASSEMBLY document | the file's own text, unchanged |
38//!
39//! "One lift per PART, not per occurrence" matches the exporter's rule
40//! exactly: two instances of one part share the part's `ADVANCED_FACE`s, so an
41//! annotation on the part IS an annotation on every instance, and writing it
42//! once per occurrence would multiply it on the next round trip.
43//!
44//! # Why the occurrence chain, and not a component id
45//!
46//! The kernel does not know what the consumer will call its components — ACOMP
47//! ids are minted by the import that consumes this ([`BREP_render`]'s
48//! `model_io.rs`). So an occurrence-scoped reference carries the STEP
49//! occurrence chain it resolves through, root first, and the consumer rewrites
50//! it into its own namespace with [`rewrite_occurrence_refs`]. A chain that
51//! the consumer cannot place (a sub-assembly node it flattened away) leaves
52//! the reference unresolved and reports it — never a silent drop.
53
54use super::assembly::{StepOccurrence, StepProduct};
55use super::parse::Entity;
56use super::pmi::{file_mentions_pmi, lift_pmi, NameMap};
57use super::{HashMap, HashSet, Resolver};
58use crate::feature_pipeline::pmi::{PmiAnnotation, PmiState, PmiView};
59
60/// The reference-name prefix an OCCURRENCE-scoped annotation carries out of a
61/// structured import, until its consumer rewrites it:
62/// `OCC#<edge>#<edge>…:<part-local name>`, the occurrence chain root first.
63/// An empty chain (`OCC:`) is the root product's own geometry.
64///
65/// `#` cannot occur in a name this importer mints, so a consumer finds every
66/// one of them by prefix alone.
67pub const OCCURRENCE_REF_PREFIX: &str = "OCC";
68
69/// The feature id a structured import's part document gives its geometry —
70/// `BREP_render`'s `native_part_document` writes exactly this, and a lifted
71/// annotation's references have to name what that feature will stamp.
72pub(super) const PART_IMPORT_FEATURE: &str = "IMPORT3D1";
73
74/// Tag an already product-qualified name with the occurrence that pins it:
75/// `3#Face` → `3@1042#Face`. Leaves a name with no product qualifier alone
76/// (the flat lane's names, which have no occurrences to pin to).
77pub(super) fn pin_occurrence(name: &str, occurrence: usize) -> String {
78 match name.split_once('#') {
79 Some((product, local)) if !product.contains('@') => {
80 format!("{product}@{occurrence}#{local}")
81 }
82 _ => name.to_string(),
83 }
84}
85
86/// Read a product-qualified name back: `(product index, pinned occurrence,
87/// part-local name)`. `None` for a name this module did not qualify — an
88/// unresolved reference, or a free note's absent one.
89fn split_qualified(name: &str) -> Option<(usize, Option<usize>, &str)> {
90 let (qualifier, local) = name.split_once('#')?;
91 let (product, occurrence) = match qualifier.split_once('@') {
92 Some((product, occurrence)) => (product, Some(occurrence.parse().ok()?)),
93 None => (qualifier, None),
94 };
95 Some((product.parse().ok()?, occurrence, local))
96}
97
98/// What one structured import's PMI came to.
99#[derive(Default)]
100pub(super) struct AssemblyPmi {
101 /// Parallel to the assembly's products: that part document's own PMI.
102 pub per_product: Vec<Option<PmiState>>,
103 /// The PMI that belongs to the ASSEMBLY document — occurrence-scoped
104 /// annotations, cross-part ones, and everything with no geometry of its
105 /// own (a free note, a saved view).
106 pub assembly: Option<PmiState>,
107 /// What did not come in cleanly. Every one of these is a KEPT annotation,
108 /// never a dropped one.
109 pub diagnostics: Vec<String>,
110}
111
112/// Each product's ordered face refs, one entry per body of
113/// [`StepProduct::bodies`] — the pairing `read_step_assembly` collects while
114/// it builds them, and the only thing that ties an `ADVANCED_FACE` in the file
115/// to `IMPORT3D1_Face_i` in the part document.
116pub(super) type ProductFaceRefs = Vec<Vec<Vec<usize>>>;
117
118/// Lift a structured import's PMI (module docs). `scale_of` gives a product's
119/// own millimetres-per-unit, because a mixed-unit assembly's vertex
120/// coordinates are read per product exactly as its geometry is.
121pub(super) fn lift(
122 text: &str,
123 entities: &HashMap<usize, Entity>,
124 products: &[StepProduct],
125 face_refs: &ProductFaceRefs,
126 occurrences: &[StepOccurrence],
127 roots: &[usize],
128 global_scale: f64,
129 mut scale_of: impl FnMut(usize) -> f64,
130) -> AssemblyPmi {
131 let mut out = AssemblyPmi {
132 per_product: vec![None; products.len()],
133 ..AssemblyPmi::default()
134 };
135 if !file_mentions_pmi(text) {
136 return out;
137 }
138
139 // --- one name map, every product tagged --------------------------------
140 let mut names = NameMap::empty(entities, global_scale);
141 for (index, product) in products.iter().enumerate() {
142 let Some(refs) = face_refs.get(index) else {
143 continue;
144 };
145 let resolver = Resolver::new(entities, scale_of(product.pd_ref));
146 let body_names =
147 crate::feature_pipeline::imported_solid_names(product.bodies.len(), PART_IMPORT_FEATURE);
148 names.add_bodies(entities, &resolver, &body_names, refs, &format!("{index}#"));
149 }
150
151 // --- which aspects the file pinned to an occurrence ---------------------
152 let edges: HashSet<usize> = occurrences.iter().map(|edge| edge.edge_ref).collect();
153 let pins = occurrence_pins(entities, &edges);
154
155 let mut diagnostics = Vec::new();
156 let Some(state) = lift_pmi(text, entities, &names, &pins, &mut diagnostics) else {
157 out.diagnostics = diagnostics;
158 return out;
159 };
160 out.diagnostics = diagnostics;
161
162 // --- partition -----------------------------------------------------------
163 let paths = OccurrencePaths::build(products.len(), occurrences, roots);
164 let mut assembly = PmiState::default();
165 let mut per_product: Vec<PmiState> = vec![PmiState::default(); products.len()];
166 for view in &state.views {
167 // One view of the file can hold annotations of several parts: each
168 // document gets the view it needs, minted in its own id space, and a
169 // document with nothing in a view never gets an empty one.
170 let mut assembly_rows: Vec<PmiAnnotation> = Vec::new();
171 let mut product_rows: Vec<Vec<PmiAnnotation>> = vec![Vec::new(); products.len()];
172 for annotation in &view.annotations {
173 let refs = subject_references(&annotation.params);
174 let every = all_references(&annotation.params);
175 let owners: Vec<usize> = {
176 let mut owners: Vec<usize> = refs.iter().map(|(product, _, _)| *product).collect();
177 owners.sort_unstable();
178 owners.dedup();
179 owners
180 };
181 let pinned = refs.iter().find_map(|(_, occurrence, _)| *occurrence);
182 // The plane gets no vote on OWNERSHIP but it still has to resolve:
183 // an annotation laid out in another part's face can only live in
184 // the assembly, where both names exist.
185 let outside = |product: usize| {
186 every
187 .iter()
188 .any(|(other, occurrence, _)| *other != product || occurrence.is_some())
189 };
190 match (owners.as_slice(), pinned) {
191 // A part's own annotation: one product, and the file never
192 // said which instance. One lift for the part, whatever the
193 // instance count.
194 ([product], None) if !outside(*product) => {
195 let mut annotation = annotation.clone();
196 rewrite_references(&mut annotation.params, |_, _, local, _| local.to_string());
197 product_rows[*product].push(annotation);
198 }
199 // Everything else is the assembly's business: pinned to an
200 // instance, spanning two parts, or naming no geometry at all.
201 _ => {
202 let mut annotation = annotation.clone();
203 rewrite_references(
204 &mut annotation.params,
205 |product, occurrence, local, original| {
206 // A pin naming an occurrence no chain ends in
207 // (a malformed file) still tells us the PRODUCT,
208 // so fall back to that product's own chain rather
209 // than losing the reference entirely.
210 match paths
211 .chain(product, occurrence)
212 .or_else(|| paths.chain(product, None))
213 {
214 Some(chain) => occurrence_ref(&chain, local),
215 // Reachable from no root at all: keep the
216 // qualified name, which resolves to nothing and
217 // says which product it meant, rather than a
218 // bare part-local name that might resolve to
219 // something else.
220 None => original.to_string(),
221 }
222 },
223 );
224 for (product, occurrence, local) in &every {
225 if paths.chain(*product, *occurrence).is_some() {
226 continue;
227 }
228 out.diagnostics.push(if paths.chain(*product, None).is_some() {
229 format!(
230 "PMI: '{local}' names occurrence #{} of product {product}, which \
231 no chain from a root ends in; the reference was resolved through \
232 that product's first occurrence instead",
233 occurrence.unwrap_or_default()
234 )
235 } else {
236 format!(
237 "PMI: '{local}' annotates product {product}, which this import \
238 placed through no occurrence at all; the reference is kept \
239 unresolved"
240 )
241 });
242 }
243 assembly_rows.push(annotation);
244 }
245 }
246 }
247 if !assembly_rows.is_empty() {
248 push_view(&mut assembly, view, assembly_rows);
249 }
250 for (product, rows) in product_rows.into_iter().enumerate() {
251 if !rows.is_empty() {
252 push_view(&mut per_product[product], view, rows);
253 }
254 }
255 }
256 for (index, state) in per_product.into_iter().enumerate() {
257 if !state.views.is_empty() {
258 out.per_product[index] = Some(state);
259 }
260 }
261 if !assembly.views.is_empty() {
262 out.assembly = Some(assembly);
263 }
264 out
265}
266
267/// Copy one lifted view into a document's state with the rows that belong to
268/// it, minting the view's id in THAT document's id space.
269fn push_view(state: &mut PmiState, source: &PmiView, annotations: Vec<PmiAnnotation>) {
270 let id = state.next_id("VIEW");
271 let annotations = annotations
272 .into_iter()
273 .map(|mut annotation| {
274 let prefix = crate::feature_pipeline::pmi::pmi_type(&annotation.kind)
275 .map(|def| def.short_name)
276 .unwrap_or("PMI");
277 let fresh = state.next_id(prefix);
278 if let Some(object) = annotation.params.as_object_mut() {
279 object.insert("id".into(), serde_json::Value::String(fresh));
280 }
281 annotation
282 })
283 .collect();
284 state.views.push(PmiView {
285 id,
286 name: source.name.clone(),
287 camera: source.camera.clone(),
288 display: source.display.clone(),
289 annotations,
290 });
291}
292
293/// The field naming an annotation's ANNOTATION PLANE — the plane it is laid
294/// out in, not what it is about.
295const PLANE_FIELD: &str = "plane";
296
297/// Every product-qualified reference an annotation's params carry, minus its
298/// annotation plane.
299///
300/// The plane is excluded because it decides nothing about OWNERSHIP: it is
301/// where the callout is drawn, and our own exporter writes one for every
302/// annotation including a view-aligned note, which a re-import then binds to
303/// whatever planar face it coincides with. Letting that decide would move a
304/// free note with no subject at all onto whichever part happened to have a
305/// parallel face — which is exactly what the round-trip test caught. The plane
306/// is still REWRITTEN with everything else; it just does not get a vote.
307fn all_references(params: &serde_json::Value) -> Vec<(usize, Option<usize>, String)> {
308 let mut out = Vec::new();
309 visit_strings(params, &mut |text| {
310 if let Some((product, occurrence, local)) = split_qualified(text) {
311 out.push((product, occurrence, local.to_string()));
312 }
313 });
314 out
315}
316
317fn subject_references(params: &serde_json::Value) -> Vec<(usize, Option<usize>, String)> {
318 let mut out = Vec::new();
319 let mut push = |text: &str| {
320 if let Some((product, occurrence, local)) = split_qualified(text) {
321 out.push((product, occurrence, local.to_string()));
322 }
323 };
324 match params {
325 serde_json::Value::Object(map) => {
326 for (key, value) in map {
327 if key == PLANE_FIELD {
328 continue;
329 }
330 visit_strings(value, &mut push);
331 }
332 }
333 other => visit_strings(other, &mut push),
334 }
335 out
336}
337
338/// Rewrite every product-qualified reference in place. The closure is handed
339/// the product, the pinned occurrence, the part-local name, and the ORIGINAL
340/// qualified text — which it can hand back when nothing better resolves.
341fn rewrite_references(
342 params: &mut serde_json::Value,
343 mut name: impl FnMut(usize, Option<usize>, &str, &str) -> String,
344) {
345 map_strings(params, &mut |text| {
346 split_qualified(text)
347 .map(|(product, occurrence, local)| name(product, occurrence, local, text))
348 });
349}
350
351fn visit_strings(value: &serde_json::Value, visit: &mut impl FnMut(&str)) {
352 match value {
353 serde_json::Value::String(text) => visit(text),
354 serde_json::Value::Array(items) => items.iter().for_each(|item| visit_strings(item, visit)),
355 serde_json::Value::Object(map) => map.values().for_each(|item| visit_strings(item, visit)),
356 _ => {}
357 }
358}
359
360fn map_strings(value: &mut serde_json::Value, map: &mut impl FnMut(&str) -> Option<String>) {
361 match value {
362 serde_json::Value::String(text) => {
363 if let Some(replacement) = map(text) {
364 *text = replacement;
365 }
366 }
367 serde_json::Value::Array(items) => items.iter_mut().for_each(|item| map_strings(item, map)),
368 serde_json::Value::Object(object) => {
369 object.values_mut().for_each(|item| map_strings(item, map))
370 }
371 _ => {}
372 }
373}
374
375/// `OCC#12#47:IMPORT3D1_Face_3` — an occurrence chain and a part-local name.
376fn occurrence_ref(chain: &[usize], local: &str) -> String {
377 let mut out = String::from(OCCURRENCE_REF_PREFIX);
378 for edge in chain {
379 out.push('#');
380 out.push_str(&edge.to_string());
381 }
382 out.push(':');
383 out.push_str(local);
384 out
385}
386
387/// Read an occurrence-scoped reference back: `(chain, part-local name)`.
388/// `None` for any other name.
389pub fn occurrence_ref_parts(name: &str) -> Option<(Vec<usize>, &str)> {
390 let (head, local) = name.split_once(':')?;
391 let mut parts = head.split('#');
392 if parts.next()? != OCCURRENCE_REF_PREFIX {
393 return None;
394 }
395 let chain: Option<Vec<usize>> = parts.map(|edge| edge.parse().ok()).collect();
396 Some((chain?, local))
397}
398
399/// Rewrite every occurrence-scoped reference in a lifted annotation's params
400/// into the consumer's own namespace. `namespace` turns an occurrence chain
401/// into the prefix a reference to that instance's geometry opens with
402/// (`"ACOMP3:"`, or `""` for a component that IS the document); `None` from it
403/// means the consumer placed no component for that chain, and the reference is
404/// left naming the chain so the row reports itself rather than resolving to
405/// the wrong instance.
406///
407/// The one function a consumer of [`StepAssembly::pmi`](super::StepAssembly)
408/// needs: the chain encoding is this module's business, not theirs.
409pub fn rewrite_occurrence_refs(
410 params: &mut serde_json::Value,
411 mut namespace: impl FnMut(&[usize]) -> Option<String>,
412) -> usize {
413 let mut unplaced = 0usize;
414 map_strings(params, &mut |text| {
415 let (chain, local) = occurrence_ref_parts(text)?;
416 match namespace(&chain) {
417 Some(prefix) => Some(format!("{prefix}{local}")),
418 None => {
419 unplaced += 1;
420 None
421 }
422 }
423 });
424 unplaced
425}
426
427/// Shape aspects the file pinned to an occurrence: `SHAPE_ASPECT.of_shape` is
428/// a `PRODUCT_DEFINITION_SHAPE` whose definition is one of this assembly's
429/// `NEXT_ASSEMBLY_USAGE_OCCURRENCE`s.
430///
431/// An aspect on a PART's own product shape is absent here, and that is the
432/// common case: part PMI travels with the part.
433fn occurrence_pins(
434 entities: &HashMap<usize, Entity>,
435 edges: &HashSet<usize>,
436) -> HashMap<usize, usize> {
437 // The occurrence each PRODUCT_DEFINITION_SHAPE defines, for the shapes
438 // that define one at all.
439 let mut occurrence_of_shape: HashMap<usize, usize> = HashMap::default();
440 for (id, entity) in entities {
441 let Some(args) = entity.find("PRODUCT_DEFINITION_SHAPE") else {
442 continue;
443 };
444 let Some(definition) = args.get(2).and_then(|value| value.as_ref_id().ok()) else {
445 continue;
446 };
447 if edges.contains(&definition) {
448 occurrence_of_shape.insert(*id, definition);
449 }
450 }
451 let mut pins = HashMap::default();
452 if occurrence_of_shape.is_empty() {
453 return pins;
454 }
455 for (id, entity) in entities {
456 let Some(args) = entity.find("SHAPE_ASPECT") else {
457 continue;
458 };
459 let Some(of_shape) = args.get(2).and_then(|value| value.as_ref_id().ok()) else {
460 continue;
461 };
462 if let Some(edge) = occurrence_of_shape.get(&of_shape) {
463 pins.insert(*id, *edge);
464 }
465 }
466 pins
467}
468
469/// How many root→node occurrence chains the walk records per product before it
470/// stops. A real assembly has one per instance; the cap only bounds a
471/// pathological diamond graph, whose chain count is exponential in its depth.
472const MAX_CHAINS: usize = 4096;
473
474/// Every occurrence chain that reaches each product, root first.
475struct OccurrencePaths {
476 chains: Vec<Vec<Vec<usize>>>,
477}
478
479impl OccurrencePaths {
480 fn build(products: usize, occurrences: &[StepOccurrence], roots: &[usize]) -> Self {
481 let mut chains: Vec<Vec<Vec<usize>>> = vec![Vec::new(); products];
482 // Children by ascending `edge_ref`, so a product reached several ways
483 // resolves to the same chain on every run.
484 let mut edges: Vec<&StepOccurrence> = occurrences.iter().collect();
485 edges.sort_by_key(|edge| edge.edge_ref);
486 let mut total = 0usize;
487 let mut stack: Vec<(usize, Vec<usize>)> =
488 roots.iter().rev().map(|root| (*root, Vec::new())).collect();
489 while let Some((product, chain)) = stack.pop() {
490 if total >= MAX_CHAINS || product >= products {
491 break;
492 }
493 total += 1;
494 chains[product].push(chain.clone());
495 for edge in edges.iter().rev() {
496 if edge.parent != product || chain.contains(&edge.edge_ref) {
497 continue;
498 }
499 let mut next = chain.clone();
500 next.push(edge.edge_ref);
501 stack.push((edge.child, next));
502 }
503 }
504 Self { chains }
505 }
506
507 /// The chain for one reference: the one ending in the pinned occurrence
508 /// when the file pinned it, else the product's first chain (ascending
509 /// `edge_ref` at every level, so it is the same chain on every run).
510 fn chain(&self, product: usize, occurrence: Option<usize>) -> Option<Vec<usize>> {
511 let chains = self.chains.get(product)?;
512 match occurrence {
513 Some(edge) => chains
514 .iter()
515 .find(|chain| chain.last() == Some(&edge))
516 .or_else(|| chains.iter().find(|chain| chain.contains(&edge)))
517 .cloned(),
518 None => chains.first().cloned(),
519 }
520 }
521}
522