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