Skip to main content

brep_kernel/io/step_import/
assembly.rs

1//! STRUCTURED STEP import: read the product structure and keep it.
2//!
3//! [`collect_step_solids`] resolves a STEP assembly graph and then throws the
4//! structure away — it composes each occurrence's world transform, bakes it
5//! into a copy of the part, and returns a flat `Vec<BrepSolid>`. That is the
6//! right answer for "import these bodies", and it stays exactly as it is.
7//!
8//! This module is the other answer. It reads the SAME graph in the SAME single
9//! parse and returns it: one [`StepProduct`] per structure node holding that
10//! node's own bodies IN ITS OWN LOCAL FRAME, and one [`StepOccurrence`] per
11//! structure EDGE — a NEXT_ASSEMBLY_USAGE_OCCURRENCE or a MAPPED_ITEM
12//! (`mapped.rs`) — carrying the child→parent placement.
13//! **Nothing here is transformed.** Composing the tree — and so choosing what a
14//! "component" is — belongs to the caller, which maps products onto parts-library
15//! entries and occurrences onto assembly-component instances.
16//!
17//! Two invariants make that safe:
18//!
19//! - **ONE parse, each body built ONCE.** The parse is shared with nothing (the
20//!   caller passes the text once), and inside it every distinct [`StepBody`] is
21//!   reconstructed a single time and cloned into its product, exactly as
22//!   `resolve_assembly` caches per body.
23//! - **The same walk.** Tree shape, visit order, transform composition and the
24//!   cycle guard all come from [`walk_occurrences`], which the flat lane also
25//!   uses, so the two lanes cannot drift apart. The kernel test suite asserts
26//!   the strong form of this: replaying this module's output through the flat
27//!   lane's own `transform_brep` call reproduces `resolve_assembly`'s solids
28//!   BIT-for-bit on every single-context assembly fixture.
29//!
30//! **Units are resolved PER PRODUCT here**, not once per file.
31//! `derive_length_scale_mm` picks one global scale out of `HashMap` iteration
32//! order, which a mixed-unit assembly — each product representation carrying
33//! its own `GLOBAL_UNIT_ASSIGNED_CONTEXT` — makes both wrong and
34//! nondeterministic. See [`ProductScales`].
35
36use super::*;
37use crate::feature_pipeline::pmi::PmiState;
38
39/// One node of the file's product structure: a PRODUCT_DEFINITION, or a
40/// representation that a MAPPED_ITEM instantiates and no product definition owns
41/// (`bodies::representation_owner_index`).
42#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
43pub struct StepProduct {
44    /// The node's entity id — a PRODUCT_DEFINITION, or the representation that
45    /// stands in for one. The stable identity and dedup key either way, because
46    /// Part 21 ids are unique within a file.
47    pub pd_ref: usize,
48    /// `PRODUCT.name`, the representation's name for a node that is one, or
49    /// `pd#<id>` when the file carries neither.
50    pub name: String,
51    /// `PRODUCT.id` — the vendor part number, for the BOM. May be empty.
52    ///
53    /// The CONSUMER is what makes this durable: the app's assembly intake
54    /// records it as `partAttributes.Part_Number` on the part document it mints
55    /// (`BREP_render`'s `model_io.rs::part_attributes`), which is the record
56    /// `io/step/export_tree.rs` reads back out as `PRODUCT.id`. An intake that
57    /// dropped it would silently replace a vendor's part numbers with our own
58    /// file names on the first export -> import -> export.
59    pub id: String,
60    /// This product's OWN solids, in its OWN local frame and at its OWN unit
61    /// scale. Empty ⇒ a pure assembly node (structure, no geometry).
62    pub bodies: Vec<BrepSolid>,
63    /// The styled colour of each entry of `bodies`, PARALLEL to it (index `i`
64    /// is body `i`). Default-filled when the file carries no presentation
65    /// entities, so it is always the same length as `bodies` and a caller can
66    /// `zip` without a length check.
67    pub appearances: Vec<BodyAppearance>,
68    /// Bodies of this product that did not reconstruct. Graceful degradation:
69    /// counted, never fatal, and never a reason to reject the file.
70    pub failed_bodies: usize,
71    /// The file's PMI that annotates THIS product's own geometry, with
72    /// references naming the faces / edges / vertices the part document's
73    /// `IMPORT3D1` feature will stamp (`assembly_pmi.rs`).
74    ///
75    /// ONE lift per PART, however many occurrences place it — an annotation on
76    /// a part's face IS an annotation on every instance of that part, which is
77    /// the exporter's rule read backwards (`io/step/assembly.rs`: two
78    /// instances of one part share the part's `ADVANCED_FACE`s). PMI the file
79    /// pinned to a particular INSTANCE is not here; it rides
80    /// [`StepAssembly::pmi`].
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub pmi: Option<PmiState>,
83}
84
85/// One occurrence: a placement of `child` inside `parent`, from a NAUO edge or a
86/// mapped item.
87#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
88pub struct StepOccurrence {
89    /// The entity that IS this occurrence: a NEXT_ASSEMBLY_USAGE_OCCURRENCE, or
90    /// the MAPPED_ITEM that placed a shared representation (`mapped.rs`). Part 21
91    /// ids are unique per file, so this identifies the occurrence either way, and
92    /// consumers order sibling occurrences by it for determinism.
93    pub edge_ref: usize,
94    /// Index into [`StepAssembly::products`].
95    pub parent: usize,
96    /// Index into [`StepAssembly::products`].
97    pub child: usize,
98    /// The instance label ("bolt-3") — see `edge_designator` for which of a
99    /// NAUO's several label attributes wins, and what a mapped item contributes.
100    pub designator: String,
101    /// child-local → parent-local, row-major affine, translations in MILLIMETRES
102    /// (scaled by the PARENT representation's unit, which is the frame the
103    /// placement is expressed in). NOT a world transform: the caller composes
104    /// these down from [`StepAssembly::roots`].
105    pub placement: [f64; 16],
106    /// RᵀR = I and det R = +1 within 1e-9 — i.e. the placement is a pure
107    /// rotation + translation, representable as a translate/rotate component
108    /// pose. A mirrored or scaled occurrence has no such representation and its
109    /// non-rigid factor must be baked into a distinct part by the caller, which
110    /// is what `BREP_render`'s assembly intake does. A MAPPED_ITEM through a
111    /// CARTESIAN_TRANSFORMATION_OPERATOR_3D is the placement that makes this
112    /// `false` today (`mapped.rs`).
113    pub rigid: bool,
114}
115
116/// A STEP file's product structure, with geometry, and without a single
117/// transform applied to it.
118#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
119pub struct StepAssembly {
120    /// Every product the structure mentions, ordered by ascending `pd_ref`.
121    pub products: Vec<StepProduct>,
122    /// One entry per structure edge, ordered by ascending `edge_ref`.
123    pub occurrences: Vec<StepOccurrence>,
124    /// Indices of the products that are never an occurrence child — where a
125    /// consumer starts composing world transforms.
126    pub roots: Vec<usize>,
127    /// The first thing that did not come in: a body that failed to reconstruct,
128    /// or a mapping that was refused (`StepMappedItemError`). A refused mapping
129    /// is NOT counted in any product's `failed_bodies`, which counts bodies that
130    /// failed to BUILD — a placement we cannot express is a different thing, and
131    /// the geometry under it built perfectly well.
132    pub first_error: Option<String>,
133    /// The file's ASSEMBLY-level PMI: annotations the file pinned to an
134    /// OCCURRENCE rather than a part, annotations spanning two parts, and
135    /// annotations with no geometry of their own (a free note, a saved view).
136    ///
137    /// References to a component's geometry carry the STEP occurrence chain
138    /// they resolve through (`OCCURRENCE_REF_PREFIX`): the kernel cannot know
139    /// what the consumer will call its components, so the consumer rewrites
140    /// them with `rewrite_occurrence_refs`.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub pmi: Option<PmiState>,
143    /// What the PMI lift could not do cleanly — a reference naming geometry
144    /// this import did not build, an occurrence chain nothing placed. Every
145    /// one of these describes a KEPT annotation: PMI is never dropped for want
146    /// of a reference, it arrives as a row that reports itself.
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub pmi_diagnostics: Vec<String>,
149    /// Open `SHELL_BASED_SURFACE_MODEL` sheets this lane skipped, summed over
150    /// every product's bodies: not solids, so in no product's `failed_bodies`,
151    /// and uncounted before 2026-10-12 (`SHEETS_SKIPPED` on the flat lane).
152    #[serde(default)]
153    pub sheets_skipped: usize,
154}
155
156/// Read a STEP file's product structure AND build each product's own bodies, in
157/// ONE parse, with each distinct body built ONCE.
158///
159/// `Ok(None)` when the file carries no assembly structure worth using — no
160/// structure edges at all (neither NAUO nor mapped item), or none that the walk
161/// reaches a built body through. That is the same condition on which
162/// `collect_step_solids` abandons the assembly branch, so a caller can treat
163/// `None` as "fall back to the flat lane" and get exactly today's behaviour.
164///
165/// `Err` only for a file that is not Part 21 or does not parse — a broken
166/// *assembly* degrades, it does not fail.
167pub fn read_step_assembly(text: &str) -> Result<Option<StepAssembly>, String> {
168    if !text.contains("ISO-10303-21") {
169        return Err("step_import: not an ISO-10303-21 Part 21 file".into());
170    }
171    let debug_timing = std::env::var("BREP_DEBUG_STEP_LOOP").is_ok();
172    let parse_started = debug_timing.then(web_time::Instant::now);
173    let entities = parse_data_section(text)?;
174    if let Some(started) = parse_started {
175        eprintln!("PHASE parse: {:?} ({} entities)", started.elapsed(), entities.len());
176    }
177    let mut phase_clock = debug_timing.then(web_time::Instant::now);
178    let mut phase = |name: &str| {
179        if let Some(started) = phase_clock.as_mut() {
180            eprintln!("PHASE assembly {name}: {:?}", started.elapsed());
181            *started = web_time::Instant::now();
182        }
183    };
184    let global_scale = derive_length_scale_mm(&entities);
185    let edges = structure_edges(&entities, &Resolver::new(&entities, global_scale));
186    phase("structure edges");
187    if edges.is_empty() {
188        return Ok(None); // no product structure: the flat lane is the answer
189    }
190
191    let structure = StructureIndex::new(&entities);
192    phase("structure index");
193    let mut scales = ProductScales::new(&structure, global_scale);
194
195    // Each edge's placement, read at the scale of the representation each of its
196    // frames is an item of: an ITEM_DEFINED_TRANSFORMATION's AXIS2_PLACEMENT_3D
197    // origins are lengths in the PARENT representation's context (§3.6.2), so
198    // the parent's unit — not the file-global one — converts them to
199    // millimetres, and a mapped item's `mapping_origin` is an item of the CHILD
200    // representation and takes the child's (`mapped.rs`). The rotation blocks
201    // are dimensionless and unaffected.
202    //
203    // (Strictly, a NAUO's `item_1` lives in the CHILD representation too, so a
204    // file that BOTH mixes units AND gives item_1 a non-identity origin would
205    // want its two frames scaled separately. Every producer we have seen writes
206    // item_1 as the child rep's origin placement, where the two agree; splitting
207    // them would mean reimplementing `nauo_edge_transform` rather than handing
208    // it a Resolver, which is a worse trade for a case no fixture exhibits. A
209    // MAPPED_ITEM is not in that position: its two frames are REQUIRED to be
210    // items of the two different representations, so the split is the only
211    // correct reading and `mapped-item-nested-nauo.step` exhibits it.)
212    //
213    // A mapping we cannot express as a rigid placement is REFUSED here: its edge
214    // is dropped, so nothing under it is instantiated, and the refusal is
215    // reported through `first_error`.
216    let ResolvedEdges {
217        edges,
218        transforms,
219        refusals,
220    } = resolve_structure_edges(&structure, &edges, |node| scales.get(node));
221    phase("resolve edges");
222    let mut first_error: Option<String> = refusals.first().map(ToString::to_string);
223    let mut sheets_skipped = 0usize;
224    if edges.is_empty() {
225        return Ok(None); // every edge refused: nothing to build a structure from
226    }
227
228    // Every structure node the surviving edges mention, by ascending entity id.
229    let mut pd_refs: Vec<usize> = edges
230        .iter()
231        .flat_map(|edge| [edge.parent_pd, edge.child_pd])
232        .collect();
233    pd_refs.sort_unstable();
234    pd_refs.dedup();
235    let product_index: HashMap<usize, usize> = pd_refs
236        .iter()
237        .enumerate()
238        .map(|(index, &pd)| (pd, index))
239        .collect();
240
241    // Each product's own bodies, built at that product's own unit scale. The
242    // cache is keyed by (body, scale) rather than by body alone: two products
243    // can only share a body entity by sharing a representation, in which case
244    // they share a context and the scale key is redundant — but if they ever do
245    // not, the second product gets its own build instead of the first's.
246    // The file's presentation entities, read ONCE for the whole document.
247    let styles = StyleTable::read(&entities);
248    let build_part = |(body, scale_key): (StepBody, u64)| {
249        // The readings are dropped below, so the build does not take them.
250        let resolver = Resolver::new(&entities, f64::from_bits(scale_key)).without_trim_readings();
251        build_step_body(&resolver, body)
252            .map(|(solid, _readings)| {
253                let appearance = step_body_appearance(&resolver, &styles, body, &solid);
254                (solid, appearance)
255            })
256            .map_err(|error| format!("step_import: part body {body:?} failed to build: {error}"))
257    };
258    // Every distinct (body, scale) the product loop below asks for, in the
259    // order it asks, built up front -- in parallel under the `parallel`
260    // feature. The loop then only reads the cache.
261    let product_scales: Vec<f64> = pd_refs.iter().map(|&pd| scales.get(pd)).collect();
262    let mut part_keys: Vec<(StepBody, u64)> = Vec::new();
263    {
264        let mut seen: HashSet<(StepBody, u64)> = HashSet::default();
265        for (&pd, &scale) in pd_refs.iter().zip(&product_scales) {
266            let resolver = Resolver::new(&entities, scale);
267            for body in pd_step_bodies(&structure, &resolver, pd) {
268                if seen.insert((body, scale.to_bits())) {
269                    part_keys.push((body, scale.to_bits()));
270                }
271            }
272        }
273    }
274    phase(&format!("body keys ({} distinct bodies)", part_keys.len()));
275    // Under the PHASE switch, each body's own build time too, slowest first:
276    // which parts an import is waiting on.
277    let body_times: std::sync::Mutex<Vec<(std::time::Duration, StepBody, Option<String>)>> = Default::default();
278    let timed_build = |key: (StepBody, u64)| {
279        let started = debug_timing.then(web_time::Instant::now);
280        let built = build_part(key);
281        if let Some(started) = started {
282            let error = built.as_ref().err().map(|error| error.chars().take(300).collect());
283            body_times.lock().expect("timing lock").push((started.elapsed(), key.0, error));
284        }
285        built
286    };
287    let mut solid_cache: HashMap<(StepBody, u64), Result<(BrepSolid, BodyAppearance), String>> =
288        build_distinct_bodies(
289            part_keys,
290            |(body, scale_key)| step_body_face_count(&Resolver::new(&entities, f64::from_bits(scale_key)), body),
291            &timed_build,
292        ).into_iter().collect();
293    phase("body builds");
294    if debug_timing {
295        let mut times = body_times.into_inner().expect("timing lock");
296        times.sort_by(|a, b| b.0.cmp(&a.0));
297        for (elapsed, body, error) in times.iter().take(15) {
298            let outcome = if error.is_some() { " (failed)" } else { "" };
299            eprintln!("PHASE assembly   slow body {body:?}: {elapsed:?}{outcome}");
300        }
301        let failed: Vec<_> = times.iter().filter(|(_, _, error)| error.is_some()).collect();
302        let failed_time: std::time::Duration = failed.iter().map(|(elapsed, _, _)| *elapsed).sum();
303        eprintln!("PHASE assembly   {} failed bodies took {failed_time:?} in all", failed.len());
304        for (elapsed, body, error) in failed {
305            eprintln!("PHASE assembly   failed body {body:?} after {elapsed:?}: {}", error.as_deref().unwrap_or(""));
306        }
307    }
308    let mut products: Vec<StepProduct> = Vec::with_capacity(pd_refs.len());
309    // The face refs of every body that built, in `bodies` order — the pairing
310    // an annotation's `ADVANCED_FACE` is mapped to `IMPORT3D1_Face_i` through
311    // (`assembly_pmi.rs`). Collected only when the file mentions PMI at all,
312    // so a plain assembly pays nothing for it.
313    let wants_pmi = super::pmi::file_mentions_pmi(text);
314    let mut face_refs: super::assembly_pmi::ProductFaceRefs = Vec::with_capacity(pd_refs.len());
315    for (&pd, &scale) in pd_refs.iter().zip(&product_scales) {
316        let resolver = Resolver::new(&entities, scale);
317        let scale_key = scale.to_bits();
318        let mut bodies = Vec::new();
319        let mut appearances = Vec::new();
320        let mut product_face_refs = Vec::new();
321        let mut failed_bodies = 0usize;
322        for body in pd_step_bodies(&structure, &resolver, pd) {
323            let built = solid_cache
324                .entry((body, scale_key))
325                .or_insert_with(|| build_part((body, scale_key)));
326            match built {
327                // Pushed together, so `bodies` and `appearances` stay parallel
328                // through every skip arm below.
329                Ok((solid, appearance)) => {
330                    if wants_pmi {
331                        product_face_refs.push(super::pmi::body_face_refs(&resolver, body, solid));
332                    }
333                    bodies.push(solid.clone());
334                    appearances.push(appearance.clone());
335                }
336                // An OPEN SHELL_BASED_SURFACE_MODEL sheet is not a solid body:
337                // skipped, never a failed body, exactly as both other lanes do;
338                // counted as a skipped sheet since 2026-10-12.
339                Err(_) if matches!(body, StepBody::SurfaceModelShell { .. }) => sheets_skipped += 1,
340                Err(error) => {
341                    failed_bodies += 1;
342                    if first_error.is_none() {
343                        first_error = Some(error.clone());
344                    }
345                }
346            }
347        }
348        face_refs.push(product_face_refs);
349        products.push(StepProduct {
350            pd_ref: pd,
351            name: product_name(&resolver, pd),
352            id: product_id(&resolver, pd),
353            bodies,
354            appearances,
355            failed_bodies,
356            pmi: None,
357        });
358    }
359
360    // Which products the structure actually REACHES — through the flat lane's
361    // own walk, so the cycle guard and root set are identical. A graph that
362    // reaches no geometry is not a structure we can import, so the caller falls
363    // back to the flat lane, exactly as `collect_step_solids` does when
364    // `resolve_assembly` yields no solid.
365    //
366    // (One corner differs harmlessly: a body that builds but fails
367    // `transform_brep` yields no flat solid, yet counts as reached here.)
368    phase("products");
369    let mut reached: HashSet<usize> = HashSet::default();
370    {
371        let resolver = Resolver::new(&entities, global_scale);
372        walk_occurrences(&resolver, &edges, &transforms, |node| {
373            reached.insert(node.pd);
374        });
375    }
376    let reaches_geometry = products
377        .iter()
378        .any(|product| !product.bodies.is_empty() && reached.contains(&product.pd_ref));
379    if !reaches_geometry {
380        return Ok(None);
381    }
382
383    let label_resolver = Resolver::new(&entities, global_scale);
384    let occurrences: Vec<StepOccurrence> = edges
385        .iter()
386        .map(|edge| {
387            let placement = transforms
388                .get(&edge.edge_id)
389                .copied()
390                .unwrap_or_else(mat4_identity);
391            StepOccurrence {
392                edge_ref: edge.edge_id,
393                parent: product_index[&edge.parent_pd],
394                child: product_index[&edge.child_pd],
395                designator: edge_designator(&label_resolver, edge),
396                placement,
397                rigid: transform_is_rigid(&placement),
398            }
399        })
400        .collect();
401
402    let roots: Vec<usize> = assembly_roots(&edges)
403        .into_iter()
404        .map(|pd| product_index[&pd])
405        .collect();
406
407    // The file's PMI, split between the part documents this import will mint
408    // and the assembly document that places them (`assembly_pmi.rs`). Read
409    // here rather than by the consumer because this is where the file text,
410    // the parse and each product's own face pairing are all in hand at once —
411    // the same reason the geometry is built here.
412    phase("walk + occurrences");
413    let mut pmi = super::assembly_pmi::AssemblyPmi::default();
414    if wants_pmi {
415        pmi = super::assembly_pmi::lift(
416            text,
417            &entities,
418            &products,
419            &face_refs,
420            &occurrences,
421            &roots,
422            global_scale,
423            |pd| scales.get(pd),
424        );
425        for (product, state) in products.iter_mut().zip(pmi.per_product.drain(..)) {
426            product.pmi = state;
427        }
428    }
429
430    phase("pmi");
431    Ok(Some(StepAssembly {
432        products,
433        occurrences,
434        roots,
435        first_error,
436        pmi: pmi.assembly,
437        pmi_diagnostics: pmi.diagnostics,
438        sheets_skipped,
439    }))
440}
441
442/// Per-product length units, memoised.
443///
444/// A product's geometry is expressed in the REPRESENTATION_CONTEXT of the shape
445/// representation that defines it, and that context carries its own unit
446/// assignment. `Resolver` is only `{entities, length_scale}`, so building a
447/// product at its own scale costs one struct rebuild — which is the whole
448/// implementation of "per-product units".
449///
450/// Falls back to the file-global scale for a product with no resolvable context
451/// (a pure assembly node with no shape representation, typically), so a
452/// single-context file resolves to the identical scale everywhere and the
453/// structured lane's geometry stays byte-identical to the flat lane's.
454pub(super) struct ProductScales<'a> {
455    structure: &'a StructureIndex<'a>,
456    global: f64,
457    cache: HashMap<usize, f64>,
458}
459
460impl<'a> ProductScales<'a> {
461    pub(super) fn new(structure: &'a StructureIndex<'a>, global: f64) -> Self {
462        Self {
463            structure,
464            global,
465            cache: HashMap::default(),
466        }
467    }
468
469    /// Millimetres per native length unit for `pd`'s own representation. The
470    /// candidate SRs come from `pd_root_srs`, which sorts and dedups, so a
471    /// product with several representations resolves deterministically (unlike
472    /// `derive_length_scale_mm`, which reads whatever the `HashMap` yields).
473    pub(super) fn get(&mut self, pd: usize) -> f64 {
474        if let Some(&scale) = self.cache.get(&pd) {
475            return scale;
476        }
477        let scale = pd_root_srs(self.structure, pd)
478            .into_iter()
479            .filter_map(|sr| self.structure.entities.get(&sr).and_then(representation_context))
480            .find_map(|context| context_length_scale_mm(self.structure.entities, context))
481            .unwrap_or(self.global);
482        self.cache.insert(pd, scale);
483        scale
484    }
485}
486
487/// Is this affine's linear block a pure rotation — RᵀR = I and det R = +1 to
488/// 1e-9? A mirrored (det = −1) or scaled occurrence is NOT representable as a
489/// component pose, and its caller must bake the non-rigid factor into a
490/// distinct part rather than silently reuse the unmirrored twin.
491///
492/// A NAUO placement cannot fail this: it is built from two AXIS2_PLACEMENT_3Ds,
493/// and `Resolver::placement` always returns an orthonormal right-handed frame. A
494/// MAPPED_ITEM's can, and does — its target may be a
495/// CARTESIAN_TRANSFORMATION_OPERATOR_3D with a left-handed triad or a `scale`
496/// (`mapped.rs`) — so this flag is now LIVE rather than carried for a future
497/// lane, and `mapped-item-mirrored.step` is the fixture that makes it false.
498///
499/// `false` is not a refusal. It is the statement that this placement is not a
500/// component pose, which the caller answers by splitting `world = rigid · factor`
501/// and baking `factor` into a distinct part — `BREP_render`'s `split_rigid` /
502/// `build_library_entry`, whose mirrored part is named `… (mirrored)` and counted
503/// in the import notice's "N mirrored instances baked". The flat import lane bakes
504/// the same reflection directly into the posed geometry it returns
505/// (`resolve_assembly`, through `transform_brep`'s orientation reversal), so the
506/// two lanes agree about what the file means.
507pub(super) fn transform_is_rigid(matrix: &Mat4) -> bool {
508    crate::AffineTransform { elements: *matrix }.is_rigid(crate::RIGID_TOL)
509}
510
511/// The instance label of one structure edge: a mapped item's own name
512/// (`mapped::mapped_item_designator`), or a NAUO's designator below.
513fn edge_designator(resolver: &Resolver, edge: &AssemblyEdge) -> String {
514    match edge.kind {
515        StructureEdgeKind::Nauo => nauo_designator(resolver, edge.edge_id),
516        StructureEdgeKind::Mapped => mapped_item_designator(resolver, edge.edge_id),
517    }
518}
519
520/// The instance label of a NAUO —
521/// `NEXT_ASSEMBLY_USAGE_OCCURRENCE(id, name, description, relating, related,
522/// reference_designator)`.
523///
524/// Producers disagree about which attribute carries it, so take the first
525/// NON-BLANK of `reference_designator` → `name` → `description` → `id`. The
526/// three assembly fixtures each land on a different one: AssemblyExample and
527/// io1-ac put it in `name`; as1-ug writes `name` as a single space and the real
528/// designator ('ROD', 'NUT1', …) in `description`; a producer that fills
529/// `reference_designator` is the standard-conforming case and wins outright.
530fn nauo_designator(resolver: &Resolver, nauo: usize) -> String {
531    let field = |args: &[Value], index: usize| match args.get(index) {
532        Some(Value::Str(text)) if !text.trim().is_empty() => Some(text.trim().to_string()),
533        _ => None,
534    };
535    (|| {
536        let args = resolver
537            .get(nauo)
538            .ok()?
539            .find("NEXT_ASSEMBLY_USAGE_OCCURRENCE")?;
540        field(args, 5)
541            .or_else(|| field(args, 1))
542            .or_else(|| field(args, 2))
543            .or_else(|| field(args, 0))
544    })()
545    .unwrap_or_else(|| format!("nauo#{nauo}"))
546}