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/// The geometry a reference name points at, as the IMPORT3D feature names it.
116struct NameMap {
117    faces: HashMap<usize, String>,
118    edges: HashMap<usize, String>,
119    vertices: HashMap<usize, String>,
120    length_scale: f64,
121    /// Degrees per file plane-angle unit.
122    angle_to_degrees: f64,
123}
124
125/// Degrees per plane-angle unit of the file (`SI_UNIT($,.RADIAN.)` →
126/// 57.29…; a `CONVERSION_BASED_UNIT` of 0.01745 rad → 1).
127fn angle_scale_degrees(entities: &HashMap<usize, Entity>) -> f64 {
128    for entity in entities.values() {
129        if !entity.has("PLANE_ANGLE_UNIT") {
130            continue;
131        }
132        if let Some(args) = entity.find("CONVERSION_BASED_UNIT") {
133            if let Some(measure) = arg_ref(args, 1).and_then(|id| entities.get(&id)) {
134                if let Some(value) = measure
135                    .find("PLANE_ANGLE_MEASURE_WITH_UNIT")
136                    .or_else(|| measure.find("MEASURE_WITH_UNIT"))
137                    .and_then(|args| args.first())
138                    .and_then(measure_value)
139                {
140                    return value.to_degrees();
141                }
142            }
143        }
144    }
145    1f64.to_degrees()
146}
147
148impl NameMap {
149    fn build(text: &str, entities: &HashMap<usize, Entity>, feature_name: &str) -> Result<Self, String> {
150        let imported = collect_step_solids(text)?;
151        let resolver = Resolver {
152            entities,
153            length_scale: derive_length_scale_mm(entities),
154        };
155        let count = imported.solids.len();
156        let body_names = crate::feature_pipeline::imported_solid_names(count, feature_name);
157        let mut faces = HashMap::default();
158        let mut edges = HashMap::default();
159        let mut vertices = HashMap::default();
160        for (body_index, body_faces) in imported.face_refs.iter().enumerate() {
161            let Some(body_name) = body_names.get(body_index) else {
162                continue;
163            };
164            // Faces, positionally.
165            for (face_index, face_ref) in body_faces.iter().enumerate() {
166                faces
167                    .entry(*face_ref)
168                    .or_insert_with(|| format!("{body_name}_Face_{face_index}"));
169            }
170            // Edges: `{A}|{B}[n]` in first-encounter (face → loop → coedge)
171            // order, with the two adjacent face names sorted — the pipeline's
172            // `stamp_derived_edge_names` convention.
173            let mut adjacency: HashMap<usize, Vec<String>> = HashMap::default();
174            let mut order: Vec<usize> = Vec::new();
175            for (face_index, face_ref) in body_faces.iter().enumerate() {
176                let face_name = format!("{body_name}_Face_{face_index}");
177                for edge_ref in face_edge_refs(&resolver, *face_ref) {
178                    let uses = adjacency.entry(edge_ref).or_default();
179                    if uses.is_empty() {
180                        order.push(edge_ref);
181                    }
182                    uses.push(face_name.clone());
183                    // Vertices, from the edge's ends.
184                    if let Some(entity) = entities.get(&edge_ref) {
185                        if let Some(args) = entity.find("EDGE_CURVE") {
186                            for slot in [1usize, 2] {
187                                if let Some(vertex_ref) = arg_ref(args, slot) {
188                                    if let Ok(point) = resolver.step_vertex_point(vertex_ref) {
189                                        vertices.entry(vertex_ref).or_insert_with(|| {
190                                            format!("{body_name}@{},{},{}", trim_real(point.x), trim_real(point.y), trim_real(point.z))
191                                        });
192                                    }
193                                }
194                            }
195                        }
196                    }
197                }
198            }
199            let mut counts: HashMap<String, usize> = HashMap::default();
200            for edge_ref in order {
201                let mut names = adjacency.remove(&edge_ref).unwrap_or_default();
202                names.sort();
203                names.dedup();
204                let base = names.join("|");
205                let n = counts.entry(base.clone()).or_default();
206                edges.entry(edge_ref).or_insert_with(|| format!("{base}[{n}]"));
207                *n += 1;
208            }
209        }
210        Ok(Self {
211            faces,
212            edges,
213            vertices,
214            length_scale: resolver.length_scale,
215            angle_to_degrees: angle_scale_degrees(entities),
216        })
217    }
218
219    fn name_of(&self, geometry_ref: usize) -> Option<String> {
220        self.faces
221            .get(&geometry_ref)
222            .or_else(|| self.edges.get(&geometry_ref))
223            .or_else(|| self.vertices.get(&geometry_ref))
224            .cloned()
225    }
226}
227
228/// A real with no trailing zeros (`10.5`, `0`, `-2`), the vertex-ref form.
229fn trim_real(value: f64) -> String {
230    let rounded = (value * 1e9).round() / 1e9;
231    let text = format!("{rounded:.9}");
232    let trimmed = text.trim_end_matches('0').trim_end_matches('.');
233    if trimmed == "-0" || trimmed.is_empty() {
234        "0".into()
235    } else {
236        trimmed.to_string()
237    }
238}
239
240/// The EDGE_CURVE refs a face's bounds use, loop order.
241fn face_edge_refs(resolver: &Resolver, face_ref: usize) -> Vec<usize> {
242    let mut out = Vec::new();
243    let Ok(face) = resolver.get(face_ref) else {
244        return out;
245    };
246    let Some(args) = face.find("ADVANCED_FACE").or_else(|| face.find("FACE_SURFACE")) else {
247        return out;
248    };
249    for bound_ref in arg_refs(args, 1) {
250        let Ok(bound) = resolver.get(bound_ref) else { continue };
251        let Some(bound_args) = bound
252            .find("FACE_OUTER_BOUND")
253            .or_else(|| bound.find("FACE_BOUND"))
254        else {
255            continue;
256        };
257        let Some(loop_ref) = arg_ref(bound_args, 1) else { continue };
258        let Ok(edge_loop) = resolver.get(loop_ref) else { continue };
259        let Some(loop_args) = edge_loop.find("EDGE_LOOP") else { continue };
260        for oriented_ref in arg_refs(loop_args, 1) {
261            let Ok(oriented) = resolver.get(oriented_ref) else { continue };
262            let Some(oriented_args) = oriented.find("ORIENTED_EDGE") else { continue };
263            if let Some(edge_ref) = arg_ref(oriented_args, 3) {
264                out.push(edge_ref);
265            }
266        }
267    }
268    out
269}
270
271/// A body's ordered face refs, shell by shell (empty on a pairing mismatch).
272pub(super) fn body_face_refs(resolver: &Resolver, body: StepBody, built: &BrepSolid) -> Vec<usize> {
273    let Some(shells) = step_body_claimed_shells(resolver, body) else {
274        return Vec::new();
275    };
276    if shells.len() != built.shells.len() {
277        return Vec::new();
278    }
279    let mut out = Vec::new();
280    for (shell_ref, built_shell) in shells.iter().zip(&built.shells) {
281        let Some(faces) = super::bodies::shell_face_refs_pub(resolver, *shell_ref) else {
282            return Vec::new();
283        };
284        if faces.len() != built_shell.faces.len() {
285            return Vec::new();
286        }
287        out.extend(faces);
288    }
289    out
290}
291
292/// One semantic element found in the file.
293struct Semantic {
294    entity: usize,
295    annotation: PmiAnnotation,
296}
297
298/// The aspect → geometry-name map (SHAPE_ASPECT / DATUM_FEATURE →
299/// GEOMETRIC_ITEM_SPECIFIC_USAGE → geometry).
300fn aspect_names(entities: &HashMap<usize, Entity>, names: &NameMap) -> HashMap<usize, String> {
301    let mut out = HashMap::default();
302    for entity in entities.values() {
303        let Some(args) = entity.find("GEOMETRIC_ITEM_SPECIFIC_USAGE") else {
304            continue;
305        };
306        let (Some(aspect), Some(geometry)) = (arg_ref(args, 2), arg_ref(args, 4)) else {
307            continue;
308        };
309        if let Some(name) = names.name_of(geometry) {
310            out.entry(aspect).or_insert(name);
311        }
312    }
313    // ITEM_IDENTIFIED_REPRESENTATION_USAGE with a single item (older files).
314    for entity in entities.values() {
315        let Some(args) = entity.find("ITEM_IDENTIFIED_REPRESENTATION_USAGE") else {
316            continue;
317        };
318        let Some(aspect) = arg_ref(args, 2) else { continue };
319        let geometry = match args.get(4) {
320            Some(Value::Ref(id)) => Some(*id),
321            Some(Value::Typed(_, inner)) => inner.first().and_then(|v| v.as_ref_id().ok()),
322            _ => None,
323        };
324        if let Some(name) = geometry.and_then(|g| names.name_of(g)) {
325            out.entry(aspect).or_insert(name);
326        }
327    }
328    out
329}
330
331/// The datum letter behind a datum-ish reference (a DATUM, or a
332/// DATUM_REFERENCE_COMPARTMENT / _ELEMENT / AP214 DATUM_REFERENCE wrapping one).
333fn datum_letter(entities: &HashMap<usize, Entity>, id: usize, depth: usize) -> Option<(String, String)> {
334    if depth > 4 {
335        return None;
336    }
337    let entity = entities.get(&id)?;
338    if let Some(args) = entity.find("DATUM") {
339        return Some((arg_string(args, 4).to_ascii_uppercase(), String::new()));
340    }
341    if let Some(args) = entity.find("DATUM_REFERENCE") {
342        return datum_letter(entities, arg_ref(args, 1)?, depth + 1);
343    }
344    for keyword in ["DATUM_REFERENCE_COMPARTMENT", "DATUM_REFERENCE_ELEMENT", "GENERAL_DATUM_REFERENCE"] {
345        if let Some(args) = entity.find(keyword) {
346            // Full form: (name, description, of_shape, pd, base, modifiers);
347            // the abbreviated form some writers emit: (base, modifiers).
348            let (base, modifiers) = if args.len() >= 6 {
349                (args.get(4), args.get(5))
350            } else {
351                (args.first(), args.get(1))
352            };
353            let base_ref = match base {
354                Some(Value::Ref(id)) => *id,
355                Some(Value::Typed(_, inner)) => inner.first()?.as_ref_id().ok()?,
356                _ => return None,
357            };
358            let (letter, _) = datum_letter(entities, base_ref, depth + 1)?;
359            let modifier = modifiers
360                .and_then(|value| value.as_list().ok())
361                .map(|items| items.iter().filter_map(modifier_text).next().unwrap_or_default())
362                .unwrap_or_default();
363            return Some((letter, modifier));
364        }
365    }
366    None
367}
368
369fn modifier_text(value: &Value) -> Option<String> {
370    let name = match value {
371        Value::Enum(name) => name.clone(),
372        Value::Typed(_, inner) => match inner.first()? {
373            Value::Enum(name) => name.clone(),
374            _ => return None,
375        },
376        _ => return None,
377    };
378    match name.as_str() {
379        "MAXIMUM_MATERIAL_REQUIREMENT" => Some("MMC".into()),
380        "LEAST_MATERIAL_REQUIREMENT" => Some("LMC".into()),
381        _ => None,
382    }
383}
384
385/// The 'nominal value' / 'upper limit' / 'lower limit' items and the ± bounds
386/// of a dimension entity.
387struct DimensionValues {
388    nominal: Option<f64>,
389    upper_limit: Option<f64>,
390    lower_limit: Option<f64>,
391    plus_minus: Option<(f64, f64)>,
392    /// A 'hole callout' descriptive item.
393    callout: Option<String>,
394    angle: bool,
395}
396
397fn dimension_values(entities: &HashMap<usize, Entity>, dimension: usize, names: &NameMap) -> DimensionValues {
398    let mut values = DimensionValues {
399        nominal: None,
400        upper_limit: None,
401        lower_limit: None,
402        plus_minus: None,
403        callout: None,
404        angle: false,
405    };
406    let scale = |entity: &Entity, value: f64| -> f64 {
407        if entity.has("PLANE_ANGLE_MEASURE_WITH_UNIT") {
408            value * names.angle_to_degrees
409        } else {
410            value * names.length_scale
411        }
412    };
413    for entity in entities.values() {
414        if let Some(args) = entity.find("DIMENSIONAL_CHARACTERISTIC_REPRESENTATION") {
415            if arg_ref(args, 0) != Some(dimension) {
416                continue;
417            }
418            let Some(representation) = arg_ref(args, 1).and_then(|id| entities.get(&id)) else {
419                continue;
420            };
421            let Some(rep_args) = representation
422                .find("SHAPE_DIMENSION_REPRESENTATION")
423                .or_else(|| representation.find("REPRESENTATION"))
424            else {
425                continue;
426            };
427            for item_ref in arg_refs(rep_args, 1) {
428                let Some(item) = entities.get(&item_ref) else { continue };
429                let name = item
430                    .find("REPRESENTATION_ITEM")
431                    .map(|args| arg_string(args, 0))
432                    .unwrap_or_default();
433                if let Some(args) = item.find("DESCRIPTIVE_REPRESENTATION_ITEM") {
434                    if arg_string(args, 0) == "hole callout" {
435                        values.callout = Some(arg_string(args, 1));
436                    }
437                    continue;
438                }
439                let Some(raw) = item
440                    .find("MEASURE_WITH_UNIT")
441                    .and_then(|args| args.first())
442                    .and_then(measure_value)
443                else {
444                    continue;
445                };
446                if item.has("PLANE_ANGLE_MEASURE_WITH_UNIT") {
447                    values.angle = true;
448                }
449                let value = scale(item, raw);
450                match name.as_str() {
451                    "upper limit" => values.upper_limit = Some(value),
452                    "lower limit" => values.lower_limit = Some(value),
453                    _ => {
454                        if values.nominal.is_none() || name == "nominal value" {
455                            values.nominal = Some(value);
456                        }
457                    }
458                }
459            }
460        }
461        if let Some(args) = entity.find("PLUS_MINUS_TOLERANCE") {
462            if arg_ref(args, 1) != Some(dimension) {
463                continue;
464            }
465            let Some(range) = arg_ref(args, 0).and_then(|id| entities.get(&id)) else {
466                continue;
467            };
468            let Some(range_args) = range.find("TOLERANCE_VALUE") else {
469                continue;
470            };
471            let bound = |slot: usize| -> Option<f64> {
472                let measure = arg_ref(range_args, slot).and_then(|id| entities.get(&id))?;
473                let raw = measure
474                    .find("LENGTH_MEASURE_WITH_UNIT")
475                    .or_else(|| measure.find("PLANE_ANGLE_MEASURE_WITH_UNIT"))
476                    .or_else(|| measure.find("MEASURE_WITH_UNIT"))
477                    .and_then(|args| args.first())
478                    .and_then(measure_value)?;
479                Some(scale(measure, raw))
480            };
481            if let (Some(lower), Some(upper)) = (bound(0), bound(1)) {
482                values.plus_minus = Some((lower, upper));
483            }
484        }
485    }
486    values
487}
488
489/// Fold the values into a dimension annotation's tolerance block.
490fn apply_values(params: &mut serde_json::Map<String, serde_json::Value>, values: &DimensionValues) {
491    if let (Some(nominal), Some(upper), Some(lower)) = (values.nominal, values.upper_limit, values.lower_limit) {
492        params.insert("tolMode".into(), "limits".into());
493        params.insert("tolUpper".into(), serde_json::json!(round6(upper - nominal)));
494        params.insert("tolLower".into(), serde_json::json!(round6(nominal - lower)));
495    } else if let Some((lower, upper)) = values.plus_minus {
496        if (lower.abs() - upper.abs()).abs() < 1e-9 {
497            params.insert("tolMode".into(), "symmetric".into());
498            params.insert("tolUpper".into(), serde_json::json!(round6(upper.abs())));
499        } else {
500            params.insert("tolMode".into(), "deviation".into());
501            params.insert("tolUpper".into(), serde_json::json!(round6(upper.abs())));
502            params.insert("tolLower".into(), serde_json::json!(round6(lower.abs())));
503        }
504    }
505}
506
507fn round6(value: f64) -> f64 {
508    (value * 1e6).round() / 1e6
509}
510
511/// A saved view decoded from a `DRAUGHTING_MODEL` + `CAMERA_MODEL_D3`.
512struct ViewIn {
513    name: String,
514    camera: Option<PmiCamera>,
515    /// The callouts it shows (direct items + its annotation planes' elements).
516    callouts: Vec<usize>,
517}
518
519fn read_camera(entities: &HashMap<usize, Entity>, resolver: &Resolver, camera_ref: usize) -> Option<(String, PmiCamera)> {
520    let camera = entities.get(&camera_ref)?;
521    let args = camera.find("CAMERA_MODEL_D3")?;
522    let name = arg_string(args, 0);
523    let frame = resolver.placement(arg_ref(args, 1)?).ok()?;
524    let volume = entities.get(&arg_ref(args, 2)?)?;
525    let volume_args = volume.find("VIEW_VOLUME")?;
526    let parallel = volume_args.first().map(|v| v.enum_is("PARALLEL")).unwrap_or(true);
527    let distance = resolver.length(volume_args.get(2)?.as_real().ok()?).max(1e-6);
528    let window = entities.get(&arg_ref(volume_args, 8)?)?;
529    let window_args = window.find("PLANAR_BOX")?;
530    let width = resolver.length(window_args.get(1)?.as_real().ok()?);
531    let height = resolver.length(window_args.get(2)?.as_real().ok()?).max(1e-9);
532    let eye = frame.origin;
533    let target = eye.add(frame.z.scale(distance));
534    let projection = if parallel {
535        PmiProjection::Orthographic {
536            half_height: height * 0.5,
537        }
538    } else {
539        PmiProjection::Perspective {
540            fov_y_deg: 2.0 * (height * 0.5 / distance).atan().to_degrees(),
541        }
542    };
543    let aspect = if height > 0.0 { width / height } else { 1.5 };
544    Some((
545        name,
546        PmiCamera {
547            eye: [eye.x, eye.y, eye.z],
548            target: [target.x, target.y, target.z],
549            up: [frame.y.x, frame.y.y, frame.y.z],
550            projection,
551            viewport: [800.0 * aspect, 800.0],
552        },
553    ))
554}
555
556/// The callouts of an ANNOTATION_PLANE (its elements) or a callout itself.
557fn expand_item(entities: &HashMap<usize, Entity>, item: usize, out: &mut Vec<usize>) {
558    let Some(entity) = entities.get(&item) else { return };
559    if let Some(args) = entity.find("ANNOTATION_PLANE") {
560        for element in arg_refs(args, 3) {
561            expand_item(entities, element, out);
562        }
563    } else if entity.has("DRAUGHTING_CALLOUT") || entity.has("ANNOTATION_OCCURRENCE") || entity.has("ANNOTATION_CURVE_OCCURRENCE") || entity.has("ANNOTATION_TEXT_OCCURRENCE") {
564        out.push(item);
565    }
566}
567
568/// callout → the ANNOTATION_PLANE listing it (nested planes flattened; the
569/// first plane naming a callout wins).
570fn plane_of_callouts(entities: &HashMap<usize, Entity>) -> HashMap<usize, usize> {
571    let mut planes: Vec<(&usize, &Entity)> = entities.iter().filter(|(_, e)| e.has("ANNOTATION_PLANE")).collect();
572    planes.sort_by_key(|(id, _)| **id);
573    let mut out: HashMap<usize, usize> = HashMap::default();
574    for (id, entity) in planes {
575        let Some(args) = entity.find("ANNOTATION_PLANE") else { continue };
576        let mut members = Vec::new();
577        for element in arg_refs(args, 3) {
578            expand_item(entities, element, &mut members);
579        }
580        for member in members {
581            out.entry(member).or_insert(*id);
582        }
583    }
584    out
585}
586
587/// The imported planar face whose plane an ANNOTATION_PLANE's placement
588/// coincides with (normal parallel, origin on the plane, lowest face entity
589/// first) — the `plane` reference the lifted annotation lays out in. `None`
590/// when the plane matches no face: the annotation then aligns to its view.
591fn plane_face_name(
592    entities: &HashMap<usize, Entity>,
593    resolver: &Resolver,
594    names: &NameMap,
595    plane: usize,
596) -> Option<String> {
597    let args = entities.get(&plane)?.find("ANNOTATION_PLANE")?;
598    let item = arg_ref(args, 2)?;
599    let placement = match entities.get(&item) {
600        Some(entity) if entity.has("PLANE") => arg_ref(entity.find("PLANE")?, 1)?,
601        Some(entity) if entity.has("AXIS2_PLACEMENT_3D") => item,
602        _ => return None,
603    };
604    let frame = resolver.placement(placement).ok()?;
605    let normal = frame.z.normalized().ok()?;
606    let tolerance = 1e-4 * frame.origin.length().max(1.0);
607    let mut faces: Vec<(&usize, &String)> = names.faces.iter().collect();
608    faces.sort();
609    for (face_id, name) in faces {
610        let Some(face_args) = entities.get(face_id).and_then(|e| e.find("ADVANCED_FACE")) else { continue };
611        let Some(surface) = arg_ref(face_args, 2).and_then(|id| entities.get(&id)) else { continue };
612        let Some(plane_args) = surface.find("PLANE") else { continue };
613        let Some(face_frame) = arg_ref(plane_args, 1).and_then(|p| resolver.placement(p).ok()) else { continue };
614        let Ok(face_normal) = face_frame.z.normalized() else { continue };
615        if face_normal.dot(normal).abs() < 1.0 - 1e-6 {
616            continue;
617        }
618        if face_frame.origin.sub(frame.origin).dot(normal).abs() > tolerance {
619            continue;
620        }
621        return Some(name.clone());
622    }
623    None
624}
625
626/// The PMI validation properties of a file (practice §10.3), keyed by the
627/// presentation item they characterize (a callout or one of its subsets):
628/// the equivalent unicode string and the polyline centre point.
629#[derive(Default)]
630struct ValidationProps {
631    unicode: HashMap<usize, String>,
632    centre: HashMap<usize, [f64; 3]>,
633}
634
635impl ValidationProps {
636    fn read(entities: &HashMap<usize, Entity>, resolver: &Resolver) -> Self {
637        let mut out = Self::default();
638        for entity in entities.values() {
639            let Some(args) = entity.find("PROPERTY_DEFINITION_REPRESENTATION") else { continue };
640            let (Some(definition), Some(representation)) = (arg_ref(args, 0), arg_ref(args, 1)) else { continue };
641            let Some(within) = entities
642                .get(&definition)
643                .and_then(|e| e.find("PROPERTY_DEFINITION"))
644                .and_then(|a| arg_ref(a, 2))
645            else {
646                continue;
647            };
648            let Some(item) = entities
649                .get(&within)
650                .and_then(|e| e.find("CHARACTERIZED_ITEM_WITHIN_REPRESENTATION"))
651                .and_then(|a| arg_ref(a, 2))
652            else {
653                continue;
654            };
655            let Some(items) = entities.get(&representation).and_then(|e| e.find("REPRESENTATION")) else { continue };
656            for value in arg_refs(items, 1) {
657                let Some(value_entity) = entities.get(&value) else { continue };
658                if let Some(dri) = value_entity.find("DESCRIPTIVE_REPRESENTATION_ITEM") {
659                    if arg_string(dri, 0) == "equivalent unicode string" {
660                        out.unicode.entry(item).or_insert_with(|| arg_string(dri, 1));
661                    }
662                }
663                if let Some(point) = value_entity.find("CARTESIAN_POINT") {
664                    if arg_string(point, 0) == "polyline centre point" {
665                        if let Ok(p) = resolver.point(value) {
666                            out.centre.entry(item).or_insert([p.x, p.y, p.z]);
667                        }
668                    }
669                }
670            }
671        }
672        out
673    }
674}
675
676/// The label position and text of a callout. Text: the callout's equivalent
677/// unicode string (Graphic Presentation), else its `TEXT_LITERAL`
678/// (character-based files). Position: the centre point of the callout's
679/// text subset, else the callout's own centre point, else the text
680/// literal's placement, else the first polyline point.
681fn callout_label(
682    entities: &HashMap<usize, Entity>,
683    resolver: &Resolver,
684    props: &ValidationProps,
685    callout: usize,
686) -> (Option<[f64; 3]>, String) {
687    let mut text = props.unicode.get(&callout).cloned().unwrap_or_default();
688    let mut position = entities
689        .get(&callout)
690        .and_then(|e| e.find("DRAUGHTING_CALLOUT"))
691        .map(|args| arg_refs(args, 1))
692        .unwrap_or_default()
693        .iter()
694        .find_map(|subset| props.centre.get(subset).copied())
695        .or_else(|| props.centre.get(&callout).copied());
696    let mut fallback = None;
697    let mut visit = |id: usize| {
698        let Some(entity) = entities.get(&id) else { return };
699        if let Some(args) = entity.find("TEXT_LITERAL") {
700            if text.is_empty() {
701                text = arg_string(args, 0);
702            }
703            if position.is_none() {
704                if let Some(frame) = arg_ref(args, 1).and_then(|p| resolver.placement(p).ok()) {
705                    position = Some([frame.origin.x, frame.origin.y, frame.origin.z]);
706                }
707            }
708        }
709        if let Some(args) = entity.find("POLYLINE") {
710            if fallback.is_none() {
711                if let Some(point) = arg_refs(args, 1).first().and_then(|p| resolver.point(*p).ok()) {
712                    fallback = Some([point.x, point.y, point.z]);
713                }
714            }
715        }
716    };
717    let mut queue = vec![callout];
718    let mut seen: HashSet<usize> = HashSet::default();
719    while let Some(id) = queue.pop() {
720        if !seen.insert(id) {
721            continue;
722        }
723        visit(id);
724        let Some(entity) = entities.get(&id) else { continue };
725        for (keyword, slot) in [("DRAUGHTING_CALLOUT", 1usize), ("ANNOTATION_CURVE_OCCURRENCE", 2), ("ANNOTATION_TEXT_OCCURRENCE", 2), ("ANNOTATION_OCCURRENCE", 2), ("GEOMETRIC_CURVE_SET", 1)] {
726            if let Some(args) = entity.find(keyword) {
727                match args.get(slot) {
728                    Some(Value::Ref(target)) => queue.push(*target),
729                    Some(Value::List(items)) => queue.extend(items.iter().filter_map(|v| v.as_ref_id().ok())),
730                    _ => {}
731                }
732            }
733        }
734    }
735    (position.or(fallback), text)
736}
737
738/// Read a file's PMI into a [`PmiState`] whose references name the geometry
739/// an IMPORT3D feature `feature_name` will stamp. `Ok(None)` when the file
740/// carries no PMI.
741pub fn read_step_pmi(text: &str, feature_name: &str) -> Result<Option<PmiState>, String> {
742    if !text.contains("DIMENSIONAL_")
743        && !text.contains("_TOLERANCE(")
744        && !text.contains("DATUM(")
745        && !text.contains("CAMERA_MODEL_D3(")
746        && !text.contains("DRAUGHTING_CALLOUT(")
747    {
748        return Ok(None);
749    }
750    let entities = parse_data_section(text)?;
751    let names = NameMap::build(text, &entities, feature_name)?;
752    let resolver = Resolver {
753        entities: &entities,
754        length_scale: names.length_scale,
755    };
756    let aspects = aspect_names(&entities, &names);
757    let aspect_name = |id: usize| -> Option<String> { aspects.get(&id).cloned() };
758    let mut ids: BTreeMap<usize, &Entity> = entities.iter().map(|(id, entity)| (*id, entity)).collect();
759    let mut state = PmiState::default();
760    let mut semantics: Vec<Semantic> = Vec::new();
761    let mut params = |pairs: Vec<(&str, serde_json::Value)>| -> serde_json::Map<String, serde_json::Value> {
762        let mut map = serde_json::Map::new();
763        for (key, value) in pairs {
764            map.insert(key.to_string(), value);
765        }
766        map
767    };
768
769    // Datums first (frames reference their letters).
770    let mut datum_ids: HashMap<usize, String> = HashMap::default();
771    for (id, entity) in ids.iter() {
772        let Some(args) = entity.find("DATUM") else { continue };
773        let letter = arg_string(args, 4).to_ascii_uppercase();
774        if letter.is_empty() {
775            continue;
776        }
777        // The datum feature: SHAPE_ASPECT_RELATIONSHIP(relating = feature, related = datum).
778        let feature = entities.values().find_map(|candidate| {
779            let rel = candidate.find("SHAPE_ASPECT_RELATIONSHIP")?;
780            (arg_ref(rel, 3) == Some(*id)).then(|| arg_ref(rel, 2)).flatten()
781        });
782        let Some(target) = feature.and_then(aspect_name) else {
783            continue;
784        };
785        let annotation_id = state.next_id("DTM");
786        datum_ids.insert(*id, letter.clone());
787        semantics.push(Semantic {
788            entity: *id,
789            annotation: PmiAnnotation {
790                kind: "datum".into(),
791                enabled: true,
792                params: serde_json::Value::Object(params(vec![
793                    ("id", annotation_id.into()),
794                    ("target", target.into()),
795                    ("letter", letter.into()),
796                ])),
797                label_world: None,
798            },
799        });
800    }
801
802    // Dimensions.
803    for (id, entity) in ids.iter() {
804        let mut fields: Option<(&str, serde_json::Map<String, serde_json::Value>)> = None;
805        if let Some(args) = entity.find("ANGULAR_LOCATION").or_else(|| entity.find("DIMENSIONAL_LOCATION")) {
806            let (Some(a), Some(b)) = (arg_ref(args, 2).and_then(aspect_name), arg_ref(args, 3).and_then(aspect_name)) else {
807                continue;
808            };
809            let values = dimension_values(&entities, *id, &names);
810            if entity.has("ANGULAR_LOCATION") {
811                let mut map = params(vec![("targets", serde_json::json!([a, b])), ("decimals", 1.into())]);
812                if let Some(nominal) = values.nominal {
813                    let kind = if nominal > 180.0 { "reflex" } else if nominal > 90.0 + 1e-9 { "obtuse" } else { "acute" };
814                    map.insert("angleType".into(), kind.into());
815                }
816                apply_values(&mut map, &values);
817                fields = Some(("angle", map));
818            } else {
819                let mut map = params(vec![("targets", serde_json::json!([a, b]))]);
820                if let Some(orientation) = orientation_axis(&entities, *id) {
821                    map.insert("alignment".into(), orientation.into());
822                }
823                apply_values(&mut map, &values);
824                fields = Some(("linear", map));
825            }
826        } else if let Some(args) = entity.find("DIMENSIONAL_SIZE") {
827            let Some(target) = arg_ref(args, 0).and_then(aspect_name) else {
828                continue;
829            };
830            let name = arg_string(args, 1).to_ascii_lowercase();
831            let values = dimension_values(&entities, *id, &names);
832            if let Some(callout) = &values.callout {
833                let _ = callout;
834                let map = params(vec![("target", target.into()), ("showQuantity", true.into())]);
835                fields = Some(("holeCallout", map));
836            } else if name.contains("diameter") || name.contains("radius") {
837                let mut map = params(vec![
838                    ("target", target.into()),
839                    ("displayStyle", if name.contains("radius") { "radius" } else { "diameter" }.into()),
840                ]);
841                apply_values(&mut map, &values);
842                fields = Some(("radial", map));
843            } else {
844                let mut map = params(vec![("targets", serde_json::json!([target]))]);
845                apply_values(&mut map, &values);
846                fields = Some(("linear", map));
847            }
848        }
849        if let Some((kind, mut map)) = fields {
850            let prefix = match kind {
851                "angle" => "ANG",
852                "radial" => "RAD",
853                "holeCallout" => "HOLE",
854                _ => "DIM",
855            };
856            map.insert("id".into(), state.next_id(prefix).into());
857            semantics.push(Semantic {
858                entity: *id,
859                annotation: PmiAnnotation {
860                    kind: kind.into(),
861                    enabled: true,
862                    params: serde_json::Value::Object(map),
863                    label_world: None,
864                },
865            });
866        }
867    }
868
869    // Geometric tolerances.
870    for (id, entity) in ids.iter() {
871        let Some((kind_record, base_args)) = entity.records.iter().find_map(|(keyword, args)| {
872            characteristic_for_entity(keyword).map(|c| (c, args))
873        }) else {
874            continue;
875        };
876        // The base attributes live on GEOMETRIC_TOLERANCE in a complex
877        // instance, on the typed record itself in a simple one.
878        let args = entity.find("GEOMETRIC_TOLERANCE").unwrap_or(base_args);
879        if args.len() < 4 {
880            continue;
881        }
882        let Some(target) = arg_ref(args, 3).and_then(aspect_name) else {
883            continue;
884        };
885        let magnitude = arg_ref(args, 2)
886            .and_then(|m| entities.get(&m))
887            .and_then(|m| m.find("LENGTH_MEASURE_WITH_UNIT").or_else(|| m.find("MEASURE_WITH_UNIT")))
888            .and_then(|a| a.first())
889            .and_then(measure_value)
890            .map(|v| v * names.length_scale)
891            .unwrap_or(0.1);
892        let mut map = params(vec![
893            ("target", target.into()),
894            ("characteristic", kind_record.id.into()),
895            ("zoneValue", serde_json::json!(round6(magnitude))),
896        ]);
897        if let Some(mod_args) = entity.find("GEOMETRIC_TOLERANCE_WITH_MODIFIERS") {
898            if let Some(items) = mod_args.first().and_then(|v| v.as_list().ok()) {
899                if let Some(modifier) = items.iter().filter_map(modifier_text).next() {
900                    map.insert("materialCondition".into(), modifier.into());
901                }
902            }
903        }
904        if let Some(ref_args) = entity.find("GEOMETRIC_TOLERANCE_WITH_DATUM_REFERENCE") {
905            let mut letters: Vec<(String, String)> = Vec::new();
906            for system_ref in arg_refs(ref_args, 0) {
907                let Some(system) = entities.get(&system_ref) else { continue };
908                if let Some(system_args) = system.find("DATUM_SYSTEM") {
909                    for constituent in arg_refs(system_args, 4) {
910                        if let Some(pair) = datum_letter(&entities, constituent, 0) {
911                            letters.push(pair);
912                        }
913                    }
914                } else if let Some(pair) = datum_letter(&entities, system_ref, 0) {
915                    letters.push(pair);
916                }
917            }
918            for (slot, (letter, modifier)) in ["A", "B", "C"].iter().zip(letters) {
919                map.insert(format!("datum{slot}"), letter.into());
920                if !modifier.is_empty() {
921                    map.insert(format!("datum{slot}Modifier"), modifier.into());
922                }
923            }
924        }
925        let cylindrical = entities.values().any(|zone| {
926            zone.find("TOLERANCE_ZONE")
927                .map(|zone_args| {
928                    arg_refs(zone_args, 4).contains(id)
929                        && arg_ref(zone_args, 5)
930                            .and_then(|form| entities.get(&form))
931                            .and_then(|form| form.find("TOLERANCE_ZONE_FORM"))
932                            .map(|form_args| arg_string(form_args, 0).to_ascii_lowercase().contains("cylindrical"))
933                            .unwrap_or(false)
934                })
935                .unwrap_or(false)
936        });
937        if cylindrical {
938            map.insert("zoneDiameter".into(), true.into());
939        }
940        map.insert("id".into(), state.next_id("FCF").into());
941        semantics.push(Semantic {
942            entity: *id,
943            annotation: PmiAnnotation {
944                kind: "fcf".into(),
945                enabled: true,
946                params: serde_json::Value::Object(map),
947                label_world: None,
948            },
949        });
950    }
951    ids.clear();
952
953    // Presentation links: semantic entity (or its datum feature aspect) → callout.
954    let mut callout_of: HashMap<usize, usize> = HashMap::default();
955    for entity in entities.values() {
956        let Some(args) = entity.find("DRAUGHTING_MODEL_ITEM_ASSOCIATION") else { continue };
957        if let (Some(definition), Some(callout)) = (arg_ref(args, 2), arg_ref(args, 4)) {
958            callout_of.entry(definition).or_insert(callout);
959        }
960    }
961    // A datum's callout is linked through its datum feature aspect.
962    let datum_feature_of: HashMap<usize, usize> = entities
963        .values()
964        .filter_map(|entity| {
965            let rel = entity.find("SHAPE_ASPECT_RELATIONSHIP")?;
966            Some((arg_ref(rel, 3)?, arg_ref(rel, 2)?))
967        })
968        .collect();
969    let mut linked_callouts: HashSet<usize> = HashSet::default();
970    let mut annotations: Vec<(Option<usize>, PmiAnnotation)> = Vec::new();
971    // A callout's ANNOTATION_PLANE that coincides with a planar face becomes
972    // the annotation's `plane` reference; any other plane leaves it aligned
973    // to the view.
974    let plane_of = plane_of_callouts(&entities);
975    let props = ValidationProps::read(&entities, &resolver);
976    let mut plane_faces: HashMap<usize, Option<String>> = HashMap::default();
977    let mut plane_param = |callout: usize, annotation: &mut PmiAnnotation| {
978        let Some(plane) = plane_of.get(&callout).copied() else { return };
979        let face = plane_faces
980            .entry(plane)
981            .or_insert_with(|| plane_face_name(&entities, &resolver, &names, plane))
982            .clone();
983        if let (Some(face), Some(object)) = (face, annotation.params.as_object_mut()) {
984            object.insert("plane".into(), serde_json::Value::String(face));
985        }
986    };
987    for semantic in semantics {
988        let callout = callout_of
989            .get(&semantic.entity)
990            .copied()
991            .or_else(|| datum_feature_of.get(&semantic.entity).and_then(|f| callout_of.get(f)).copied());
992        let mut annotation = semantic.annotation;
993        if let Some(callout) = callout {
994            linked_callouts.insert(callout);
995            let (position, _) = callout_label(&entities, &resolver, &props, callout);
996            annotation.label_world = position;
997            plane_param(callout, &mut annotation);
998        }
999        annotations.push((callout, annotation));
1000    }
1001    // Free 3D text callouts become notes.
1002    for (id, entity) in entities.iter() {
1003        if !entity.has("DRAUGHTING_CALLOUT") || linked_callouts.contains(id) {
1004            continue;
1005        }
1006        let (position, text) = callout_label(&entities, &resolver, &props, *id);
1007        if text.trim().is_empty() {
1008            continue;
1009        }
1010        let annotation_id = state.next_id("NOTE");
1011        let mut annotation = PmiAnnotation {
1012            kind: "note".into(),
1013            enabled: true,
1014            params: serde_json::Value::Object(params(vec![("id", annotation_id.into()), ("text", text.into())])),
1015            label_world: position,
1016        };
1017        plane_param(*id, &mut annotation);
1018        annotations.push((Some(*id), annotation));
1019    }
1020    if annotations.is_empty() && !text.contains("CAMERA_MODEL_D3(") {
1021        return Ok(None);
1022    }
1023
1024    // Saved views.
1025    let mut views_in: Vec<ViewIn> = Vec::new();
1026    let mut models: Vec<(&usize, &Entity)> = entities.iter().filter(|(_, e)| e.has("DRAUGHTING_MODEL")).collect();
1027    models.sort_by_key(|(id, _)| **id);
1028    for (_, model) in models {
1029        let Some(args) = model.find("DRAUGHTING_MODEL") else { continue };
1030        let items = arg_refs(args, 1);
1031        let camera = items
1032            .iter()
1033            .find_map(|item| read_camera(&entities, &resolver, *item));
1034        let Some((camera_name, camera)) = camera else {
1035            continue; // the global model (no camera) is not a saved view
1036        };
1037        let mut callouts = Vec::new();
1038        for item in &items {
1039            expand_item(&entities, *item, &mut callouts);
1040        }
1041        let model_name = arg_string(args, 0);
1042        views_in.push(ViewIn {
1043            name: if camera_name.trim().is_empty() { model_name } else { camera_name },
1044            camera: Some(camera),
1045            callouts,
1046        });
1047    }
1048    let mut placed: HashSet<usize> = HashSet::default();
1049    for view_in in views_in {
1050        let id = state.next_id("VIEW");
1051        let mut view = PmiView {
1052            id,
1053            name: view_in.name,
1054            camera: view_in.camera,
1055            display: PmiDisplay::default(),
1056            annotations: Vec::new(),
1057        };
1058        for (index, (callout, annotation)) in annotations.iter().enumerate() {
1059            if let Some(callout) = callout {
1060                if view_in.callouts.contains(callout) && !placed.contains(&index) {
1061                    view.annotations.push(annotation.clone());
1062                    placed.insert(index);
1063                }
1064            }
1065        }
1066        state.views.push(view);
1067    }
1068    let unplaced: Vec<PmiAnnotation> = annotations
1069        .iter()
1070        .enumerate()
1071        .filter(|(index, _)| !placed.contains(index))
1072        .map(|(_, (_, annotation))| annotation.clone())
1073        .collect();
1074    if !unplaced.is_empty() {
1075        let id = state.next_id("VIEW");
1076        state.views.push(PmiView {
1077            id,
1078            name: "Imported PMI".into(),
1079            camera: None,
1080            display: PmiDisplay::default(),
1081            annotations: unplaced,
1082        });
1083    }
1084    if state.views.is_empty() {
1085        return Ok(None);
1086    }
1087    Ok(Some(state))
1088}
1089
1090/// The world axis of an oriented dimension's 'orientation' placement (X / Y / Z).
1091fn orientation_axis(entities: &HashMap<usize, Entity>, dimension: usize) -> Option<&'static str> {
1092    for entity in entities.values() {
1093        let Some(args) = entity.find("DIMENSIONAL_CHARACTERISTIC_REPRESENTATION") else { continue };
1094        if arg_ref(args, 0) != Some(dimension) {
1095            continue;
1096        }
1097        let Some(representation) = arg_ref(args, 1).and_then(|id| entities.get(&id)) else { continue };
1098        let Some(rep_args) = representation.find("SHAPE_DIMENSION_REPRESENTATION") else { continue };
1099        for item_ref in arg_refs(rep_args, 1) {
1100            let Some(item) = entities.get(&item_ref) else { continue };
1101            let Some(placement) = item.find("AXIS2_PLACEMENT_3D") else { continue };
1102            if arg_string(placement, 0) != "orientation" {
1103                continue;
1104            }
1105            let Some(direction) = arg_ref(placement, 3).and_then(|id| entities.get(&id)) else { continue };
1106            let Some(list) = direction.find("DIRECTION").and_then(|d| d.get(1)).and_then(|v| v.as_list().ok()) else { continue };
1107            let components: Vec<f64> = list.iter().filter_map(|v| v.as_real().ok()).collect();
1108            let axis = components
1109                .iter()
1110                .enumerate()
1111                .max_by(|a, b| a.1.abs().partial_cmp(&b.1.abs()).unwrap_or(std::cmp::Ordering::Equal))
1112                .map(|(index, _)| index)?;
1113            return Some(["X", "Y", "Z"][axis.min(2)]);
1114        }
1115    }
1116    None
1117}