Skip to main content

brep_kernel/io/step/
pmi.rs

1//! AP242 PMI in the STEP writer: SEMANTIC representation (dimensions with
2//! their values and tolerances, datums, geometric tolerances with datum
3//! systems, modifiers and zones — typed from the kernel's PMI store, never
4//! from display strings), POLYLINE PRESENTATION (the same layout the
5//! viewport draws, from [`crate::feature_pipeline::pmi::layout`]) and SAVED
6//! VIEWS (one `DRAUGHTING_MODEL` + `CAMERA_MODEL_D3` per PMI view, related
7//! to the global draughting model), laid out per the CAx-IF "Recommended
8//! Practices for the Representation and Presentation of PMI (AP242)" v4.0:
9//!
10//! - every referenced face / edge / vertex gets ONE `SHAPE_ASPECT` +
11//!   `GEOMETRIC_ITEM_SPECIFIC_USAGE` (shared by every annotation on it) with
12//!   an `ID_ATTRIBUTE`;
13//! - `DIMENSIONAL_LOCATION('linear distance')` / `ANGULAR_LOCATION` between
14//!   two aspects, `DIMENSIONAL_SIZE('diameter' | 'radius' | 'spherical …' |
15//!   'curve length')` on one; values through
16//!   `DIMENSIONAL_CHARACTERISTIC_REPRESENTATION` →
17//!   `SHAPE_DIMENSION_REPRESENTATION` with 'nominal value' (and 'upper limit'
18//!   / 'lower limit' for a limits block) measure items, and a
19//!   `PLUS_MINUS_TOLERANCE` / `TOLERANCE_VALUE` pair for ± / deviation blocks;
20//! - `DATUM` + `DATUM_FEATURE` + `SHAPE_ASPECT_RELATIONSHIP`;
21//! - the fourteen `*_TOLERANCE` entities, complex with
22//!   `GEOMETRIC_TOLERANCE_WITH_DATUM_REFERENCE` (a `DATUM_SYSTEM` of
23//!   `DATUM_REFERENCE_COMPARTMENT`s) and `GEOMETRIC_TOLERANCE_WITH_MODIFIERS`,
24//!   plus a `TOLERANCE_ZONE` of form 'cylindrical or circular' for a ⌀ zone;
25//! - `DRAUGHTING_CALLOUT` → `ANNOTATION_CURVE_OCCURRENCE` →
26//!   `GEOMETRIC_CURVE_SET` of `POLYLINE`s plus an
27//!   `ANNOTATION_TEXT_OCCURRENCE` → `TEXT_LITERAL`, on the view's
28//!   `ANNOTATION_PLANE`; `DRAUGHTING_MODEL_ITEM_ASSOCIATION('PMI
29//!   representation to presentation link')` ties each semantic element to
30//!   its callout through the global draughting model;
31//! - a saved view = `DRAUGHTING_MODEL(name, (styled MAPPED_ITEM of the
32//!   shape, CAMERA_MODEL_D3, its callouts))` related to the global model by
33//!   `MECHANICAL_DESIGN_AND_DRAUGHTING_RELATIONSHIP(view, global)`; the
34//!   camera is a `VIEW_VOLUME` (.PARALLEL. / .CENTRAL.) with the view
35//!   reference system at the eye, z along the viewing direction, the view
36//!   plane at the target and a `PLANAR_BOX` window centred on it.
37//!
38//! Non-ASCII text (⌀ ± ° and the GD&T symbols) is written with Part 21's
39//! `\X2\…\X0\` control directives, per the CAx-IF Unicode recommendation.
40
41use super::{
42    id_list, real, write_direction, write_placement, write_point, StepItemOwner, StepWriter,
43};
44use crate::feature_pipeline::pmi::annotations::fcf::{characteristic, datum_references};
45use crate::feature_pipeline::pmi::layout::{present, LayoutStyle};
46use crate::feature_pipeline::pmi::{
47    PmiAnnotation, PmiGeometry, PmiProjection, PmiReport, PmiState, PmiStatus,
48    ToleranceBlock, ToleranceMode,
49};
50use crate::feature_pipeline::Env;
51use crate::Vec3;
52use rustc_hash::FxHashMap as HashMap;
53
54/// The PMI to write: the document block and the tail's resolution of it.
55pub struct StepPmi<'a> {
56    pub state: &'a PmiState,
57    pub report: &'a PmiReport,
58}
59
60/// What the geometry pass left for the PMI writer.
61pub(crate) struct StepContext<'a> {
62    pub product_shape: usize,
63    /// The `ADVANCED_BREP_SHAPE_REPRESENTATION`.
64    pub representation: usize,
65    pub geometry_context: usize,
66    pub length_unit: usize,
67    pub angle_unit: usize,
68    /// Face name → its `ADVANCED_FACE` and the product that OWNS it. In a
69    /// structured export a component's face lives in the PART's product, so the
70    /// aspect it carries must be attached there, not to the root document.
71    pub faces: &'a HashMap<String, (usize, StepItemOwner)>,
72    pub edges: &'a HashMap<String, (usize, StepItemOwner)>,
73    /// Solid name → its owner and its `VERTEX_POINT`s (ROOT-space point, entity
74    /// id) — root space because that is the frame a `{body}@x,y,z` reference is
75    /// written in, whatever product the vertex ended up in.
76    pub vertices: &'a HashMap<String, (StepItemOwner, Vec<(Vec3, usize)>)>,
77}
78
79/// Millimetres per point (label text sizes are in points).
80const MM_PER_POINT: f64 = 25.4 / 72.0;
81/// Presentation line width (model units).
82const LINE_WIDTH: f64 = 0.13;
83
84/// A Part 21 string: quotes doubled, non-ASCII in `\X2\…\X0\` (UTF-16 code
85/// units, the encoding every AP242 consumer reads), newlines as ` / `.
86pub(crate) fn step_text(value: &str) -> String {
87    let mut out = String::new();
88    let mut run: Vec<u16> = Vec::new();
89    let flush = |run: &mut Vec<u16>, out: &mut String| {
90        if !run.is_empty() {
91            out.push_str("\\X2\\");
92            for unit in run.iter() {
93                out.push_str(&format!("{unit:04X}"));
94            }
95            out.push_str("\\X0\\");
96            run.clear();
97        }
98    };
99    for ch in value.replace('\n', " / ").chars() {
100        if ch.is_ascii() && !ch.is_ascii_control() {
101            flush(&mut run, &mut out);
102            if ch == '\'' {
103                out.push_str("''");
104            } else if ch == '\\' {
105                out.push_str("\\\\");
106            } else {
107                out.push(ch);
108            }
109        } else if !ch.is_ascii() {
110            let mut units = [0u16; 2];
111            for unit in ch.encode_utf16(&mut units) {
112                run.push(*unit);
113            }
114        }
115    }
116    flush(&mut run, &mut out);
117    out
118}
119
120/// The referenced geometry an aspect attaches to: the entity, and the product
121/// whose shape it defines.
122#[derive(Clone, Copy)]
123struct Item {
124    entity: usize,
125    owner: StepItemOwner,
126}
127
128struct Emitter<'a, 'b> {
129    writer: &'a mut StepWriter,
130    context: &'a StepContext<'b>,
131    /// Reference name → its shape aspect (a `SHAPE_ASPECT` or, for a datum
132    /// feature, the `DATUM_FEATURE`), shared by every annotation on it.
133    aspects: HashMap<String, usize>,
134    /// Datum letter → `DATUM` entity.
135    datums: HashMap<String, usize>,
136    /// Reference names that resolved to no entity in this file. Counted, never
137    /// dropped in silence — an annotation whose geometry moved out from under
138    /// it (or was deleted) is a fact the export report has to carry.
139    unresolved: std::collections::BTreeSet<String>,
140    null_style: usize,
141    curve_style: usize,
142}
143
144impl Emitter<'_, '_> {
145    fn add(&mut self, body: impl Into<String>) -> usize {
146        self.writer.add(body)
147    }
148
149    /// Resolve a PMI reference name to the geometry entity it names, counting
150    /// the miss when it names none.
151    fn item(&mut self, name: &str) -> Option<Item> {
152        let found = self.lookup(name);
153        if found.is_none() {
154            self.unresolved.insert(name.to_string());
155        }
156        found
157    }
158
159    /// [`Self::item`] without the bookkeeping.
160    fn lookup(&self, name: &str) -> Option<Item> {
161        if let Some((solid, coords)) = name.split_once('@') {
162            let mut parts = coords.split(',').map(|part| part.trim().parse::<f64>().ok());
163            let (x, y, z) = (parts.next()??, parts.next()??, parts.next()??);
164            let query = Vec3::new(x, y, z);
165            let (owner, points) = self.context.vertices.get(solid)?;
166            let mut best: Option<(f64, usize)> = None;
167            for (point, id) in points {
168                let distance = point.sub(query).length();
169                if best.map(|(d, _)| distance < d).unwrap_or(true) {
170                    best = Some((distance, *id));
171                }
172            }
173            return best
174                .filter(|(d, _)| *d < 1e-6 + 1e-9 * query.length())
175                .map(|(_, entity)| Item {
176                    entity,
177                    owner: *owner,
178                });
179        }
180        if let Some((entity, owner)) = self.context.faces.get(name) {
181            return Some(Item {
182                entity: *entity,
183                owner: *owner,
184            });
185        }
186        if let Some((entity, owner)) = self.context.edges.get(name) {
187            return Some(Item {
188                entity: *entity,
189                owner: *owner,
190            });
191        }
192        None
193    }
194
195    /// The shape aspect for `name` (created once, with its GISU + id).
196    fn aspect(&mut self, name: &str) -> Option<usize> {
197        if let Some(id) = self.aspects.get(name) {
198            return Some(*id);
199        }
200        let item = self.item(name)?;
201        let aspect = self.add(format!(
202            "SHAPE_ASPECT('{}','',#{},.T.)",
203            step_text(name),
204            item.owner.product_shape
205        ));
206        self.link_aspect(aspect, item, "");
207        self.add(format!("ID_ATTRIBUTE('{}',#{aspect})", step_text(name)));
208        self.aspects.insert(name.to_string(), aspect);
209        Some(aspect)
210    }
211
212    fn link_aspect(&mut self, aspect: usize, item: Item, description: &str) {
213        let target = item.entity;
214        self.add(format!(
215            "GEOMETRIC_ITEM_SPECIFIC_USAGE('','{}',#{aspect},#{},#{target})",
216            step_text(description),
217            item.owner.representation
218        ));
219    }
220
221    fn length_measure(&mut self, value: f64) -> Result<usize, String> {
222        Ok(self.add(format!(
223            "LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{})",
224            real(value)?,
225            self.context.length_unit
226        )))
227    }
228
229    fn angle_measure(&mut self, degrees: f64) -> Result<usize, String> {
230        Ok(self.add(format!(
231            "PLANE_ANGLE_MEASURE_WITH_UNIT(PLANE_ANGLE_MEASURE({}),#{})",
232            real(degrees.to_radians())?,
233            self.context.angle_unit
234        )))
235    }
236
237    /// A named measure representation item (`'nominal value'` …).
238    fn measure_item(&mut self, name: &str, value: f64, angle: bool) -> Result<usize, String> {
239        Ok(if angle {
240            self.add(format!(
241                "(MEASURE_REPRESENTATION_ITEM()MEASURE_WITH_UNIT(PLANE_ANGLE_MEASURE({}),#{})PLANE_ANGLE_MEASURE_WITH_UNIT()REPRESENTATION_ITEM('{name}'))",
242                real(value.to_radians())?,
243                self.context.angle_unit
244            ))
245        } else {
246            self.add(format!(
247                "(LENGTH_MEASURE_WITH_UNIT()MEASURE_REPRESENTATION_ITEM()MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{})REPRESENTATION_ITEM('{name}'))",
248                real(value)?,
249                self.context.length_unit
250            ))
251        })
252    }
253
254    /// The value + tolerance block of a dimension entity `dimension`.
255    fn dimension_values(
256        &mut self,
257        dimension: usize,
258        value: f64,
259        angle: bool,
260        tolerance: &ToleranceBlock,
261        is_reference: bool,
262        extra_items: &[usize],
263    ) -> Result<(), String> {
264        let mut items = vec![self.measure_item("nominal value", value, angle)?];
265        let toleranced = !is_reference;
266        if toleranced && tolerance.mode == ToleranceMode::Limits {
267            items.push(self.measure_item("upper limit", value + tolerance.upper, angle)?);
268            items.push(self.measure_item("lower limit", value - tolerance.lower, angle)?);
269        }
270        items.extend_from_slice(extra_items);
271        let representation = self.add(format!(
272            "SHAPE_DIMENSION_REPRESENTATION('',{},#{})",
273            id_list(&items),
274            self.context.geometry_context
275        ));
276        self.add(format!(
277            "DIMENSIONAL_CHARACTERISTIC_REPRESENTATION(#{dimension},#{representation})"
278        ));
279        if toleranced {
280            if let (ToleranceMode::Symmetric | ToleranceMode::Deviation, Some((lower, upper))) =
281                (tolerance.mode, tolerance.bounds())
282            {
283                let (lower, upper) = if angle {
284                    (self.angle_measure(lower)?, self.angle_measure(upper)?)
285                } else {
286                    (self.length_measure(lower)?, self.length_measure(upper)?)
287                };
288                let range = self.add(format!("TOLERANCE_VALUE(#{lower},#{upper})"));
289                self.add(format!("PLUS_MINUS_TOLERANCE(#{range},#{dimension})"));
290            }
291        }
292        Ok(())
293    }
294
295    /// The semantic representation element of one annotation (`None` for
296    /// presentation-only kinds, or when a reference has no exported geometry).
297    fn semantic(
298        &mut self,
299        annotation: &PmiAnnotation,
300        report: &crate::feature_pipeline::pmi::PmiAnnotationReport,
301        env: &Env,
302    ) -> Result<Option<usize>, String> {
303        let id = report.id.as_str();
304        let tolerance = ToleranceBlock::read(annotation, env).unwrap_or(ToleranceBlock {
305            mode: ToleranceMode::None,
306            upper: 0.0,
307            lower: 0.0,
308        });
309        let is_reference = annotation.flag("isReference");
310        let value = report.value.unwrap_or(0.0);
311        match &report.geometry {
312            PmiGeometry::Linear { a, b, component } => {
313                let aspects: Vec<usize> = report
314                    .references
315                    .iter()
316                    .filter_map(|name| self.aspect(name))
317                    .collect();
318                let dimension = match aspects.as_slice() {
319                    [single] if report.references.len() == 1 => {
320                        self.add(format!("DIMENSIONAL_SIZE(#{single},'curve length')"))
321                    }
322                    [first, second] => self.add(format!(
323                        "DIMENSIONAL_LOCATION('linear distance','',#{first},#{second})"
324                    )),
325                    _ => return Ok(None),
326                };
327                self.add(format!("ID_ATTRIBUTE('{}',#{dimension})", step_text(id)));
328                // An aligned component is an ORIENTED location: its axis is the
329                // x direction of an 'orientation' placement in the items.
330                let mut extra = Vec::new();
331                if let Some(axis) = component {
332                    let x = match axis {
333                        'X' => Vec3::new(1.0, 0.0, 0.0),
334                        'Y' => Vec3::new(0.0, 1.0, 0.0),
335                        _ => Vec3::new(0.0, 0.0, 1.0),
336                    };
337                    let z = x.perpendicular().unwrap_or(Vec3::new(0.0, 0.0, 1.0));
338                    let origin = write_point(self.writer, Vec3::new(a[0], a[1], a[2]))?;
339                    let z_dir = write_direction(self.writer, z)?;
340                    let x_dir = write_direction(self.writer, x)?;
341                    extra.push(self.add(format!(
342                        "AXIS2_PLACEMENT_3D('orientation',#{origin},#{z_dir},#{x_dir})"
343                    )));
344                }
345                let _ = b;
346                self.dimension_values(dimension, value, false, &tolerance, is_reference, &extra)?;
347                Ok(Some(dimension))
348            }
349            PmiGeometry::Radial { diameter, sphere, .. } => {
350                let Some(aspect) = report.references.first().and_then(|name| self.aspect(name)) else {
351                    return Ok(None);
352                };
353                let name = match (diameter, sphere) {
354                    (true, false) => "diameter",
355                    (false, false) => "radius",
356                    (true, true) => "spherical diameter",
357                    (false, true) => "spherical radius",
358                };
359                let dimension = self.add(format!("DIMENSIONAL_SIZE(#{aspect},'{name}')"));
360                self.add(format!("ID_ATTRIBUTE('{}',#{dimension})", step_text(id)));
361                self.dimension_values(dimension, value, false, &tolerance, is_reference, &[])?;
362                Ok(Some(dimension))
363            }
364            PmiGeometry::Angular { degrees, .. } => {
365                let aspects: Vec<usize> = report
366                    .references
367                    .iter()
368                    .filter_map(|name| self.aspect(name))
369                    .collect();
370                let [first, second] = aspects.as_slice() else {
371                    return Ok(None);
372                };
373                let selection = if *degrees > 180.0 { ".LARGE." } else { ".EQUAL." };
374                let dimension = self.add(format!(
375                    "ANGULAR_LOCATION('angular location','',#{first},#{second},{selection})"
376                ));
377                self.add(format!("ID_ATTRIBUTE('{}',#{dimension})", step_text(id)));
378                self.dimension_values(dimension, *degrees, true, &tolerance, is_reference, &[])?;
379                Ok(Some(dimension))
380            }
381            PmiGeometry::Hole { .. } => {
382                let Some(aspect) = report.references.first().and_then(|name| self.aspect(name)) else {
383                    return Ok(None);
384                };
385                let dimension = self.add(format!("DIMENSIONAL_SIZE(#{aspect},'diameter')"));
386                self.add(format!("ID_ATTRIBUTE('{}',#{dimension})", step_text(id)));
387                let callout = self.add(format!(
388                    "DESCRIPTIVE_REPRESENTATION_ITEM('hole callout','{}')",
389                    step_text(&report.text)
390                ));
391                let none = ToleranceBlock {
392                    mode: ToleranceMode::None,
393                    upper: 0.0,
394                    lower: 0.0,
395                };
396                self.dimension_values(dimension, value, false, &none, false, &[callout])?;
397                Ok(Some(dimension))
398            }
399            PmiGeometry::Datum { letter, .. } => {
400                // Written up front by `write_datums`; the DMIA definition is
401                // the datum feature aspect.
402                let _ = letter;
403                Ok(report.references.first().and_then(|name| self.aspects.get(name).copied()))
404            }
405            PmiGeometry::Fcf { frame, .. } => {
406                let Some(aspect) = report.references.first().and_then(|name| self.aspect(name)) else {
407                    return Ok(None);
408                };
409                let Some(kind) = characteristic(&frame.characteristic) else {
410                    return Ok(None);
411                };
412                let magnitude = self.length_measure(value)?;
413                // Datum system.
414                let datums = datum_references(annotation);
415                let mut compartments = Vec::new();
416                for (letter, modifier) in &datums {
417                    let Some(datum) = self.datums.get(letter).copied() else {
418                        return Ok(None);
419                    };
420                    let modifiers = match material_condition(modifier) {
421                        Some(condition) => format!("(SIMPLE_DATUM_REFERENCE_MODIFIER({condition}))"),
422                        None => "$".into(),
423                    };
424                    compartments.push(self.add(format!(
425                        "DATUM_REFERENCE_COMPARTMENT('','',#{},.F.,#{datum},{modifiers})",
426                        self.context.product_shape
427                    )));
428                }
429                let system = if compartments.is_empty() {
430                    None
431                } else {
432                    Some(self.add(format!(
433                        "DATUM_SYSTEM('','',#{},.F.,{})",
434                        self.context.product_shape,
435                        id_list(&compartments)
436                    )))
437                };
438                let condition = material_condition(annotation.text("materialCondition"));
439                let base = format!(
440                    "GEOMETRIC_TOLERANCE('{}','',#{magnitude},#{aspect})",
441                    step_text(id)
442                );
443                let tolerance = if system.is_none() && condition.is_none() {
444                    self.add(format!(
445                        "{}('{}','',#{magnitude},#{aspect})",
446                        kind.step_entity,
447                        step_text(id)
448                    ))
449                } else {
450                    // Complex instance: types in alphabetical order.
451                    let mut parts: Vec<String> = vec![base];
452                    if let Some(system) = system {
453                        parts.push(format!("GEOMETRIC_TOLERANCE_WITH_DATUM_REFERENCE((#{system}))"));
454                    }
455                    if let Some(condition) = condition {
456                        parts.push(format!("GEOMETRIC_TOLERANCE_WITH_MODIFIERS(({condition}))"));
457                    }
458                    parts.push(format!("{}()", kind.step_entity));
459                    parts.sort();
460                    self.add(format!("({})", parts.join("")))
461                };
462                if annotation.flag("zoneDiameter") {
463                    let form = self.add("TOLERANCE_ZONE_FORM('cylindrical or circular')");
464                    self.add(format!(
465                        "TOLERANCE_ZONE('','',#{},.F.,(#{tolerance}),#{form})",
466                        self.context.product_shape
467                    ));
468                }
469                Ok(Some(tolerance))
470            }
471            PmiGeometry::None
472            | PmiGeometry::Leader { .. }
473            | PmiGeometry::Note { .. }
474            | PmiGeometry::Explode { .. } => Ok(None),
475        }
476    }
477
478    /// The datums of every view, up front (frames reference them).
479    fn write_datums(&mut self, pmi: &StepPmi<'_>) {
480        for view in &pmi.report.views {
481            for report in &view.annotations {
482                let PmiGeometry::Datum { letter, .. } = &report.geometry else {
483                    continue;
484                };
485                if report.status != PmiStatus::Ok || !report.enabled || self.datums.contains_key(letter) {
486                    continue;
487                }
488                let Some(name) = report.references.first() else {
489                    continue;
490                };
491                let Some(item) = self.item(name) else {
492                    continue;
493                };
494                let datum = self.add(format!(
495                    "DATUM('{}','',#{},.F.,'{}')",
496                    step_text(&report.id),
497                    item.owner.product_shape,
498                    step_text(letter)
499                ));
500                let feature = self.add(format!(
501                    "DATUM_FEATURE('{}','',#{},.T.)",
502                    step_text(name),
503                    item.owner.product_shape
504                ));
505                self.link_aspect(feature, item, "datum feature");
506                self.add(format!("ID_ATTRIBUTE('{}',#{feature})", step_text(name)));
507                self.add(format!("SHAPE_ASPECT_RELATIONSHIP('','',#{feature},#{datum})"));
508                self.datums.insert(letter.clone(), datum);
509                self.aspects.entry(name.clone()).or_insert(feature);
510            }
511        }
512    }
513
514    /// The presentation of one annotation: its callout (polylines + text).
515    fn callout(
516        &mut self,
517        report: &crate::feature_pipeline::pmi::PmiAnnotationReport,
518        style: &LayoutStyle,
519    ) -> Result<CalloutOut, String> {
520        let drawn = present(&report.geometry, report.label_world, &report.text, style);
521        let name = step_text(&report.id);
522        let set_name = step_text(&curve_set_name(report));
523        let polyline = |emitter: &mut Self, points: &[[f64; 3]], stats: &mut PolylineStats| -> Result<Option<usize>, String> {
524            if points.len() < 2 {
525                return Ok(None);
526            }
527            stats.add(points);
528            let mut ids = Vec::with_capacity(points.len());
529            for point in points {
530                ids.push(write_point(emitter.writer, Vec3::new(point[0], point[1], point[2]))?);
531            }
532            Ok(Some(emitter.add(format!("POLYLINE('{name}',{})", id_list(&ids)))))
533        };
534        // Geometry subset: dimension / extension / leader lines, arrowheads
535        // (closed), frames.
536        let mut geometry_stats = PolylineStats::default();
537        let mut curves = Vec::new();
538        for line in &drawn.polylines {
539            if let Some(id) = polyline(self, line, &mut geometry_stats)? {
540                curves.push(id);
541            }
542        }
543        for arrow in &drawn.arrows {
544            let closed = [arrow[0], arrow[1], arrow[2], arrow[0]];
545            if let Some(id) = polyline(self, &closed, &mut geometry_stats)? {
546                curves.push(id);
547            }
548        }
549        for frame in &drawn.frames {
550            if let Some(id) = polyline(self, frame, &mut geometry_stats)? {
551                curves.push(id);
552            }
553        }
554        // Text subset: the runs stroked with the PMI font (Graphic
555        // Presentation — the practice's character-based TEXT_LITERAL route is
556        // shelved, so the text IS polylines like everything else).
557        let mut text_stats = PolylineStats::default();
558        let mut text_curves = Vec::new();
559        for run in &drawn.texts {
560            for stroke in run.strokes() {
561                if let Some(id) = polyline(self, &stroke, &mut text_stats)? {
562                    text_curves.push(id);
563                }
564            }
565        }
566        let mut contents = Vec::new();
567        let subset = |emitter: &mut Self, curves: &[usize]| -> Option<usize> {
568            if curves.is_empty() {
569                return None;
570            }
571            let set = emitter.add(format!("GEOMETRIC_CURVE_SET('{set_name}',{})", id_list(curves)));
572            Some(emitter.add(format!(
573                "ANNOTATION_CURVE_OCCURRENCE('{name}',(#{}),#{set})",
574                emitter.curve_style
575            )))
576        };
577        if let Some(id) = subset(self, &curves) {
578            contents.push(id);
579        }
580        let text_subset = subset(self, &text_curves);
581        contents.extend(text_subset);
582        let id = self.add(format!("DRAUGHTING_CALLOUT('{name}',{})", id_list(&contents)));
583        let text = drawn
584            .texts
585            .iter()
586            .map(|run| run.text.as_str())
587            .collect::<Vec<_>>()
588            .join(" ");
589        let mut total = geometry_stats;
590        total.merge(&text_stats);
591        Ok(CalloutOut {
592            id,
593            text,
594            total,
595            text_subset,
596            text_stats,
597        })
598    }
599
600    /// The PMI validation properties of a callout (practice §10.3, combined
601    /// per §10.3.4): the polyline curve length, the polyline centre point
602    /// and the equivalent unicode string, at the callout, plus the centre
603    /// point and string of its text subset (what gives a reader the label
604    /// position back).
605    fn validation(&mut self, callout: &CalloutOut, global: usize) -> Result<(), String> {
606        let unicode = step_text(&callout.text);
607        let length_unit = self.context.length_unit;
608        let geometry_context = self.context.geometry_context;
609        let property = |emitter: &mut Self, item: usize, stats: &PolylineStats, with_length: bool| -> Result<(), String> {
610            let within = emitter.add(format!(
611                "CHARACTERIZED_ITEM_WITHIN_REPRESENTATION('','',#{item},#{global})"
612            ));
613            let definition = emitter.add(format!("PROPERTY_DEFINITION('pmi validation property','',#{within})"));
614            let mut items = Vec::new();
615            if with_length {
616                items.push(emitter.add(format!(
617                    "MEASURE_REPRESENTATION_ITEM('polyline curve length',POSITIVE_LENGTH_MEASURE({}),#{length_unit})",
618                    real(stats.length)?
619                )));
620            }
621            let centre = stats.centroid();
622            items.push(emitter.add(format!(
623                "CARTESIAN_POINT('polyline centre point',({},{},{}))",
624                real(centre[0])?,
625                real(centre[1])?,
626                real(centre[2])?
627            )));
628            items.push(emitter.add(format!(
629                "DESCRIPTIVE_REPRESENTATION_ITEM('equivalent unicode string','{unicode}')"
630            )));
631            let representation = emitter.add(format!(
632                "REPRESENTATION('',{},#{geometry_context})",
633                id_list(&items)
634            ));
635            emitter.add(format!("PROPERTY_DEFINITION_REPRESENTATION(#{definition},#{representation})"));
636            Ok(())
637        };
638        property(self, callout.id, &callout.total, true)?;
639        if let Some(text_subset) = callout.text_subset {
640            property(self, text_subset, &callout.text_stats, false)?;
641        }
642        Ok(())
643    }
644}
645
646/// A written callout with what its validation properties need.
647struct CalloutOut {
648    /// The `DRAUGHTING_CALLOUT`.
649    id: usize,
650    /// The equivalent unicode string (the runs joined).
651    text: String,
652    total: PolylineStats,
653    /// The text subset's `ANNOTATION_CURVE_OCCURRENCE`, when there is text.
654    text_subset: Option<usize>,
655    text_stats: PolylineStats,
656}
657
658/// Length-weighted polyline statistics (practice §10.3.1): the total curve
659/// length and the centroid of the segment midpoints weighted by length.
660#[derive(Debug, Clone, Copy, Default)]
661struct PolylineStats {
662    length: f64,
663    moment: [f64; 3],
664    /// A fallback for zero-length sets: the first point.
665    first: Option<[f64; 3]>,
666}
667
668impl PolylineStats {
669    fn add(&mut self, points: &[[f64; 3]]) {
670        if self.first.is_none() {
671            self.first = points.first().copied();
672        }
673        for pair in points.windows(2) {
674            let (a, b) = (pair[0], pair[1]);
675            let length = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2) + (b[2] - a[2]).powi(2)).sqrt();
676            self.length += length;
677            for axis in 0..3 {
678                self.moment[axis] += (a[axis] + b[axis]) * 0.5 * length;
679            }
680        }
681    }
682
683    fn merge(&mut self, other: &PolylineStats) {
684        self.length += other.length;
685        for axis in 0..3 {
686            self.moment[axis] += other.moment[axis];
687        }
688        if self.first.is_none() {
689            self.first = other.first;
690        }
691    }
692
693    fn centroid(&self) -> [f64; 3] {
694        if self.length > 1e-12 {
695            [self.moment[0] / self.length, self.moment[1] / self.length, self.moment[2] / self.length]
696        } else {
697            self.first.unwrap_or([0.0; 3])
698        }
699    }
700}
701
702/// The `GEOMETRIC_CURVE_SET` name — the presented PMI type from the
703/// practice's Table 14 (not semantic; a tree label for the reader).
704fn curve_set_name(report: &crate::feature_pipeline::pmi::PmiAnnotationReport) -> String {
705    match report.kind.as_str() {
706        "linear" => "linear dimension".into(),
707        "radial" => match &report.geometry {
708            PmiGeometry::Radial { diameter: false, .. } => "radial dimension".into(),
709            _ => "diameter dimension".into(),
710        },
711        "angle" => "angular dimension".into(),
712        "holeCallout" => "diameter dimension".into(),
713        "datum" => "datum".into(),
714        "fcf" => match &report.geometry {
715            PmiGeometry::Fcf { frame, .. } => match frame.characteristic.as_str() {
716                "profileLine" => "profile of line".into(),
717                "profileSurface" => "profile of surface".into(),
718                "circularRunout" => "circular runout".into(),
719                "totalRunout" => "total runout".into(),
720                other => other.to_ascii_lowercase(),
721            },
722            _ => "general tolerance".into(),
723        },
724        _ => "note".into(),
725    }
726}
727
728/// The AP242 enumeration for a material condition modifier.
729fn material_condition(modifier: &str) -> Option<&'static str> {
730    match modifier.trim().to_ascii_uppercase().as_str() {
731        "MMC" => Some(".MAXIMUM_MATERIAL_REQUIREMENT."),
732        "LMC" => Some(".LEAST_MATERIAL_REQUIREMENT."),
733        _ => None,
734    }
735}
736
737/// The camera of a view as a `CAMERA_MODEL_D3` (+ its view volume).
738fn write_camera(
739    emitter: &mut Emitter<'_, '_>,
740    name: &str,
741    camera: &crate::feature_pipeline::pmi::PmiCamera,
742) -> Result<usize, String> {
743    let eye = Vec3::new(camera.eye[0], camera.eye[1], camera.eye[2]);
744    let target = Vec3::new(camera.target[0], camera.target[1], camera.target[2]);
745    let view = camera.view_direction();
746    let view = Vec3::new(view[0], view[1], view[2]);
747    let up_hint = Vec3::new(camera.up[0], camera.up[1], camera.up[2]);
748    let up = crate::feature_pipeline::pmi::resolve::perpendicular_in_plane(view, up_hint);
749    // Right-handed frame: x × y = z with z = the viewing direction.
750    let right = up.cross(view).normalized().unwrap_or(Vec3::new(1.0, 0.0, 0.0));
751    let distance = target.sub(eye).length().max(1e-6);
752    let aspect = if camera.viewport[1] > 0.0 {
753        camera.viewport[0] / camera.viewport[1]
754    } else {
755        1.5
756    };
757    let (projection, height) = match camera.projection {
758        PmiProjection::Orthographic { half_height } => (".PARALLEL.", 2.0 * half_height),
759        PmiProjection::Perspective { fov_y_deg } => {
760            (".CENTRAL.", 2.0 * distance * (fov_y_deg.to_radians() * 0.5).tan())
761        }
762    };
763    let width = height * aspect;
764    let corner = emitter.add(format!(
765        "CARTESIAN_POINT('',({},{}))",
766        real(-width * 0.5)?,
767        real(-height * 0.5)?
768    ));
769    let window_placement = emitter.add(format!("AXIS2_PLACEMENT_2D('',#{corner},$)"));
770    let window = emitter.add(format!(
771        "PLANAR_BOX('',{},{},#{window_placement})",
772        real(width)?,
773        real(height)?
774    ));
775    let projection_point = write_point(emitter.writer, Vec3::new(0.0, 0.0, 0.0))?;
776    let volume = emitter.add(format!(
777        "VIEW_VOLUME({projection},#{projection_point},{},{},.F.,{},.F.,.T.,#{window})",
778        real(distance)?,
779        real(0.0)?,
780        real(distance * 2.0)?
781    ));
782    let reference = write_placement(emitter.writer, eye, view, right)?;
783    Ok(emitter.add(format!(
784        "CAMERA_MODEL_D3('{}',#{reference},#{volume})",
785        step_text(name)
786    )))
787}
788
789/// Write the whole PMI block. Called after `SHAPE_DEFINITION_REPRESENTATION`.
790/// Write the document's PMI, returning how many DISTINCT reference names named
791/// no entity in the file (every annotation on such a name is skipped).
792pub(crate) fn write_pmi(
793    writer: &mut StepWriter,
794    context: &StepContext<'_>,
795    pmi: &StepPmi<'_>,
796) -> Result<usize, String> {
797    if pmi.state.views.is_empty() {
798        return Ok(0);
799    }
800    let env = Env::build("", &serde_json::Value::Null).unwrap_or_else(Env::poisoned);
801    let null_style = writer.add("PRESENTATION_STYLE_ASSIGNMENT((NULL_STYLE(.NULL.)))");
802    let colour = writer.add("COLOUR_RGB('',0.,0.,0.)");
803    let curve_font = writer.add("DRAUGHTING_PRE_DEFINED_CURVE_FONT('continuous')");
804    let curve_style = writer.add(format!(
805        "CURVE_STYLE('',#{curve_font},POSITIVE_LENGTH_MEASURE({}),#{colour})",
806        real(LINE_WIDTH)?
807    ));
808    let curve_style = writer.add(format!("PRESENTATION_STYLE_ASSIGNMENT((#{curve_style}))"));
809    let mut emitter = Emitter {
810        writer,
811        context,
812        aspects: HashMap::default(),
813        datums: HashMap::default(),
814        unresolved: std::collections::BTreeSet::new(),
815        null_style,
816        curve_style,
817    };
818    // The shape, mapped into the draughting models with a null style.
819    let identity = write_placement(
820        emitter.writer,
821        Vec3::new(0.0, 0.0, 0.0),
822        Vec3::new(0.0, 0.0, 1.0),
823        Vec3::new(1.0, 0.0, 0.0),
824    )?;
825    let map = emitter.add(format!("REPRESENTATION_MAP(#{identity},#{})", context.representation));
826    let mapped = emitter.add(format!("MAPPED_ITEM('',#{map},#{identity})"));
827    let styled_shape = emitter.add(format!("STYLED_ITEM('',(#{null_style}),#{mapped})"));
828
829    emitter.write_datums(pmi);
830
831    // Per view: semantic elements, callouts, the annotation plane.
832    struct ViewOut {
833        name: String,
834        /// The view-aligned annotation plane, then one per picked plane.
835        planes: Vec<usize>,
836        camera: Option<usize>,
837        callouts: Vec<CalloutOut>,
838        links: Vec<(usize, usize)>,
839    }
840    let mut views_out: Vec<ViewOut> = Vec::new();
841    for view in &pmi.state.views {
842        let Some(view_report) = pmi.report.view(&view.id) else {
843            continue;
844        };
845        let camera = view.camera.as_ref();
846        let (view_dir, view_up, target) = match camera {
847            Some(camera) => (camera.view_direction(), camera.up, camera.target),
848            None => ([0.0, 0.0, -1.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]),
849        };
850        let text_height = view.display.text_size_pt * MM_PER_POINT;
851        let style = LayoutStyle {
852            arrow: text_height * 0.9,
853            text_height,
854            view_dir,
855            view_up,
856            plane: None,
857        };
858        let view_vec = Vec3::new(view_dir[0], view_dir[1], view_dir[2]);
859        let up_vec = crate::feature_pipeline::pmi::resolve::perpendicular_in_plane(
860            view_vec,
861            Vec3::new(view_up[0], view_up[1], view_up[2]),
862        );
863        let right = up_vec.cross(view_vec.scale(-1.0)).normalized().unwrap_or(Vec3::new(1.0, 0.0, 0.0));
864        // View-aligned callouts go on the view's annotation plane; callouts
865        // in a picked plane group per plane reference (one ANNOTATION_PLANE
866        // each, placed ON that plane).
867        let mut callouts = Vec::new();
868        let mut view_aligned = Vec::new();
869        let mut plane_groups: Vec<(String, crate::feature_pipeline::pmi::PmiPlane, Vec<usize>)> = Vec::new();
870        let mut links = Vec::new();
871        for (annotation, report) in view.annotations.iter().zip(view_report.annotations.iter()) {
872            if report.status != PmiStatus::Ok || !report.enabled {
873                continue;
874            }
875            if matches!(report.geometry, PmiGeometry::Explode { .. } | PmiGeometry::None) {
876                continue;
877            }
878            let semantic = emitter.semantic(annotation, report, &env)?;
879            // A view-aligned row is drawn in the view-parallel plane through
880            // its label (practice §9.1: polylines lie in a plane parallel to
881            // their ANNOTATION_PLANE); a picked plane is used as is.
882            let row_style = LayoutStyle {
883                plane: report.plane.or(Some(crate::feature_pipeline::pmi::PmiPlane {
884                    origin: report.label_world,
885                    normal: [-view_dir[0], -view_dir[1], -view_dir[2]],
886                    x_axis: [right.x, right.y, right.z],
887                })),
888                ..style
889            };
890            let callout = emitter.callout(report, &row_style)?;
891            let callout_id = callout.id;
892            callouts.push(callout);
893            match report.plane {
894                Some(plane) => {
895                    let key = annotation.plane_ref().unwrap_or("").to_string();
896                    match plane_groups.iter_mut().find(|(name, _, _)| *name == key) {
897                        Some(group) => group.2.push(callout_id),
898                        None => plane_groups.push((key, plane, vec![callout_id])),
899                    }
900                }
901                None => view_aligned.push(callout_id),
902            }
903            if let Some(semantic) = semantic {
904                links.push((semantic, callout_id));
905            }
906        }
907        let placement = write_placement(
908            emitter.writer,
909            Vec3::new(target[0], target[1], target[2]),
910            view_vec.scale(-1.0),
911            right,
912        )?;
913        let plane_geometry = emitter.add(format!("PLANE('',#{placement})"));
914        let mut planes = vec![emitter.add(format!(
915            "ANNOTATION_PLANE('{}',(#{null_style}),#{plane_geometry},{})",
916            step_text(&view.name),
917            id_list(&view_aligned)
918        ))];
919        for (key, plane, members) in &plane_groups {
920            let placement = write_placement(
921                emitter.writer,
922                Vec3::new(plane.origin[0], plane.origin[1], plane.origin[2]),
923                Vec3::new(plane.normal[0], plane.normal[1], plane.normal[2]),
924                Vec3::new(plane.x_axis[0], plane.x_axis[1], plane.x_axis[2]),
925            )?;
926            let geometry = emitter.add(format!("PLANE('',#{placement})"));
927            planes.push(emitter.add(format!(
928                "ANNOTATION_PLANE('{}',(#{null_style}),#{geometry},{})",
929                step_text(&format!("{} / {key}", view.name)),
930                id_list(members)
931            )));
932        }
933        let camera_id = match camera {
934            Some(camera) => Some(write_camera(&mut emitter, &view.name, camera)?),
935            None => None,
936        };
937        views_out.push(ViewOut {
938            name: view.name.clone(),
939            planes,
940            camera: camera_id,
941            callouts,
942            links,
943        });
944    }
945
946    // The global draughting model: every annotation plane + the shape.
947    let mut global_items = vec![styled_shape];
948    global_items.extend(views_out.iter().flat_map(|view| view.planes.iter().copied()));
949    let global = emitter.add(format!(
950        "DRAUGHTING_MODEL('',{},#{})",
951        id_list(&global_items),
952        context.geometry_context
953    ));
954    for view in &views_out {
955        let mut items = vec![styled_shape];
956        if let Some(camera) = view.camera {
957            items.push(camera);
958        }
959        items.extend(view.callouts.iter().map(|callout| callout.id));
960        let model = emitter.add(format!(
961            "DRAUGHTING_MODEL('{}',{},#{})",
962            step_text(&view.name),
963            id_list(&items),
964            context.geometry_context
965        ));
966        emitter.add(format!(
967            "MECHANICAL_DESIGN_AND_DRAUGHTING_RELATIONSHIP('','',#{model},#{global})"
968        ));
969        for (semantic, callout) in &view.links {
970            emitter.add(format!(
971                "DRAUGHTING_MODEL_ITEM_ASSOCIATION('PMI representation to presentation link','',#{semantic},#{global},#{callout})"
972            ));
973        }
974    }
975    // PMI validation properties (§10.3) hang off the global model.
976    for view in &views_out {
977        for callout in &view.callouts {
978            emitter.validation(callout, global)?;
979        }
980    }
981    Ok(emitter.unresolved.len())
982}