Skip to main content

cadmpeg_codec_step/
export.rs

1// SPDX-License-Identifier: Apache-2.0
2//! IR → STEP Part 21 builder.
3
4use std::collections::{BTreeMap, BTreeSet, HashMap};
5use std::io::Write;
6
7use cadmpeg_ir::appearance::{Appearance, AppearanceTarget};
8use cadmpeg_ir::geometry::{
9    Curve, CurveGeometry, Pcurve, ProceduralCurve, ProceduralCurveDefinition, ProceduralSurface,
10    ProceduralSurfaceDefinition, Surface, SurfaceGeometry,
11};
12use cadmpeg_ir::ids::{OccurrenceId, ProductDefinitionId};
13use cadmpeg_ir::pmi::{
14    DimensionKind, GeometricToleranceKind, PmiDefinition, PmiQuantity, PmiTarget,
15};
16use cadmpeg_ir::presentation::PresentationItem;
17use cadmpeg_ir::products::{AssemblyGraph, OccurrenceParent, PrototypeReference};
18use cadmpeg_ir::report::{ExportReport, LossNote};
19use cadmpeg_ir::topology::{
20    Body, BodyKind, Coedge, Edge, Face, Loop, LoopBoundaryRole, Point, Sense, Shell, Vertex,
21};
22use cadmpeg_ir::CadIr;
23use cadmpeg_ir::{FidelityResolution, WritePath};
24
25use crate::error::StepError;
26use crate::geometry;
27use crate::loss::StepLossCode;
28use crate::options::{StepSchema, StepUnsupportedPolicy, StepWriteOptions};
29use crate::writer::{real, refs, string, Emitter, Ref};
30
31const EPS_IDENTITY: f64 = 1e-12;
32
33/// Serializes an IR document as an ISO 10303-21 STEP Part 21 file for the
34/// schema selected by [`StepWriteOptions::schema`].
35///
36/// The output declares that schema and a millimetre length unit. Coordinate
37/// values are not rescaled. The IR linear tolerance becomes the representation
38/// context's uncertainty value.
39///
40/// Geometry conversion completes before this function writes the header. Under
41/// [`StepUnsupportedPolicy::Reject`], unsupported content returns
42/// [`StepError::Unsupported`] before any output byte is written. Otherwise the
43/// function streams the header, DATA instances, and closing records to `w`. An
44/// I/O error can therefore leave a partial file and returns no report.
45///
46/// On success, the report contains DATA entity counts and loss notes for
47/// reductions that the selected schema cannot carry.
48pub fn write_step(
49    ir: &CadIr,
50    w: &mut (impl Write + ?Sized),
51    opts: &StepWriteOptions,
52) -> Result<ExportReport, StepError> {
53    let mut b = Builder::new(ir, opts.schema);
54    b.build();
55    let report = b.finish_report();
56    let lines = b.emitter.into_lines();
57
58    if opts.unsupported == StepUnsupportedPolicy::Reject && !report.losses.is_empty() {
59        return Err(StepError::Unsupported(
60            report
61                .losses
62                .iter()
63                .map(|loss| loss.message.as_str())
64                .collect::<Vec<_>>()
65                .join("; "),
66        ));
67    }
68
69    write_header(w, opts)?;
70    writeln!(w, "DATA;")?;
71    for line in &lines {
72        writeln!(w, "{line}")?;
73    }
74    writeln!(w, "ENDSEC;")?;
75    writeln!(w, "END-ISO-10303-21;")?;
76    Ok(report)
77}
78
79fn write_header(w: &mut (impl Write + ?Sized), opts: &StepWriteOptions) -> std::io::Result<()> {
80    let ts = if opts.timestamp.is_empty() {
81        "1970-01-01T00:00:00"
82    } else {
83        &opts.timestamp
84    };
85    writeln!(w, "ISO-10303-21;")?;
86    writeln!(w, "HEADER;")?;
87    writeln!(
88        w,
89        "FILE_DESCRIPTION(({}),'2;1');",
90        string("CAD model exported by cadmpeg")
91    )?;
92    writeln!(
93        w,
94        "FILE_NAME({},{},({}),({}),{},{},{});",
95        string(&opts.product_name),
96        string(ts),
97        string(&opts.author),
98        string(&opts.organization),
99        string("cadmpeg-step"),
100        string(&opts.originating_system),
101        string("")
102    )?;
103    writeln!(w, "FILE_SCHEMA(({}));", string(opts.schema.file_schema()))?;
104    writeln!(w, "ENDSEC;")?;
105    Ok(())
106}
107
108#[derive(Clone, Copy)]
109struct ColorSpec<'a> {
110    color: cadmpeg_ir::topology::Color,
111    appearance: Option<&'a Appearance>,
112    binding_id: Option<&'a str>,
113}
114
115struct LoopSegment {
116    coedge_id: String,
117    start_vertex: String,
118    end_vertex: String,
119}
120
121pub(crate) struct Builder<'a> {
122    ir: &'a CadIr,
123    schema: StepSchema,
124    emitter: Emitter,
125    losses: Vec<LossNote>,
126    notes: Vec<String>,
127
128    points: HashMap<&'a str, &'a Point>,
129    bodies: HashMap<&'a str, &'a Body>,
130    shells: HashMap<&'a str, &'a Shell>,
131    faces: HashMap<&'a str, &'a Face>,
132    loops: HashMap<&'a str, &'a Loop>,
133    vertices: HashMap<&'a str, &'a Vertex>,
134    edges: HashMap<&'a str, &'a Edge>,
135    coedges: HashMap<&'a str, &'a Coedge>,
136    surfaces: HashMap<&'a str, &'a Surface>,
137    curves: HashMap<&'a str, &'a Curve>,
138    pcurves: HashMap<&'a str, &'a Pcurve>,
139    procedural_surfaces: HashMap<&'a str, &'a ProceduralSurface>,
140    procedural_curves: HashMap<&'a str, &'a ProceduralCurve>,
141    edge_coedges: HashMap<&'a str, Vec<(&'a str, &'a str)>>,
142
143    surface_refs: HashMap<String, Ref>,
144    curve_refs: HashMap<String, Ref>,
145    edge_refs: HashMap<String, Ref>,
146    vertex_refs: HashMap<String, Ref>,
147    point_refs: HashMap<String, Ref>,
148    pcurve_context: Option<Ref>,
149    active_surfaces: BTreeSet<String>,
150    pub(crate) active_curves: BTreeSet<String>,
151    written_procedural_surfaces: BTreeSet<String>,
152    written_procedural_curves: BTreeSet<String>,
153
154    /// Edges skipped because they carry no attributed 3D curve, deduplicated
155    /// (a shared edge is reached once per coedge) and aggregated into a single
156    /// counted loss note.
157    curveless_edges: BTreeSet<String>,
158
159    /// Faces skipped because their surface geometry is unknown (opaque), so no
160    /// STEP surface exists to build an `ADVANCED_FACE` on. Deduplicated (a face
161    /// is reached once per shell) and aggregated into a single counted loss.
162    unknown_surface_faces: BTreeSet<String>,
163
164    /// Individual enclosing topology relations that could not be emitted.
165    topology_relation_losses: BTreeSet<String>,
166
167    face_step_refs: HashMap<String, Ref>,
168    /// First emitted exact solid or shell for each body, used by AP242 tessellation links.
169    body_step_refs: HashMap<String, Ref>,
170    default_product_definition_shape: Option<Ref>,
171    body_shape_refs: HashMap<String, Ref>,
172    pub(crate) body_item_refs: HashMap<String, Vec<Ref>>,
173    body_step_item_refs: HashMap<String, Vec<Ref>>,
174    product_step_refs: HashMap<String, Ref>,
175    occurrence_step_refs: HashMap<String, Ref>,
176    tessellation_step_refs: HashMap<String, Ref>,
177    pmi_step_refs: HashMap<String, Ref>,
178    written_appearance_bindings: BTreeSet<String>,
179    unstyled_colors: usize,
180    unwritten_geometry_carriers: BTreeSet<String>,
181    unwritten_pcurve_carriers: BTreeSet<String>,
182    missing_parent_products: BTreeSet<String>,
183    empty_regions: BTreeSet<String>,
184    empty_wire_regions: BTreeSet<String>,
185    missing_wire_shells: BTreeSet<(String, String)>,
186    hidden_bodies_without_items: BTreeSet<String>,
187    dangling_appearance_bindings: BTreeSet<(String, String)>,
188    colorless_appearance_bindings: BTreeSet<(String, String)>,
189    written_pmi: usize,
190    length_unit: Option<Ref>,
191    angle_unit: Option<Ref>,
192    ratio_unit: Option<Ref>,
193    geometry_emission_depth: usize,
194}
195
196impl<'a> Builder<'a> {
197    pub(crate) fn new(ir: &'a CadIr, schema: StepSchema) -> Self {
198        let loop_surfaces = ir
199            .model
200            .faces
201            .iter()
202            .flat_map(|face| {
203                face.loops
204                    .iter()
205                    .map(move |loop_id| (loop_id.as_str(), face.surface.as_str()))
206            })
207            .collect::<HashMap<_, _>>();
208        let coedge_surfaces: HashMap<&str, &str> = ir
209            .model
210            .loops
211            .iter()
212            .filter_map(|loop_| {
213                loop_surfaces
214                    .get(loop_.id.as_str())
215                    .map(|surface| (loop_, *surface))
216            })
217            .flat_map(|(loop_, surface)| {
218                loop_
219                    .coedges
220                    .iter()
221                    .map(move |coedge| (coedge.as_str(), surface))
222            })
223            .collect();
224        let mut edge_coedges = HashMap::<&str, Vec<(&str, &str)>>::new();
225        for coedge in &ir.model.coedges {
226            let Some(surface) = coedge_surfaces.get(coedge.id.as_str()) else {
227                continue;
228            };
229            for pcurve in &coedge.pcurves {
230                edge_coedges
231                    .entry(coedge.edge.as_str())
232                    .or_default()
233                    .push((pcurve.pcurve.as_str(), *surface));
234            }
235        }
236        Builder {
237            ir,
238            schema,
239            emitter: Emitter::new(),
240            losses: Vec::new(),
241            notes: Vec::new(),
242            points: ir.model.points.iter().map(|p| (p.id.as_str(), p)).collect(),
243            bodies: ir
244                .model
245                .bodies
246                .iter()
247                .map(|body| (body.id.as_str(), body))
248                .collect(),
249            shells: ir
250                .model
251                .shells
252                .iter()
253                .map(|shell| (shell.id.as_str(), shell))
254                .collect(),
255            faces: ir
256                .model
257                .faces
258                .iter()
259                .map(|face| (face.id.as_str(), face))
260                .collect(),
261            loops: ir
262                .model
263                .loops
264                .iter()
265                .map(|loop_| (loop_.id.as_str(), loop_))
266                .collect(),
267            vertices: ir
268                .model
269                .vertices
270                .iter()
271                .map(|v| (v.id.as_str(), v))
272                .collect(),
273            edges: ir.model.edges.iter().map(|e| (e.id.as_str(), e)).collect(),
274            coedges: ir
275                .model
276                .coedges
277                .iter()
278                .map(|c| (c.id.as_str(), c))
279                .collect(),
280            surfaces: ir
281                .model
282                .surfaces
283                .iter()
284                .map(|s| (s.id.as_str(), s))
285                .collect(),
286            curves: ir.model.curves.iter().map(|c| (c.id.as_str(), c)).collect(),
287            pcurves: ir
288                .model
289                .pcurves
290                .iter()
291                .map(|p| (p.id.as_str(), p))
292                .collect(),
293            procedural_surfaces: ir
294                .model
295                .procedural_surfaces
296                .iter()
297                .map(|surface| (surface.surface.as_str(), surface))
298                .collect(),
299            procedural_curves: ir
300                .model
301                .procedural_curves
302                .iter()
303                .map(|curve| (curve.curve.as_str(), curve))
304                .collect(),
305            edge_coedges,
306            surface_refs: HashMap::new(),
307            curve_refs: HashMap::new(),
308            edge_refs: HashMap::new(),
309            vertex_refs: HashMap::new(),
310            point_refs: HashMap::new(),
311            pcurve_context: None,
312            active_surfaces: BTreeSet::new(),
313            active_curves: BTreeSet::new(),
314            written_procedural_surfaces: BTreeSet::new(),
315            written_procedural_curves: BTreeSet::new(),
316            curveless_edges: BTreeSet::new(),
317            unknown_surface_faces: BTreeSet::new(),
318            topology_relation_losses: BTreeSet::new(),
319            face_step_refs: HashMap::new(),
320            body_step_refs: HashMap::new(),
321            default_product_definition_shape: None,
322            body_shape_refs: HashMap::new(),
323            body_item_refs: HashMap::new(),
324            body_step_item_refs: HashMap::new(),
325            product_step_refs: HashMap::new(),
326            occurrence_step_refs: HashMap::new(),
327            tessellation_step_refs: HashMap::new(),
328            pmi_step_refs: HashMap::new(),
329            written_appearance_bindings: BTreeSet::new(),
330            unstyled_colors: 0,
331            unwritten_geometry_carriers: BTreeSet::new(),
332            unwritten_pcurve_carriers: BTreeSet::new(),
333            missing_parent_products: BTreeSet::new(),
334            empty_regions: BTreeSet::new(),
335            empty_wire_regions: BTreeSet::new(),
336            missing_wire_shells: BTreeSet::new(),
337            hidden_bodies_without_items: BTreeSet::new(),
338            dangling_appearance_bindings: BTreeSet::new(),
339            colorless_appearance_bindings: BTreeSet::new(),
340            written_pmi: 0,
341            length_unit: None,
342            angle_unit: None,
343            ratio_unit: None,
344            geometry_emission_depth: 0,
345        }
346    }
347
348    fn loss(&mut self, code: StepLossCode, message: String) {
349        self.losses.push(code.note(message));
350    }
351
352    fn topology_relation_loss(&mut self, identity: String, code: StepLossCode, message: String) {
353        if self.topology_relation_losses.insert(identity) {
354            self.loss(code, message);
355        }
356    }
357
358    pub(crate) fn build(&mut self) {
359        let context = self.emit_context();
360
361        let shape_items = self.emit_shape_items(context);
362        let mut standalone_items = self.emit_standalone_geometry();
363        let has_standalone_geometry = !standalone_items.is_empty();
364        let mut emitted_items = shape_items;
365        emitted_items.extend(standalone_items.iter().copied());
366        if emitted_items.is_empty() && !self.ir.model.bodies.is_empty() {
367            self.loss(
368                StepLossCode::NoExportableSolids,
369                "no exportable solids: the IR document contains no body/region/shell \
370                          geometry, so the STEP representation is empty"
371                    .to_string(),
372            );
373            emitted_items.clear();
374        }
375        let mut items = emitted_items;
376        let origin = geometry::placement(
377            &mut self.emitter,
378            cadmpeg_ir::math::Point3::new(0.0, 0.0, 0.0),
379            cadmpeg_ir::math::Vector3::new(0.0, 0.0, 1.0),
380            cadmpeg_ir::math::Vector3::new(1.0, 0.0, 0.0),
381        );
382        items.push(origin);
383
384        if self.ir.model.product_definitions.is_empty() {
385            let product_def_shape = self.emit_product_structure();
386            self.default_product_definition_shape = Some(product_def_shape);
387            let representation_kind = if !has_standalone_geometry
388                && !self.ir.model.bodies.is_empty()
389                && self.ir.model.bodies.iter().all(|body| {
390                    body.transform
391                        .is_none_or(|transform| is_identity(&transform.rows))
392                })
393                && self
394                    .ir
395                    .model
396                    .bodies
397                    .iter()
398                    .all(|body| body.kind == BodyKind::Solid)
399            {
400                "ADVANCED_BREP_SHAPE_REPRESENTATION"
401            } else {
402                "SHAPE_REPRESENTATION"
403            };
404            let representation = self.emitter.emit(
405                representation_kind,
406                &format!("'',{},{context}", refs(&items)),
407            );
408            self.emitter.emit(
409                "SHAPE_DEFINITION_REPRESENTATION",
410                &format!("{product_def_shape},{representation}"),
411            );
412        } else {
413            self.emit_product_graph(context);
414            if has_standalone_geometry {
415                standalone_items.push(origin);
416                self.emitter.emit(
417                    "SHAPE_REPRESENTATION",
418                    &format!(
419                        "{},{},{context}",
420                        string("standalone geometry"),
421                        refs(&standalone_items)
422                    ),
423                );
424            }
425        }
426
427        self.emit_visibility();
428        self.emit_tessellations(context);
429        self.emit_presentation(context);
430        self.emit_pmi(context);
431        self.emit_layers();
432        self.note_unrepresented();
433    }
434
435    fn emit_presentation(&mut self, context: Ref) {
436        let ir = self.ir;
437        let appearances: HashMap<&str, &Appearance> = ir
438            .model
439            .appearances
440            .iter()
441            .map(|appearance| (appearance.id.as_str(), appearance))
442            .collect();
443        let mut body_colors: HashMap<&str, ColorSpec<'_>> = HashMap::new();
444        let mut face_colors: HashMap<&str, ColorSpec<'_>> = HashMap::new();
445        let mut dangling_appearance_bindings = BTreeSet::new();
446        let mut colorless_appearance_bindings = BTreeSet::new();
447        for binding in &ir.model.appearance_bindings {
448            let Some(appearance) = appearances.get(binding.appearance.as_str()).copied() else {
449                dangling_appearance_bindings
450                    .insert((binding.id.clone(), binding.appearance.0.clone()));
451                continue;
452            };
453            let Some(color) = appearance.base_color else {
454                colorless_appearance_bindings
455                    .insert((binding.id.clone(), binding.appearance.0.clone()));
456                continue;
457            };
458            let spec = ColorSpec {
459                color,
460                appearance: Some(appearance),
461                binding_id: Some(&binding.id),
462            };
463            match &binding.target {
464                AppearanceTarget::Body(id) => {
465                    body_colors.entry(id.as_str()).or_insert(spec);
466                }
467                AppearanceTarget::Face(id) => {
468                    face_colors.entry(id.as_str()).or_insert(spec);
469                }
470                AppearanceTarget::Surface(_)
471                | AppearanceTarget::Curve(_)
472                | AppearanceTarget::Point(_)
473                | AppearanceTarget::Edge(_)
474                | AppearanceTarget::Vertex(_)
475                | AppearanceTarget::Tessellation(_)
476                | AppearanceTarget::Source { .. } => {}
477            }
478        }
479        self.dangling_appearance_bindings
480            .extend(dangling_appearance_bindings);
481        self.colorless_appearance_bindings
482            .extend(colorless_appearance_bindings);
483        for body in &ir.model.bodies {
484            if let Some(color) = body.color {
485                body_colors.entry(body.id.as_str()).or_insert(ColorSpec {
486                    color,
487                    appearance: None,
488                    binding_id: None,
489                });
490            }
491        }
492        for face in &ir.model.faces {
493            if let Some(color) = face.color {
494                face_colors.entry(face.id.as_str()).or_insert(ColorSpec {
495                    color,
496                    appearance: None,
497                    binding_id: None,
498                });
499            }
500        }
501
502        let mut face_body: HashMap<&str, &str> = HashMap::new();
503        for region in &ir.model.regions {
504            let body = region.body.0.as_str();
505            for shell_id in &region.shells {
506                let Some(shell) = self.shells.get(shell_id.as_str()).copied() else {
507                    continue;
508                };
509                for face in &shell.faces {
510                    face_body.insert(face.0.as_str(), body);
511                }
512            }
513        }
514
515        // Face-level STYLED_ITEM only: OCCT/VTK viewers read colors from
516        // ADVANCED_FACE and ignore MANIFOLD_SOLID_BREP. Bodies with no face use
517        // the shape carrier for whole-body color.
518        let mut style_refs: HashMap<String, Ref> = HashMap::new();
519        let mut styled = Vec::new();
520        let mut faces: Vec<(String, Ref)> = self
521            .face_step_refs
522            .iter()
523            .map(|(id, r)| (id.clone(), *r))
524            .collect();
525        faces.sort_by(|a, b| a.0.cmp(&b.0));
526        let mut styled_bodies: BTreeSet<&str> = BTreeSet::new();
527        for (face_id, face) in &faces {
528            let own = face_colors.get(face_id.as_str()).copied();
529            let body = face_body.get(face_id.as_str()).copied();
530            let inherited = body.and_then(|b| body_colors.get(b).copied());
531            let Some(spec) = own.or(inherited) else {
532                continue;
533            };
534            // The body color is only counted as represented when a face without
535            // its own override receives it.
536            if own.is_none() {
537                if let Some(b) = body {
538                    styled_bodies.insert(b);
539                }
540            }
541            if let Some(binding_id) = spec.binding_id {
542                self.written_appearance_bindings
543                    .insert(binding_id.to_string());
544            }
545            let name = spec
546                .appearance
547                .and_then(|appearance| appearance.name.as_deref())
548                .unwrap_or("");
549            let style = self.surface_style(spec.color, name, &mut style_refs);
550            styled.push(
551                self.emitter
552                    .emit("STYLED_ITEM", &format!("'color',({style}),{face}")),
553            );
554        }
555        let mut direct_unstyled = BTreeSet::new();
556        for binding in &ir.model.appearance_bindings {
557            if self.written_appearance_bindings.contains(&binding.id) {
558                continue;
559            }
560            let Some(appearance) = appearances.get(binding.appearance.as_str()).copied() else {
561                continue;
562            };
563            let Some(color) = appearance.base_color else {
564                continue;
565            };
566            let (target, style_kind) = match &binding.target {
567                AppearanceTarget::Face(id) => {
568                    (self.face_step_refs.get(id.as_str()).copied(), "surface")
569                }
570                AppearanceTarget::Surface(id) => {
571                    (self.surface_refs.get(id.as_str()).copied(), "surface")
572                }
573                AppearanceTarget::Curve(id) => (self.curve_refs.get(id.as_str()).copied(), "curve"),
574                AppearanceTarget::Edge(id) => (self.edge_refs.get(id.as_str()).copied(), "curve"),
575                AppearanceTarget::Point(id) => (self.point_refs.get(id.as_str()).copied(), "point"),
576                AppearanceTarget::Vertex(id) => {
577                    (self.vertex_refs.get(id.as_str()).copied(), "point")
578                }
579                AppearanceTarget::Tessellation(id) => {
580                    (self.tessellation_step_refs.get(id).copied(), "surface")
581                }
582                AppearanceTarget::Body(_) | AppearanceTarget::Source { .. } => continue,
583            };
584            let Some(target) = target else {
585                let target_id = match &binding.target {
586                    AppearanceTarget::Face(id) => id.0.clone(),
587                    AppearanceTarget::Surface(id) => id.0.clone(),
588                    AppearanceTarget::Curve(id) => id.0.clone(),
589                    AppearanceTarget::Edge(id) => id.0.clone(),
590                    AppearanceTarget::Point(id) => id.0.clone(),
591                    AppearanceTarget::Vertex(id) => id.0.clone(),
592                    AppearanceTarget::Tessellation(id) => id.clone(),
593                    AppearanceTarget::Body(_) | AppearanceTarget::Source { .. } => continue,
594                };
595                direct_unstyled.insert(target_id);
596                continue;
597            };
598            let name = appearance.name.as_deref().unwrap_or("");
599            let style = match style_kind {
600                "surface" => self.surface_style(color, name, &mut style_refs),
601                "curve" => self.curve_style(color, name, &mut style_refs),
602                "point" => self.point_style(color, name, &mut style_refs),
603                _ => unreachable!(),
604            };
605            self.written_appearance_bindings.insert(binding.id.clone());
606            styled.push(
607                self.emitter
608                    .emit("STYLED_ITEM", &format!("'color',({style}),{target}")),
609            );
610        }
611        for (body_id, spec) in &body_colors {
612            if styled_bodies.contains(body_id) {
613                continue;
614            }
615            let mut targets = self
616                .body_item_refs
617                .get(*body_id)
618                .cloned()
619                .unwrap_or_default();
620            if targets.is_empty() {
621                targets.extend(self.body_shape_refs.get(*body_id).copied());
622            }
623            if targets.is_empty() {
624                continue;
625            }
626            if let Some(binding_id) = spec.binding_id {
627                self.written_appearance_bindings
628                    .insert(binding_id.to_string());
629            }
630            let name = spec
631                .appearance
632                .and_then(|appearance| appearance.name.as_deref())
633                .unwrap_or("");
634            let style = if self
635                .bodies
636                .get(*body_id)
637                .is_some_and(|body| body.kind == BodyKind::Wire)
638            {
639                self.curve_style(spec.color, name, &mut style_refs)
640            } else {
641                self.surface_style(spec.color, name, &mut style_refs)
642            };
643            styled.extend(targets.into_iter().map(|target| {
644                self.emitter
645                    .emit("STYLED_ITEM", &format!("'color',({style}),{target}"))
646            }));
647            styled_bodies.insert(body_id);
648        }
649        // A color is unrepresented when no emitted ADVANCED_FACE could carry it:
650        // a face override whose face was skipped, or a body whose faces were all
651        // skipped (hidden bodies or faces without an explicit STEP surface),
652        // and no whole-body carrier was emitted.
653        let emitted: BTreeSet<&str> = self.face_step_refs.keys().map(String::as_str).collect();
654        let mut unstyled_targets = face_colors
655            .keys()
656            .filter(|id| !emitted.contains(**id as &str))
657            .map(|id| (*id).to_string())
658            .collect::<BTreeSet<_>>();
659        unstyled_targets.extend(
660            body_colors
661                .keys()
662                .filter(|id| !styled_bodies.contains(**id as &str))
663                .map(|id| (*id).to_string()),
664        );
665        unstyled_targets.extend(direct_unstyled);
666        self.unstyled_colors = unstyled_targets.len();
667        if styled.is_empty() {
668            return;
669        }
670        self.emitter.emit(
671            "MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION",
672            &format!("'',{},{context}", refs(&styled)),
673        );
674    }
675
676    fn surface_style(
677        &mut self,
678        color: cadmpeg_ir::topology::Color,
679        name: &str,
680        cache: &mut HashMap<String, Ref>,
681    ) -> Ref {
682        let rgb = format!(
683            "{},{},{}",
684            real(f64::from(color.r)),
685            real(f64::from(color.g)),
686            real(f64::from(color.b))
687        );
688        let key = format!("surface:{name}:{rgb}");
689        if let Some(style) = cache.get(&key) {
690            return *style;
691        }
692        let colour = self
693            .emitter
694            .emit("COLOUR_RGB", &format!("{},{rgb}", string(name)));
695        let fill_colour = self
696            .emitter
697            .emit("FILL_AREA_STYLE_COLOUR", &format!("'',{colour}"));
698        let fill = self
699            .emitter
700            .emit("FILL_AREA_STYLE", &format!("'',({fill_colour})"));
701        let style_fill = self
702            .emitter
703            .emit("SURFACE_STYLE_FILL_AREA", &fill.to_string());
704        let side = self
705            .emitter
706            .emit("SURFACE_SIDE_STYLE", &format!("'',({style_fill})"));
707        let usage = self
708            .emitter
709            .emit("SURFACE_STYLE_USAGE", &format!(".BOTH.,{side}"));
710        let assignment = self
711            .emitter
712            .emit("PRESENTATION_STYLE_ASSIGNMENT", &format!("({usage})"));
713        cache.insert(key, assignment);
714        assignment
715    }
716
717    fn curve_style(
718        &mut self,
719        color: cadmpeg_ir::topology::Color,
720        name: &str,
721        cache: &mut HashMap<String, Ref>,
722    ) -> Ref {
723        let rgb = format!(
724            "{},{},{}",
725            real(f64::from(color.r)),
726            real(f64::from(color.g)),
727            real(f64::from(color.b))
728        );
729        let key = format!("curve:{name}:{rgb}");
730        if let Some(style) = cache.get(&key) {
731            return *style;
732        }
733        let colour = self
734            .emitter
735            .emit("COLOUR_RGB", &format!("{},{rgb}", string(name)));
736        let font = self
737            .emitter
738            .emit("DRAUGHTING_PRE_DEFINED_CURVE_FONT", &string("continuous"));
739        let curve = self.emitter.emit(
740            "CURVE_STYLE",
741            &format!("'',{font},POSITIVE_LENGTH_MEASURE(0.1),{colour}"),
742        );
743        let assignment = self
744            .emitter
745            .emit("PRESENTATION_STYLE_ASSIGNMENT", &format!("({curve})"));
746        cache.insert(key, assignment);
747        assignment
748    }
749
750    fn point_style(
751        &mut self,
752        color: cadmpeg_ir::topology::Color,
753        name: &str,
754        cache: &mut HashMap<String, Ref>,
755    ) -> Ref {
756        let rgb = format!(
757            "{},{},{}",
758            real(f64::from(color.r)),
759            real(f64::from(color.g)),
760            real(f64::from(color.b))
761        );
762        let key = format!("point:{name}:{rgb}");
763        if let Some(style) = cache.get(&key) {
764            return *style;
765        }
766        let colour = self
767            .emitter
768            .emit("COLOUR_RGB", &format!("{},{rgb}", string(name)));
769        let point = self.emitter.emit(
770            "POINT_STYLE",
771            &format!("'',.DOT.,POSITIVE_LENGTH_MEASURE(1.),{colour}"),
772        );
773        let assignment = self
774            .emitter
775            .emit("PRESENTATION_STYLE_ASSIGNMENT", &format!("({point})"));
776        cache.insert(key, assignment);
777        assignment
778    }
779
780    fn emit_layers(&mut self) {
781        for layer in self.ir.model.presentation_layers.clone() {
782            let mut assigned = Vec::new();
783            let mut unsupported = 0usize;
784            for item in layer.items {
785                let references = match item {
786                    PresentationItem::Body { body } => self
787                        .body_item_refs
788                        .get(body.as_str())
789                        .cloned()
790                        .filter(|references| !references.is_empty())
791                        .or_else(|| {
792                            self.body_shape_refs
793                                .get(body.as_str())
794                                .copied()
795                                .map(|reference| vec![reference])
796                        })
797                        .unwrap_or_default(),
798                    PresentationItem::Face { face } => self
799                        .face_step_refs
800                        .get(face.as_str())
801                        .copied()
802                        .into_iter()
803                        .collect(),
804                    PresentationItem::Edge { edge } => self
805                        .edge_refs
806                        .get(edge.as_str())
807                        .copied()
808                        .into_iter()
809                        .collect(),
810                    PresentationItem::Vertex { vertex } => self
811                        .vertex_refs
812                        .get(vertex.as_str())
813                        .copied()
814                        .into_iter()
815                        .collect(),
816                    PresentationItem::Curve { curve } => self
817                        .curve_refs
818                        .get(curve.as_str())
819                        .copied()
820                        .into_iter()
821                        .collect(),
822                    PresentationItem::Surface { surface } => self
823                        .surface_refs
824                        .get(surface.as_str())
825                        .copied()
826                        .into_iter()
827                        .collect(),
828                    PresentationItem::Product { product } => self
829                        .product_step_refs
830                        .get(product.as_str())
831                        .copied()
832                        .into_iter()
833                        .collect(),
834                    PresentationItem::Occurrence { occurrence } => self
835                        .occurrence_step_refs
836                        .get(occurrence.as_str())
837                        .copied()
838                        .into_iter()
839                        .collect(),
840                    PresentationItem::Pmi { annotation } => self
841                        .pmi_step_refs
842                        .get(annotation.as_str())
843                        .copied()
844                        .into_iter()
845                        .collect(),
846                    PresentationItem::Point { point } => self
847                        .point_refs
848                        .get(point.as_str())
849                        .copied()
850                        .into_iter()
851                        .collect(),
852                    PresentationItem::Source { .. } => Vec::new(),
853                    PresentationItem::Tessellation { tessellation } => self
854                        .tessellation_step_refs
855                        .get(&tessellation)
856                        .copied()
857                        .into_iter()
858                        .collect(),
859                };
860                if references.is_empty() {
861                    unsupported += 1;
862                } else {
863                    assigned.extend(references);
864                }
865            }
866            if unsupported > 0 {
867                self.loss(
868                    StepLossCode::LayerItemWithoutCarrier,
869                    format!(
870                        "layer '{}' has {unsupported} item(s) without a writable STEP carrier",
871                        layer.name
872                    ),
873                );
874            }
875            if !assigned.is_empty() {
876                self.emitter.emit(
877                    "PRESENTATION_LAYER_ASSIGNMENT",
878                    &format!(
879                        "{},{},{}",
880                        string(&layer.name),
881                        string(layer.description.as_deref().unwrap_or("")),
882                        refs(&assigned)
883                    ),
884                );
885            }
886        }
887    }
888
889    fn emit_product_structure(&mut self) -> Ref {
890        let name = self
891            .ir
892            .model
893            .bodies
894            .first()
895            .and_then(|b| b.name.clone())
896            .unwrap_or_else(|| "cadmpeg_model".to_string());
897
898        let (application, protocol, year) = self.schema.application_protocol();
899        let app_ctx = self
900            .emitter
901            .emit("APPLICATION_CONTEXT", &string(application));
902        self.emitter.emit(
903            "APPLICATION_PROTOCOL_DEFINITION",
904            &format!(
905                "{},{},{year},{app_ctx}",
906                string("international standard"),
907                string(protocol)
908            ),
909        );
910        let prod_ctx = self.emitter.emit(
911            "PRODUCT_CONTEXT",
912            &format!("'',{app_ctx},{}", string("mechanical")),
913        );
914        let product = self.emitter.emit(
915            "PRODUCT",
916            &format!("{},{},'',({prod_ctx})", string(&name), string(&name)),
917        );
918        let formation = self
919            .emitter
920            .emit("PRODUCT_DEFINITION_FORMATION", &format!("'','',{product}"));
921        let pd_ctx = self.emitter.emit(
922            "PRODUCT_DEFINITION_CONTEXT",
923            &format!(
924                "{},{app_ctx},{}",
925                string("part definition"),
926                string("design")
927            ),
928        );
929        let product_def = self.emitter.emit(
930            "PRODUCT_DEFINITION",
931            &format!("{},'',{formation},{pd_ctx}", string("design")),
932        );
933        self.emitter
934            .emit("PRODUCT_DEFINITION_SHAPE", &format!("'','',{product_def}"))
935    }
936
937    fn emit_product_graph(&mut self, context: Ref) {
938        let (application, protocol, year) = self.schema.application_protocol();
939        let app_context = self
940            .emitter
941            .emit("APPLICATION_CONTEXT", &string(application));
942        self.emitter.emit(
943            "APPLICATION_PROTOCOL_DEFINITION",
944            &format!(
945                "{},{},{year},{app_context}",
946                string("international standard"),
947                string(protocol)
948            ),
949        );
950        let product_context = self.emitter.emit(
951            "PRODUCT_CONTEXT",
952            &format!("'',{app_context},{}", string("mechanical")),
953        );
954        let definition_context = self.emitter.emit(
955            "PRODUCT_DEFINITION_CONTEXT",
956            &format!(
957                "{},{app_context},{}",
958                string("part definition"),
959                string("design")
960            ),
961        );
962
963        let ir = self.ir;
964        let products = &ir.model.product_definitions;
965        let occurrences = &ir.model.occurrences;
966        let Ok(graph) = AssemblyGraph::new(occurrences) else {
967            self.loss(
968                StepLossCode::AssemblyGraphInvalid,
969                "assembly occurrence graph is invalid".into(),
970            );
971            return;
972        };
973        let occurrence_products = occurrences
974            .iter()
975            .filter_map(|occurrence| match &occurrence.prototype {
976                PrototypeReference::Local { definition } => {
977                    Some((occurrence.id.clone(), definition.clone()))
978                }
979                PrototypeReference::External { .. } | PrototypeReference::Unresolved => None,
980            })
981            .collect::<HashMap<OccurrenceId, ProductDefinitionId>>();
982        let mut product_origins = HashMap::<ProductDefinitionId, Ref>::new();
983        for product in products {
984            product_origins.insert(
985                product.id.clone(),
986                geometry::placement(
987                    &mut self.emitter,
988                    cadmpeg_ir::math::Point3::new(0.0, 0.0, 0.0),
989                    cadmpeg_ir::math::Vector3::new(0.0, 0.0, 1.0),
990                    cadmpeg_ir::math::Vector3::new(1.0, 0.0, 0.0),
991                ),
992            );
993        }
994        let mut representation_placements = HashMap::<ProductDefinitionId, Vec<Ref>>::new();
995        let mut occurrence_placements = HashMap::<OccurrenceId, (Ref, Ref)>::new();
996        for occurrence in occurrences {
997            let OccurrenceParent::Occurrence { occurrence: parent } = &occurrence.parent else {
998                continue;
999            };
1000            let Some(parent_product) = occurrence_products.get(parent) else {
1001                continue;
1002            };
1003            let Some(child_product) = occurrence_products.get(&occurrence.id) else {
1004                continue;
1005            };
1006            let Some(&from) = product_origins.get(child_product) else {
1007                continue;
1008            };
1009            let transform = if occurrence.link_transform.unwrap_or(false) {
1010                occurrence.transform.compose(occurrence.prototype_transform)
1011            } else {
1012                occurrence.transform
1013            };
1014            if !transform.is_proper_rigid() || occurrence.scale != [1.0; 3] {
1015                continue;
1016            }
1017            let rows = transform.rows;
1018            let to = geometry::placement(
1019                &mut self.emitter,
1020                cadmpeg_ir::math::Point3::new(rows[0][3], rows[1][3], rows[2][3]),
1021                cadmpeg_ir::math::Vector3::new(rows[0][2], rows[1][2], rows[2][2]),
1022                cadmpeg_ir::math::Vector3::new(rows[0][0], rows[1][0], rows[2][0]),
1023            );
1024            representation_placements
1025                .entry(parent_product.clone())
1026                .or_default()
1027                .push(to);
1028            occurrence_placements.insert(occurrence.id.clone(), (from, to));
1029        }
1030        let mut definitions = HashMap::<ProductDefinitionId, Ref>::new();
1031        let mut representations = HashMap::<ProductDefinitionId, Ref>::new();
1032        for product in products {
1033            let product_id = product
1034                .part_number
1035                .as_deref()
1036                .or(product.source_name.as_deref())
1037                .unwrap_or(product.id.as_str());
1038            let name = product
1039                .label
1040                .as_deref()
1041                .or(product.source_name.as_deref())
1042                .unwrap_or(product_id);
1043            let description = product.description.as_deref().unwrap_or("");
1044            let product_ref = self.emitter.emit(
1045                "PRODUCT",
1046                &format!(
1047                    "{},{},{},({product_context})",
1048                    string(product_id),
1049                    string(name),
1050                    string(description)
1051                ),
1052            );
1053            self.product_step_refs
1054                .insert(product.id.0.clone(), product_ref);
1055            let formation = self.emitter.emit(
1056                "PRODUCT_DEFINITION_FORMATION",
1057                &format!("'','',{product_ref}"),
1058            );
1059            let definition = self.emitter.emit(
1060                "PRODUCT_DEFINITION",
1061                &format!(
1062                    "{},{},{formation},{definition_context}",
1063                    string(product_id),
1064                    string(description)
1065                ),
1066            );
1067            let shape = self
1068                .emitter
1069                .emit("PRODUCT_DEFINITION_SHAPE", &format!("'','',{definition}"));
1070            self.default_product_definition_shape.get_or_insert(shape);
1071            let mut body_items = product
1072                .bodies
1073                .iter()
1074                .flat_map(|body| {
1075                    self.body_item_refs
1076                        .get(body.as_str())
1077                        .into_iter()
1078                        .flatten()
1079                        .copied()
1080                })
1081                .collect::<Vec<_>>();
1082            if let Some(origin) = product_origins.get(&product.id) {
1083                body_items.push(*origin);
1084            }
1085            if let Some(placements) = representation_placements.get(&product.id) {
1086                body_items.extend(placements);
1087            }
1088            let representation = self.emitter.emit(
1089                "SHAPE_REPRESENTATION",
1090                &format!("{},{},{context}", string(name), refs(&body_items)),
1091            );
1092            self.emitter.emit(
1093                "SHAPE_DEFINITION_REPRESENTATION",
1094                &format!("{shape},{representation}"),
1095            );
1096            definitions.insert(product.id.clone(), definition);
1097            representations.insert(product.id.clone(), representation);
1098        }
1099
1100        for occurrence in occurrences {
1101            let OccurrenceParent::Occurrence { occurrence: parent } = &occurrence.parent else {
1102                let transform = if occurrence.link_transform.unwrap_or(false) {
1103                    occurrence.transform.compose(occurrence.prototype_transform)
1104                } else {
1105                    occurrence.transform
1106                };
1107                if !is_identity(&transform.rows) || occurrence.scale != [1.0; 3] {
1108                    self.loss(
1109                        StepLossCode::RootOccurrencePlacementNotRepresentable,
1110                        format!(
1111                            "root occurrence '{}' has a placement or scale that is not representable",
1112                            occurrence.id
1113                        ),
1114                    );
1115                }
1116                continue;
1117            };
1118            let Some(parent_occurrence) = graph.occurrence(parent) else {
1119                self.loss(
1120                    StepLossCode::OccurrenceUnresolvedParent,
1121                    format!("occurrence '{}' has an unresolved parent", occurrence.id),
1122                );
1123                continue;
1124            };
1125            let Some(parent_product) = occurrence_products.get(&parent_occurrence.id) else {
1126                self.missing_parent_products.insert(occurrence.id.0.clone());
1127                continue;
1128            };
1129            let Some(child_product) = occurrence_products.get(&occurrence.id) else {
1130                self.loss(
1131                    StepLossCode::OccurrenceNoLocalProduct,
1132                    format!(
1133                        "occurrence '{}' has no local product definition",
1134                        occurrence.id
1135                    ),
1136                );
1137                continue;
1138            };
1139            let Some((
1140                &parent_definition,
1141                &child_definition,
1142                &parent_representation,
1143                &child_representation,
1144            )) = definitions
1145                .get(parent_product)
1146                .zip(definitions.get(child_product))
1147                .zip(representations.get(parent_product))
1148                .zip(representations.get(child_product))
1149                .map(|(((a, b), c), d)| (a, b, c, d))
1150            else {
1151                continue;
1152            };
1153            let transform = if occurrence.link_transform.unwrap_or(false) {
1154                occurrence.transform.compose(occurrence.prototype_transform)
1155            } else {
1156                occurrence.transform
1157            };
1158            if !transform.is_proper_rigid() || occurrence.scale != [1.0; 3] {
1159                self.loss(
1160                    StepLossCode::OccurrencePlacementNotRigid,
1161                    format!("occurrence '{}' placement is not rigid", occurrence.id),
1162                );
1163                continue;
1164            }
1165            let occurrence_name = occurrence.name.as_deref().unwrap_or(occurrence.id.as_str());
1166            let usage = self.emitter.emit(
1167                "NEXT_ASSEMBLY_USAGE_OCCURRENCE",
1168                &format!(
1169                    "{},{},'',{parent_definition},{child_definition},$",
1170                    string(occurrence.id.as_str()),
1171                    string(occurrence_name)
1172                ),
1173            );
1174            self.occurrence_step_refs
1175                .insert(occurrence.id.0.clone(), usage);
1176            let usage_shape = self
1177                .emitter
1178                .emit("PRODUCT_DEFINITION_SHAPE", &format!("'','',{usage}"));
1179            let Some(&(from, to)) = occurrence_placements.get(&occurrence.id) else {
1180                continue;
1181            };
1182            let transform = self
1183                .emitter
1184                .emit("ITEM_DEFINED_TRANSFORMATION", &format!("'','',{from},{to}"));
1185            let relationship = self.emitter.emit_raw(
1186                "REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION",
1187                &format!(
1188                    "( REPRESENTATION_RELATIONSHIP('','',{child_representation},{parent_representation}) REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION({transform}) SHAPE_REPRESENTATION_RELATIONSHIP() )"
1189                ),
1190            );
1191            self.emitter.emit(
1192                "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION",
1193                &format!("{relationship},{usage_shape}"),
1194            );
1195        }
1196    }
1197
1198    fn emit_context(&mut self) -> Ref {
1199        let len = self.emit_length_unit();
1200        let angle = self.emit_angle_unit();
1201        let solid = self.emitter.emit_raw(
1202            "SOLID_ANGLE_UNIT",
1203            "( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() )",
1204        );
1205        let unc = self.emitter.emit(
1206            "UNCERTAINTY_MEASURE_WITH_UNIT",
1207            &format!(
1208                "LENGTH_MEASURE({}),{len},{},{}",
1209                real(self.ir.tolerances.linear),
1210                string("distance_accuracy_value"),
1211                string("maximum model space distance")
1212            ),
1213        );
1214        self.emitter.emit_raw(
1215            "GEOMETRIC_REPRESENTATION_CONTEXT",
1216            &format!(
1217                "( GEOMETRIC_REPRESENTATION_CONTEXT(3) \
1218                 GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT(({unc})) \
1219                 GLOBAL_UNIT_ASSIGNED_CONTEXT(({len},{angle},{solid})) \
1220                 REPRESENTATION_CONTEXT('Context','3D') )"
1221            ),
1222        )
1223    }
1224
1225    fn emit_length_unit(&mut self) -> Ref {
1226        if let Some(unit) = self.length_unit {
1227            return unit;
1228        }
1229        let unit = self.emitter.emit_raw(
1230            "LENGTH_UNIT",
1231            "( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) )",
1232        );
1233        self.length_unit = Some(unit);
1234        unit
1235    }
1236
1237    fn emit_angle_unit(&mut self) -> Ref {
1238        if let Some(unit) = self.angle_unit {
1239            return unit;
1240        }
1241        let unit = self.emitter.emit_raw(
1242            "PLANE_ANGLE_UNIT",
1243            "( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) )",
1244        );
1245        self.angle_unit = Some(unit);
1246        unit
1247    }
1248
1249    fn emit_ratio_unit(&mut self) -> Ref {
1250        if let Some(unit) = self.ratio_unit {
1251            return unit;
1252        }
1253        let unit = self
1254            .emitter
1255            .emit_raw("RATIO_UNIT", "( NAMED_UNIT(*) RATIO_UNIT() )");
1256        self.ratio_unit = Some(unit);
1257        unit
1258    }
1259
1260    /// Emit one shape item per region; visibility is represented separately
1261    /// when the target application protocol supports `INVISIBILITY`.
1262    fn emit_shape_items(&mut self, context: Ref) -> Vec<Ref> {
1263        let mut items = Vec::new();
1264        let ir = self.ir;
1265        for region in &ir.model.regions {
1266            let body_kind = self
1267                .bodies
1268                .get(region.body.as_str())
1269                .map_or(BodyKind::General, |body| body.kind);
1270            let has_surface_topology = region.shells.iter().any(|shell_id| {
1271                self.shells
1272                    .get(shell_id.as_str())
1273                    .is_some_and(|shell| !shell.faces.is_empty())
1274            });
1275            let has_wire_topology = region.shells.iter().any(|shell_id| {
1276                self.shells.get(shell_id.as_str()).is_some_and(|shell| {
1277                    !shell.wire_edges.is_empty() || !shell.free_vertices.is_empty()
1278                })
1279            });
1280            let mixed_wire = body_kind == BodyKind::General && has_wire_topology;
1281            if body_kind == BodyKind::Wire
1282                || (body_kind == BodyKind::General && !has_surface_topology)
1283            {
1284                if let Some(item) = self.emit_wire_region(region) {
1285                    let shape_item = self.place_body_item(&region.body, item, context);
1286                    items.push(shape_item);
1287                    self.body_shape_refs
1288                        .entry(region.body.0.clone())
1289                        .or_insert(shape_item);
1290                    self.body_item_refs
1291                        .entry(region.body.0.clone())
1292                        .or_default()
1293                        .push(shape_item);
1294                    self.body_step_item_refs
1295                        .entry(region.body.0.clone())
1296                        .or_default()
1297                        .push(item);
1298                    self.body_step_refs
1299                        .entry(region.body.0.clone())
1300                        .or_insert(item);
1301                } else {
1302                    self.empty_wire_regions.insert(region.id.0.clone());
1303                }
1304                continue;
1305            }
1306            let closed = body_kind == BodyKind::Solid;
1307            let Some((outer_id, void_ids)) = region.shells.split_first() else {
1308                self.empty_regions.insert(region.id.0.clone());
1309                continue;
1310            };
1311            let Some(outer) = self.emit_shell(outer_id.as_str(), closed) else {
1312                self.topology_relation_loss(
1313                    format!("region:{}:outer-shell:{}", region.id, outer_id),
1314                    StepLossCode::RegionNoWritableOuterShell,
1315                    format!("region {} has no writable outer shell", region.id),
1316                );
1317                continue;
1318            };
1319            let mut voids = Vec::new();
1320            for sid in void_ids {
1321                if let Some(void) = self.emit_shell(sid.as_str(), closed) {
1322                    voids.push(void);
1323                } else {
1324                    self.topology_relation_loss(
1325                        format!("region:{}:void-shell:{}", region.id, sid),
1326                        StepLossCode::RegionOmittedVoidShell,
1327                        format!(
1328                            "region {} omitted void shell {} because it has no writable faces",
1329                            region.id, sid
1330                        ),
1331                    );
1332                }
1333            }
1334            let mut shell_refs = Vec::with_capacity(1 + voids.len());
1335            shell_refs.push(outer);
1336            shell_refs.extend_from_slice(&voids);
1337            let item = if !closed {
1338                self.emitter.emit(
1339                    "SHELL_BASED_SURFACE_MODEL",
1340                    &format!("'',{}", refs(&shell_refs)),
1341                )
1342            } else if voids.is_empty() {
1343                self.emitter
1344                    .emit("MANIFOLD_SOLID_BREP", &format!("'',{outer}"))
1345            } else {
1346                let void_refs: Vec<Ref> = voids
1347                    .iter()
1348                    .map(|s| {
1349                        self.emitter
1350                            .emit("ORIENTED_CLOSED_SHELL", &format!("'',*,{s},.F."))
1351                    })
1352                    .collect();
1353                self.emitter.emit(
1354                    "BREP_WITH_VOIDS",
1355                    &format!("'',{outer},{}", refs(&void_refs)),
1356                )
1357            };
1358            let shape_item = self.place_body_item(&region.body, item, context);
1359            items.push(shape_item);
1360            self.body_shape_refs
1361                .entry(region.body.0.clone())
1362                .or_insert(shape_item);
1363            self.body_item_refs
1364                .entry(region.body.0.clone())
1365                .or_default()
1366                .push(shape_item);
1367            self.body_step_item_refs
1368                .entry(region.body.0.clone())
1369                .or_default()
1370                .push(item);
1371            self.body_step_refs
1372                .entry(region.body.0.clone())
1373                .or_insert(if closed { item } else { outer });
1374            if mixed_wire {
1375                if let Some(item) = self.emit_wire_region(region) {
1376                    let shape_item = self.place_body_item(&region.body, item, context);
1377                    items.push(shape_item);
1378                    self.body_shape_refs
1379                        .entry(region.body.0.clone())
1380                        .or_insert(shape_item);
1381                    self.body_item_refs
1382                        .entry(region.body.0.clone())
1383                        .or_default()
1384                        .push(shape_item);
1385                    self.body_step_item_refs
1386                        .entry(region.body.0.clone())
1387                        .or_default()
1388                        .push(item);
1389                    self.body_step_refs
1390                        .entry(region.body.0.clone())
1391                        .or_insert(item);
1392                } else {
1393                    self.empty_wire_regions.insert(region.id.0.clone());
1394                }
1395            }
1396        }
1397        items
1398    }
1399
1400    fn place_body_item(
1401        &mut self,
1402        body_id: &cadmpeg_ir::ids::BodyId,
1403        item: Ref,
1404        context: Ref,
1405    ) -> Ref {
1406        let transform = self
1407            .bodies
1408            .get(body_id.as_str())
1409            .and_then(|body| body.transform);
1410        let Some(transform) = transform.filter(|transform| !is_identity(&transform.rows)) else {
1411            return item;
1412        };
1413        if !is_rigid_transform(&transform.rows) {
1414            self.loss(
1415                StepLossCode::BodyNonRigidTransform,
1416                format!("body '{body_id}' carries a non-rigid transform"),
1417            );
1418            return item;
1419        }
1420        let origin = geometry::placement(
1421            &mut self.emitter,
1422            cadmpeg_ir::math::Point3::new(0.0, 0.0, 0.0),
1423            cadmpeg_ir::math::Vector3::new(0.0, 0.0, 1.0),
1424            cadmpeg_ir::math::Vector3::new(1.0, 0.0, 0.0),
1425        );
1426        let representation = self.emitter.emit(
1427            "SHAPE_REPRESENTATION",
1428            &format!("'body-local',({item}),{context}"),
1429        );
1430        let map = self
1431            .emitter
1432            .emit("REPRESENTATION_MAP", &format!("{origin},{representation}"));
1433        let rows = transform.rows;
1434        let target = geometry::placement(
1435            &mut self.emitter,
1436            cadmpeg_ir::math::Point3::new(rows[0][3], rows[1][3], rows[2][3]),
1437            cadmpeg_ir::math::Vector3::new(rows[0][2], rows[1][2], rows[2][2]),
1438            cadmpeg_ir::math::Vector3::new(rows[0][0], rows[1][0], rows[2][0]),
1439        );
1440        self.emitter.emit(
1441            "MAPPED_ITEM",
1442            &format!("'cadmpeg body placement',{map},{target}"),
1443        )
1444    }
1445
1446    fn emit_visibility(&mut self) {
1447        if !self.schema.supports_visibility() {
1448            let hidden = self
1449                .ir
1450                .model
1451                .bodies
1452                .iter()
1453                .filter(|body| body.visible == Some(false))
1454                .count();
1455            if hidden != 0 {
1456                self.loss(
1457                    StepLossCode::HiddenBodyVisibilityUnsupported,
1458                    format!(
1459                        "{hidden} hidden body visibility assignment(s) are unsupported by {}",
1460                        self.schema.file_schema()
1461                    ),
1462                );
1463            }
1464            return;
1465        }
1466        let mut hidden = Vec::new();
1467        let mut hidden_without_items = Vec::new();
1468        for body in &self.ir.model.bodies {
1469            if body.visible != Some(false) {
1470                continue;
1471            }
1472            if let Some(references) = self.body_step_item_refs.get(body.id.as_str()) {
1473                if !references.is_empty() {
1474                    hidden.extend(references.iter().copied());
1475                    continue;
1476                }
1477            }
1478            if let Some(reference) = self.body_step_refs.get(body.id.as_str()).copied() {
1479                hidden.push(reference);
1480            } else {
1481                hidden_without_items.push(body.id.0.clone());
1482            }
1483        }
1484        self.hidden_bodies_without_items
1485            .extend(hidden_without_items);
1486        if !hidden.is_empty() {
1487            self.emitter.emit("INVISIBILITY", &refs(&hidden));
1488        }
1489    }
1490
1491    fn emit_wire_region(&mut self, region: &cadmpeg_ir::topology::Region) -> Option<Ref> {
1492        let mut shells = Vec::new();
1493        for shell_id in &region.shells {
1494            if let Some(shell) = self.shells.get(shell_id.as_str()).copied().cloned() {
1495                shells.push(shell);
1496            } else {
1497                self.missing_wire_shells
1498                    .insert((region.id.0.clone(), shell_id.0.clone()));
1499            }
1500        }
1501        let mut connected_sets = Vec::new();
1502        for shell in shells {
1503            if !shell.free_vertices.is_empty() {
1504                self.loss(
1505                    StepLossCode::WireShellFreeVertices,
1506                    format!(
1507                        "wire shell '{}' has {} free vertex/vertices without an edge-based STEP carrier",
1508                        shell.id,
1509                        shell.free_vertices.len()
1510                    ),
1511                );
1512            }
1513            let edges = shell
1514                .wire_edges
1515                .iter()
1516                .filter_map(|edge| self.emit_edge(edge.as_str()))
1517                .collect::<Vec<_>>();
1518            if !edges.is_empty() {
1519                connected_sets.push(
1520                    self.emitter
1521                        .emit("CONNECTED_EDGE_SET", &format!("'',{}", refs(&edges))),
1522                );
1523            }
1524        }
1525        if connected_sets.is_empty() {
1526            return None;
1527        }
1528        Some(self.emitter.emit(
1529            "EDGE_BASED_WIREFRAME_MODEL",
1530            &format!("'',{}", refs(&connected_sets)),
1531        ))
1532    }
1533
1534    fn emit_standalone_geometry(&mut self) -> Vec<Ref> {
1535        let surface_ids = self
1536            .ir
1537            .model
1538            .surfaces
1539            .iter()
1540            .filter(|surface| !self.surface_refs.contains_key(surface.id.as_str()))
1541            .map(|surface| surface.id.0.clone())
1542            .collect::<Vec<_>>();
1543        let mut members = Vec::new();
1544        let mut has_surfaces = false;
1545        for surface_id in surface_ids {
1546            if let Some(reference) = self.emit_surface(&surface_id) {
1547                members.push(reference);
1548                has_surfaces = true;
1549            } else {
1550                self.unwritten_geometry_carriers.insert(surface_id);
1551            }
1552        }
1553        let curve_ids = self
1554            .ir
1555            .model
1556            .curves
1557            .iter()
1558            .filter(|curve| !self.curve_refs.contains_key(curve.id.as_str()))
1559            .map(|curve| curve.id.0.clone())
1560            .collect::<Vec<_>>();
1561        for curve_id in curve_ids {
1562            if let Some(reference) = self.emit_curve(&curve_id) {
1563                members.push(reference);
1564            } else {
1565                self.unwritten_geometry_carriers.insert(curve_id);
1566            }
1567        }
1568        let point_ids = self
1569            .ir
1570            .model
1571            .points
1572            .iter()
1573            .filter(|point| !self.point_refs.contains_key(point.id.as_str()))
1574            .map(|point| point.id.0.clone())
1575            .collect::<Vec<_>>();
1576        for point_id in point_ids {
1577            let Some(point) = self.points.get(point_id.as_str()).copied() else {
1578                continue;
1579            };
1580            let reference = geometry::point(&mut self.emitter, point.position);
1581            self.point_refs.insert(point_id, reference);
1582            members.push(reference);
1583        }
1584        if members.is_empty() {
1585            Vec::new()
1586        } else {
1587            vec![self.emitter.emit(
1588                if has_surfaces {
1589                    "GEOMETRIC_SET"
1590                } else {
1591                    "GEOMETRIC_CURVE_SET"
1592                },
1593                &format!("'',{}", refs(&members)),
1594            )]
1595        }
1596    }
1597
1598    fn emit_tessellations(&mut self, context: Ref) {
1599        if self.ir.model.tessellations.is_empty() {
1600            return;
1601        }
1602        if !self.schema.supports_tessellation() {
1603            self.loss(
1604                StepLossCode::TessellationRequiresAp242,
1605                format!(
1606                    "{} tessellation(s) require an AP242 target",
1607                    self.ir.model.tessellations.len()
1608                ),
1609            );
1610            return;
1611        }
1612
1613        let ir = self.ir;
1614        let mut representation_items = Vec::new();
1615        for mesh in &ir.model.tessellations {
1616            if !mesh.feature_edges.is_empty() {
1617                self.loss(
1618                    StepLossCode::TessellationFeatureEdges,
1619                    format!(
1620                        "tessellation '{}' feature-edge classification is not represented",
1621                        mesh.id
1622                    ),
1623                );
1624            }
1625            if !mesh.corner_normals.is_empty() {
1626                self.loss(
1627                    StepLossCode::TessellationCornerNormals,
1628                    format!(
1629                        "tessellation '{}' corner normals are not represented",
1630                        mesh.id
1631                    ),
1632                );
1633            }
1634            if !mesh.triangle_groups.is_empty() {
1635                self.loss(
1636                    StepLossCode::TessellationTriangleGroups,
1637                    format!(
1638                        "tessellation '{}' triangle groups are not represented",
1639                        mesh.id
1640                    ),
1641                );
1642            }
1643            if !mesh.texture_assignments.is_empty() {
1644                self.loss(
1645                    StepLossCode::TessellationTextureAssignments,
1646                    format!(
1647                        "tessellation '{}' texture assignments are not represented",
1648                        mesh.id
1649                    ),
1650                );
1651            }
1652            if mesh.vertices.is_empty()
1653                || mesh.triangles.is_empty()
1654                || mesh
1655                    .triangles
1656                    .iter()
1657                    .flatten()
1658                    .any(|index| *index as usize >= mesh.vertices.len())
1659                || (!mesh.normals.is_empty() && mesh.normals.len() != mesh.vertices.len())
1660            {
1661                self.loss(
1662                    StepLossCode::TessellationInvalidCardinality,
1663                    format!(
1664                        "tessellation '{}' has invalid vertex/index/normal cardinality",
1665                        mesh.id
1666                    ),
1667                );
1668                continue;
1669            }
1670            let coordinates = mesh
1671                .vertices
1672                .iter()
1673                .map(|point| format!("({},{},{})", real(point.x), real(point.y), real(point.z)))
1674                .collect::<Vec<_>>()
1675                .join(",");
1676            let coordinates = self.emitter.emit(
1677                "COORDINATES_LIST",
1678                &format!(
1679                    "{}, {},({coordinates})",
1680                    string(&mesh.id),
1681                    mesh.vertices.len()
1682                ),
1683            );
1684            let normals = if mesh.normals.is_empty() {
1685                "$".to_string()
1686            } else {
1687                format!(
1688                    "({})",
1689                    mesh.normals
1690                        .iter()
1691                        .map(|normal| format!(
1692                            "({},{},{})",
1693                            real(normal.x),
1694                            real(normal.y),
1695                            real(normal.z)
1696                        ))
1697                        .collect::<Vec<_>>()
1698                        .join(",")
1699                )
1700            };
1701            let point_indices = (1..=mesh.vertices.len())
1702                .map(|index| index.to_string())
1703                .collect::<Vec<_>>()
1704                .join(",");
1705            let linked_body = mesh.body.as_ref().and_then(|body| {
1706                let link = self.body_step_refs.get(body.as_str()).copied()?;
1707                let kind = self.bodies.get(body.as_str())?.kind;
1708                matches!(kind, BodyKind::Solid | BodyKind::Sheet).then_some((kind, link))
1709            });
1710            if let Some(body) = &mesh.body {
1711                if linked_body.is_none() {
1712                    self.loss(
1713                        StepLossCode::TessellationBodyLinkUnwritable,
1714                        format!(
1715                            "tessellation '{}' body '{}' has no writable AP242 tessellation link",
1716                            mesh.id, body
1717                        ),
1718                    );
1719                }
1720            }
1721            let mut reduced_fields = Vec::new();
1722            if !mesh.faces.is_empty() {
1723                reduced_fields.push(format!("{} face ownership link(s)", mesh.faces.len()));
1724            }
1725            if mesh.chordal_deflection.is_some() {
1726                reduced_fields.push("chordal deflection".to_string());
1727            }
1728            if !mesh.channels.is_empty() {
1729                reduced_fields.push(format!("{} data channel(s)", mesh.channels.len()));
1730            }
1731            if !reduced_fields.is_empty() {
1732                self.loss(
1733                    StepLossCode::TessellationMetadataReduced,
1734                    format!(
1735                        "tessellation '{}' reduced unsupported metadata: {}",
1736                        mesh.id,
1737                        reduced_fields.join(", ")
1738                    ),
1739                );
1740            }
1741            let item = if let Some((kind, link)) = linked_body {
1742                let triangles = mesh
1743                    .triangles
1744                    .iter()
1745                    .map(|triangle| {
1746                        format!(
1747                            "({},{},{})",
1748                            triangle[0] + 1,
1749                            triangle[1] + 1,
1750                            triangle[2] + 1
1751                        )
1752                    })
1753                    .collect::<Vec<_>>()
1754                    .join(",");
1755                let face = self.emitter.emit(
1756                    "TRIANGULATED_FACE",
1757                    &format!(
1758                        "{},{coordinates},{},{normals},$,({point_indices}),({triangles})",
1759                        string(&mesh.id),
1760                        mesh.vertices.len()
1761                    ),
1762                );
1763                self.emitter.emit(
1764                    if kind == BodyKind::Solid {
1765                        "TESSELLATED_SOLID"
1766                    } else {
1767                        "TESSELLATED_SHELL"
1768                    },
1769                    &format!("{},({face}),{link}", string(&mesh.id)),
1770                )
1771            } else {
1772                let triangles = mesh
1773                    .triangles
1774                    .iter()
1775                    .map(|triangle| {
1776                        format!(
1777                            "({},{},{})",
1778                            triangle[0] + 1,
1779                            triangle[1] + 1,
1780                            triangle[2] + 1
1781                        )
1782                    })
1783                    .collect::<Vec<_>>()
1784                    .join(",");
1785                self.emitter.emit(
1786                    "TRIANGULATED_SURFACE_SET",
1787                    &format!(
1788                        "{},{coordinates},{},{normals},({point_indices}),({triangles})",
1789                        string(&mesh.id),
1790                        mesh.vertices.len()
1791                    ),
1792                )
1793            };
1794            self.tessellation_step_refs.insert(mesh.id.clone(), item);
1795            representation_items.push(item);
1796        }
1797        if !representation_items.is_empty() {
1798            self.emitter.emit(
1799                "TESSELLATED_SHAPE_REPRESENTATION",
1800                &format!("'',{},{context}", refs(&representation_items)),
1801            );
1802        }
1803    }
1804
1805    fn emit_shell(&mut self, shell_id: &str, closed: bool) -> Option<Ref> {
1806        let shell = self.shells.get(shell_id).copied()?;
1807        let face_ids: Vec<String> = shell.faces.iter().map(|f| f.0.clone()).collect();
1808        let mut face_refs = Vec::new();
1809        for fid in &face_ids {
1810            if let Some(r) = self.emit_face(fid) {
1811                face_refs.push(r);
1812            } else {
1813                let outer = self.faces.get(fid.as_str()).is_some_and(|face| {
1814                    face.loops.iter().any(|loop_id| {
1815                        self.loops
1816                            .get(loop_id.as_str())
1817                            .is_some_and(|loop_| loop_.boundary_role == LoopBoundaryRole::Outer)
1818                    }) || face.loops.first().is_some_and(|loop_id| {
1819                        !face.loops.iter().any(|candidate| {
1820                            self.loops
1821                                .get(candidate.as_str())
1822                                .is_some_and(|loop_| loop_.boundary_role == LoopBoundaryRole::Outer)
1823                        }) && self.loops.contains_key(loop_id.as_str())
1824                    })
1825                });
1826                self.topology_relation_loss(
1827                    format!("shell:{shell_id}:face:{fid}"),
1828                    if outer {
1829                        StepLossCode::ShellOmittedOuterFace
1830                    } else {
1831                        StepLossCode::ShellOmittedInnerFace
1832                    },
1833                    format!(
1834                        "shell {shell_id} omitted face {fid} because the face has no writable topology"
1835                    ),
1836                );
1837            }
1838        }
1839        if face_refs.is_empty() {
1840            return None;
1841        }
1842        Some(self.emitter.emit(
1843            if closed { "CLOSED_SHELL" } else { "OPEN_SHELL" },
1844            &format!("'',{}", refs(&face_refs)),
1845        ))
1846    }
1847
1848    fn emit_face(&mut self, face_id: &str) -> Option<Ref> {
1849        let face = self.faces.get(face_id).copied()?;
1850        let surface_id = face.surface.0.clone();
1851        // A face resting on an unknown (opaque) surface cannot become an
1852        // ADVANCED_FACE: STEP requires a real surface. Skip it and aggregate the
1853        // loss rather than fabricate placeholder geometry.
1854        if let Some(surf) = self.surfaces.get(surface_id.as_str()) {
1855            if !geometry::surface_is_supported(&surf.geometry) {
1856                self.unknown_surface_faces.insert(face_id.to_string());
1857                return None;
1858            }
1859        }
1860        let loop_ids: Vec<String> = face.loops.iter().map(|l| l.0.clone()).collect();
1861        let same_sense = matches!(face.sense, Sense::Forward);
1862
1863        let Some(surf_ref) = self.emit_surface(&surface_id) else {
1864            self.unknown_surface_faces.insert(face_id.to_string());
1865            return None;
1866        };
1867
1868        let mut bound_refs = Vec::new();
1869        for (i, lid) in loop_ids.iter().enumerate() {
1870            if let Some(loop_ref) = self.emit_loop(lid) {
1871                let kind = if matches!(
1872                    self.loops
1873                        .get(lid.as_str())
1874                        .map(|loop_| loop_.boundary_role),
1875                    Some(LoopBoundaryRole::Outer)
1876                ) || (i == 0
1877                    && !loop_ids.iter().any(|id| {
1878                        self.loops
1879                            .get(id.as_str())
1880                            .is_some_and(|loop_| loop_.boundary_role == LoopBoundaryRole::Outer)
1881                    })) {
1882                    "FACE_OUTER_BOUND"
1883                } else {
1884                    "FACE_BOUND"
1885                };
1886                let b = self.emitter.emit(kind, &format!("'',{loop_ref},.T."));
1887                bound_refs.push(b);
1888            } else {
1889                let outer = self
1890                    .loops
1891                    .get(lid.as_str())
1892                    .is_some_and(|loop_| loop_.boundary_role == LoopBoundaryRole::Outer)
1893                    || (i == 0
1894                        && !loop_ids.iter().any(|id| {
1895                            self.loops
1896                                .get(id.as_str())
1897                                .is_some_and(|loop_| loop_.boundary_role == LoopBoundaryRole::Outer)
1898                        }));
1899                self.topology_relation_loss(
1900                    format!("face:{face_id}:loop:{lid}"),
1901                    if outer {
1902                        StepLossCode::FaceOmittedOuterLoop
1903                    } else {
1904                        StepLossCode::FaceOmittedInnerLoop
1905                    },
1906                    format!(
1907                        "face {face_id} omitted loop {lid} because the loop has no writable topology"
1908                    ),
1909                );
1910            }
1911        }
1912        if bound_refs.is_empty() {
1913            self.topology_relation_loss(
1914                format!("face:{face_id}:bound-list"),
1915                StepLossCode::FaceNoWritableBounds,
1916                format!("face {face_id} has no writable bounds"),
1917            );
1918            return None;
1919        }
1920        let flag = if same_sense { ".T." } else { ".F." };
1921        let advanced_face = self.emitter.emit(
1922            "ADVANCED_FACE",
1923            &format!(
1924                "{},{},{surf_ref},{flag}",
1925                string(face.name.as_deref().unwrap_or("")),
1926                refs(&bound_refs)
1927            ),
1928        );
1929        self.face_step_refs
1930            .insert(face_id.to_string(), advanced_face);
1931        Some(advanced_face)
1932    }
1933
1934    fn emit_loop(&mut self, loop_id: &str) -> Option<Ref> {
1935        let Some(lp) = self.loops.get(loop_id).copied() else {
1936            self.topology_relation_loss(
1937                format!("loop:{loop_id}:record"),
1938                StepLossCode::LoopRecordMissing,
1939                format!("loop {loop_id} was omitted because its record is missing"),
1940            );
1941            return None;
1942        };
1943        if lp.coedges.is_empty() && lp.vertex_uses.len() == 1 {
1944            let vertex_id = lp.vertex_uses[0].vertex.as_str();
1945            let Some(vertex) = self.emit_vertex(vertex_id) else {
1946                self.topology_relation_loss(
1947                    format!("loop:{loop_id}:vertex:{vertex_id}"),
1948                    StepLossCode::LoopVertexMissing,
1949                    format!("loop {loop_id} was omitted because vertex {vertex_id} is missing"),
1950                );
1951                return None;
1952            };
1953            return Some(self.emitter.emit("VERTEX_LOOP", &format!("'',{vertex}")));
1954        }
1955        let coedge_ids = self.ordered_loop_coedges(loop_id, lp)?;
1956        let mut oe_refs = Vec::new();
1957        for cid in &coedge_ids {
1958            let Some(coedge) = self.coedges.get(cid.as_str()).copied() else {
1959                self.topology_relation_loss(
1960                    format!("loop:{loop_id}:coedge:{cid}"),
1961                    StepLossCode::LoopCoedgeRecordMissing,
1962                    format!("loop {loop_id} omitted coedge {cid} because its record is missing"),
1963                );
1964                return None;
1965            };
1966            let orientation = matches!(coedge.sense, Sense::Forward);
1967            let Some(edge_ref) = self.emit_edge(coedge.edge.as_str()) else {
1968                self.topology_relation_loss(
1969                    format!("loop:{loop_id}:edge:{}", coedge.edge),
1970                    StepLossCode::LoopEdgeNotWritable,
1971                    format!(
1972                        "loop {loop_id} omitted edge {} because the edge is not writable",
1973                        coedge.edge
1974                    ),
1975                );
1976                return None;
1977            };
1978            let flag = if orientation { ".T." } else { ".F." };
1979            let oe = self
1980                .emitter
1981                .emit("ORIENTED_EDGE", &format!("'',*,*,{edge_ref},{flag}"));
1982            oe_refs.push(oe);
1983        }
1984        if oe_refs.is_empty() {
1985            self.topology_relation_loss(
1986                format!("loop:{loop_id}:edge-list"),
1987                StepLossCode::LoopNoWritableEdges,
1988                format!("loop {loop_id} has no writable edges"),
1989            );
1990            return None;
1991        }
1992        Some(
1993            self.emitter
1994                .emit("EDGE_LOOP", &format!("'',{}", refs(&oe_refs))),
1995        )
1996    }
1997
1998    fn ordered_loop_coedges(&mut self, loop_id: &str, lp: &Loop) -> Option<Vec<String>> {
1999        let mut segments = Vec::with_capacity(lp.coedges.len());
2000        let mut seen = BTreeSet::new();
2001
2002        for coedge_id in &lp.coedges {
2003            let coedge_key = coedge_id.as_str();
2004            if !seen.insert(coedge_key) {
2005                self.topology_relation_loss(
2006                    format!("loop:{loop_id}:duplicate-coedge:{coedge_id}"),
2007                    StepLossCode::LoopDuplicateCoedge,
2008                    format!(
2009                        "loop {loop_id} cannot be ordered because coedge {coedge_id} occurs more than once"
2010                    ),
2011                );
2012                return None;
2013            }
2014            let Some(coedge) = self.coedges.get(coedge_key).copied() else {
2015                self.topology_relation_loss(
2016                    format!("loop:{loop_id}:coedge:{coedge_id}"),
2017                    StepLossCode::LoopCoedgeMissingForOrder,
2018                    format!(
2019                        "loop {loop_id} cannot be ordered because coedge {coedge_id} is missing"
2020                    ),
2021                );
2022                return None;
2023            };
2024            let edge_key = coedge.edge.as_str();
2025            let Some(edge) = self.edges.get(edge_key).copied() else {
2026                self.topology_relation_loss(
2027                    format!("loop:{loop_id}:edge:{edge_key}"),
2028                    StepLossCode::LoopEdgeMissingForOrder,
2029                    format!("loop {loop_id} cannot be ordered because edge {edge_key} is missing"),
2030                );
2031                return None;
2032            };
2033            let Some(curve_id) = edge.curve.as_ref() else {
2034                self.curveless_edges.insert(edge_key.to_string());
2035                self.topology_relation_loss(
2036                    format!("edge:{edge_key}:curve"),
2037                    StepLossCode::EdgeNo3dCurve,
2038                    format!("edge {edge_key} was omitted because it has no 3D curve"),
2039                );
2040                continue;
2041            };
2042            let Some(curve) = self.curves.get(curve_id.as_str()).copied() else {
2043                self.topology_relation_loss(
2044                    format!("edge:{edge_key}:curve:{curve_id}"),
2045                    StepLossCode::EdgeCurveNotWritable,
2046                    format!("edge {edge_key} was omitted because curve {curve_id} is not writable"),
2047                );
2048                continue;
2049            };
2050            if !geometry::curve_is_supported(&curve.geometry) {
2051                self.curveless_edges.insert(edge_key.to_string());
2052                self.topology_relation_loss(
2053                    format!("edge:{edge_key}:unsupported-curve:{curve_id}"),
2054                    StepLossCode::EdgeCurveUnsupported,
2055                    format!("edge {edge_key} was omitted because curve {curve_id} is unsupported"),
2056                );
2057                continue;
2058            }
2059            let (start_vertex, end_vertex) = match coedge.sense {
2060                Sense::Forward => (&edge.start, &edge.end),
2061                Sense::Reversed => (&edge.end, &edge.start),
2062            };
2063            segments.push(LoopSegment {
2064                coedge_id: coedge_id.0.clone(),
2065                start_vertex: start_vertex.0.clone(),
2066                end_vertex: end_vertex.0.clone(),
2067            });
2068        }
2069
2070        let mut outgoing: HashMap<String, Vec<usize>> = HashMap::new();
2071        let mut incoming: HashMap<&str, usize> = HashMap::new();
2072        for (index, segment) in segments.iter().enumerate() {
2073            outgoing
2074                .entry(segment.start_vertex.clone())
2075                .or_default()
2076                .push(index);
2077            *incoming.entry(segment.end_vertex.as_str()).or_default() += 1;
2078        }
2079        if segments.iter().any(|segment| {
2080            outgoing.get(&segment.start_vertex).map_or(0, Vec::len)
2081                != incoming
2082                    .get(segment.start_vertex.as_str())
2083                    .copied()
2084                    .unwrap_or(0)
2085        }) {
2086            self.topology_relation_loss(
2087                format!("loop:{loop_id}:continuity"),
2088                StepLossCode::LoopNoContinuousOrdering,
2089                format!("loop {loop_id} has no continuous vertex-to-vertex coedge ordering"),
2090            );
2091            return None;
2092        }
2093
2094        // The IR coedge list and its next/previous links are not sufficient to
2095        // establish the STEP path order. Build an Eulerian circuit from the
2096        // oriented edge endpoints instead; this also handles repeated vertices
2097        // without choosing a geometric edge by position in the source list.
2098        for candidates in outgoing.values_mut() {
2099            candidates.reverse();
2100        }
2101        let first_start = segments.first()?.start_vertex.clone();
2102        let mut vertex_stack = vec![first_start.clone()];
2103        let mut edge_stack = Vec::new();
2104        let mut circuit = Vec::with_capacity(segments.len());
2105        while let Some(vertex) = vertex_stack.last().cloned() {
2106            let next_edge = outgoing.get_mut(&vertex).and_then(Vec::pop);
2107            if let Some(edge_index) = next_edge {
2108                vertex_stack.push(segments[edge_index].end_vertex.clone());
2109                edge_stack.push(edge_index);
2110            } else {
2111                vertex_stack.pop();
2112                if let Some(edge_index) = edge_stack.pop() {
2113                    circuit.push(edge_index);
2114                }
2115            }
2116        }
2117        circuit.reverse();
2118
2119        if circuit.len() != segments.len() {
2120            self.topology_relation_loss(
2121                format!("loop:{loop_id}:continuity"),
2122                StepLossCode::LoopNoContinuousOrdering,
2123                format!("loop {loop_id} has no continuous vertex-to-vertex coedge ordering"),
2124            );
2125            return None;
2126        }
2127
2128        if let Some(first_index) = circuit.iter().position(|index| *index == 0) {
2129            circuit.rotate_left(first_index);
2130        }
2131        let mut current_vertex = segments.first()?.start_vertex.as_str();
2132        for edge_index in &circuit {
2133            let segment = &segments[*edge_index];
2134            if segment.start_vertex != current_vertex {
2135                self.topology_relation_loss(
2136                    format!("loop:{loop_id}:continuity"),
2137                    StepLossCode::LoopNoContinuousOrdering,
2138                    format!("loop {loop_id} has no continuous vertex-to-vertex coedge ordering"),
2139                );
2140                return None;
2141            }
2142            current_vertex = segment.end_vertex.as_str();
2143        }
2144        if current_vertex != first_start {
2145            self.topology_relation_loss(
2146                format!("loop:{loop_id}:continuity"),
2147                StepLossCode::LoopNoContinuousOrdering,
2148                format!("loop {loop_id} has no continuous vertex-to-vertex coedge ordering"),
2149            );
2150            return None;
2151        }
2152
2153        Some(
2154            circuit
2155                .into_iter()
2156                .map(|index| segments[index].coedge_id.clone())
2157                .collect(),
2158        )
2159    }
2160
2161    fn emit_edge(&mut self, edge_id: &str) -> Option<Ref> {
2162        if let Some(r) = self.edge_refs.get(edge_id) {
2163            return Some(*r);
2164        }
2165        let Some(edge) = self.edges.get(edge_id).copied() else {
2166            self.topology_relation_loss(
2167                format!("edge:{edge_id}:record"),
2168                StepLossCode::EdgeRecordMissing,
2169                format!("edge {edge_id} was omitted because its record is missing"),
2170            );
2171            return None;
2172        };
2173        let Some(v1) = self.emit_vertex(edge.start.as_str()) else {
2174            self.topology_relation_loss(
2175                format!("edge:{edge_id}:start-vertex:{}", edge.start),
2176                StepLossCode::EdgeStartVertexMissing,
2177                format!(
2178                    "edge {edge_id} was omitted because start vertex {} is missing",
2179                    edge.start
2180                ),
2181            );
2182            return None;
2183        };
2184        let Some(v2) = self.emit_vertex(edge.end.as_str()) else {
2185            self.topology_relation_loss(
2186                format!("edge:{edge_id}:end-vertex:{}", edge.end),
2187                StepLossCode::EdgeEndVertexMissing,
2188                format!(
2189                    "edge {edge_id} was omitted because end vertex {} is missing",
2190                    edge.end
2191                ),
2192            );
2193            return None;
2194        };
2195        let Some(curve_id) = &edge.curve else {
2196            self.curveless_edges.insert(edge_id.to_string());
2197            self.topology_relation_loss(
2198                format!("edge:{edge_id}:curve"),
2199                StepLossCode::EdgeNo3dCurve,
2200                format!("edge {edge_id} was omitted because it has no 3D curve"),
2201            );
2202            return None;
2203        };
2204        if self
2205            .curves
2206            .get(curve_id.as_str())
2207            .is_some_and(|curve| !geometry::curve_is_supported(&curve.geometry))
2208        {
2209            self.curveless_edges.insert(edge_id.to_string());
2210            self.topology_relation_loss(
2211                format!("edge:{edge_id}:unsupported-curve:{curve_id}"),
2212                StepLossCode::EdgeCurveUnsupported,
2213                format!("edge {edge_id} was omitted because curve {curve_id} is unsupported"),
2214            );
2215            return None;
2216        }
2217        let Some(basis_curve) = self.emit_curve(curve_id.as_str()) else {
2218            self.topology_relation_loss(
2219                format!("edge:{edge_id}:curve:{curve_id}"),
2220                StepLossCode::EdgeCurveNotWritable,
2221                format!("edge {edge_id} was omitted because curve {curve_id} is not writable"),
2222            );
2223            return None;
2224        };
2225        let associated = self.edge_coedges.get(edge_id).cloned().unwrap_or_default();
2226        let mut pcurve_refs = Vec::new();
2227        for (pcurve_id, surface_id) in associated {
2228            if let Some(pcurve) = self.emit_pcurve(pcurve_id, surface_id) {
2229                pcurve_refs.push(pcurve);
2230            } else if self.pcurves.contains_key(pcurve_id) {
2231                self.unwritten_pcurve_carriers.insert(pcurve_id.to_string());
2232            }
2233        }
2234        let curve_ref = if pcurve_refs.is_empty() {
2235            basis_curve
2236        } else {
2237            self.emitter.emit(
2238                "SURFACE_CURVE",
2239                &format!("'',{basis_curve},{},.CURVE_3D.", refs(&pcurve_refs)),
2240            )
2241        };
2242        // same_sense = .T.: the edge runs start→end along the curve's own
2243        // parameterization, the convention IR curves follow.
2244        let r = self
2245            .emitter
2246            .emit("EDGE_CURVE", &format!("'',{v1},{v2},{curve_ref},.T."));
2247        self.edge_refs.insert(edge_id.to_string(), r);
2248        Some(r)
2249    }
2250
2251    fn emit_pcurve(&mut self, pcurve_id: &str, surface_id: &str) -> Option<Ref> {
2252        let pcurve = self.pcurves.get(pcurve_id).copied()?;
2253        let surface = self.emit_surface(surface_id)?;
2254        let curve = geometry::pcurve(&mut self.emitter, &pcurve.geometry)?;
2255        let context = if let Some(context) = self.pcurve_context {
2256            context
2257        } else {
2258            let context = self.emitter.emit_raw(
2259                "GEOMETRIC_REPRESENTATION_CONTEXT",
2260                "( GEOMETRIC_REPRESENTATION_CONTEXT(2) PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('uv','2D') )",
2261            );
2262            self.pcurve_context = Some(context);
2263            context
2264        };
2265        let representation = self.emitter.emit(
2266            "DEFINITIONAL_REPRESENTATION",
2267            &format!("'',({curve}),{context}"),
2268        );
2269        Some(
2270            self.emitter
2271                .emit("PCURVE", &format!("'',{surface},{representation}")),
2272        )
2273    }
2274
2275    fn emit_vertex(&mut self, vertex_id: &str) -> Option<Ref> {
2276        if let Some(r) = self.vertex_refs.get(vertex_id) {
2277            return Some(*r);
2278        }
2279        let vertex = self.vertices.get(vertex_id).copied()?;
2280        let pt = self.points.get(vertex.point.as_str()).copied()?;
2281        let cp = geometry::point(&mut self.emitter, pt.position);
2282        self.point_refs.insert(vertex.point.0.clone(), cp);
2283        let r = self.emitter.emit("VERTEX_POINT", &format!("'',{cp}"));
2284        self.vertex_refs.insert(vertex_id.to_string(), r);
2285        Some(r)
2286    }
2287
2288    fn emit_surface(&mut self, surface_id: &str) -> Option<Ref> {
2289        if let Some(r) = self.surface_refs.get(surface_id) {
2290            return Some(*r);
2291        }
2292        if self.geometry_emission_depth >= 256
2293            || !self.active_surfaces.insert(surface_id.to_string())
2294        {
2295            return None;
2296        }
2297        self.geometry_emission_depth += 1;
2298        let result = (|| {
2299            let surf = self.surfaces.get(surface_id).copied()?;
2300            let procedural = self
2301                .procedural_surfaces
2302                .get(surface_id)
2303                .map(|procedural| (procedural.id.0.clone(), procedural.definition.clone()));
2304            let emitted = procedural.and_then(|(id, definition)| {
2305                self.emit_procedural_surface(&surf.geometry, &definition)
2306                    .map(|reference| (id, reference))
2307            });
2308            let r = if let Some((id, reference)) = emitted {
2309                self.written_procedural_surfaces.insert(id);
2310                reference
2311            } else if !geometry::surface_is_supported(&surf.geometry) {
2312                return None;
2313            } else {
2314                geometry::surface(&mut self.emitter, &surf.geometry)?
2315            };
2316            Some(r)
2317        })();
2318        self.active_surfaces.remove(surface_id);
2319        self.geometry_emission_depth -= 1;
2320        if let Some(r) = result {
2321            self.surface_refs.insert(surface_id.to_string(), r);
2322        }
2323        result
2324    }
2325
2326    fn emit_procedural_surface(
2327        &mut self,
2328        solved: &SurfaceGeometry,
2329        definition: &ProceduralSurfaceDefinition,
2330    ) -> Option<Ref> {
2331        let logical = |value: Option<bool>| match value {
2332            Some(true) => ".T.",
2333            Some(false) => ".F.",
2334            None => ".U.",
2335        };
2336        match definition {
2337            ProceduralSurfaceDefinition::LinearSweep {
2338                directrix,
2339                direction,
2340            } => {
2341                let directrix = self.emit_curve(directrix.as_str())?;
2342                let direction_ref = geometry::direction(&mut self.emitter, *direction);
2343                let vector = self.emitter.emit(
2344                    "VECTOR",
2345                    &format!("'',{direction_ref},{}", real(direction.norm())),
2346                );
2347                Some(self.emitter.emit(
2348                    "SURFACE_OF_LINEAR_EXTRUSION",
2349                    &format!("'',{directrix},{vector}"),
2350                ))
2351            }
2352            ProceduralSurfaceDefinition::AxisRevolution {
2353                directrix,
2354                axis_origin,
2355                axis_direction,
2356            } => {
2357                let directrix = self.emit_curve(directrix.as_str())?;
2358                let origin = geometry::point(&mut self.emitter, *axis_origin);
2359                let direction = geometry::direction(&mut self.emitter, *axis_direction);
2360                let axis = self
2361                    .emitter
2362                    .emit("AXIS1_PLACEMENT", &format!("'',{origin},{direction}"));
2363                Some(
2364                    self.emitter
2365                        .emit("SURFACE_OF_REVOLUTION", &format!("'',{directrix},{axis}")),
2366                )
2367            }
2368            ProceduralSurfaceDefinition::ParallelOffset {
2369                support,
2370                distance,
2371                self_intersect,
2372            } => {
2373                let support = self.emit_surface(support.as_str())?;
2374                Some(self.emitter.emit(
2375                    "OFFSET_SURFACE",
2376                    &format!(
2377                        "'',{support},{},{}",
2378                        real(*distance),
2379                        logical(*self_intersect)
2380                    ),
2381                ))
2382            }
2383            ProceduralSurfaceDefinition::Subset {
2384                support,
2385                parameter_ranges,
2386                u_sense: Some(u_sense),
2387                v_sense: Some(v_sense),
2388            } => {
2389                let support = self.emit_surface(support.as_str())?;
2390                Some(self.emitter.emit(
2391                    "RECTANGULAR_TRIMMED_SURFACE",
2392                    &format!(
2393                        "'',{support},{},{},{},{},{},{}",
2394                        real(parameter_ranges[0][0]),
2395                        real(parameter_ranges[0][1]),
2396                        real(parameter_ranges[1][0]),
2397                        real(parameter_ranges[1][1]),
2398                        if *u_sense { ".T." } else { ".F." },
2399                        if *v_sense { ".T." } else { ".F." },
2400                    ),
2401                ))
2402            }
2403            ProceduralSurfaceDefinition::Replica { source, transform } => {
2404                let source = self.emit_surface(source.as_str())?;
2405                let operator = geometry::transformation_operator(&mut self.emitter, *transform);
2406                Some(
2407                    self.emitter
2408                        .emit("SURFACE_REPLICA", &format!("'',{source},{operator}")),
2409                )
2410            }
2411            ProceduralSurfaceDefinition::DegenerateTorus { select_outer } => {
2412                let SurfaceGeometry::Torus {
2413                    center,
2414                    axis,
2415                    ref_direction,
2416                    major_radius,
2417                    minor_radius,
2418                } = solved
2419                else {
2420                    return None;
2421                };
2422                let placement =
2423                    geometry::placement(&mut self.emitter, *center, *axis, *ref_direction);
2424                Some(self.emitter.emit(
2425                    "DEGENERATE_TOROIDAL_SURFACE",
2426                    &format!(
2427                        "'',{placement},{},{},{}",
2428                        real(major_radius.abs()),
2429                        real(minor_radius.abs()),
2430                        if *select_outer { ".T." } else { ".F." }
2431                    ),
2432                ))
2433            }
2434            _ => None,
2435        }
2436    }
2437
2438    pub(crate) fn emit_curve(&mut self, curve_id: &str) -> Option<Ref> {
2439        if let Some(r) = self.curve_refs.get(curve_id) {
2440            return Some(*r);
2441        }
2442        if self.geometry_emission_depth >= 256 || !self.active_curves.insert(curve_id.to_string()) {
2443            return None;
2444        }
2445        self.geometry_emission_depth += 1;
2446        let result = (|| {
2447            let geometry = self.curves.get(curve_id)?.geometry.clone();
2448            let procedural = self
2449                .procedural_curves
2450                .get(curve_id)
2451                .map(|procedural| (procedural.id.0.clone(), procedural.definition.clone()));
2452            let emitted = procedural.and_then(|(id, definition)| {
2453                self.emit_procedural_curve(&definition)
2454                    .map(|reference| (id, reference))
2455            });
2456            let r = if let Some((id, reference)) = emitted {
2457                self.written_procedural_curves.insert(id);
2458                reference
2459            } else if let CurveGeometry::Composite {
2460                segments,
2461                self_intersect,
2462            } = &geometry
2463            {
2464                let mut segment_refs = Vec::with_capacity(segments.len());
2465                for segment in segments {
2466                    let curve = self.emit_curve(segment.curve.as_str())?;
2467                    let transition = match segment.transition {
2468                    cadmpeg_ir::geometry::CompositeCurveTransition::Discontinuous => {
2469                        ".DISCONTINUOUS."
2470                    }
2471                    cadmpeg_ir::geometry::CompositeCurveTransition::Continuous => ".CONTINUOUS.",
2472                    cadmpeg_ir::geometry::CompositeCurveTransition::ContSameGradient => {
2473                        ".CONTSAMEGRADIENT."
2474                    }
2475                    cadmpeg_ir::geometry::CompositeCurveTransition::ContSameGradientSameCurvature => {
2476                        ".CONTSAMEGRADIENTSAMECURVATURE."
2477                    }
2478                };
2479                    segment_refs.push(self.emitter.emit(
2480                        "COMPOSITE_CURVE_SEGMENT",
2481                        &format!(
2482                            "{transition},{},{curve}",
2483                            if segment.same_sense { ".T." } else { ".F." }
2484                        ),
2485                    ));
2486                }
2487                self.emitter.emit(
2488                    "COMPOSITE_CURVE",
2489                    &format!(
2490                        "'',{},{}",
2491                        refs(&segment_refs),
2492                        match self_intersect {
2493                            Some(true) => ".T.",
2494                            Some(false) => ".F.",
2495                            None => ".U.",
2496                        }
2497                    ),
2498                )
2499            } else if !geometry::curve_is_supported(&geometry) {
2500                return None;
2501            } else {
2502                geometry::curve(&mut self.emitter, &geometry)?
2503            };
2504            Some(r)
2505        })();
2506        self.active_curves.remove(curve_id);
2507        self.geometry_emission_depth -= 1;
2508        if let Some(r) = result {
2509            self.curve_refs.insert(curve_id.to_string(), r);
2510        }
2511        result
2512    }
2513
2514    fn emit_procedural_curve(&mut self, definition: &ProceduralCurveDefinition) -> Option<Ref> {
2515        match definition {
2516            ProceduralCurveDefinition::Subset {
2517                source,
2518                parameter_range: [start, end],
2519                sense,
2520            } => {
2521                let source = self.emit_curve(source.as_str())?;
2522                let (start, end) = if *sense {
2523                    (*start, *end)
2524                } else {
2525                    (*end, *start)
2526                };
2527                Some(self.emitter.emit(
2528                    "TRIMMED_CURVE",
2529                    &format!(
2530                        "'',{source},(PARAMETER_VALUE({})),(PARAMETER_VALUE({})),{},.PARAMETER.",
2531                        real(start),
2532                        real(end),
2533                        if *sense { ".T." } else { ".F." }
2534                    ),
2535                ))
2536            }
2537            ProceduralCurveDefinition::Replica { source, transform } => {
2538                let source = self.emit_curve(source.as_str())?;
2539                let operator = geometry::transformation_operator(&mut self.emitter, *transform);
2540                Some(
2541                    self.emitter
2542                        .emit("CURVE_REPLICA", &format!("'',{source},{operator}")),
2543                )
2544            }
2545            ProceduralCurveDefinition::SpatialOffset {
2546                source,
2547                distance,
2548                reference_direction,
2549                self_intersect,
2550            } => {
2551                let source = self.emit_curve(source.as_str())?;
2552                let direction = geometry::direction(&mut self.emitter, *reference_direction);
2553                let self_intersect = match self_intersect {
2554                    Some(true) => ".T.",
2555                    Some(false) => ".F.",
2556                    None => ".U.",
2557                };
2558                Some(self.emitter.emit(
2559                    "OFFSET_CURVE_3D",
2560                    &format!(
2561                        "'',{source},{},{self_intersect},{direction}",
2562                        real(*distance)
2563                    ),
2564                ))
2565            }
2566            _ => None,
2567        }
2568    }
2569
2570    fn emit_pmi(&mut self, context: Ref) {
2571        if self.ir.model.pmi.is_empty() || !self.schema.supports_semantic_pmi() {
2572            return;
2573        }
2574        let annotations = self.ir.model.pmi.clone();
2575        let Some(pds) = self.default_product_definition_shape else {
2576            return;
2577        };
2578        let mut annotation_refs = HashMap::new();
2579        let mut aspects = HashMap::<String, Ref>::new();
2580        for annotation in &annotations {
2581            for target in &annotation.targets {
2582                let PmiTarget::ShapeAspect { source_id } = target else {
2583                    continue;
2584                };
2585                aspects.entry(source_id.clone()).or_insert_with(|| {
2586                    self.emitter.emit(
2587                        "SHAPE_ASPECT",
2588                        &format!("{},'',{pds},.T.", string(source_id)),
2589                    )
2590                });
2591            }
2592        }
2593        let fallback_aspect = self
2594            .emitter
2595            .emit("SHAPE_ASPECT", &format!("'PMI target','',{pds},.T."));
2596        let target_ref = |annotation: &cadmpeg_ir::PmiAnnotation| {
2597            annotation.targets.iter().find_map(|target| {
2598                if let PmiTarget::ShapeAspect { source_id } = target {
2599                    aspects.get(source_id).copied()
2600                } else {
2601                    None
2602                }
2603            })
2604        };
2605        let targets_exact = |annotation: &cadmpeg_ir::PmiAnnotation| {
2606            annotation
2607                .targets
2608                .iter()
2609                .all(|target| matches!(target, PmiTarget::ShapeAspect { .. }))
2610        };
2611
2612        for annotation in &annotations {
2613            if let PmiDefinition::Datum { identification } = &annotation.definition {
2614                let datum = self.emitter.emit(
2615                    "DATUM",
2616                    &format!(
2617                        "{},$,{pds},.F.,{}",
2618                        string(annotation.name.as_deref().unwrap_or("")),
2619                        string(identification)
2620                    ),
2621                );
2622                annotation_refs.insert(annotation.id.clone(), datum);
2623                self.written_pmi += usize::from(targets_exact(annotation));
2624            }
2625        }
2626        for annotation in &annotations {
2627            if let PmiDefinition::DatumSystem { references } = &annotation.definition {
2628                let mut groups = BTreeMap::<(u32, Option<u32>), Vec<_>>::new();
2629                for reference in references {
2630                    groups
2631                        .entry((reference.precedence, reference.common_group))
2632                        .or_default()
2633                        .push(reference);
2634                }
2635                let compartments = groups
2636                    .values()
2637                    .filter_map(|group| {
2638                        let datum_refs = group
2639                            .iter()
2640                            .map(|reference| annotation_refs.get(&reference.datum).copied())
2641                            .collect::<Option<Vec<_>>>()?;
2642                        if group[0].common_group.is_none() && group.len() != 1 {
2643                            return None;
2644                        }
2645                        let (datum, modifiers) = if group[0].common_group.is_some() {
2646                            let elements = group
2647                                .iter()
2648                                .zip(datum_refs)
2649                                .map(|(reference, datum)| {
2650                                    let modifiers =
2651                                        self.emit_datum_modifiers(&reference.modifiers)?;
2652                                    Some(self.emitter.emit(
2653                                        "DATUM_REFERENCE_ELEMENT",
2654                                        &format!("'',$,{pds},.F.,{datum},({modifiers})"),
2655                                    ))
2656                                })
2657                                .collect::<Option<Vec<_>>>()?;
2658                            (
2659                                format!("COMMON_DATUM_LIST({})", refs(&elements)),
2660                                String::new(),
2661                            )
2662                        } else {
2663                            (
2664                                datum_refs[0].to_string(),
2665                                self.emit_datum_modifiers(&group[0].modifiers)?,
2666                            )
2667                        };
2668                        Some(self.emitter.emit(
2669                            "DATUM_REFERENCE_COMPARTMENT",
2670                            &format!("'',$,{pds},.F.,{datum},({modifiers})"),
2671                        ))
2672                    })
2673                    .collect::<Vec<_>>();
2674                let complete = compartments.len() == groups.len();
2675                if compartments.is_empty() {
2676                    continue;
2677                }
2678                let system = self.emitter.emit(
2679                    "DATUM_SYSTEM",
2680                    &format!(
2681                        "{},'',{pds},.F.,{}",
2682                        string(annotation.name.as_deref().unwrap_or("")),
2683                        refs(&compartments)
2684                    ),
2685                );
2686                annotation_refs.insert(annotation.id.clone(), system);
2687                self.written_pmi += usize::from(targets_exact(annotation) && complete);
2688            }
2689        }
2690        for annotation in &annotations {
2691            match &annotation.definition {
2692                PmiDefinition::Dimension {
2693                    dimension,
2694                    nominal,
2695                    lower_deviation,
2696                    upper_deviation,
2697                    limits_and_fits,
2698                } => {
2699                    let aspect = target_ref(annotation).unwrap_or(fallback_aspect);
2700                    let name = annotation.name.as_deref().unwrap_or("");
2701                    let (entity, kind_exact) = match dimension {
2702                        DimensionKind::Size => ("DIMENSIONAL_SIZE", true),
2703                        DimensionKind::Location => ("DIMENSIONAL_LOCATION", true),
2704                        DimensionKind::Angular => ("ANGULAR_SIZE", true),
2705                        // AP242 represents diameter and radius as a
2706                        // DIMENSIONAL_SIZE whose name identifies the size
2707                        // category; DIAMETER_SIZE and RADIUS_SIZE are not
2708                        // entity types.
2709                        DimensionKind::Diameter | DimensionKind::Radius => {
2710                            ("DIMENSIONAL_SIZE", true)
2711                        }
2712                        DimensionKind::Other(_) => ("DIMENSIONAL_SIZE", false),
2713                    };
2714                    let characteristic_name = match dimension {
2715                        DimensionKind::Diameter => "diameter",
2716                        DimensionKind::Radius => "radius",
2717                        _ => name,
2718                    };
2719                    let parameters = match dimension {
2720                        DimensionKind::Location => {
2721                            format!("{},$,{aspect},{aspect}", string(characteristic_name))
2722                        }
2723                        DimensionKind::Angular => {
2724                            format!("{aspect},{},.SMALL.", string(characteristic_name))
2725                        }
2726                        _ => format!("{aspect},{}", string(characteristic_name)),
2727                    };
2728                    let characteristic = self.emitter.emit(entity, &parameters);
2729                    if let Some(value) = nominal {
2730                        let measure = self.emit_pmi_measure_representation_item(*value, name);
2731                        let representation = self.emitter.emit(
2732                            "SHAPE_DIMENSION_REPRESENTATION",
2733                            &format!("'',({measure}),{context}"),
2734                        );
2735                        self.emitter.emit(
2736                            "DIMENSIONAL_CHARACTERISTIC_REPRESENTATION",
2737                            &format!("{characteristic},{representation}"),
2738                        );
2739                    }
2740                    if let (Some(lower), Some(upper)) = (lower_deviation, upper_deviation) {
2741                        let lower = self.emit_pmi_measure(*lower);
2742                        let upper = self.emit_pmi_measure(*upper);
2743                        let tolerance = self
2744                            .emitter
2745                            .emit("TOLERANCE_VALUE", &format!("{lower},{upper}"));
2746                        self.emitter.emit(
2747                            "PLUS_MINUS_TOLERANCE",
2748                            &format!("{tolerance},{characteristic}"),
2749                        );
2750                    }
2751                    if let Some(fit) = limits_and_fits {
2752                        let fit = self.emitter.emit(
2753                            "LIMITS_AND_FITS",
2754                            &format!(
2755                                "{},{},{},{}",
2756                                string(&fit.form_variance),
2757                                string(&fit.zone_variance),
2758                                string(&fit.grade),
2759                                string(&fit.source)
2760                            ),
2761                        );
2762                        self.emitter
2763                            .emit("PLUS_MINUS_TOLERANCE", &format!("{fit},{characteristic}"));
2764                    }
2765                    annotation_refs.insert(annotation.id.clone(), characteristic);
2766                    let deviations_exact = lower_deviation.is_some() == upper_deviation.is_some();
2767                    self.written_pmi +=
2768                        usize::from(targets_exact(annotation) && deviations_exact && kind_exact);
2769                }
2770                PmiDefinition::GeometricTolerance {
2771                    tolerance,
2772                    magnitude,
2773                    defined_unit,
2774                    defined_area_unit,
2775                    defined_area_second_unit,
2776                    datum_system,
2777                    modifiers,
2778                } => {
2779                    let kind_exact = !matches!(tolerance, GeometricToleranceKind::Other(value) if value != "geometric_tolerance");
2780                    let entity = match tolerance {
2781                        GeometricToleranceKind::Straightness => "STRAIGHTNESS_TOLERANCE",
2782                        GeometricToleranceKind::Flatness => "FLATNESS_TOLERANCE",
2783                        GeometricToleranceKind::Roundness => "ROUNDNESS_TOLERANCE",
2784                        GeometricToleranceKind::Cylindricity => "CYLINDRICITY_TOLERANCE",
2785                        GeometricToleranceKind::Coaxiality => "COAXIALITY_TOLERANCE",
2786                        GeometricToleranceKind::LineProfile => "LINE_PROFILE_TOLERANCE",
2787                        GeometricToleranceKind::SurfaceProfile => "SURFACE_PROFILE_TOLERANCE",
2788                        GeometricToleranceKind::Angularity => "ANGULARITY_TOLERANCE",
2789                        GeometricToleranceKind::Perpendicularity => "PERPENDICULARITY_TOLERANCE",
2790                        GeometricToleranceKind::Parallelism => "PARALLELISM_TOLERANCE",
2791                        GeometricToleranceKind::Position => "POSITION_TOLERANCE",
2792                        GeometricToleranceKind::Concentricity => "CONCENTRICITY_TOLERANCE",
2793                        GeometricToleranceKind::Symmetry => "SYMMETRY_TOLERANCE",
2794                        GeometricToleranceKind::CircularRunout => "CIRCULAR_RUNOUT_TOLERANCE",
2795                        GeometricToleranceKind::TotalRunout => "TOTAL_RUNOUT_TOLERANCE",
2796                        GeometricToleranceKind::Other(_) => continue,
2797                    };
2798                    let aspect = target_ref(annotation).unwrap_or(fallback_aspect);
2799                    let modifier_values = Self::emit_geometric_tolerance_modifiers(modifiers);
2800                    if !modifiers.is_empty() && modifier_values.is_none() {
2801                        continue;
2802                    }
2803                    let area_unit =
2804                        Self::emit_geometric_tolerance_area_unit(defined_area_unit.as_deref());
2805                    if defined_area_unit.is_some() && area_unit.is_none()
2806                        || defined_area_second_unit.is_some() && area_unit.is_none()
2807                    {
2808                        continue;
2809                    }
2810                    let datum_ref = datum_system
2811                        .as_ref()
2812                        .and_then(|id| annotation_refs.get(id).copied());
2813                    if datum_system.is_some() && datum_ref.is_none() {
2814                        continue;
2815                    }
2816                    let measure = self.emit_pmi_measure(*magnitude);
2817                    let tolerance_ref = if datum_ref.is_none()
2818                        && modifiers.is_empty()
2819                        && defined_unit.is_none()
2820                        && area_unit.is_none()
2821                    {
2822                        self.emitter.emit(
2823                            entity,
2824                            &format!(
2825                                "{},'',{measure},{aspect}",
2826                                string(annotation.name.as_deref().unwrap_or(""))
2827                            ),
2828                        )
2829                    } else {
2830                        let mut parts = vec![format!(
2831                            "GEOMETRIC_TOLERANCE({},{},{measure},{aspect})",
2832                            string(annotation.name.as_deref().unwrap_or("")),
2833                            "''"
2834                        )];
2835                        if area_unit.is_some() || defined_unit.is_some() {
2836                            let defined_unit = defined_unit.as_ref().map_or_else(
2837                                || "$".into(),
2838                                |unit| self.emit_pmi_measure(*unit).to_string(),
2839                            );
2840                            parts.push(format!(
2841                                "GEOMETRIC_TOLERANCE_WITH_DEFINED_UNIT({defined_unit})"
2842                            ));
2843                        }
2844                        if let Some(area_unit) = area_unit {
2845                            let second_unit = defined_area_second_unit.as_ref().map_or_else(
2846                                || "$".into(),
2847                                |unit| self.emit_pmi_measure(*unit).to_string(),
2848                            );
2849                            parts.push(format!(
2850                                "GEOMETRIC_TOLERANCE_WITH_DEFINED_AREA_UNIT(.{area_unit}.,{second_unit})"
2851                            ));
2852                        }
2853                        if let Some(datum) = datum_ref {
2854                            parts.push(format!(
2855                                "GEOMETRIC_TOLERANCE_WITH_DATUM_REFERENCE(({datum}))"
2856                            ));
2857                        }
2858                        if let Some(modifiers) = modifier_values {
2859                            parts
2860                                .push(format!("GEOMETRIC_TOLERANCE_WITH_MODIFIERS(({modifiers}))"));
2861                        }
2862                        parts.push(format!("{entity}()"));
2863                        self.emitter
2864                            .emit_raw("GEOMETRIC_TOLERANCE", &format!("({})", parts.join(" ")))
2865                    };
2866                    annotation_refs.insert(annotation.id.clone(), tolerance_ref);
2867                    self.written_pmi += usize::from(targets_exact(annotation) && kind_exact);
2868                }
2869                PmiDefinition::Datum { .. }
2870                | PmiDefinition::DatumSystem { .. }
2871                | PmiDefinition::Presentation { .. } => {}
2872            }
2873        }
2874        let mut presentation_items = Vec::new();
2875        let mut presentation_semantics = Vec::new();
2876        for annotation in &annotations {
2877            let PmiDefinition::Presentation {
2878                text,
2879                placement,
2880                semantics,
2881            } = &annotation.definition
2882            else {
2883                continue;
2884            };
2885            let (Some(text), Some(placement)) = (text.as_deref(), placement.as_ref()) else {
2886                continue;
2887            };
2888            if !annotation.targets.is_empty() || !is_rigid_transform(&placement.rows) {
2889                continue;
2890            }
2891            let rows = placement.rows;
2892            let placement = geometry::placement(
2893                &mut self.emitter,
2894                cadmpeg_ir::math::Point3::new(rows[0][3], rows[1][3], rows[2][3]),
2895                cadmpeg_ir::math::Vector3::new(rows[0][2], rows[1][2], rows[2][2]),
2896                cadmpeg_ir::math::Vector3::new(rows[0][0], rows[1][0], rows[2][0]),
2897            );
2898            let font_source = self.emitter.emit("EXTERNAL_SOURCE", "'ISO 3098'");
2899            let font = self.emitter.emit(
2900                "EXTERNALLY_DEFINED_TEXT_FONT",
2901                &format!("IDENTIFIER('ISO 3098'),{font_source}"),
2902            );
2903            let literal = self.emitter.emit(
2904                "TEXT_LITERAL",
2905                &format!("{},{placement},'left',.RIGHT.,{font}", string(text)),
2906            );
2907            let semantic_refs = semantics
2908                .iter()
2909                .filter_map(|semantic| annotation_refs.get(semantic).copied())
2910                .collect::<Vec<_>>();
2911            if semantic_refs.len() != semantics.len() {
2912                continue;
2913            }
2914            let style = self
2915                .emitter
2916                .emit("PRESENTATION_STYLE_ASSIGNMENT", "(.NULL.)");
2917            let occurrence = self.emitter.emit(
2918                "ANNOTATION_TEXT_OCCURRENCE",
2919                &format!(
2920                    "{},{},{literal}",
2921                    string(annotation.name.as_deref().unwrap_or("")),
2922                    refs(&[style])
2923                ),
2924            );
2925            presentation_items.push(occurrence);
2926            presentation_semantics.push((occurrence, semantic_refs));
2927            annotation_refs.insert(annotation.id.clone(), occurrence);
2928            self.written_pmi += 1;
2929        }
2930        if !presentation_items.is_empty() {
2931            let model = self.emitter.emit(
2932                "DRAUGHTING_MODEL",
2933                &format!(
2934                    "'PMI presentation',{}, {context}",
2935                    refs(&presentation_items)
2936                ),
2937            );
2938            for (occurrence, semantics) in presentation_semantics {
2939                for semantic in semantics {
2940                    self.emitter.emit(
2941                        "DRAUGHTING_MODEL_ITEM_ASSOCIATION",
2942                        &format!("'','',{semantic},{model},{occurrence}"),
2943                    );
2944                }
2945            }
2946        }
2947        for (annotation, reference) in annotation_refs {
2948            self.pmi_step_refs.insert(annotation.0, reference);
2949        }
2950    }
2951
2952    fn emit_datum_modifiers(&mut self, source: &[String]) -> Option<String> {
2953        const SIMPLE: &[&str] = &[
2954            "free_state",
2955            "basic",
2956            "translation",
2957            "least_material_requirement",
2958            "maximum_material_requirement",
2959            "point",
2960            "line",
2961            "plane",
2962            "orientation",
2963            "any_cross_section",
2964            "any_longitudinal_section",
2965            "contacting_feature",
2966            "distance_variable",
2967            "degree_of_freedom_constraint_x",
2968            "degree_of_freedom_constraint_y",
2969            "degree_of_freedom_constraint_z",
2970            "degree_of_freedom_constraint_u",
2971            "degree_of_freedom_constraint_v",
2972            "degree_of_freedom_constraint_w",
2973            "minor_diameter",
2974            "major_diameter",
2975            "pitch_diameter",
2976        ];
2977        const WITH_VALUE: &[&str] = &[
2978            "circular_or_cylindrical",
2979            "spherical",
2980            "distance",
2981            "projected",
2982        ];
2983        enum Modifier {
2984            Simple(String),
2985            WithValue { kind: String, value: f64 },
2986        }
2987        let parsed = source
2988            .iter()
2989            .map(|modifier| {
2990                if let Some((kind, value)) = modifier.split_once(':') {
2991                    let kind = kind.to_ascii_lowercase();
2992                    let value = value.parse::<f64>().ok()?;
2993                    if !value.is_finite() {
2994                        return None;
2995                    }
2996                    WITH_VALUE
2997                        .contains(&kind.as_str())
2998                        .then_some(Modifier::WithValue { kind, value })
2999                } else {
3000                    let modifier = modifier.to_ascii_lowercase();
3001                    SIMPLE
3002                        .contains(&modifier.as_str())
3003                        .then_some(Modifier::Simple(modifier))
3004                }
3005            })
3006            .collect::<Option<Vec<_>>>()?;
3007        let mut modifiers = Vec::with_capacity(source.len());
3008        for modifier in parsed {
3009            match modifier {
3010                Modifier::WithValue { kind, value } => {
3011                    let measure = self.emit_pmi_measure(cadmpeg_ir::PmiValue {
3012                        value,
3013                        quantity: cadmpeg_ir::PmiQuantity::Length,
3014                    });
3015                    modifiers.push(
3016                        self.emitter
3017                            .emit(
3018                                "DATUM_REFERENCE_MODIFIER_WITH_VALUE",
3019                                &format!(".{}.,{measure}", kind.to_ascii_uppercase()),
3020                            )
3021                            .to_string(),
3022                    );
3023                }
3024                Modifier::Simple(modifier) => {
3025                    modifiers.push(format!(".{}.", modifier.to_ascii_uppercase()));
3026                }
3027            }
3028        }
3029        Some(modifiers.join(","))
3030    }
3031
3032    fn emit_geometric_tolerance_modifiers(source: &[String]) -> Option<String> {
3033        const SUPPORTED: &[&str] = &[
3034            "any_cross_section",
3035            "associated_least_square_feature",
3036            "associated_maximum_inscribed_feature",
3037            "associated_minimum_inscribed_feature",
3038            "associated_minmax_feature",
3039            "associated_tangent_feature",
3040            "circle_a",
3041            "common_zone",
3042            "continuous_features",
3043            "derived_feature",
3044            "each_element",
3045            "each_radial_element",
3046            "free_state",
3047            "individually",
3048            "least_material_requirement",
3049            "line_element",
3050            "major_diameter",
3051            "maximum_material_requirement",
3052            "minor_diameter",
3053            "not_convex",
3054            "offset_zone",
3055            "peak_height",
3056            "pitch_diameter",
3057            "reciprocity_requirement",
3058            "reference_least_square_feature_with_external_material_constraint",
3059            "reference_least_square_feature_with_internal_material_constraint",
3060            "reference_least_square_feature_without_constraint",
3061            "reference_maximum_inscribed_feature",
3062            "reference_minimax_feature_with_external_material_constraint",
3063            "reference_minimax_feature_with_internal_material_constraint",
3064            "reference_minimax_feature_without_constraint",
3065            "reference_minimum_circumscribed_feature",
3066            "separate_requirement",
3067            "separate_zones",
3068            "standard_deviation",
3069            "statistical_tolerance",
3070            "stock",
3071            "tangent_plane",
3072            "total_range_deviations",
3073            "united_feature",
3074            "unspecified_angular_tolerance_zone_offset",
3075            "unspecified_linear_tolerance_zone_offset",
3076            "valley_depth",
3077            "variable_angle",
3078        ];
3079        source
3080            .iter()
3081            .map(|modifier| {
3082                let normalized = modifier.to_ascii_lowercase();
3083                SUPPORTED
3084                    .contains(&normalized.as_str())
3085                    .then(|| format!(".{}.", normalized.to_ascii_uppercase()))
3086            })
3087            .collect::<Option<Vec<_>>>()
3088            .map(|modifiers| modifiers.join(","))
3089    }
3090
3091    fn emit_geometric_tolerance_area_unit(source: Option<&str>) -> Option<String> {
3092        let source = source?;
3093        matches!(
3094            source.to_ascii_lowercase().as_str(),
3095            "circular" | "square" | "rectangular" | "cylindrical" | "spherical"
3096        )
3097        .then(|| source.to_ascii_lowercase())
3098    }
3099
3100    fn emit_pmi_measure(&mut self, value: cadmpeg_ir::PmiValue) -> Ref {
3101        let (entity, typed, unit) = match value.quantity {
3102            PmiQuantity::Length => (
3103                "LENGTH_MEASURE_WITH_UNIT",
3104                "LENGTH_MEASURE",
3105                self.emit_length_unit(),
3106            ),
3107            PmiQuantity::Angle => (
3108                "PLANE_ANGLE_MEASURE_WITH_UNIT",
3109                "PLANE_ANGLE_MEASURE",
3110                self.emit_angle_unit(),
3111            ),
3112            PmiQuantity::Ratio => ("MEASURE_WITH_UNIT", "RATIO_MEASURE", self.emit_ratio_unit()),
3113        };
3114        self.emitter
3115            .emit(entity, &format!("{typed}({}),{unit}", real(value.value)))
3116    }
3117
3118    fn emit_pmi_measure_representation_item(
3119        &mut self,
3120        value: cadmpeg_ir::PmiValue,
3121        name: &str,
3122    ) -> Ref {
3123        let (typed, unit) = match value.quantity {
3124            PmiQuantity::Length => ("LENGTH_MEASURE", self.emit_length_unit()),
3125            PmiQuantity::Angle => ("PLANE_ANGLE_MEASURE", self.emit_angle_unit()),
3126            PmiQuantity::Ratio => ("RATIO_MEASURE", self.emit_ratio_unit()),
3127        };
3128        self.emitter.emit(
3129            "MEASURE_REPRESENTATION_ITEM",
3130            &format!("{},{typed}({}),{unit}", string(name), real(value.value)),
3131        )
3132    }
3133
3134    fn note_unrepresented(&mut self) {
3135        let mut referenced_shells = BTreeSet::new();
3136        let mut referenced_faces = BTreeSet::new();
3137        let mut referenced_loops = BTreeSet::new();
3138        let mut referenced_coedges = BTreeSet::new();
3139        let mut referenced_edges = BTreeSet::new();
3140        let mut referenced_vertices = BTreeSet::new();
3141        for region in &self.ir.model.regions {
3142            for shell_id in &region.shells {
3143                if !referenced_shells.insert(shell_id.as_str()) {
3144                    continue;
3145                }
3146                let Some(shell) = self.shells.get(shell_id.as_str()).copied() else {
3147                    continue;
3148                };
3149                for edge_id in &shell.wire_edges {
3150                    referenced_edges.insert(edge_id.as_str());
3151                    if let Some(edge) = self.edges.get(edge_id.as_str()).copied() {
3152                        referenced_vertices.insert(edge.start.as_str());
3153                        referenced_vertices.insert(edge.end.as_str());
3154                    }
3155                }
3156                referenced_vertices.extend(
3157                    shell
3158                        .free_vertices
3159                        .iter()
3160                        .map(cadmpeg_ir::ids::VertexId::as_str),
3161                );
3162                for face_id in &shell.faces {
3163                    if !referenced_faces.insert(face_id.as_str()) {
3164                        continue;
3165                    }
3166                    let Some(face) = self.faces.get(face_id.as_str()).copied() else {
3167                        continue;
3168                    };
3169                    for loop_id in &face.loops {
3170                        if !referenced_loops.insert(loop_id.as_str()) {
3171                            continue;
3172                        }
3173                        let Some(loop_) = self.loops.get(loop_id.as_str()).copied() else {
3174                            continue;
3175                        };
3176                        referenced_vertices
3177                            .extend(loop_.vertex_uses.iter().map(|use_| use_.vertex.as_str()));
3178                        for vertex_use in &loop_.vertex_uses {
3179                            if let Some(after) = &vertex_use.after {
3180                                referenced_coedges.insert(after.as_str());
3181                            }
3182                        }
3183                        for coedge_id in &loop_.coedges {
3184                            if !referenced_coedges.insert(coedge_id.as_str()) {
3185                                continue;
3186                            }
3187                            let Some(coedge) = self.coedges.get(coedge_id.as_str()).copied() else {
3188                                continue;
3189                            };
3190                            referenced_edges.insert(coedge.edge.as_str());
3191                            if let Some(edge) = self.edges.get(coedge.edge.as_str()).copied() {
3192                                referenced_vertices.insert(edge.start.as_str());
3193                                referenced_vertices.insert(edge.end.as_str());
3194                            }
3195                        }
3196                    }
3197                }
3198            }
3199        }
3200        let omitted_bodies = self
3201            .ir
3202            .model
3203            .bodies
3204            .iter()
3205            .filter(|body| !self.body_shape_refs.contains_key(body.id.as_str()))
3206            .count();
3207        let omitted_shells = self
3208            .ir
3209            .model
3210            .shells
3211            .iter()
3212            .filter(|shell| !referenced_shells.contains(shell.id.as_str()))
3213            .count();
3214        let omitted_faces = self
3215            .ir
3216            .model
3217            .faces
3218            .iter()
3219            .filter(|face| !referenced_faces.contains(face.id.as_str()))
3220            .count();
3221        let omitted_loops = self
3222            .ir
3223            .model
3224            .loops
3225            .iter()
3226            .filter(|loop_| !referenced_loops.contains(loop_.id.as_str()))
3227            .count();
3228        let omitted_coedges = self
3229            .ir
3230            .model
3231            .coedges
3232            .iter()
3233            .filter(|coedge| !referenced_coedges.contains(coedge.id.as_str()))
3234            .count();
3235        let omitted_edges = self
3236            .ir
3237            .model
3238            .edges
3239            .iter()
3240            .filter(|edge| !referenced_edges.contains(edge.id.as_str()))
3241            .count();
3242        let omitted_vertices = self
3243            .ir
3244            .model
3245            .vertices
3246            .iter()
3247            .filter(|vertex| !referenced_vertices.contains(vertex.id.as_str()))
3248            .count();
3249        let omitted_topology = [
3250            ("body", omitted_bodies),
3251            ("shell", omitted_shells),
3252            ("face", omitted_faces),
3253            ("loop", omitted_loops),
3254            ("coedge", omitted_coedges),
3255            ("edge", omitted_edges),
3256            ("vertex", omitted_vertices),
3257        ];
3258        if omitted_topology.iter().any(|(_, count)| *count > 0) {
3259            let details = omitted_topology
3260                .iter()
3261                .filter(|(_, count)| *count > 0)
3262                .map(|(kind, count)| format!("{count} {kind}(s)"))
3263                .collect::<Vec<_>>()
3264                .join(", ");
3265            self.loss(
3266                StepLossCode::TopologyUnreachableFromRegion,
3267                format!("topology not reachable from any emitted region shape item: {details}"),
3268            );
3269        }
3270        let nonstandard_analytic_surfaces = self
3271            .ir
3272            .model
3273            .surfaces
3274            .iter()
3275            .filter(|surface| match &surface.geometry {
3276                SurfaceGeometry::Sphere { radius, .. } => *radius < 0.0,
3277                SurfaceGeometry::Torus {
3278                    major_radius,
3279                    minor_radius,
3280                    ..
3281                } => {
3282                    *major_radius < 0.0
3283                        || *minor_radius < 0.0
3284                        || (minor_radius.abs() > major_radius.abs()
3285                            && !self.ir.model.procedural_surfaces.iter().any(|procedural| {
3286                                procedural.surface == surface.id
3287                                    && self.written_procedural_surfaces.contains(&procedural.id.0)
3288                                    && matches!(
3289                                        procedural.definition,
3290                                        ProceduralSurfaceDefinition::DegenerateTorus { .. }
3291                                    )
3292                            }))
3293                }
3294                _ => false,
3295            })
3296            .count();
3297        if nonstandard_analytic_surfaces > 0 {
3298            self.loss(
3299                StepLossCode::AnalyticSurfaceNormalized,
3300                format!(
3301                    "{nonstandard_analytic_surfaces} signed or self-intersecting analytic \
3302                     surface(s) were normalized to positive STEP radii"
3303                ),
3304            );
3305        }
3306        let elliptical_cones = self
3307            .ir
3308            .model
3309            .surfaces
3310            .iter()
3311            .filter(|surface| {
3312                matches!(
3313                    surface.geometry,
3314                    SurfaceGeometry::Cone { ratio, .. } if ratio != 1.0
3315                )
3316            })
3317            .count();
3318        if elliptical_cones > 0 {
3319            self.loss(
3320                StepLossCode::EllipticalConeReduced,
3321                format!(
3322                    "{elliptical_cones} elliptical cone surface(s) were reduced to circular STEP CONICAL_SURFACE carriers"
3323                ),
3324            );
3325        }
3326        if !self.curveless_edges.is_empty() {
3327            self.loss(
3328                StepLossCode::CurvelessEdgeOmitted,
3329                format!(
3330                    "{} edge(s) have no typed 3D curve or carry a STEP-unsupported transform and were omitted from \
3331                     their edge loops (STEP EDGE_CURVE requires a 3D curve)",
3332                    self.curveless_edges.len()
3333                ),
3334            );
3335        }
3336        if !self.unknown_surface_faces.is_empty() {
3337            self.loss(
3338                StepLossCode::UnknownSurfaceFaceOmitted,
3339                format!(
3340                    "{} face(s) rest on an unknown or STEP-unsupported surface and were omitted \
3341                     from the STEP shell (an ADVANCED_FACE requires a surface); their \
3342                     topology remains in the IR",
3343                    self.unknown_surface_faces.len()
3344                ),
3345            );
3346        }
3347        if !self.unwritten_geometry_carriers.is_empty() {
3348            let carriers = self
3349                .unwritten_geometry_carriers
3350                .iter()
3351                .map(|id| format!("'{id}'"))
3352                .collect::<Vec<_>>()
3353                .join(", ");
3354            self.loss(
3355                StepLossCode::GeometryCarrierNotWritten,
3356                format!(
3357                    "{} geometry carrier(s) were not written: {carriers}",
3358                    self.unwritten_geometry_carriers.len()
3359                ),
3360            );
3361        }
3362        if !self.unwritten_pcurve_carriers.is_empty() {
3363            let pcurves = self
3364                .unwritten_pcurve_carriers
3365                .iter()
3366                .map(|id| format!("'{id}'"))
3367                .collect::<Vec<_>>()
3368                .join(", ");
3369            self.loss(
3370                StepLossCode::PcurveCarrierUnwritable,
3371                format!(
3372                    "{} coedge pcurve carrier(s) use geometry or surface references that were not writable: {pcurves}",
3373                    self.unwritten_pcurve_carriers.len()
3374                ),
3375            );
3376        }
3377        if !self.missing_parent_products.is_empty() {
3378            let occurrences = self
3379                .missing_parent_products
3380                .iter()
3381                .map(|id| format!("'{id}'"))
3382                .collect::<Vec<_>>()
3383                .join(", ");
3384            self.loss(
3385                StepLossCode::AssemblyOccurrenceOmittedNoParentProduct,
3386                format!(
3387                    "{} assembly occurrence(s) were omitted because their parent has no local product definition: {occurrences}",
3388                    self.missing_parent_products.len()
3389                ),
3390            );
3391        }
3392        if !self.empty_regions.is_empty() {
3393            let regions = self
3394                .empty_regions
3395                .iter()
3396                .map(|id| format!("'{id}'"))
3397                .collect::<Vec<_>>()
3398                .join(", ");
3399            self.loss(
3400                StepLossCode::RegionNoShellList,
3401                format!(
3402                    "{} region(s) have no shell list and were not written to STEP: {regions}",
3403                    self.empty_regions.len()
3404                ),
3405            );
3406        }
3407        if !self.empty_wire_regions.is_empty() {
3408            let regions = self
3409                .empty_wire_regions
3410                .iter()
3411                .map(|id| format!("'{id}'"))
3412                .collect::<Vec<_>>()
3413                .join(", ");
3414            self.loss(
3415                StepLossCode::WireRegionNoConnectedEdgeSet,
3416                format!(
3417                    "{} wire region(s) had no writable connected edge set and were not written to STEP: {regions}",
3418                    self.empty_wire_regions.len()
3419                ),
3420            );
3421        }
3422        if !self.missing_wire_shells.is_empty() {
3423            let shells = self
3424                .missing_wire_shells
3425                .iter()
3426                .map(|(region, shell)| format!("'{region}' -> '{shell}'"))
3427                .collect::<Vec<_>>()
3428                .join(", ");
3429            self.loss(
3430                StepLossCode::WireRegionMissingShell,
3431                format!(
3432                    "{} wire region/shell relation(s) referenced missing shell records: {shells}",
3433                    self.missing_wire_shells.len()
3434                ),
3435            );
3436        }
3437        if !self.hidden_bodies_without_items.is_empty() {
3438            let bodies = self
3439                .hidden_bodies_without_items
3440                .iter()
3441                .map(|id| format!("'{id}'"))
3442                .collect::<Vec<_>>()
3443                .join(", ");
3444            self.loss(
3445                StepLossCode::HiddenBodyOmitted,
3446                format!(
3447                    "{} hidden body/bodies had no emitted STEP item and were omitted from INVISIBILITY: {bodies}",
3448                    self.hidden_bodies_without_items.len()
3449                ),
3450            );
3451        }
3452        if !self.dangling_appearance_bindings.is_empty() {
3453            let bindings = self
3454                .dangling_appearance_bindings
3455                .iter()
3456                .map(|(binding, appearance)| format!("'{binding}' -> '{appearance}'"))
3457                .collect::<Vec<_>>()
3458                .join(", ");
3459            self.loss(
3460                StepLossCode::AppearanceBindingMissingAsset,
3461                format!(
3462                    "{} appearance binding(s) reference missing appearance assets and were not written: {bindings}",
3463                    self.dangling_appearance_bindings.len()
3464                ),
3465            );
3466        }
3467        if !self.colorless_appearance_bindings.is_empty() {
3468            let bindings = self
3469                .colorless_appearance_bindings
3470                .iter()
3471                .map(|(binding, appearance)| format!("'{binding}' -> '{appearance}'"))
3472                .collect::<Vec<_>>()
3473                .join(", ");
3474            self.loss(
3475                StepLossCode::AppearanceBindingNoBaseColor,
3476                format!(
3477                    "{} appearance binding(s) reference appearances without a base color and were not written: {bindings}",
3478                    self.colorless_appearance_bindings.len()
3479                ),
3480            );
3481        }
3482        let missing_pcurve_count = self
3483            .ir
3484            .model
3485            .coedges
3486            .iter()
3487            .flat_map(|coedge| &coedge.pcurves)
3488            .filter(|use_| !self.pcurves.contains_key(use_.pcurve.as_str()))
3489            .count();
3490        if missing_pcurve_count > 0 {
3491            self.loss(
3492                StepLossCode::CoedgePcurveNoGeometry,
3493                format!(
3494                    "{missing_pcurve_count} coedge pcurve reference(s) have no geometry and were not written"
3495                ),
3496            );
3497        }
3498        let reduced_pcurve_count = self
3499            .ir
3500            .model
3501            .coedges
3502            .iter()
3503            .flat_map(|coedge| &coedge.pcurves)
3504            .filter_map(|use_| self.pcurves.get(use_.pcurve.as_str()))
3505            .filter(|pcurve| {
3506                pcurve.wrapper_reversed.is_some()
3507                    || pcurve.native_tail_flags.is_some()
3508                    || pcurve.parameter_range.is_some()
3509                    || pcurve.fit_tolerance.is_some()
3510            })
3511            .count();
3512        if reduced_pcurve_count > 0 {
3513            self.loss(
3514                StepLossCode::CoedgePcurveNativeMetadata,
3515                format!(
3516                    "{reduced_pcurve_count} emitted coedge pcurve(s) carry native-only metadata not represented in STEP"
3517                ),
3518            );
3519        }
3520        let pcurve_use_metadata_count = self
3521            .ir
3522            .model
3523            .coedges
3524            .iter()
3525            .flat_map(|coedge| &coedge.pcurves)
3526            .chain(
3527                self.ir
3528                    .model
3529                    .loops
3530                    .iter()
3531                    .flat_map(|loop_| &loop_.vertex_uses)
3532                    .flat_map(|vertex_use| &vertex_use.pcurves),
3533            )
3534            .filter(|use_| use_.isoparametric.is_some() || use_.parameter_range.is_some())
3535            .count();
3536        if pcurve_use_metadata_count > 0 {
3537            self.loss(
3538                StepLossCode::PcurveUseNativeMetadata,
3539                format!(
3540                    "{pcurve_use_metadata_count} pcurve use(s) carry native-only parameter metadata not represented in STEP"
3541                ),
3542            );
3543        }
3544        let coedge_use_curve_metadata_count = self
3545            .ir
3546            .model
3547            .coedges
3548            .iter()
3549            .filter(|coedge| {
3550                coedge.use_curve.is_some() || coedge.use_curve_parameter_range.is_some()
3551            })
3552            .count();
3553        if coedge_use_curve_metadata_count > 0 {
3554            self.loss(
3555                StepLossCode::CoedgeUseCurveNotRepresented,
3556                format!(
3557                    "{coedge_use_curve_metadata_count} coedge-local 3D curve use(s) were not represented in STEP"
3558                ),
3559            );
3560        }
3561        // EDGE_CURVE carries its bounded domain through the two vertex
3562        // endpoints. The IR parameter interval is a derived parameterization
3563        // detail, not a separate STEP attribute, so it is not a loss here.
3564        let topology_metadata = [
3565            (
3566                "face tolerance",
3567                self.ir
3568                    .model
3569                    .faces
3570                    .iter()
3571                    .filter(|face| face.tolerance.is_some())
3572                    .count(),
3573            ),
3574            (
3575                "edge tolerance",
3576                self.ir
3577                    .model
3578                    .edges
3579                    .iter()
3580                    .filter(|edge| edge.tolerance.is_some())
3581                    .count(),
3582            ),
3583            (
3584                "vertex tolerance",
3585                self.ir
3586                    .model
3587                    .vertices
3588                    .iter()
3589                    .filter(|vertex| vertex.tolerance.is_some())
3590                    .count(),
3591            ),
3592        ];
3593        let topology_metadata_count = topology_metadata
3594            .iter()
3595            .map(|(_, count)| count)
3596            .sum::<usize>();
3597        if topology_metadata_count > 0 {
3598            let details = topology_metadata
3599                .iter()
3600                .filter(|(_, count)| *count > 0)
3601                .map(|(kind, count)| format!("{kind}={count}"))
3602                .collect::<Vec<_>>()
3603                .join(", ");
3604            self.loss(
3605                StepLossCode::TopologyMetadataNotRepresented,
3606                format!(
3607                    "{topology_metadata_count} topology metadata value(s) were not represented in STEP: {details}"
3608                ),
3609            );
3610        }
3611        if !self.ir.model.subds.is_empty() {
3612            self.loss(
3613                StepLossCode::SubdOmitted,
3614                format!(
3615                    "{} subdivision surface(s) were omitted because this STEP writer \
3616                     does not encode SubD control cages",
3617                    self.ir.model.subds.len()
3618                ),
3619            );
3620        }
3621        let design_arenas = [
3622            ("feature", self.ir.model.features.len()),
3623            (
3624                "feature input topology",
3625                self.ir.model.feature_input_topologies.len(),
3626            ),
3627            (
3628                "feature result topology",
3629                self.ir.model.feature_result_topologies.len(),
3630            ),
3631            ("configuration", self.ir.model.configurations.len()),
3632            ("parameter", self.ir.model.parameters.len()),
3633            ("sketch", self.ir.model.sketches.len()),
3634            ("sketch entity", self.ir.model.sketch_entities.len()),
3635            ("sketch constraint", self.ir.model.sketch_constraints.len()),
3636            ("spatial sketch", self.ir.model.spatial_sketches.len()),
3637            (
3638                "spatial sketch entity",
3639                self.ir.model.spatial_sketch_entities.len(),
3640            ),
3641            (
3642                "spatial sketch constraint",
3643                self.ir.model.spatial_sketch_constraints.len(),
3644            ),
3645            ("spreadsheet", self.ir.model.spreadsheets.len()),
3646        ];
3647        let design_record_count = design_arenas.iter().map(|(_, count)| count).sum::<usize>();
3648        if design_record_count > 0 {
3649            let details = design_arenas
3650                .iter()
3651                .filter(|(_, count)| *count > 0)
3652                .map(|(kind, count)| format!("{kind}={count}"))
3653                .collect::<Vec<_>>()
3654                .join(", ");
3655            self.loss(
3656                StepLossCode::ParametricDesignRecordsOmitted,
3657                format!(
3658                    "{design_record_count} parametric/design record(s) were not represented in STEP: {details}"
3659                ),
3660            );
3661        }
3662        let presentation_arenas = [
3663            ("drawing", self.ir.model.drawings.len()),
3664            (
3665                "presentation document",
3666                self.ir.model.presentation_documents.len(),
3667            ),
3668            ("view presentation", self.ir.model.view_presentations.len()),
3669        ];
3670        let presentation_record_count = presentation_arenas
3671            .iter()
3672            .map(|(_, count)| count)
3673            .sum::<usize>();
3674        if presentation_record_count > 0 {
3675            let details = presentation_arenas
3676                .iter()
3677                .filter(|(_, count)| *count > 0)
3678                .map(|(kind, count)| format!("{kind}={count}"))
3679                .collect::<Vec<_>>()
3680                .join(", ");
3681            self.loss(
3682                StepLossCode::DrawingPresentationRecordsOmitted,
3683                format!(
3684                    "{presentation_record_count} drawing/presentation record(s) were not represented in STEP: {details}"
3685                ),
3686            );
3687        }
3688        if !self.ir.model.semantic_annotations.is_empty() {
3689            self.loss(
3690                StepLossCode::SemanticAnnotationOmitted,
3691                format!(
3692                    "{} semantic annotation(s) were not represented in STEP",
3693                    self.ir.model.semantic_annotations.len()
3694                ),
3695            );
3696        }
3697        if !self.ir.model.assets.is_empty() {
3698            self.loss(
3699                StepLossCode::DocumentAssetOmitted,
3700                format!(
3701                    "{} document asset(s) were not represented in STEP",
3702                    self.ir.model.assets.len()
3703                ),
3704            );
3705        }
3706        if !self.ir.model.assembly_joints.is_empty() {
3707            self.loss(
3708                StepLossCode::AssemblyJointOmitted,
3709                format!(
3710                    "{} assembly joint(s) were not represented in STEP",
3711                    self.ir.model.assembly_joints.len()
3712                ),
3713            );
3714        }
3715        let non_part_products = self
3716            .ir
3717            .model
3718            .product_definitions
3719            .iter()
3720            .filter(|product| {
3721                !matches!(
3722                    &product.kind,
3723                    cadmpeg_ir::products::ProductDefinitionKind::Part
3724                )
3725            })
3726            .count();
3727        if non_part_products > 0 {
3728            self.loss(
3729                StepLossCode::ProductNonPartKind,
3730                format!(
3731                    "{non_part_products} product definition(s) use a non-part kind not represented in STEP"
3732                ),
3733            );
3734        }
3735        let bom_property_count = self
3736            .ir
3737            .model
3738            .product_definitions
3739            .iter()
3740            .map(|product| product.bom_properties.len())
3741            .sum::<usize>();
3742        if bom_property_count > 0 {
3743            self.loss(
3744                StepLossCode::ProductBomPropertyOmitted,
3745                format!(
3746                    "{bom_property_count} product BOM property value(s) were not represented in STEP"
3747                ),
3748            );
3749        }
3750        let external_occurrences = self
3751            .ir
3752            .model
3753            .occurrences
3754            .iter()
3755            .filter(|occurrence| {
3756                matches!(
3757                    &occurrence.prototype,
3758                    cadmpeg_ir::products::PrototypeReference::External { .. }
3759                )
3760            })
3761            .count();
3762        if external_occurrences > 0 {
3763            self.loss(
3764                StepLossCode::OccurrenceExternalProduct,
3765                format!(
3766                    "{external_occurrences} occurrence(s) reference external product definitions and were not represented in STEP"
3767                ),
3768            );
3769        }
3770        let unresolved_occurrences = self
3771            .ir
3772            .model
3773            .occurrences
3774            .iter()
3775            .filter(|occurrence| {
3776                matches!(
3777                    &occurrence.prototype,
3778                    cadmpeg_ir::products::PrototypeReference::Unresolved
3779                )
3780            })
3781            .count();
3782        if unresolved_occurrences > 0 {
3783            self.loss(
3784                StepLossCode::OccurrenceNoWritableProduct,
3785                format!(
3786                    "{unresolved_occurrences} occurrence(s) have no writable product definition"
3787                ),
3788            );
3789        }
3790        let occurrence_metadata = self
3791            .ir
3792            .model
3793            .occurrences
3794            .iter()
3795            .map(|occurrence| {
3796                usize::from(!occurrence.linked_subelements.is_empty())
3797                    + usize::from(occurrence.visible.is_some())
3798                    + usize::from(occurrence.element_component.is_some())
3799                    + usize::from(occurrence.claim_child.is_some())
3800                    + usize::from(occurrence.copy_on_change.is_some())
3801                    + usize::from(occurrence.copy_on_change_source.is_some())
3802                    + usize::from(occurrence.copy_on_change_group.is_some())
3803                    + usize::from(occurrence.copy_on_change_touched.is_some())
3804            })
3805            .sum::<usize>();
3806        if occurrence_metadata > 0 {
3807            self.loss(
3808                StepLossCode::OccurrenceMetadataOmitted,
3809                format!(
3810                    "{occurrence_metadata} occurrence metadata value(s) were not represented in STEP"
3811                ),
3812            );
3813        }
3814        let unwritten_pmi = self.ir.model.pmi.len().saturating_sub(self.written_pmi);
3815        if unwritten_pmi > 0 {
3816            self.loss(
3817                StepLossCode::PmiAnnotationNotWritten,
3818                format!("{unwritten_pmi} PMI annotation(s) were not written to STEP"),
3819            );
3820        }
3821        // STEP-native source associations identify records already represented
3822        // by the writer's own STEP graph. They are not lossy foreign-source
3823        // metadata. Keep strict-mode rejection for associations from other
3824        // codecs, which this writer cannot reproduce.
3825        let source_object_count = self
3826            .ir
3827            .model
3828            .surfaces
3829            .iter()
3830            .filter(|surface| {
3831                surface
3832                    .source_object
3833                    .as_ref()
3834                    .is_some_and(|source| source.format != "step")
3835            })
3836            .count()
3837            + self
3838                .ir
3839                .model
3840                .curves
3841                .iter()
3842                .filter(|curve| {
3843                    curve
3844                        .source_object
3845                        .as_ref()
3846                        .is_some_and(|source| source.format != "step")
3847                })
3848                .count()
3849            + self
3850                .ir
3851                .model
3852                .subds
3853                .iter()
3854                .filter(|subd| {
3855                    subd.source_object
3856                        .as_ref()
3857                        .is_some_and(|source| source.format != "step")
3858                })
3859                .count()
3860            + self
3861                .ir
3862                .model
3863                .tessellations
3864                .iter()
3865                .filter(|tessellation| {
3866                    tessellation
3867                        .source_object
3868                        .as_ref()
3869                        .is_some_and(|source| source.format != "step")
3870                })
3871                .count();
3872        let source_object_count = source_object_count
3873            + self
3874                .ir
3875                .model
3876                .points
3877                .iter()
3878                .filter(|point| {
3879                    point
3880                        .source_object
3881                        .as_ref()
3882                        .is_some_and(|source| source.format != "step")
3883                })
3884                .count();
3885        if source_object_count > 0 {
3886            self.loss(
3887                StepLossCode::SourceAssociationOmitted,
3888                format!(
3889                    "{source_object_count} source-object association(s) were not represented in STEP"
3890                ),
3891            );
3892        }
3893        let unknown_count = self
3894            .ir
3895            .native
3896            .loss_counts()
3897            .into_iter()
3898            .filter(|count| count.kind == "unknowns")
3899            .map(|count| count.count)
3900            .sum::<usize>();
3901        if unknown_count > 0 {
3902            self.loss(
3903                StepLossCode::PassthroughRecordOmitted,
3904                format!("{unknown_count} uninterpreted passthrough record(s) were not represented in STEP"),
3905            );
3906        }
3907        if self.unstyled_colors > 0 {
3908            self.loss(
3909                StepLossCode::DisplayColorUnstyled,
3910                format!(
3911                    "{} display color(s) had no emitted STEP item and were not written \
3912                     to STEP presentation",
3913                    self.unstyled_colors
3914                ),
3915            );
3916        }
3917        let lossy_appearances = self
3918            .ir
3919            .model
3920            .appearances
3921            .iter()
3922            .filter(|appearance| {
3923                let bindings = self
3924                    .ir
3925                    .model
3926                    .appearance_bindings
3927                    .iter()
3928                    .filter(|binding| binding.appearance == appearance.id)
3929                    .collect::<Vec<_>>();
3930                appearance.asset_guid.is_some()
3931                    || appearance.visual_guid.is_some()
3932                    || appearance.physical_token.is_some()
3933                    || appearance
3934                        .schema
3935                        .as_deref()
3936                        .is_some_and(|schema| schema != "step_surface_style")
3937                    || appearance.category.is_some()
3938                    || !appearance.properties.is_empty()
3939                    || appearance.base_color.is_none_or(|color| color.a != 1.0)
3940                    || bindings.is_empty()
3941                    || bindings
3942                        .iter()
3943                        .any(|binding| !self.written_appearance_bindings.contains(&binding.id))
3944            })
3945            .count();
3946        if lossy_appearances > 0 {
3947            self.loss(
3948                StepLossCode::AppearanceReducedToBaseColor,
3949                format!(
3950                    "{lossy_appearances} appearance asset(s) were reduced to STYLED_ITEM base colors; \
3951                     schemas, textures, and shader properties were not written to STEP"
3952                ),
3953            );
3954        }
3955        let lossy_binding_metadata = self
3956            .ir
3957            .model
3958            .appearance_bindings
3959            .iter()
3960            .filter(|binding| binding.object_type.is_some() || !binding.channels.is_empty())
3961            .count();
3962        if lossy_binding_metadata > 0 {
3963            self.loss(
3964                StepLossCode::AppearanceBindingMetadataReduced,
3965                format!(
3966                    "{lossy_binding_metadata} appearance binding(s) carry source object or channel metadata not represented in STEP"
3967                ),
3968            );
3969        }
3970        if !self.ir.model.attributes.is_empty() {
3971            self.loss(
3972                StepLossCode::SourceAttributeNotWritten,
3973                format!(
3974                    "{} source attribute record(s) were not written to STEP",
3975                    self.ir.model.attributes.len()
3976                ),
3977            );
3978        }
3979        let procedural_surface_count = self
3980            .ir
3981            .model
3982            .procedural_surfaces
3983            .iter()
3984            .filter(|procedural| !self.written_procedural_surfaces.contains(&procedural.id.0))
3985            .count();
3986        let procedural_curve_count = self
3987            .ir
3988            .model
3989            .procedural_curves
3990            .iter()
3991            .filter(|procedural| !self.written_procedural_curves.contains(&procedural.id.0))
3992            .count();
3993        if procedural_surface_count > 0 || procedural_curve_count > 0 {
3994            self.loss(
3995                StepLossCode::ProceduralReducedToCarrier,
3996                format!(
3997                    "{procedural_surface_count} procedural surface definition(s) and {procedural_curve_count} procedural curve definition(s) were reduced to their solved STEP carriers"
3998                ),
3999            );
4000        }
4001        let source_native_records: usize = self
4002            .ir
4003            .native
4004            .loss_counts()
4005            .iter()
4006            .filter(|loss| loss.kind != "unknowns")
4007            .map(|loss| loss.count)
4008            .sum();
4009        if source_native_records > 0 {
4010            self.loss(
4011                StepLossCode::SourceNativeRecordOmitted,
4012                format!(
4013                    "{source_native_records} source-native record(s) were not represented in STEP"
4014                ),
4015            );
4016        }
4017    }
4018
4019    fn finish_report(&self) -> ExportReport {
4020        ExportReport {
4021            format: "step".into(),
4022            census: cadmpeg_ir::EntityCensus {
4023                basis: cadmpeg_ir::CensusBasis::TargetRecords,
4024                counts: self.emitter.counts(),
4025            },
4026            fidelity: FidelityResolution::NotProvided,
4027            // STEP is a target-only format here: every record is emitted from
4028            // the neutral IR, with no source container to replay or patch.
4029            write_path: WritePath::Synthesized,
4030            losses: self.losses.clone(),
4031            notes: self.notes.clone(),
4032        }
4033    }
4034}
4035
4036fn is_identity(rows: &[[f64; 4]; 4]) -> bool {
4037    for (i, row) in rows.iter().enumerate() {
4038        for (j, &v) in row.iter().enumerate() {
4039            let expect = if i == j { 1.0 } else { 0.0 };
4040            if (v - expect).abs() > EPS_IDENTITY {
4041                return false;
4042            }
4043        }
4044    }
4045    true
4046}
4047
4048pub(crate) fn is_rigid_transform(rows: &[[f64; 4]; 4]) -> bool {
4049    cadmpeg_ir::transform::Transform { rows: *rows }.is_proper_rigid()
4050}