Skip to main content

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