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