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