Skip to main content

brep_kernel/io/step_import/
pmi.rs

1//! AP242 PMI on import — the read side of `io/step/pmi.rs`.
2//!
3//! Lifts a file's SEMANTIC PMI (dimensional locations / sizes / angular
4//! locations with their values and ± / limit tolerances, datums, geometric
5//! tolerances with datum systems, modifiers and zones), its SAVED VIEWS
6//! (`DRAUGHTING_MODEL` + `CAMERA_MODEL_D3`) and the label positions of its
7//! polyline presentation into a [`PmiState`] whose references name the faces
8//! / edges / vertices the IMPORT3D feature will stamp — so PMI written by
9//! this kernel, or by any AP242 CAD system, arrives as live, editable
10//! annotations rather than dead geometry.
11//!
12//! Reference mapping: face `i` of built body `b` IS `#face_refs[b][i]` (the
13//! importer's own pairing guarantee), so an `ADVANCED_FACE` maps to the
14//! positional name `{body}_Face_{i}` (`{feature}_SOLID_0k` bodies for a
15//! multi-body file). Edges map through their two adjacent faces to the
16//! derived `{A}|{B}[n]` name the pipeline stamps; vertices to `{body}@x,y,z`.
17//! 3D text callouts with no semantic element import as notes. Annotations no
18//! saved view lists land in an "Imported PMI" view.
19
20use super::bodies::{collect_step_solids, step_body_claimed_shells, StepBody};
21use super::parse::{parse_data_section, Entity, Value};
22use super::*;
23use crate::feature_pipeline::pmi::annotations::fcf::characteristic_for_entity;
24use crate::feature_pipeline::pmi::{
25    PmiAnnotation, PmiCamera, PmiDisplay, PmiProjection, PmiState, PmiView,
26};
27use std::collections::BTreeMap;
28
29/// Decode Part 21 string directives: `\X2\HHHH…\X0\` (UTF-16 code units),
30/// `\X4\HHHHHHHH…\X0\`, `\X\HH` (ISO 8859-1), `\S\c`, `\N\` (newline).
31pub(crate) fn decode_step_text(raw: &str) -> String {
32    let mut out = String::new();
33    let mut rest = raw;
34    while let Some(index) = rest.find('\\') {
35        out.push_str(&rest[..index]);
36        let tail = &rest[index..];
37        if let Some(after) = tail.strip_prefix("\\X2\\") {
38            let end = after.find("\\X0\\").unwrap_or(after.len());
39            let hex = &after[..end];
40            let units: Vec<u16> = hex
41                .as_bytes()
42                .chunks(4)
43                .filter_map(|chunk| u16::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok())
44                .collect();
45            out.push_str(&String::from_utf16_lossy(&units));
46            rest = &after[(end + 4).min(after.len())..];
47        } else if let Some(after) = tail.strip_prefix("\\X4\\") {
48            let end = after.find("\\X0\\").unwrap_or(after.len());
49            let hex = &after[..end];
50            for chunk in hex.as_bytes().chunks(8) {
51                if let Some(ch) = std::str::from_utf8(chunk)
52                    .ok()
53                    .and_then(|text| u32::from_str_radix(text, 16).ok())
54                    .and_then(char::from_u32)
55                {
56                    out.push(ch);
57                }
58            }
59            rest = &after[(end + 4).min(after.len())..];
60        } else if let Some(after) = tail.strip_prefix("\\X\\") {
61            if let Some(byte) = after.get(..2).and_then(|hex| u8::from_str_radix(hex, 16).ok()) {
62                out.push(byte as char);
63                rest = &after[2..];
64            } else {
65                rest = after;
66            }
67        } else if let Some(after) = tail.strip_prefix("\\N\\") {
68            out.push('\n');
69            rest = after;
70        } else if let Some(after) = tail.strip_prefix("\\S\\") {
71            let mut chars = after.chars();
72            if let Some(ch) = chars.next() {
73                out.push(((ch as u32 + 128) as u8) as char);
74            }
75            rest = chars.as_str();
76        } else if let Some(after) = tail.strip_prefix("\\\\") {
77            out.push('\\');
78            rest = after;
79        } else {
80            out.push('\\');
81            rest = &tail[1..];
82        }
83    }
84    out.push_str(rest);
85    out
86}
87
88fn arg_string(args: &[Value], index: usize) -> String {
89    match args.get(index) {
90        Some(Value::Str(text)) => decode_step_text(text),
91        _ => String::new(),
92    }
93}
94
95fn arg_ref(args: &[Value], index: usize) -> Option<usize> {
96    args.get(index).and_then(|value| value.as_ref_id().ok())
97}
98
99fn arg_refs(args: &[Value], index: usize) -> Vec<usize> {
100    args.get(index)
101        .and_then(|value| value.as_list().ok())
102        .map(|items| items.iter().filter_map(|item| item.as_ref_id().ok()).collect())
103        .unwrap_or_default()
104}
105
106/// The numeric payload of a measure select (`LENGTH_MEASURE(0.1)`,
107/// `PLANE_ANGLE_MEASURE(1.5)`) or a bare real.
108fn measure_value(value: &Value) -> Option<f64> {
109    match value {
110        Value::Typed(_, inner) => inner.first().and_then(measure_value),
111        other => other.as_real().ok(),
112    }
113}
114
115/// What a reference name opens with when the file named geometry this
116/// importer did not mint: the STEP entity id of the shape aspect that named
117/// it. Nothing resolves it, which is the point — the annotation reaches the
118/// PMI panel as a row in error, naming the entity a reader can look up in the
119/// file, instead of vanishing between the two formats.
120pub(super) const UNRESOLVED_REF_PREFIX: &str = "STEP#";
121
122/// The geometry a reference name points at, as the IMPORT3D feature names it.
123pub(super) struct NameMap {
124    faces: HashMap<usize, String>,
125    edges: HashMap<usize, String>,
126    vertices: HashMap<usize, String>,
127    pub(super) length_scale: f64,
128    /// Degrees per file plane-angle unit.
129    angle_to_degrees: f64,
130}
131
132/// Degrees per plane-angle unit of the file (`SI_UNIT($,.RADIAN.)` →
133/// 57.29…; a `CONVERSION_BASED_UNIT` of 0.01745 rad → 1).
134fn angle_scale_degrees(entities: &HashMap<usize, Entity>) -> f64 {
135    for entity in entities.values() {
136        if !entity.has("PLANE_ANGLE_UNIT") {
137            continue;
138        }
139        if let Some(args) = entity.find("CONVERSION_BASED_UNIT") {
140            if let Some(measure) = arg_ref(args, 1).and_then(|id| entities.get(&id)) {
141                if let Some(value) = measure
142                    .find("PLANE_ANGLE_MEASURE_WITH_UNIT")
143                    .or_else(|| measure.find("MEASURE_WITH_UNIT"))
144                    .and_then(|args| args.first())
145                    .and_then(measure_value)
146                {
147                    return value.to_degrees();
148                }
149            }
150        }
151    }
152    1f64.to_degrees()
153}
154
155impl NameMap {
156    /// The FLAT lane's map: every solid in the file, in file order, named by
157    /// the IMPORT3D feature that is about to stamp them.
158    fn build(text: &str, entities: &HashMap<usize, Entity>, feature_name: &str) -> Result<Self, String> {
159        let imported = collect_step_solids(text)?;
160        let scale = derive_length_scale_mm(entities);
161        let resolver = Resolver::new(entities, scale);
162        let count = imported.solids.len();
163        let body_names = crate::feature_pipeline::imported_solid_names(count, feature_name);
164        let mut map = Self {
165            faces: HashMap::default(),
166            edges: HashMap::default(),
167            vertices: HashMap::default(),
168            length_scale: scale,
169            angle_to_degrees: angle_scale_degrees(entities),
170        };
171        map.add_bodies(entities, &resolver, &body_names, &imported.face_refs, "");
172        Ok(map)
173    }
174
175    /// An EMPTY map at the file's global units — the base a structured lift
176    /// adds one product at a time to ([`Self::add_bodies`]).
177    pub(super) fn empty(entities: &HashMap<usize, Entity>, length_scale: f64) -> Self {
178        Self {
179            faces: HashMap::default(),
180            edges: HashMap::default(),
181            vertices: HashMap::default(),
182            length_scale,
183            angle_to_degrees: angle_scale_degrees(entities),
184        }
185    }
186
187    /// Name one set of bodies — `face_refs[b]` is body `b`'s ordered face refs,
188    /// `body_names[b]` the scene name the pipeline will stamp on it.
189    ///
190    /// `qualifier` is prepended to every name this call mints. The flat lane
191    /// passes `""`; a STRUCTURED lift passes a per-product tag, because two
192    /// part documents both call their first body `IMPORT3D1` and their names
193    /// would otherwise collide in one map (`assembly_pmi.rs`).
194    pub(super) fn add_bodies(
195        &mut self,
196        entities: &HashMap<usize, Entity>,
197        resolver: &Resolver,
198        body_names: &[String],
199        face_refs: &[Vec<usize>],
200        qualifier: &str,
201    ) {
202        for (body_index, body_faces) in face_refs.iter().enumerate() {
203            let Some(body_name) = body_names.get(body_index) else {
204                continue;
205            };
206            // Faces, positionally.
207            for (face_index, face_ref) in body_faces.iter().enumerate() {
208                self.faces
209                    .entry(*face_ref)
210                    .or_insert_with(|| format!("{qualifier}{body_name}_Face_{face_index}"));
211            }
212            // Edges: `{A}|{B}[n]` in first-encounter (face → loop → coedge)
213            // order, with the two adjacent face names sorted — the pipeline's
214            // `stamp_derived_edge_names` convention. The qualifier goes on the
215            // FINISHED name, so the two face names it joins stay local.
216            let mut adjacency: HashMap<usize, Vec<String>> = HashMap::default();
217            let mut order: Vec<usize> = Vec::new();
218            for (face_index, face_ref) in body_faces.iter().enumerate() {
219                let face_name = format!("{body_name}_Face_{face_index}");
220                for edge_ref in face_edge_refs(resolver, *face_ref) {
221                    let uses = adjacency.entry(edge_ref).or_default();
222                    if uses.is_empty() {
223                        order.push(edge_ref);
224                    }
225                    uses.push(face_name.clone());
226                    // Vertices, from the edge's ends.
227                    if let Some(entity) = entities.get(&edge_ref) {
228                        if let Some(args) = entity.find("EDGE_CURVE") {
229                            for slot in [1usize, 2] {
230                                if let Some(vertex_ref) = arg_ref(args, slot) {
231                                    if let Ok(point) = resolver.step_vertex_point(vertex_ref) {
232                                        self.vertices.entry(vertex_ref).or_insert_with(|| {
233                                            format!("{qualifier}{body_name}@{},{},{}", trim_real(point.x), trim_real(point.y), trim_real(point.z))
234                                        });
235                                    }
236                                }
237                            }
238                        }
239                    }
240                }
241            }
242            let mut counts: HashMap<String, usize> = HashMap::default();
243            for edge_ref in order {
244                let mut names = adjacency.remove(&edge_ref).unwrap_or_default();
245                names.sort();
246                names.dedup();
247                let base = names.join("|");
248                let n = counts.entry(base.clone()).or_default();
249                self.edges
250                    .entry(edge_ref)
251                    .or_insert_with(|| format!("{qualifier}{base}[{n}]"));
252                *n += 1;
253            }
254        }
255    }
256
257    fn name_of(&self, geometry_ref: usize) -> Option<String> {
258        self.faces
259            .get(&geometry_ref)
260            .or_else(|| self.edges.get(&geometry_ref))
261            .or_else(|| self.vertices.get(&geometry_ref))
262            .cloned()
263    }
264}
265
266/// A real with no trailing zeros (`10.5`, `0`, `-2`), the vertex-ref form.
267fn trim_real(value: f64) -> String {
268    let rounded = (value * 1e9).round() / 1e9;
269    let text = format!("{rounded:.9}");
270    let trimmed = text.trim_end_matches('0').trim_end_matches('.');
271    if trimmed == "-0" || trimmed.is_empty() {
272        "0".into()
273    } else {
274        trimmed.to_string()
275    }
276}
277
278/// The EDGE_CURVE refs a face's bounds use, loop order.
279fn face_edge_refs(resolver: &Resolver, face_ref: usize) -> Vec<usize> {
280    let mut out = Vec::new();
281    let Ok(face) = resolver.get(face_ref) else {
282        return out;
283    };
284    let Some(args) = face.find("ADVANCED_FACE").or_else(|| face.find("FACE_SURFACE")) else {
285        return out;
286    };
287    for bound_ref in arg_refs(args, 1) {
288        let Ok(bound) = resolver.get(bound_ref) else { continue };
289        let Some(bound_args) = bound
290            .find("FACE_OUTER_BOUND")
291            .or_else(|| bound.find("FACE_BOUND"))
292        else {
293            continue;
294        };
295        let Some(loop_ref) = arg_ref(bound_args, 1) else { continue };
296        let Ok(edge_loop) = resolver.get(loop_ref) else { continue };
297        let Some(loop_args) = edge_loop.find("EDGE_LOOP") else { continue };
298        for oriented_ref in arg_refs(loop_args, 1) {
299            let Ok(oriented) = resolver.get(oriented_ref) else { continue };
300            let Some(oriented_args) = oriented.find("ORIENTED_EDGE") else { continue };
301            if let Some(edge_ref) = arg_ref(oriented_args, 3) {
302                out.push(edge_ref);
303            }
304        }
305    }
306    out
307}
308
309/// A body's ordered face refs, shell by shell (empty on a pairing mismatch).
310pub(super) fn body_face_refs(resolver: &Resolver, body: StepBody, built: &BrepSolid) -> Vec<usize> {
311    let Some(shells) = step_body_claimed_shells(resolver, body) else {
312        return Vec::new();
313    };
314    if shells.len() != built.shells.len() {
315        return Vec::new();
316    }
317    let mut out = Vec::new();
318    for (shell_ref, built_shell) in shells.iter().zip(&built.shells) {
319        let Some(faces) = super::bodies::shell_face_refs_pub(resolver, *shell_ref) else {
320            return Vec::new();
321        };
322        if faces.len() != built_shell.faces.len() {
323            return Vec::new();
324        }
325        out.extend(faces);
326    }
327    out
328}
329
330/// One semantic element found in the file.
331struct Semantic {
332    entity: usize,
333    annotation: PmiAnnotation,
334}
335
336/// The aspect → geometry-name map (SHAPE_ASPECT / DATUM_FEATURE →
337/// GEOMETRIC_ITEM_SPECIFIC_USAGE → geometry).
338///
339/// `pins` is the STRUCTURED lane's occurrence map: a shape aspect whose
340/// `of_shape` is an OCCURRENCE's `PRODUCT_DEFINITION_SHAPE` — the file saying
341/// "this tolerance is on THIS instance", not on the part — has its geometry
342/// name tagged with that occurrence, which `assembly_pmi.rs` reads back off
343/// the finished annotation. Empty for the flat lane, where there are no
344/// occurrences to pin to.
345fn aspect_names(
346    entities: &HashMap<usize, Entity>,
347    names: &NameMap,
348    pins: &HashMap<usize, usize>,
349) -> HashMap<usize, String> {
350    let mut out = HashMap::default();
351    for entity in entities.values() {
352        let Some(args) = entity.find("GEOMETRIC_ITEM_SPECIFIC_USAGE") else {
353            continue;
354        };
355        let (Some(aspect), Some(geometry)) = (arg_ref(args, 2), arg_ref(args, 4)) else {
356            continue;
357        };
358        if let Some(name) = names.name_of(geometry) {
359            out.entry(aspect).or_insert(name);
360        }
361    }
362    // ITEM_IDENTIFIED_REPRESENTATION_USAGE with a single item (older files).
363    for entity in entities.values() {
364        let Some(args) = entity.find("ITEM_IDENTIFIED_REPRESENTATION_USAGE") else {
365            continue;
366        };
367        let Some(aspect) = arg_ref(args, 2) else { continue };
368        let geometry = match args.get(4) {
369            Some(Value::Ref(id)) => Some(*id),
370            Some(Value::Typed(_, inner)) => inner.first().and_then(|v| v.as_ref_id().ok()),
371            _ => None,
372        };
373        if let Some(name) = geometry.and_then(|g| names.name_of(g)) {
374            out.entry(aspect).or_insert(name);
375        }
376    }
377    for (aspect, occurrence) in pins {
378        if let Some(name) = out.get_mut(aspect) {
379            *name = assembly_pmi::pin_occurrence(name, *occurrence);
380        }
381    }
382    out
383}
384
385/// The datum letter behind a datum-ish reference (a DATUM, or a
386/// DATUM_REFERENCE_COMPARTMENT / _ELEMENT / AP214 DATUM_REFERENCE wrapping one).
387fn datum_letter(entities: &HashMap<usize, Entity>, id: usize, depth: usize) -> Option<(String, String)> {
388    if depth > 4 {
389        return None;
390    }
391    let entity = entities.get(&id)?;
392    if let Some(args) = entity.find("DATUM") {
393        return Some((arg_string(args, 4).to_ascii_uppercase(), String::new()));
394    }
395    if let Some(args) = entity.find("DATUM_REFERENCE") {
396        return datum_letter(entities, arg_ref(args, 1)?, depth + 1);
397    }
398    for keyword in ["DATUM_REFERENCE_COMPARTMENT", "DATUM_REFERENCE_ELEMENT", "GENERAL_DATUM_REFERENCE"] {
399        if let Some(args) = entity.find(keyword) {
400            // Full form: (name, description, of_shape, pd, base, modifiers);
401            // the abbreviated form some writers emit: (base, modifiers).
402            let (base, modifiers) = if args.len() >= 6 {
403                (args.get(4), args.get(5))
404            } else {
405                (args.first(), args.get(1))
406            };
407            let base_ref = match base {
408                Some(Value::Ref(id)) => *id,
409                Some(Value::Typed(_, inner)) => inner.first()?.as_ref_id().ok()?,
410                _ => return None,
411            };
412            let (letter, _) = datum_letter(entities, base_ref, depth + 1)?;
413            let modifier = modifiers
414                .and_then(|value| value.as_list().ok())
415                .map(|items| items.iter().filter_map(modifier_text).next().unwrap_or_default())
416                .unwrap_or_default();
417            return Some((letter, modifier));
418        }
419    }
420    None
421}
422
423fn modifier_text(value: &Value) -> Option<String> {
424    let name = match value {
425        Value::Enum(name) => name.clone(),
426        Value::Typed(_, inner) => match inner.first()? {
427            Value::Enum(name) => name.clone(),
428            _ => return None,
429        },
430        _ => return None,
431    };
432    match name.as_str() {
433        "MAXIMUM_MATERIAL_REQUIREMENT" => Some("MMC".into()),
434        "LEAST_MATERIAL_REQUIREMENT" => Some("LMC".into()),
435        _ => None,
436    }
437}
438
439/// The 'nominal value' / 'upper limit' / 'lower limit' items and the ± bounds
440/// of a dimension entity.
441struct DimensionValues {
442    nominal: Option<f64>,
443    upper_limit: Option<f64>,
444    lower_limit: Option<f64>,
445    plus_minus: Option<(f64, f64)>,
446    /// A 'hole callout' descriptive item.
447    callout: Option<String>,
448    angle: bool,
449}
450
451fn dimension_values(entities: &HashMap<usize, Entity>, dimension: usize, names: &NameMap) -> DimensionValues {
452    let mut values = DimensionValues {
453        nominal: None,
454        upper_limit: None,
455        lower_limit: None,
456        plus_minus: None,
457        callout: None,
458        angle: false,
459    };
460    let scale = |entity: &Entity, value: f64| -> f64 {
461        if entity.has("PLANE_ANGLE_MEASURE_WITH_UNIT") {
462            value * names.angle_to_degrees
463        } else {
464            value * names.length_scale
465        }
466    };
467    for entity in entities.values() {
468        if let Some(args) = entity.find("DIMENSIONAL_CHARACTERISTIC_REPRESENTATION") {
469            if arg_ref(args, 0) != Some(dimension) {
470                continue;
471            }
472            let Some(representation) = arg_ref(args, 1).and_then(|id| entities.get(&id)) else {
473                continue;
474            };
475            let Some(rep_args) = representation
476                .find("SHAPE_DIMENSION_REPRESENTATION")
477                .or_else(|| representation.find("REPRESENTATION"))
478            else {
479                continue;
480            };
481            for item_ref in arg_refs(rep_args, 1) {
482                let Some(item) = entities.get(&item_ref) else { continue };
483                let name = item
484                    .find("REPRESENTATION_ITEM")
485                    .map(|args| arg_string(args, 0))
486                    .unwrap_or_default();
487                if let Some(args) = item.find("DESCRIPTIVE_REPRESENTATION_ITEM") {
488                    if arg_string(args, 0) == "hole callout" {
489                        values.callout = Some(arg_string(args, 1));
490                    }
491                    continue;
492                }
493                let Some(raw) = item
494                    .find("MEASURE_WITH_UNIT")
495                    .and_then(|args| args.first())
496                    .and_then(measure_value)
497                else {
498                    continue;
499                };
500                if item.has("PLANE_ANGLE_MEASURE_WITH_UNIT") {
501                    values.angle = true;
502                }
503                let value = scale(item, raw);
504                match name.as_str() {
505                    "upper limit" => values.upper_limit = Some(value),
506                    "lower limit" => values.lower_limit = Some(value),
507                    _ => {
508                        if values.nominal.is_none() || name == "nominal value" {
509                            values.nominal = Some(value);
510                        }
511                    }
512                }
513            }
514        }
515        if let Some(args) = entity.find("PLUS_MINUS_TOLERANCE") {
516            if arg_ref(args, 1) != Some(dimension) {
517                continue;
518            }
519            let Some(range) = arg_ref(args, 0).and_then(|id| entities.get(&id)) else {
520                continue;
521            };
522            let Some(range_args) = range.find("TOLERANCE_VALUE") else {
523                continue;
524            };
525            let bound = |slot: usize| -> Option<f64> {
526                let measure = arg_ref(range_args, slot).and_then(|id| entities.get(&id))?;
527                let raw = measure
528                    .find("LENGTH_MEASURE_WITH_UNIT")
529                    .or_else(|| measure.find("PLANE_ANGLE_MEASURE_WITH_UNIT"))
530                    .or_else(|| measure.find("MEASURE_WITH_UNIT"))
531                    .and_then(|args| args.first())
532                    .and_then(measure_value)?;
533                Some(scale(measure, raw))
534            };
535            if let (Some(lower), Some(upper)) = (bound(0), bound(1)) {
536                values.plus_minus = Some((lower, upper));
537            }
538        }
539    }
540    values
541}
542
543/// Fold the values into a dimension annotation's tolerance block.
544fn apply_values(params: &mut serde_json::Map<String, serde_json::Value>, values: &DimensionValues) {
545    if let (Some(nominal), Some(upper), Some(lower)) = (values.nominal, values.upper_limit, values.lower_limit) {
546        params.insert("tolMode".into(), "limits".into());
547        params.insert("tolUpper".into(), serde_json::json!(round6(upper - nominal)));
548        params.insert("tolLower".into(), serde_json::json!(round6(nominal - lower)));
549    } else if let Some((lower, upper)) = values.plus_minus {
550        if (lower.abs() - upper.abs()).abs() < 1e-9 {
551            params.insert("tolMode".into(), "symmetric".into());
552            params.insert("tolUpper".into(), serde_json::json!(round6(upper.abs())));
553        } else {
554            params.insert("tolMode".into(), "deviation".into());
555            params.insert("tolUpper".into(), serde_json::json!(round6(upper.abs())));
556            params.insert("tolLower".into(), serde_json::json!(round6(lower.abs())));
557        }
558    }
559}
560
561fn round6(value: f64) -> f64 {
562    (value * 1e6).round() / 1e6
563}
564
565/// A saved view decoded from a `DRAUGHTING_MODEL` + `CAMERA_MODEL_D3`.
566struct ViewIn {
567    name: String,
568    camera: Option<PmiCamera>,
569    /// The callouts it shows (direct items + its annotation planes' elements).
570    callouts: Vec<usize>,
571}
572
573fn read_camera(entities: &HashMap<usize, Entity>, resolver: &Resolver, camera_ref: usize) -> Option<(String, PmiCamera)> {
574    let camera = entities.get(&camera_ref)?;
575    let args = camera.find("CAMERA_MODEL_D3")?;
576    let name = arg_string(args, 0);
577    let frame = resolver.placement(arg_ref(args, 1)?).ok()?;
578    let volume = entities.get(&arg_ref(args, 2)?)?;
579    let volume_args = volume.find("VIEW_VOLUME")?;
580    let parallel = volume_args.first().map(|v| v.enum_is("PARALLEL")).unwrap_or(true);
581    let distance = resolver.length(volume_args.get(2)?.as_real().ok()?).max(1e-6);
582    let window = entities.get(&arg_ref(volume_args, 8)?)?;
583    let window_args = window.find("PLANAR_BOX")?;
584    let width = resolver.length(window_args.get(1)?.as_real().ok()?);
585    let height = resolver.length(window_args.get(2)?.as_real().ok()?).max(1e-9);
586    let eye = frame.origin;
587    let target = eye.add(frame.z.scale(distance));
588    let projection = if parallel {
589        PmiProjection::Orthographic {
590            half_height: height * 0.5,
591        }
592    } else {
593        PmiProjection::Perspective {
594            fov_y_deg: 2.0 * (height * 0.5 / distance).atan().to_degrees(),
595        }
596    };
597    let aspect = if height > 0.0 { width / height } else { 1.5 };
598    Some((
599        name,
600        PmiCamera {
601            eye: [eye.x, eye.y, eye.z],
602            target: [target.x, target.y, target.z],
603            up: [frame.y.x, frame.y.y, frame.y.z],
604            projection,
605            viewport: [800.0 * aspect, 800.0],
606        },
607    ))
608}
609
610/// The `draughting_callout` record of an entity, whatever SUBTYPE keyword a
611/// producer wrote it under: the supertype itself (what the CAx-IF practice
612/// recommends and what our own writer emits), or one of the AP214-era
613/// draughting subtypes a vendor file carries instead —
614/// `LEADER_DIRECTED_CALLOUT`, `PROJECTION_DIRECTED_CALLOUT`,
615/// `DIMENSION_CURVE_DIRECTED_CALLOUT`. None of them adds an attribute AHEAD of
616/// `contents`, so the contents list is slot 1 under every one of those
617/// keywords, and matching on the suffix reads them all.
618///
619/// `io1-ac-214.stp` is the reason this is not simply `has("DRAUGHTING_CALLOUT")`:
620/// its two annotations are `LEADER_DIRECTED_CALLOUT`s, so an exact-keyword test
621/// lifted nothing at all from the file.
622fn callout_contents(entity: &Entity) -> Option<&[Value]> {
623    entity
624        .records
625        .iter()
626        .find(|(keyword, _)| keyword.ends_with("CALLOUT"))
627        .map(|(_, args)| args.as_slice())
628}
629
630/// Whether an entity is a `draughting_callout` of any subtype.
631fn is_callout(entity: &Entity) -> bool {
632    callout_contents(entity).is_some()
633}
634
635/// The literal text of a `text_literal` of any subtype
636/// (`TEXT_LITERAL_WITH_EXTENT` and friends). `text_literal` is a
637/// `geometric_representation_item`, so the INHERITED `name` is slot 0 and the
638/// literal itself is slot 1 — reading slot 0 returns the producer's entity
639/// label (`'C9C'`), not the annotation's words.
640fn text_literal(entity: &Entity) -> Option<String> {
641    entity
642        .records
643        .iter()
644        .find(|(keyword, _)| keyword.starts_with("TEXT_LITERAL"))
645        .map(|(_, args)| arg_string(args, 1))
646        .filter(|text| !text.is_empty())
647}
648
649/// The sub-items one presentation entity collects, in the file's own list
650/// order. Covers the simple and the COMPLEX spellings of the same thing: an
651/// `annotation_occurrence` written as a complex instance carries its item on
652/// the `STYLED_ITEM` record (slot 1), not on its own.
653fn presentation_children(entity: &Entity) -> Vec<usize> {
654    let mut out = Vec::new();
655    let mut take = |args: &[Value], slot: usize| match args.get(slot) {
656        Some(Value::Ref(target)) => out.push(*target),
657        Some(Value::List(items)) => {
658            out.extend(items.iter().filter_map(|item| item.as_ref_id().ok()))
659        }
660        _ => {}
661    };
662    for (keyword, args) in &entity.records {
663        let slot = if keyword.ends_with("CALLOUT")
664            || keyword.starts_with("COMPOSITE_TEXT")
665            || keyword == "GEOMETRIC_CURVE_SET"
666            || keyword == "STYLED_ITEM"
667        {
668            1
669        } else if keyword.ends_with("ANNOTATION_OCCURRENCE")
670            || keyword == "ANNOTATION_CURVE_OCCURRENCE"
671            || keyword == "ANNOTATION_TEXT_OCCURRENCE"
672            || keyword == "ANNOTATION_SYMBOL_OCCURRENCE"
673        {
674            2
675        } else {
676            continue;
677        };
678        take(args, slot);
679    }
680    out
681}
682
683/// The callouts of an ANNOTATION_PLANE (its elements) or a callout itself.
684fn expand_item(entities: &HashMap<usize, Entity>, item: usize, out: &mut Vec<usize>) {
685    let Some(entity) = entities.get(&item) else { return };
686    if let Some(args) = entity.find("ANNOTATION_PLANE") {
687        for element in arg_refs(args, 3) {
688            expand_item(entities, element, out);
689        }
690    } else if is_callout(entity) || entity.has("ANNOTATION_OCCURRENCE") || entity.has("ANNOTATION_CURVE_OCCURRENCE") || entity.has("ANNOTATION_TEXT_OCCURRENCE") {
691        out.push(item);
692    }
693}
694
695/// callout → the ANNOTATION_PLANE listing it (nested planes flattened; the
696/// first plane naming a callout wins).
697fn plane_of_callouts(entities: &HashMap<usize, Entity>) -> HashMap<usize, usize> {
698    let mut planes: Vec<(&usize, &Entity)> = entities.iter().filter(|(_, e)| e.has("ANNOTATION_PLANE")).collect();
699    planes.sort_by_key(|(id, _)| **id);
700    let mut out: HashMap<usize, usize> = HashMap::default();
701    for (id, entity) in planes {
702        let Some(args) = entity.find("ANNOTATION_PLANE") else { continue };
703        let mut members = Vec::new();
704        for element in arg_refs(args, 3) {
705            expand_item(entities, element, &mut members);
706        }
707        for member in members {
708            out.entry(member).or_insert(*id);
709        }
710    }
711    out
712}
713
714/// The imported planar face whose plane an ANNOTATION_PLANE's placement
715/// coincides with (normal parallel, origin on the plane, lowest face entity
716/// first) — the `plane` reference the lifted annotation lays out in. `None`
717/// when the plane matches no face: the annotation then aligns to its view.
718fn plane_face_name(
719    entities: &HashMap<usize, Entity>,
720    resolver: &Resolver,
721    names: &NameMap,
722    plane: usize,
723) -> Option<String> {
724    let args = entities.get(&plane)?.find("ANNOTATION_PLANE")?;
725    let item = arg_ref(args, 2)?;
726    let placement = match entities.get(&item) {
727        Some(entity) if entity.has("PLANE") => arg_ref(entity.find("PLANE")?, 1)?,
728        Some(entity) if entity.has("AXIS2_PLACEMENT_3D") => item,
729        _ => return None,
730    };
731    let frame = resolver.placement(placement).ok()?;
732    let normal = frame.z.normalized().ok()?;
733    let tolerance = 1e-4 * frame.origin.length().max(1.0);
734    let mut faces: Vec<(&usize, &String)> = names.faces.iter().collect();
735    faces.sort();
736    for (face_id, name) in faces {
737        let Some(face_args) = entities.get(face_id).and_then(|e| e.find("ADVANCED_FACE")) else { continue };
738        let Some(surface) = arg_ref(face_args, 2).and_then(|id| entities.get(&id)) else { continue };
739        let Some(plane_args) = surface.find("PLANE") else { continue };
740        let Some(face_frame) = arg_ref(plane_args, 1).and_then(|p| resolver.placement(p).ok()) else { continue };
741        let Ok(face_normal) = face_frame.z.normalized() else { continue };
742        if face_normal.dot(normal).abs() < 1.0 - 1e-6 {
743            continue;
744        }
745        if face_frame.origin.sub(frame.origin).dot(normal).abs() > tolerance {
746            continue;
747        }
748        return Some(name.clone());
749    }
750    None
751}
752
753/// The PMI validation properties of a file (practice §10.3), keyed by the
754/// presentation item they characterize (a callout or one of its subsets):
755/// the equivalent unicode string and the polyline centre point.
756#[derive(Default)]
757struct ValidationProps {
758    unicode: HashMap<usize, String>,
759    centre: HashMap<usize, [f64; 3]>,
760}
761
762impl ValidationProps {
763    fn read(entities: &HashMap<usize, Entity>, resolver: &Resolver) -> Self {
764        let mut out = Self::default();
765        for entity in entities.values() {
766            let Some(args) = entity.find("PROPERTY_DEFINITION_REPRESENTATION") else { continue };
767            let (Some(definition), Some(representation)) = (arg_ref(args, 0), arg_ref(args, 1)) else { continue };
768            let Some(within) = entities
769                .get(&definition)
770                .and_then(|e| e.find("PROPERTY_DEFINITION"))
771                .and_then(|a| arg_ref(a, 2))
772            else {
773                continue;
774            };
775            let Some(item) = entities
776                .get(&within)
777                .and_then(|e| e.find("CHARACTERIZED_ITEM_WITHIN_REPRESENTATION"))
778                .and_then(|a| arg_ref(a, 2))
779            else {
780                continue;
781            };
782            let Some(items) = entities.get(&representation).and_then(|e| e.find("REPRESENTATION")) else { continue };
783            for value in arg_refs(items, 1) {
784                let Some(value_entity) = entities.get(&value) else { continue };
785                if let Some(dri) = value_entity.find("DESCRIPTIVE_REPRESENTATION_ITEM") {
786                    if arg_string(dri, 0) == "equivalent unicode string" {
787                        out.unicode.entry(item).or_insert_with(|| arg_string(dri, 1));
788                    }
789                }
790                if let Some(point) = value_entity.find("CARTESIAN_POINT") {
791                    if arg_string(point, 0) == "polyline centre point" {
792                        if let Ok(p) = resolver.point(value) {
793                            out.centre.entry(item).or_insert([p.x, p.y, p.z]);
794                        }
795                    }
796                }
797            }
798        }
799        out
800    }
801}
802
803/// The label position and text of a callout. Text: the callout's equivalent
804/// unicode string (Graphic Presentation), else its `TEXT_LITERAL`
805/// (character-based files). Position: the centre point of the callout's
806/// text subset, else the callout's own centre point, else the text
807/// literal's placement, else the first polyline point.
808fn callout_label(
809    entities: &HashMap<usize, Entity>,
810    resolver: &Resolver,
811    props: &ValidationProps,
812    callout: usize,
813) -> (Option<[f64; 3]>, String) {
814    let unicode = props.unicode.get(&callout).cloned().unwrap_or_default();
815    let mut position = entities
816        .get(&callout)
817        .and_then(|entity| callout_contents(entity))
818        .map(|args| arg_refs(args, 1))
819        .unwrap_or_default()
820        .iter()
821        .find_map(|subset| props.centre.get(subset).copied())
822        .or_else(|| props.centre.get(&callout).copied());
823    let mut fallback = None;
824    // Every literal the callout collects, in the file's own list order: a
825    // `composite_text` splits one annotation's words across several
826    // `text_literal`s (io1-ac-214's note is two lines), and taking whichever
827    // one the walk reached first would keep half the sentence.
828    let mut literals: Vec<String> = Vec::new();
829    let mut visit = |id: usize, entity: &Entity| {
830        if let Some(literal) = text_literal(entity) {
831            literals.push(literal);
832            if position.is_none() {
833                if let Some(frame) = entity
834                    .records
835                    .iter()
836                    .find(|(keyword, _)| keyword.starts_with("TEXT_LITERAL"))
837                    .and_then(|(_, args)| arg_ref(args, 2))
838                    .and_then(|placement| resolver.placement(placement).ok())
839                {
840                    position = Some([frame.origin.x, frame.origin.y, frame.origin.z]);
841                }
842            }
843        }
844        if let Some(args) = entity.find("POLYLINE") {
845            if fallback.is_none() {
846                if let Some(point) = arg_refs(args, 1).first().and_then(|p| resolver.point(*p).ok()) {
847                    fallback = Some([point.x, point.y, point.z]);
848                }
849            }
850        }
851        let _ = id;
852    };
853    // Depth first in LIST order (children pushed reversed onto the stack), so
854    // the literals come out in the order the file wrote them.
855    let mut stack = vec![callout];
856    let mut seen: HashSet<usize> = HashSet::default();
857    while let Some(id) = stack.pop() {
858        if !seen.insert(id) {
859            continue;
860        }
861        let Some(entity) = entities.get(&id) else { continue };
862        visit(id, entity);
863        let mut children = presentation_children(entity);
864        children.reverse();
865        stack.extend(children);
866    }
867    let text = if unicode.is_empty() {
868        literals.join("\n")
869    } else {
870        unicode
871    };
872    (position.or(fallback), text)
873}
874
875/// Read a file's PMI into a [`PmiState`] whose references name the geometry
876/// an IMPORT3D feature `feature_name` will stamp. `Ok(None)` when the file
877/// carries no PMI.
878pub fn read_step_pmi(text: &str, feature_name: &str) -> Result<Option<PmiState>, String> {
879    if !file_mentions_pmi(text) {
880        return Ok(None);
881    }
882    let entities = parse_data_section(text)?;
883    let names = NameMap::build(text, &entities, feature_name)?;
884    Ok(lift_pmi(text, &entities, &names, &HashMap::default(), &mut Vec::new()))
885}
886
887/// The cheap text test that keeps a PMI-less file off the parse. A superset of
888/// the entity test — a mention inside a comment merely costs one parse.
889pub(super) fn file_mentions_pmi(text: &str) -> bool {
890    text.contains("DIMENSIONAL_")
891        || text.contains("_TOLERANCE(")
892        || text.contains("DATUM(")
893        || text.contains("CAMERA_MODEL_D3(")
894        || text.contains("CALLOUT(")
895}
896
897/// The lift itself, against an ALREADY-BUILT name map — so the flat lane
898/// (one map for the whole file) and the structured lane (one map per product,
899/// each entry tagged with its product, `assembly_pmi.rs`) read the file's PMI
900/// through exactly the same code.
901///
902/// `diagnostics` collects references the file made that named geometry this
903/// importer did not mint. Such an annotation is KEPT, carrying the file's own
904/// entity id as its reference (`STEP#1234`), so it reaches the PMI panel as a
905/// row with an error rather than disappearing between two file formats.
906pub(super) fn lift_pmi(
907    text: &str,
908    entities: &HashMap<usize, Entity>,
909    names: &NameMap,
910    pins: &HashMap<usize, usize>,
911    diagnostics: &mut Vec<String>,
912) -> Option<PmiState> {
913        let resolver = Resolver::new(entities, names.length_scale);
914    let aspects = aspect_names(entities, names, pins);
915    // A shape aspect whose geometry this importer did not mint still names
916    // something: the file's own entity. `read_step_pmi`'s contract is that an
917    // annotation is never dropped for want of a reference.
918    let mut unresolved: Vec<usize> = Vec::new();
919    let mut aspect_ref = |id: usize| -> String {
920        match aspects.get(&id) {
921            Some(name) => name.clone(),
922            None => {
923                unresolved.push(id);
924                format!("{UNRESOLVED_REF_PREFIX}{id}")
925            }
926        }
927    };
928    let mut ids: BTreeMap<usize, &Entity> = entities.iter().map(|(id, entity)| (*id, entity)).collect();
929    let mut state = PmiState::default();
930    let mut semantics: Vec<Semantic> = Vec::new();
931    let mut params = |pairs: Vec<(&str, serde_json::Value)>| -> serde_json::Map<String, serde_json::Value> {
932        let mut map = serde_json::Map::new();
933        for (key, value) in pairs {
934            map.insert(key.to_string(), value);
935        }
936        map
937    };
938
939    // Datums first (frames reference their letters).
940    let mut datum_ids: HashMap<usize, String> = HashMap::default();
941    for (id, entity) in ids.iter() {
942        let Some(args) = entity.find("DATUM") else { continue };
943        let letter = arg_string(args, 4).to_ascii_uppercase();
944        if letter.is_empty() {
945            continue;
946        }
947        // The datum feature: SHAPE_ASPECT_RELATIONSHIP(relating = feature, related = datum).
948        let feature = entities.values().find_map(|candidate| {
949            let rel = candidate.find("SHAPE_ASPECT_RELATIONSHIP")?;
950            (arg_ref(rel, 3) == Some(*id)).then(|| arg_ref(rel, 2)).flatten()
951        });
952        let Some(target) = feature.map(&mut aspect_ref) else {
953            continue; // the datum names no shape aspect at all
954        };
955        let annotation_id = state.next_id("DTM");
956        datum_ids.insert(*id, letter.clone());
957        semantics.push(Semantic {
958            entity: *id,
959            annotation: PmiAnnotation {
960                plugin_replay: None,
961                persistent_data: serde_json::Value::Null,
962                kind: "datum".into(),
963                enabled: true,
964                params: serde_json::Value::Object(params(vec![
965                    ("id", annotation_id.into()),
966                    ("target", target.into()),
967                    ("letter", letter.into()),
968                ])),
969                label_world: None,
970            },
971        });
972    }
973
974    // Dimensions.
975    for (id, entity) in ids.iter() {
976        let mut fields: Option<(&str, serde_json::Map<String, serde_json::Value>)> = None;
977        if let Some(args) = entity.find("ANGULAR_LOCATION").or_else(|| entity.find("DIMENSIONAL_LOCATION")) {
978            let (Some(a), Some(b)) = (
979                arg_ref(args, 2).map(&mut aspect_ref),
980                arg_ref(args, 3).map(&mut aspect_ref),
981            ) else {
982                continue; // not a two-aspect location: not a dimension we read
983            };
984            let values = dimension_values(entities, *id, &names);
985            if entity.has("ANGULAR_LOCATION") {
986                let mut map = params(vec![("targets", serde_json::json!([a, b])), ("decimals", 1.into())]);
987                if let Some(nominal) = values.nominal {
988                    let kind = if nominal > 180.0 { "reflex" } else if nominal > 90.0 + 1e-9 { "obtuse" } else { "acute" };
989                    map.insert("angleType".into(), kind.into());
990                }
991                apply_values(&mut map, &values);
992                fields = Some(("angle", map));
993            } else {
994                let mut map = params(vec![("targets", serde_json::json!([a, b]))]);
995                if let Some(orientation) = orientation_axis(entities, *id) {
996                    map.insert("alignment".into(), orientation.into());
997                }
998                apply_values(&mut map, &values);
999                fields = Some(("linear", map));
1000            }
1001        } else if let Some(args) = entity.find("DIMENSIONAL_SIZE") {
1002            let Some(target) = arg_ref(args, 0).map(&mut aspect_ref) else {
1003                continue; // the size names no shape aspect
1004            };
1005            let name = arg_string(args, 1).to_ascii_lowercase();
1006            let values = dimension_values(entities, *id, &names);
1007            if let Some(callout) = &values.callout {
1008                let _ = callout;
1009                let map = params(vec![("target", target.into()), ("showQuantity", true.into())]);
1010                fields = Some(("holeCallout", map));
1011            } else if name.contains("diameter") || name.contains("radius") {
1012                let mut map = params(vec![
1013                    ("target", target.into()),
1014                    ("displayStyle", if name.contains("radius") { "radius" } else { "diameter" }.into()),
1015                ]);
1016                apply_values(&mut map, &values);
1017                fields = Some(("radial", map));
1018            } else {
1019                let mut map = params(vec![("targets", serde_json::json!([target]))]);
1020                apply_values(&mut map, &values);
1021                fields = Some(("linear", map));
1022            }
1023        }
1024        if let Some((kind, mut map)) = fields {
1025            let prefix = match kind {
1026                "angle" => "ANG",
1027                "radial" => "RAD",
1028                "holeCallout" => "HOLE",
1029                _ => "DIM",
1030            };
1031            map.insert("id".into(), state.next_id(prefix).into());
1032            semantics.push(Semantic {
1033                entity: *id,
1034                annotation: PmiAnnotation {
1035                    plugin_replay: None,
1036                    persistent_data: serde_json::Value::Null,
1037                    kind: kind.into(),
1038                    enabled: true,
1039                    params: serde_json::Value::Object(map),
1040                    label_world: None,
1041                },
1042            });
1043        }
1044    }
1045
1046    // Geometric tolerances.
1047    for (id, entity) in ids.iter() {
1048        let Some((kind_record, base_args)) = entity.records.iter().find_map(|(keyword, args)| {
1049            characteristic_for_entity(keyword).map(|c| (c, args))
1050        }) else {
1051            continue;
1052        };
1053        // The base attributes live on GEOMETRIC_TOLERANCE in a complex
1054        // instance, on the typed record itself in a simple one.
1055        let args = entity.find("GEOMETRIC_TOLERANCE").unwrap_or(base_args);
1056        if args.len() < 4 {
1057            continue;
1058        }
1059        let Some(target) = arg_ref(args, 3).map(&mut aspect_ref) else {
1060            continue; // the tolerance names no shape aspect
1061        };
1062        let magnitude = arg_ref(args, 2)
1063            .and_then(|m| entities.get(&m))
1064            .and_then(|m| m.find("LENGTH_MEASURE_WITH_UNIT").or_else(|| m.find("MEASURE_WITH_UNIT")))
1065            .and_then(|a| a.first())
1066            .and_then(measure_value)
1067            .map(|v| v * names.length_scale)
1068            .unwrap_or(0.1);
1069        let mut map = params(vec![
1070            ("target", target.into()),
1071            ("characteristic", kind_record.id.into()),
1072            ("zoneValue", serde_json::json!(round6(magnitude))),
1073        ]);
1074        if let Some(mod_args) = entity.find("GEOMETRIC_TOLERANCE_WITH_MODIFIERS") {
1075            if let Some(items) = mod_args.first().and_then(|v| v.as_list().ok()) {
1076                if let Some(modifier) = items.iter().filter_map(modifier_text).next() {
1077                    map.insert("materialCondition".into(), modifier.into());
1078                }
1079            }
1080        }
1081        if let Some(ref_args) = entity.find("GEOMETRIC_TOLERANCE_WITH_DATUM_REFERENCE") {
1082            let mut letters: Vec<(String, String)> = Vec::new();
1083            for system_ref in arg_refs(ref_args, 0) {
1084                let Some(system) = entities.get(&system_ref) else { continue };
1085                if let Some(system_args) = system.find("DATUM_SYSTEM") {
1086                    for constituent in arg_refs(system_args, 4) {
1087                        if let Some(pair) = datum_letter(entities, constituent, 0) {
1088                            letters.push(pair);
1089                        }
1090                    }
1091                } else if let Some(pair) = datum_letter(entities, system_ref, 0) {
1092                    letters.push(pair);
1093                }
1094            }
1095            for (slot, (letter, modifier)) in ["A", "B", "C"].iter().zip(letters) {
1096                map.insert(format!("datum{slot}"), letter.into());
1097                if !modifier.is_empty() {
1098                    map.insert(format!("datum{slot}Modifier"), modifier.into());
1099                }
1100            }
1101        }
1102        let cylindrical = entities.values().any(|zone| {
1103            zone.find("TOLERANCE_ZONE")
1104                .map(|zone_args| {
1105                    arg_refs(zone_args, 4).contains(id)
1106                        && arg_ref(zone_args, 5)
1107                            .and_then(|form| entities.get(&form))
1108                            .and_then(|form| form.find("TOLERANCE_ZONE_FORM"))
1109                            .map(|form_args| arg_string(form_args, 0).to_ascii_lowercase().contains("cylindrical"))
1110                            .unwrap_or(false)
1111                })
1112                .unwrap_or(false)
1113        });
1114        if cylindrical {
1115            map.insert("zoneDiameter".into(), true.into());
1116        }
1117        map.insert("id".into(), state.next_id("FCF").into());
1118        semantics.push(Semantic {
1119            entity: *id,
1120            annotation: PmiAnnotation {
1121                plugin_replay: None,
1122                persistent_data: serde_json::Value::Null,
1123                kind: "fcf".into(),
1124                enabled: true,
1125                params: serde_json::Value::Object(map),
1126                label_world: None,
1127            },
1128        });
1129    }
1130    ids.clear();
1131
1132    // Presentation links: semantic entity (or its datum feature aspect) → callout.
1133    let mut callout_of: HashMap<usize, usize> = HashMap::default();
1134    for entity in entities.values() {
1135        let Some(args) = entity.find("DRAUGHTING_MODEL_ITEM_ASSOCIATION") else { continue };
1136        if let (Some(definition), Some(callout)) = (arg_ref(args, 2), arg_ref(args, 4)) {
1137            callout_of.entry(definition).or_insert(callout);
1138        }
1139    }
1140    // A datum's callout is linked through its datum feature aspect.
1141    let datum_feature_of: HashMap<usize, usize> = entities
1142        .values()
1143        .filter_map(|entity| {
1144            let rel = entity.find("SHAPE_ASPECT_RELATIONSHIP")?;
1145            Some((arg_ref(rel, 3)?, arg_ref(rel, 2)?))
1146        })
1147        .collect();
1148    let mut linked_callouts: HashSet<usize> = HashSet::default();
1149    let mut annotations: Vec<(Option<usize>, PmiAnnotation)> = Vec::new();
1150    // A callout's ANNOTATION_PLANE that coincides with a planar face becomes
1151    // the annotation's `plane` reference; any other plane leaves it aligned
1152    // to the view.
1153    let plane_of = plane_of_callouts(entities);
1154    let props = ValidationProps::read(entities, &resolver);
1155    let mut plane_faces: HashMap<usize, Option<String>> = HashMap::default();
1156    let mut plane_param = |callout: usize, annotation: &mut PmiAnnotation| {
1157        let Some(plane) = plane_of.get(&callout).copied() else { return };
1158        let face = plane_faces
1159            .entry(plane)
1160            .or_insert_with(|| plane_face_name(entities, &resolver, &names, plane))
1161            .clone();
1162        if let (Some(face), Some(object)) = (face, annotation.params.as_object_mut()) {
1163            object.insert("plane".into(), serde_json::Value::String(face));
1164        }
1165    };
1166    for semantic in semantics {
1167        let callout = callout_of
1168            .get(&semantic.entity)
1169            .copied()
1170            .or_else(|| datum_feature_of.get(&semantic.entity).and_then(|f| callout_of.get(f)).copied());
1171        let mut annotation = semantic.annotation;
1172        if let Some(callout) = callout {
1173            linked_callouts.insert(callout);
1174            let (position, _) = callout_label(entities, &resolver, &props, callout);
1175            annotation.label_world = position;
1176            plane_param(callout, &mut annotation);
1177        }
1178        annotations.push((callout, annotation));
1179    }
1180    // Free 3D text callouts become notes.
1181    for (id, entity) in entities.iter() {
1182        if !is_callout(entity) || linked_callouts.contains(id) {
1183            continue;
1184        }
1185        let (position, text) = callout_label(entities, &resolver, &props, *id);
1186        if text.trim().is_empty() {
1187            continue;
1188        }
1189        let annotation_id = state.next_id("NOTE");
1190        let mut annotation = PmiAnnotation {
1191            plugin_replay: None,
1192            persistent_data: serde_json::Value::Null,
1193            kind: "note".into(),
1194            enabled: true,
1195            params: serde_json::Value::Object(params(vec![("id", annotation_id.into()), ("text", text.into())])),
1196            label_world: position,
1197        };
1198        plane_param(*id, &mut annotation);
1199        annotations.push((Some(*id), annotation));
1200    }
1201    if annotations.is_empty() && !text.contains("CAMERA_MODEL_D3(") {
1202        return None;
1203    }
1204
1205    // Saved views.
1206    let mut views_in: Vec<ViewIn> = Vec::new();
1207    let mut models: Vec<(&usize, &Entity)> = entities.iter().filter(|(_, e)| e.has("DRAUGHTING_MODEL")).collect();
1208    models.sort_by_key(|(id, _)| **id);
1209    for (_, model) in models {
1210        let Some(args) = model.find("DRAUGHTING_MODEL") else { continue };
1211        let items = arg_refs(args, 1);
1212        let camera = items
1213            .iter()
1214            .find_map(|item| read_camera(entities, &resolver, *item));
1215        let Some((camera_name, camera)) = camera else {
1216            continue; // the global model (no camera) is not a saved view
1217        };
1218        let mut callouts = Vec::new();
1219        for item in &items {
1220            expand_item(entities, *item, &mut callouts);
1221        }
1222        let model_name = arg_string(args, 0);
1223        views_in.push(ViewIn {
1224            name: if camera_name.trim().is_empty() { model_name } else { camera_name },
1225            camera: Some(camera),
1226            callouts,
1227        });
1228    }
1229    let mut placed: HashSet<usize> = HashSet::default();
1230    for view_in in views_in {
1231        let id = state.next_id("VIEW");
1232        let mut view = PmiView {
1233            id,
1234            name: view_in.name,
1235            camera: view_in.camera,
1236            display: PmiDisplay::default(),
1237            annotations: Vec::new(),
1238        };
1239        for (index, (callout, annotation)) in annotations.iter().enumerate() {
1240            if let Some(callout) = callout {
1241                if view_in.callouts.contains(callout) && !placed.contains(&index) {
1242                    view.annotations.push(annotation.clone());
1243                    placed.insert(index);
1244                }
1245            }
1246        }
1247        state.views.push(view);
1248    }
1249    let unplaced: Vec<PmiAnnotation> = annotations
1250        .iter()
1251        .enumerate()
1252        .filter(|(index, _)| !placed.contains(index))
1253        .map(|(_, (_, annotation))| annotation.clone())
1254        .collect();
1255    if !unplaced.is_empty() {
1256        let id = state.next_id("VIEW");
1257        state.views.push(PmiView {
1258            id,
1259            name: "Imported PMI".into(),
1260            camera: None,
1261            display: PmiDisplay::default(),
1262            annotations: unplaced,
1263        });
1264    }
1265    if state.views.is_empty() {
1266        return None;
1267    }
1268    for aspect in unresolved {
1269        diagnostics.push(format!(
1270            "PMI: shape aspect #{aspect} names geometry this import did not build; \
1271             the annotation is kept with an unresolved reference"
1272        ));
1273    }
1274    Some(state)
1275}
1276
1277/// The world axis of an oriented dimension's 'orientation' placement (X / Y / Z).
1278fn orientation_axis(entities: &HashMap<usize, Entity>, dimension: usize) -> Option<&'static str> {
1279    for entity in entities.values() {
1280        let Some(args) = entity.find("DIMENSIONAL_CHARACTERISTIC_REPRESENTATION") else { continue };
1281        if arg_ref(args, 0) != Some(dimension) {
1282            continue;
1283        }
1284        let Some(representation) = arg_ref(args, 1).and_then(|id| entities.get(&id)) else { continue };
1285        let Some(rep_args) = representation.find("SHAPE_DIMENSION_REPRESENTATION") else { continue };
1286        for item_ref in arg_refs(rep_args, 1) {
1287            let Some(item) = entities.get(&item_ref) else { continue };
1288            let Some(placement) = item.find("AXIS2_PLACEMENT_3D") else { continue };
1289            if arg_string(placement, 0) != "orientation" {
1290                continue;
1291            }
1292            let Some(direction) = arg_ref(placement, 3).and_then(|id| entities.get(&id)) else { continue };
1293            let Some(list) = direction.find("DIRECTION").and_then(|d| d.get(1)).and_then(|v| v.as_list().ok()) else { continue };
1294            let components: Vec<f64> = list.iter().filter_map(|v| v.as_real().ok()).collect();
1295            let axis = components
1296                .iter()
1297                .enumerate()
1298                .max_by(|a, b| a.1.abs().partial_cmp(&b.1.abs()).unwrap_or(std::cmp::Ordering::Equal))
1299                .map(|(index, _)| index)?;
1300            return Some(["X", "Y", "Z"][axis.min(2)]);
1301        }
1302    }
1303    None
1304}