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 { .. }
476            | PmiGeometry::Hide { .. } => Ok(None),
477        }
478    }
479
480    /// The datums of every view, up front (frames reference them).
481    fn write_datums(&mut self, pmi: &StepPmi<'_>) {
482        for view in &pmi.report.views {
483            for report in &view.annotations {
484                let PmiGeometry::Datum { letter, .. } = &report.geometry else {
485                    continue;
486                };
487                if report.status != PmiStatus::Ok || !report.enabled || self.datums.contains_key(letter) {
488                    continue;
489                }
490                let Some(name) = report.references.first() else {
491                    continue;
492                };
493                let Some(item) = self.item(name) else {
494                    continue;
495                };
496                let datum = self.add(format!(
497                    "DATUM('{}','',#{},.F.,'{}')",
498                    step_text(&report.id),
499                    item.owner.product_shape,
500                    step_text(letter)
501                ));
502                let feature = self.add(format!(
503                    "DATUM_FEATURE('{}','',#{},.T.)",
504                    step_text(name),
505                    item.owner.product_shape
506                ));
507                self.link_aspect(feature, item, "datum feature");
508                self.add(format!("ID_ATTRIBUTE('{}',#{feature})", step_text(name)));
509                self.add(format!("SHAPE_ASPECT_RELATIONSHIP('','',#{feature},#{datum})"));
510                self.datums.insert(letter.clone(), datum);
511                self.aspects.entry(name.clone()).or_insert(feature);
512            }
513        }
514    }
515
516    /// The presentation of one annotation: its callout (polylines + text).
517    fn callout(
518        &mut self,
519        report: &crate::feature_pipeline::pmi::PmiAnnotationReport,
520        style: &LayoutStyle,
521    ) -> Result<CalloutOut, String> {
522        let drawn = present(&report.geometry, report.label_world, &report.text, style);
523        let name = step_text(&report.id);
524        let set_name = step_text(&curve_set_name(report));
525        let polyline = |emitter: &mut Self, points: &[[f64; 3]], stats: &mut PolylineStats| -> Result<Option<usize>, String> {
526            if points.len() < 2 {
527                return Ok(None);
528            }
529            stats.add(points);
530            let mut ids = Vec::with_capacity(points.len());
531            for point in points {
532                ids.push(write_point(emitter.writer, Vec3::new(point[0], point[1], point[2]))?);
533            }
534            Ok(Some(emitter.add(format!("POLYLINE('{name}',{})", id_list(&ids)))))
535        };
536        // Geometry subset: dimension / extension / leader lines, arrowheads
537        // (closed), frames.
538        let mut geometry_stats = PolylineStats::default();
539        let mut curves = Vec::new();
540        for line in &drawn.polylines {
541            if let Some(id) = polyline(self, line, &mut geometry_stats)? {
542                curves.push(id);
543            }
544        }
545        for arrow in &drawn.arrows {
546            let closed = [arrow[0], arrow[1], arrow[2], arrow[0]];
547            if let Some(id) = polyline(self, &closed, &mut geometry_stats)? {
548                curves.push(id);
549            }
550        }
551        for frame in &drawn.frames {
552            if let Some(id) = polyline(self, frame, &mut geometry_stats)? {
553                curves.push(id);
554            }
555        }
556        // Text subset: the runs stroked with the PMI font (Graphic
557        // Presentation — the practice's character-based TEXT_LITERAL route is
558        // shelved, so the text IS polylines like everything else).
559        let mut text_stats = PolylineStats::default();
560        let mut text_curves = Vec::new();
561        for run in &drawn.texts {
562            for stroke in run.strokes() {
563                if let Some(id) = polyline(self, &stroke, &mut text_stats)? {
564                    text_curves.push(id);
565                }
566            }
567        }
568        let mut contents = Vec::new();
569        let subset = |emitter: &mut Self, curves: &[usize]| -> Option<usize> {
570            if curves.is_empty() {
571                return None;
572            }
573            let set = emitter.add(format!("GEOMETRIC_CURVE_SET('{set_name}',{})", id_list(curves)));
574            Some(emitter.add(format!(
575                "ANNOTATION_CURVE_OCCURRENCE('{name}',(#{}),#{set})",
576                emitter.curve_style
577            )))
578        };
579        if let Some(id) = subset(self, &curves) {
580            contents.push(id);
581        }
582        let text_subset = subset(self, &text_curves);
583        contents.extend(text_subset);
584        let id = self.add(format!("DRAUGHTING_CALLOUT('{name}',{})", id_list(&contents)));
585        let text = drawn
586            .texts
587            .iter()
588            .map(|run| run.text.as_str())
589            .collect::<Vec<_>>()
590            .join(" ");
591        let mut total = geometry_stats;
592        total.merge(&text_stats);
593        Ok(CalloutOut {
594            id,
595            text,
596            total,
597            text_subset,
598            text_stats,
599        })
600    }
601
602    /// The PMI validation properties of a callout (practice §10.3, combined
603    /// per §10.3.4): the polyline curve length, the polyline centre point
604    /// and the equivalent unicode string, at the callout, plus the centre
605    /// point and string of its text subset (what gives a reader the label
606    /// position back).
607    fn validation(&mut self, callout: &CalloutOut, global: usize) -> Result<(), String> {
608        let unicode = step_text(&callout.text);
609        let length_unit = self.context.length_unit;
610        let geometry_context = self.context.geometry_context;
611        let property = |emitter: &mut Self, item: usize, stats: &PolylineStats, with_length: bool| -> Result<(), String> {
612            let within = emitter.add(format!(
613                "CHARACTERIZED_ITEM_WITHIN_REPRESENTATION('','',#{item},#{global})"
614            ));
615            let definition = emitter.add(format!("PROPERTY_DEFINITION('pmi validation property','',#{within})"));
616            let mut items = Vec::new();
617            if with_length {
618                items.push(emitter.add(format!(
619                    "MEASURE_REPRESENTATION_ITEM('polyline curve length',POSITIVE_LENGTH_MEASURE({}),#{length_unit})",
620                    real(stats.length)?
621                )));
622            }
623            let centre = stats.centroid();
624            items.push(emitter.add(format!(
625                "CARTESIAN_POINT('polyline centre point',({},{},{}))",
626                real(centre[0])?,
627                real(centre[1])?,
628                real(centre[2])?
629            )));
630            items.push(emitter.add(format!(
631                "DESCRIPTIVE_REPRESENTATION_ITEM('equivalent unicode string','{unicode}')"
632            )));
633            let representation = emitter.add(format!(
634                "REPRESENTATION('',{},#{geometry_context})",
635                id_list(&items)
636            ));
637            emitter.add(format!("PROPERTY_DEFINITION_REPRESENTATION(#{definition},#{representation})"));
638            Ok(())
639        };
640        property(self, callout.id, &callout.total, true)?;
641        if let Some(text_subset) = callout.text_subset {
642            property(self, text_subset, &callout.text_stats, false)?;
643        }
644        Ok(())
645    }
646}
647
648/// A written callout with what its validation properties need.
649struct CalloutOut {
650    /// The `DRAUGHTING_CALLOUT`.
651    id: usize,
652    /// The equivalent unicode string (the runs joined).
653    text: String,
654    total: PolylineStats,
655    /// The text subset's `ANNOTATION_CURVE_OCCURRENCE`, when there is text.
656    text_subset: Option<usize>,
657    text_stats: PolylineStats,
658}
659
660/// Length-weighted polyline statistics (practice §10.3.1): the total curve
661/// length and the centroid of the segment midpoints weighted by length.
662#[derive(Debug, Clone, Copy, Default)]
663struct PolylineStats {
664    length: f64,
665    moment: [f64; 3],
666    /// A fallback for zero-length sets: the first point.
667    first: Option<[f64; 3]>,
668}
669
670impl PolylineStats {
671    fn add(&mut self, points: &[[f64; 3]]) {
672        if self.first.is_none() {
673            self.first = points.first().copied();
674        }
675        for pair in points.windows(2) {
676            let (a, b) = (pair[0], pair[1]);
677            let length = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2) + (b[2] - a[2]).powi(2)).sqrt();
678            self.length += length;
679            for axis in 0..3 {
680                self.moment[axis] += (a[axis] + b[axis]) * 0.5 * length;
681            }
682        }
683    }
684
685    fn merge(&mut self, other: &PolylineStats) {
686        self.length += other.length;
687        for axis in 0..3 {
688            self.moment[axis] += other.moment[axis];
689        }
690        if self.first.is_none() {
691            self.first = other.first;
692        }
693    }
694
695    fn centroid(&self) -> [f64; 3] {
696        if self.length > 1e-12 {
697            [self.moment[0] / self.length, self.moment[1] / self.length, self.moment[2] / self.length]
698        } else {
699            self.first.unwrap_or([0.0; 3])
700        }
701    }
702}
703
704/// The `GEOMETRIC_CURVE_SET` name — the presented PMI type from the
705/// practice's Table 14 (not semantic; a tree label for the reader).
706fn curve_set_name(report: &crate::feature_pipeline::pmi::PmiAnnotationReport) -> String {
707    match report.kind.as_str() {
708        "linear" => "linear dimension".into(),
709        "radial" => match &report.geometry {
710            PmiGeometry::Radial { diameter: false, .. } => "radial dimension".into(),
711            _ => "diameter dimension".into(),
712        },
713        "angle" => "angular dimension".into(),
714        "holeCallout" => "diameter dimension".into(),
715        "datum" => "datum".into(),
716        "fcf" => match &report.geometry {
717            PmiGeometry::Fcf { frame, .. } => match frame.characteristic.as_str() {
718                "profileLine" => "profile of line".into(),
719                "profileSurface" => "profile of surface".into(),
720                "circularRunout" => "circular runout".into(),
721                "totalRunout" => "total runout".into(),
722                other => other.to_ascii_lowercase(),
723            },
724            _ => "general tolerance".into(),
725        },
726        _ => "note".into(),
727    }
728}
729
730/// The AP242 enumeration for a material condition modifier.
731fn material_condition(modifier: &str) -> Option<&'static str> {
732    match modifier.trim().to_ascii_uppercase().as_str() {
733        "MMC" => Some(".MAXIMUM_MATERIAL_REQUIREMENT."),
734        "LMC" => Some(".LEAST_MATERIAL_REQUIREMENT."),
735        _ => None,
736    }
737}
738
739/// The camera of a view as a `CAMERA_MODEL_D3` (+ its view volume).
740fn write_camera(
741    emitter: &mut Emitter<'_, '_>,
742    name: &str,
743    camera: &crate::feature_pipeline::pmi::PmiCamera,
744) -> Result<usize, String> {
745    let eye = Vec3::new(camera.eye[0], camera.eye[1], camera.eye[2]);
746    let target = Vec3::new(camera.target[0], camera.target[1], camera.target[2]);
747    let view = camera.view_direction();
748    let view = Vec3::new(view[0], view[1], view[2]);
749    let up_hint = Vec3::new(camera.up[0], camera.up[1], camera.up[2]);
750    let up = crate::feature_pipeline::pmi::resolve::perpendicular_in_plane(view, up_hint);
751    // Right-handed frame: x × y = z with z = the viewing direction.
752    let right = up.cross(view).normalized().unwrap_or(Vec3::new(1.0, 0.0, 0.0));
753    let distance = target.sub(eye).length().max(1e-6);
754    let aspect = if camera.viewport[1] > 0.0 {
755        camera.viewport[0] / camera.viewport[1]
756    } else {
757        1.5
758    };
759    let (projection, height) = match camera.projection {
760        PmiProjection::Orthographic { half_height } => (".PARALLEL.", 2.0 * half_height),
761        PmiProjection::Perspective { fov_y_deg } => {
762            (".CENTRAL.", 2.0 * distance * (fov_y_deg.to_radians() * 0.5).tan())
763        }
764    };
765    let width = height * aspect;
766    let corner = emitter.add(format!(
767        "CARTESIAN_POINT('',({},{}))",
768        real(-width * 0.5)?,
769        real(-height * 0.5)?
770    ));
771    let window_placement = emitter.add(format!("AXIS2_PLACEMENT_2D('',#{corner},$)"));
772    let window = emitter.add(format!(
773        "PLANAR_BOX('',{},{},#{window_placement})",
774        real(width)?,
775        real(height)?
776    ));
777    let projection_point = write_point(emitter.writer, Vec3::new(0.0, 0.0, 0.0))?;
778    let volume = emitter.add(format!(
779        "VIEW_VOLUME({projection},#{projection_point},{},{},.F.,{},.F.,.T.,#{window})",
780        real(distance)?,
781        real(0.0)?,
782        real(distance * 2.0)?
783    ));
784    let reference = write_placement(emitter.writer, eye, view, right)?;
785    Ok(emitter.add(format!(
786        "CAMERA_MODEL_D3('{}',#{reference},#{volume})",
787        step_text(name)
788    )))
789}
790
791/// Write the whole PMI block. Called after `SHAPE_DEFINITION_REPRESENTATION`.
792/// Write the document's PMI, returning how many DISTINCT reference names named
793/// no entity in the file (every annotation on such a name is skipped).
794pub(crate) fn write_pmi(
795    writer: &mut StepWriter,
796    context: &StepContext<'_>,
797    pmi: &StepPmi<'_>,
798) -> Result<usize, String> {
799    if pmi.state.views.is_empty() {
800        return Ok(0);
801    }
802    let env = Env::build("", &serde_json::Value::Null).unwrap_or_else(Env::poisoned);
803    // The presentation entities here are the SHARED ones (`step/styles.rs`), so
804    // annotation style and geometry colour cannot drift into two spellings of
805    // the same AP242 chain.
806    let null_style = write_style_assignment(writer, "NULL_STYLE(.NULL.)");
807    let colour = write_colour_rgb(writer, [0.0, 0.0, 0.0])?;
808    let curve_font = writer.add("DRAUGHTING_PRE_DEFINED_CURVE_FONT('continuous')");
809    let curve_style = writer.add(format!(
810        "CURVE_STYLE('',#{curve_font},POSITIVE_LENGTH_MEASURE({}),#{colour})",
811        real(LINE_WIDTH)?
812    ));
813    let curve_style = write_style_assignment(writer, &format!("#{curve_style}"));
814    let mut emitter = Emitter {
815        writer,
816        context,
817        aspects: HashMap::default(),
818        datums: HashMap::default(),
819        unresolved: std::collections::BTreeSet::new(),
820        null_style,
821        curve_style,
822    };
823    // The shape, mapped into the draughting models with a null style.
824    let identity = write_placement(
825        emitter.writer,
826        Vec3::new(0.0, 0.0, 0.0),
827        Vec3::new(0.0, 0.0, 1.0),
828        Vec3::new(1.0, 0.0, 0.0),
829    )?;
830    let map = emitter.add(format!("REPRESENTATION_MAP(#{identity},#{})", context.representation));
831    let mapped = emitter.add(format!("MAPPED_ITEM('',#{map},#{identity})"));
832    let styled_shape = write_styled_item(emitter.writer, "", null_style, mapped);
833
834    emitter.write_datums(pmi);
835
836    // Per view: semantic elements, callouts, the annotation plane.
837    struct ViewOut {
838        name: String,
839        /// The view-aligned annotation plane, then one per picked plane.
840        planes: Vec<usize>,
841        camera: Option<usize>,
842        callouts: Vec<CalloutOut>,
843        links: Vec<(usize, usize)>,
844    }
845    let mut views_out: Vec<ViewOut> = Vec::new();
846    for view in &pmi.state.views {
847        let Some(view_report) = pmi.report.view(&view.id) else {
848            continue;
849        };
850        let camera = view.camera.as_ref();
851        let (view_dir, view_up, target) = match camera {
852            Some(camera) => (camera.view_direction(), camera.up, camera.target),
853            None => ([0.0, 0.0, -1.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]),
854        };
855        let text_height = view.display.text_size_pt * MM_PER_POINT;
856        let style = LayoutStyle {
857            arrow: text_height * 0.9,
858            text_height,
859            view_dir,
860            view_up,
861            plane: None,
862        };
863        let view_vec = Vec3::new(view_dir[0], view_dir[1], view_dir[2]);
864        let up_vec = crate::feature_pipeline::pmi::resolve::perpendicular_in_plane(
865            view_vec,
866            Vec3::new(view_up[0], view_up[1], view_up[2]),
867        );
868        let right = up_vec.cross(view_vec.scale(-1.0)).normalized().unwrap_or(Vec3::new(1.0, 0.0, 0.0));
869        // View-aligned callouts go on the view's annotation plane; callouts
870        // in a picked plane group per plane reference (one ANNOTATION_PLANE
871        // each, placed ON that plane).
872        let mut callouts = Vec::new();
873        let mut view_aligned = Vec::new();
874        let mut plane_groups: Vec<(String, crate::feature_pipeline::pmi::PmiPlane, Vec<usize>)> = Vec::new();
875        let mut links = Vec::new();
876        for (annotation, report) in view.annotations.iter().zip(view_report.annotations.iter()) {
877            if report.status != PmiStatus::Ok || !report.enabled {
878                continue;
879            }
880            if matches!(report.geometry, PmiGeometry::Explode { .. } | PmiGeometry::Hide { .. } | PmiGeometry::None) {
881                continue;
882            }
883            let semantic = emitter.semantic(annotation, report, &env)?;
884            // A view-aligned row is drawn in the view-parallel plane through
885            // its label (practice §9.1: polylines lie in a plane parallel to
886            // their ANNOTATION_PLANE); a picked plane is used as is.
887            let row_style = LayoutStyle {
888                plane: report.plane.or(Some(crate::feature_pipeline::pmi::PmiPlane {
889                    origin: report.label_world,
890                    normal: [-view_dir[0], -view_dir[1], -view_dir[2]],
891                    x_axis: [right.x, right.y, right.z],
892                })),
893                ..style
894            };
895            let callout = emitter.callout(report, &row_style)?;
896            let callout_id = callout.id;
897            callouts.push(callout);
898            match report.plane {
899                Some(plane) => {
900                    let key = annotation.plane_ref().unwrap_or("").to_string();
901                    match plane_groups.iter_mut().find(|(name, _, _)| *name == key) {
902                        Some(group) => group.2.push(callout_id),
903                        None => plane_groups.push((key, plane, vec![callout_id])),
904                    }
905                }
906                None => view_aligned.push(callout_id),
907            }
908            if let Some(semantic) = semantic {
909                links.push((semantic, callout_id));
910            }
911        }
912        let placement = write_placement(
913            emitter.writer,
914            Vec3::new(target[0], target[1], target[2]),
915            view_vec.scale(-1.0),
916            right,
917        )?;
918        let plane_geometry = emitter.add(format!("PLANE('',#{placement})"));
919        let mut planes = vec![emitter.add(format!(
920            "ANNOTATION_PLANE('{}',(#{null_style}),#{plane_geometry},{})",
921            step_text(&view.name),
922            id_list(&view_aligned)
923        ))];
924        for (key, plane, members) in &plane_groups {
925            let placement = write_placement(
926                emitter.writer,
927                Vec3::new(plane.origin[0], plane.origin[1], plane.origin[2]),
928                Vec3::new(plane.normal[0], plane.normal[1], plane.normal[2]),
929                Vec3::new(plane.x_axis[0], plane.x_axis[1], plane.x_axis[2]),
930            )?;
931            let geometry = emitter.add(format!("PLANE('',#{placement})"));
932            planes.push(emitter.add(format!(
933                "ANNOTATION_PLANE('{}',(#{null_style}),#{geometry},{})",
934                step_text(&format!("{} / {key}", view.name)),
935                id_list(members)
936            )));
937        }
938        let camera_id = match camera {
939            Some(camera) => Some(write_camera(&mut emitter, &view.name, camera)?),
940            None => None,
941        };
942        views_out.push(ViewOut {
943            name: view.name.clone(),
944            planes,
945            camera: camera_id,
946            callouts,
947            links,
948        });
949    }
950
951    // The global draughting model: every annotation plane + the shape.
952    let mut global_items = vec![styled_shape];
953    global_items.extend(views_out.iter().flat_map(|view| view.planes.iter().copied()));
954    let global = emitter.add(format!(
955        "DRAUGHTING_MODEL('',{},#{})",
956        id_list(&global_items),
957        context.geometry_context
958    ));
959    for view in &views_out {
960        let mut items = vec![styled_shape];
961        if let Some(camera) = view.camera {
962            items.push(camera);
963        }
964        items.extend(view.callouts.iter().map(|callout| callout.id));
965        let model = emitter.add(format!(
966            "DRAUGHTING_MODEL('{}',{},#{})",
967            step_text(&view.name),
968            id_list(&items),
969            context.geometry_context
970        ));
971        emitter.add(format!(
972            "MECHANICAL_DESIGN_AND_DRAUGHTING_RELATIONSHIP('','',#{model},#{global})"
973        ));
974        for (semantic, callout) in &view.links {
975            emitter.add(format!(
976                "DRAUGHTING_MODEL_ITEM_ASSOCIATION('PMI representation to presentation link','',#{semantic},#{global},#{callout})"
977            ));
978        }
979    }
980    // PMI validation properties (§10.3) hang off the global model.
981    for view in &views_out {
982        for callout in &view.callouts {
983            emitter.validation(callout, global)?;
984        }
985    }
986    Ok(emitter.unresolved.len())
987}