Skip to main content

cadmpeg_step/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Reads and writes [`cadmpeg_ir::CadIr`] documents as ISO 10303-21 STEP Part
3//! 21 exchange structures for AP203, AP214, and AP242.
4//!
5//! [`write_step`] emits the application protocol selected by
6//! [`StepWriteOptions::schema`]. It writes product and representation context,
7//! connected exact shape, product occurrences, tessellation, presentation,
8//! and PMI when the target schema carries those domains.
9//!
10//! # Export workflow
11//!
12//! Construct or decode a [`cadmpeg_ir::CadIr`], choose the header metadata in
13//! [`StepWriteOptions`], then write to any [`std::io::Write`] sink:
14//!
15//! ```
16//! use cadmpeg_ir::examples::unit_cube;
17//! use cadmpeg_step::{write_step, StepWriteOptions};
18//!
19//! let ir = unit_cube();
20//! let mut bytes = Vec::new();
21//! let report = write_step(&ir, &mut bytes, &StepWriteOptions::default())?;
22//!
23//! assert!(bytes.starts_with(b"ISO-10303-21;"));
24//! assert!(report.total_entities > 0);
25//! # Ok::<(), cadmpeg_step::StepError>(())
26//! ```
27//!
28//! Review [`cadmpeg_ir::ExportReport::losses`] before retaining report-mode
29//! output. [`StepUnsupportedPolicy::Reject`] rejects all such losses before any
30//! output byte is written. Opaque records, source attributes, unsupported
31//! procedural definitions, and target-schema incompatibilities are reported or
32//! rejected rather than silently discarded. Body and face colors become
33//! per-face `STYLED_ITEM` presentation; direct geometry and tessellation
34//! bindings retain their native presentation targets.
35//!
36//! Coordinates are emitted unchanged under a millimetre length-unit context.
37//! Callers must convert non-millimetre geometry before export. Analytic curves
38//! and surfaces map to their corresponding STEP carriers. Rational and
39//! non-rational NURBS use the `*_WITH_KNOTS` entities.
40//!
41//! [`StepError`] represents output-sink failures. Since the writer streams the
42//! header and DATA section, such a failure can leave partial output.
43
44mod geometry;
45pub mod lex;
46pub mod parse;
47mod reader;
48pub mod strings;
49mod writer;
50
51use std::collections::{BTreeMap, BTreeSet, HashMap};
52use std::io::Write;
53
54use cadmpeg_ir::appearance::Appearance;
55use cadmpeg_ir::codec::{
56    Codec, CodecError, Confidence, ContainerEntry, ContainerSummary, DecodeOptions, DecodeResult,
57    Encoder,
58};
59use cadmpeg_ir::geometry::{
60    Curve, CurveGeometry, Pcurve, ProceduralCurve, ProceduralCurveDefinition, ProceduralSurface,
61    ProceduralSurfaceDefinition, Surface, SurfaceGeometry,
62};
63use cadmpeg_ir::ids::{OccurrenceId, ProductId};
64use cadmpeg_ir::product::OccurrenceParent;
65use cadmpeg_ir::report::{ExportReport, LossCategory, LossCode, LossNote, Severity};
66use cadmpeg_ir::topology::{
67    Body, BodyKind, Coedge, Edge, Face, Loop, LoopBoundaryRole, Point, Sense, Shell, Vertex,
68};
69use cadmpeg_ir::CadIr;
70
71use writer::{real, refs, string, Emitter, Ref};
72
73/// Metadata written to the STEP `FILE_NAME` header record.
74///
75/// Default values produce deterministic output. They identify the file as
76/// `cadmpeg_model`, leave the author and organization empty, use `cadmpeg` as
77/// the originating system, and substitute `1970-01-01T00:00:00` for the empty
78/// timestamp.
79#[derive(Debug, Clone)]
80pub struct StepWriteOptions {
81    /// Application protocol and edition declared by `FILE_SCHEMA`.
82    pub schema: StepSchema,
83    /// Handling of IR content the selected writer cannot represent exactly.
84    pub unsupported: StepUnsupportedPolicy,
85    /// The `FILE_NAME` name field.
86    ///
87    /// The STEP `PRODUCT` id and name come from the first IR body name, or
88    /// `cadmpeg_model` when that body has no name.
89    pub product_name: String,
90    /// The sole entry in the `FILE_NAME` author list.
91    pub author: String,
92    /// The sole entry in the `FILE_NAME` organization list.
93    pub organization: String,
94    /// The `FILE_NAME` timestamp.
95    ///
96    /// Supply an ISO 8601 value. An empty string is written as
97    /// `1970-01-01T00:00:00`.
98    pub timestamp: String,
99    /// The `FILE_NAME` originating-system field.
100    pub originating_system: String,
101}
102
103impl Default for StepWriteOptions {
104    fn default() -> Self {
105        StepWriteOptions {
106            schema: StepSchema::Ap214,
107            unsupported: StepUnsupportedPolicy::Report,
108            product_name: "cadmpeg_model".to_string(),
109            author: String::new(),
110            organization: String::new(),
111            timestamp: String::new(),
112            originating_system: "cadmpeg".to_string(),
113        }
114    }
115}
116
117/// Policy for semantic content not representable by the selected STEP target.
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
119pub enum StepUnsupportedPolicy {
120    /// Emit the representable subset and return machine-readable loss notes.
121    #[default]
122    Report,
123    /// Reject the document before writing any output byte.
124    Reject,
125}
126
127/// STEP application-protocol targets supported by the Part 21 writer.
128///
129/// The AP242 edition number and the long-form schema revision are distinct:
130/// editions 1, 2, and 3 use long-form revisions 1, 3, and 4 respectively.
131#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
132pub enum StepSchema {
133    /// AP203 edition 1 `CONFIG_CONTROL_DESIGN`.
134    Ap203Edition1,
135    /// AP203 edition 2 modular long form.
136    Ap203Edition2,
137    /// AP214 `AUTOMOTIVE_DESIGN`.
138    #[default]
139    Ap214,
140    /// AP242 edition 1 modular long form.
141    Ap242Edition1,
142    /// AP242 edition 2 modular long form.
143    Ap242Edition2,
144    /// AP242 edition 3 modular long form.
145    Ap242Edition3,
146}
147
148impl StepSchema {
149    /// Exact schema identifier written in `FILE_SCHEMA`.
150    pub const fn file_schema(self) -> &'static str {
151        match self {
152            Self::Ap203Edition1 => "CONFIG_CONTROL_DESIGN",
153            Self::Ap203Edition2 => "AP203_CONFIGURATION_CONTROLLED_3D_DESIGN_OF_MECHANICAL_PARTS_AND_ASSEMBLIES_MIM_LF { 1 0 10303 403 2 1 2 }",
154            Self::Ap214 => "AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }",
155            Self::Ap242Edition1 => "AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 1 1 4 }",
156            Self::Ap242Edition2 => "AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 3 1 4 }",
157            Self::Ap242Edition3 => "AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 4 1 4 }",
158        }
159    }
160
161    const fn supports_tessellation(self) -> bool {
162        matches!(
163            self,
164            Self::Ap242Edition1 | Self::Ap242Edition2 | Self::Ap242Edition3
165        )
166    }
167
168    const fn supports_semantic_pmi(self) -> bool {
169        self.supports_tessellation()
170    }
171
172    const fn supports_visibility(self) -> bool {
173        !matches!(self, Self::Ap203Edition1)
174    }
175
176    const fn application_protocol(self) -> (&'static str, &'static str, i32) {
177        match self {
178            Self::Ap203Edition1 => (
179                "configuration controlled 3d designs of mechanical parts and assemblies",
180                "config_control_design",
181                1994,
182            ),
183            Self::Ap203Edition2 => (
184                "configuration controlled 3d designs of mechanical parts and assemblies",
185                "ap203_configuration_controlled_3d_design_of_mechanical_parts_and_assemblies",
186                2011,
187            ),
188            Self::Ap214 => ("automotive design", "automotive_design", 2000),
189            Self::Ap242Edition1 => (
190                "managed model based 3d engineering",
191                "ap242_managed_model_based_3d_engineering",
192                2014,
193            ),
194            Self::Ap242Edition2 => (
195                "managed model based 3d engineering",
196                "ap242_managed_model_based_3d_engineering",
197                2020,
198            ),
199            Self::Ap242Edition3 => (
200                "managed model based 3d engineering",
201                "ap242_managed_model_based_3d_engineering",
202                2022,
203            ),
204        }
205    }
206}
207
208/// Failure returned while streaming STEP output.
209///
210/// Unsupported or reduced IR content appears in [`ExportReport::losses`] after a
211/// successful write.
212#[derive(Debug, thiserror::Error)]
213pub enum StepError {
214    /// Strict writing found semantics that would be reduced or omitted.
215    #[error("STEP target cannot represent the document without loss: {0}")]
216    Unsupported(String),
217    /// The output sink rejected a write.
218    #[error("failed to write STEP output: {0}")]
219    Io(#[from] std::io::Error),
220}
221
222/// Serializes an IR document as an ISO 10303-21 STEP AP214 file.
223///
224/// The output declares the `AUTOMOTIVE_DESIGN` schema and a millimetre length
225/// unit. Coordinate values are not rescaled. The IR linear tolerance becomes
226/// the representation context's uncertainty value.
227///
228/// Geometry conversion completes before this function writes the header. It
229/// then streams the header, DATA instances, and closing records to `w`. An I/O
230/// error can therefore leave a partial file and returns no report.
231///
232/// On success, the report contains DATA entity counts and loss notes for
233/// omitted or reduced content.
234pub fn write_step(
235    ir: &CadIr,
236    w: &mut (impl Write + ?Sized),
237    opts: &StepWriteOptions,
238) -> Result<ExportReport, StepError> {
239    let mut b = Builder::new(ir, opts.schema);
240    b.build();
241    let report = b.finish_report();
242    let lines = b.emitter.into_lines();
243
244    if opts.unsupported == StepUnsupportedPolicy::Reject && !report.losses.is_empty() {
245        return Err(StepError::Unsupported(
246            report
247                .losses
248                .iter()
249                .map(|loss| loss.message.as_str())
250                .collect::<Vec<_>>()
251                .join("; "),
252        ));
253    }
254
255    write_header(w, opts)?;
256    writeln!(w, "DATA;")?;
257    for line in &lines {
258        writeln!(w, "{line}")?;
259    }
260    writeln!(w, "ENDSEC;")?;
261    writeln!(w, "END-ISO-10303-21;")?;
262    Ok(report)
263}
264
265fn write_header(w: &mut (impl Write + ?Sized), opts: &StepWriteOptions) -> std::io::Result<()> {
266    let ts = if opts.timestamp.is_empty() {
267        "1970-01-01T00:00:00"
268    } else {
269        &opts.timestamp
270    };
271    writeln!(w, "ISO-10303-21;")?;
272    writeln!(w, "HEADER;")?;
273    writeln!(
274        w,
275        "FILE_DESCRIPTION(({}),'2;1');",
276        string("CAD model exported by cadmpeg")
277    )?;
278    writeln!(
279        w,
280        "FILE_NAME({},{},({}),({}),{},{},{});",
281        string(&opts.product_name),
282        string(ts),
283        string(&opts.author),
284        string(&opts.organization),
285        string("cadmpeg-step"),
286        string(&opts.originating_system),
287        string("")
288    )?;
289    writeln!(w, "FILE_SCHEMA(({}));", string(opts.schema.file_schema()))?;
290    writeln!(w, "ENDSEC;")?;
291    Ok(())
292}
293
294#[derive(Clone, Copy)]
295struct ColorSpec<'a> {
296    color: cadmpeg_ir::topology::Color,
297    appearance: Option<&'a Appearance>,
298    binding_id: Option<&'a str>,
299}
300
301struct Builder<'a> {
302    ir: &'a CadIr,
303    schema: StepSchema,
304    emitter: Emitter,
305    losses: Vec<LossNote>,
306    notes: Vec<String>,
307
308    points: HashMap<&'a str, &'a Point>,
309    bodies: HashMap<&'a str, &'a Body>,
310    shells: HashMap<&'a str, &'a Shell>,
311    faces: HashMap<&'a str, &'a Face>,
312    loops: HashMap<&'a str, &'a Loop>,
313    vertices: HashMap<&'a str, &'a Vertex>,
314    edges: HashMap<&'a str, &'a Edge>,
315    coedges: HashMap<&'a str, &'a Coedge>,
316    surfaces: HashMap<&'a str, &'a Surface>,
317    curves: HashMap<&'a str, &'a Curve>,
318    pcurves: HashMap<&'a str, &'a Pcurve>,
319    procedural_surfaces: HashMap<&'a str, &'a ProceduralSurface>,
320    procedural_curves: HashMap<&'a str, &'a ProceduralCurve>,
321    edge_coedges: HashMap<&'a str, Vec<(&'a str, &'a str)>>,
322
323    surface_refs: HashMap<String, Ref>,
324    curve_refs: HashMap<String, Ref>,
325    edge_refs: HashMap<String, Ref>,
326    vertex_refs: HashMap<String, Ref>,
327    point_refs: HashMap<String, Ref>,
328    pcurve_context: Option<Ref>,
329    active_surfaces: BTreeSet<String>,
330    active_curves: BTreeSet<String>,
331    written_procedural_surfaces: BTreeSet<String>,
332    written_procedural_curves: BTreeSet<String>,
333
334    /// Edges skipped because they carry no attributed 3D curve, deduplicated
335    /// (a shared edge is reached once per coedge) and aggregated into a single
336    /// counted loss note.
337    curveless_edges: BTreeSet<String>,
338
339    /// Faces skipped because their surface geometry is unknown (opaque), so no
340    /// STEP surface exists to build an `ADVANCED_FACE` on. Deduplicated (a face
341    /// is reached once per shell) and aggregated into a single counted loss.
342    unknown_surface_faces: BTreeSet<String>,
343
344    face_step_refs: HashMap<String, Ref>,
345    /// First emitted exact solid or shell for each body, used by AP242 tessellation links.
346    body_step_refs: HashMap<String, Ref>,
347    default_product_definition_shape: Option<Ref>,
348    body_shape_refs: HashMap<String, Ref>,
349    body_item_refs: HashMap<String, Vec<Ref>>,
350    tessellation_step_refs: HashMap<String, Ref>,
351    written_appearance_bindings: BTreeSet<String>,
352    unstyled_colors: usize,
353    unsupported_standalone_geometry: usize,
354    written_pmi: usize,
355    length_unit: Option<Ref>,
356    angle_unit: Option<Ref>,
357    ratio_unit: Option<Ref>,
358    geometry_emission_depth: usize,
359}
360
361impl<'a> Builder<'a> {
362    fn new(ir: &'a CadIr, schema: StepSchema) -> Self {
363        let loop_surfaces = ir
364            .model
365            .faces
366            .iter()
367            .flat_map(|face| {
368                face.loops
369                    .iter()
370                    .map(move |loop_id| (loop_id.as_str(), face.surface.as_str()))
371            })
372            .collect::<HashMap<_, _>>();
373        let coedge_surfaces: HashMap<&str, &str> = ir
374            .model
375            .loops
376            .iter()
377            .filter_map(|loop_| {
378                loop_surfaces
379                    .get(loop_.id.as_str())
380                    .map(|surface| (loop_, *surface))
381            })
382            .flat_map(|(loop_, surface)| {
383                loop_
384                    .coedges
385                    .iter()
386                    .map(move |coedge| (coedge.as_str(), surface))
387            })
388            .collect();
389        let mut edge_coedges = HashMap::<&str, Vec<(&str, &str)>>::new();
390        for coedge in &ir.model.coedges {
391            let Some(surface) = coedge_surfaces.get(coedge.id.as_str()) else {
392                continue;
393            };
394            for pcurve in &coedge.pcurves {
395                edge_coedges
396                    .entry(coedge.edge.as_str())
397                    .or_default()
398                    .push((pcurve.pcurve.as_str(), *surface));
399            }
400        }
401        Builder {
402            ir,
403            schema,
404            emitter: Emitter::new(),
405            losses: Vec::new(),
406            notes: Vec::new(),
407            points: ir.model.points.iter().map(|p| (p.id.as_str(), p)).collect(),
408            bodies: ir
409                .model
410                .bodies
411                .iter()
412                .map(|body| (body.id.as_str(), body))
413                .collect(),
414            shells: ir
415                .model
416                .shells
417                .iter()
418                .map(|shell| (shell.id.as_str(), shell))
419                .collect(),
420            faces: ir
421                .model
422                .faces
423                .iter()
424                .map(|face| (face.id.as_str(), face))
425                .collect(),
426            loops: ir
427                .model
428                .loops
429                .iter()
430                .map(|loop_| (loop_.id.as_str(), loop_))
431                .collect(),
432            vertices: ir
433                .model
434                .vertices
435                .iter()
436                .map(|v| (v.id.as_str(), v))
437                .collect(),
438            edges: ir.model.edges.iter().map(|e| (e.id.as_str(), e)).collect(),
439            coedges: ir
440                .model
441                .coedges
442                .iter()
443                .map(|c| (c.id.as_str(), c))
444                .collect(),
445            surfaces: ir
446                .model
447                .surfaces
448                .iter()
449                .map(|s| (s.id.as_str(), s))
450                .collect(),
451            curves: ir.model.curves.iter().map(|c| (c.id.as_str(), c)).collect(),
452            pcurves: ir
453                .model
454                .pcurves
455                .iter()
456                .map(|p| (p.id.as_str(), p))
457                .collect(),
458            procedural_surfaces: ir
459                .model
460                .procedural_surfaces
461                .iter()
462                .map(|surface| (surface.surface.as_str(), surface))
463                .collect(),
464            procedural_curves: ir
465                .model
466                .procedural_curves
467                .iter()
468                .map(|curve| (curve.curve.as_str(), curve))
469                .collect(),
470            edge_coedges,
471            surface_refs: HashMap::new(),
472            curve_refs: HashMap::new(),
473            edge_refs: HashMap::new(),
474            vertex_refs: HashMap::new(),
475            point_refs: HashMap::new(),
476            pcurve_context: None,
477            active_surfaces: BTreeSet::new(),
478            active_curves: BTreeSet::new(),
479            written_procedural_surfaces: BTreeSet::new(),
480            written_procedural_curves: BTreeSet::new(),
481            curveless_edges: BTreeSet::new(),
482            unknown_surface_faces: BTreeSet::new(),
483            face_step_refs: HashMap::new(),
484            body_step_refs: HashMap::new(),
485            default_product_definition_shape: None,
486            body_shape_refs: HashMap::new(),
487            body_item_refs: HashMap::new(),
488            tessellation_step_refs: HashMap::new(),
489            written_appearance_bindings: BTreeSet::new(),
490            unstyled_colors: 0,
491            unsupported_standalone_geometry: 0,
492            written_pmi: 0,
493            length_unit: None,
494            angle_unit: None,
495            ratio_unit: None,
496            geometry_emission_depth: 0,
497        }
498    }
499
500    fn loss(
501        &mut self,
502        code: LossCode,
503        category: LossCategory,
504        severity: Severity,
505        message: String,
506    ) {
507        self.losses.push(LossNote {
508            code,
509            category,
510            severity,
511            message,
512            provenance: None,
513        });
514    }
515
516    fn omit(
517        &mut self,
518        code: LossCode,
519        category: LossCategory,
520        severity: Severity,
521        message: String,
522    ) {
523        self.losses.push(LossNote {
524            code,
525            category,
526            severity,
527            message,
528            provenance: None,
529        });
530    }
531
532    fn build(&mut self) {
533        let context = self.emit_context();
534
535        let shape_items = self.emit_shape_items(context);
536        let mut standalone_items = self.emit_standalone_geometry();
537        let has_standalone_geometry = !standalone_items.is_empty();
538        let mut emitted_items = shape_items;
539        emitted_items.extend(standalone_items.iter().copied());
540        if emitted_items.is_empty() && !self.ir.model.bodies.is_empty() {
541            self.losses.push(LossNote {
542                code: LossCode::NoExportableSolids,
543                category: LossCategory::Topology,
544                severity: Severity::Warning,
545                message: "no exportable solids: the IR document contains no body/region/shell \
546                          geometry, so the STEP representation is empty"
547                    .to_string(),
548                provenance: None,
549            });
550            emitted_items.clear();
551        }
552        let mut items = emitted_items;
553        let origin = geometry::placement(
554            &mut self.emitter,
555            cadmpeg_ir::math::Point3::new(0.0, 0.0, 0.0),
556            cadmpeg_ir::math::Vector3::new(0.0, 0.0, 1.0),
557            cadmpeg_ir::math::Vector3::new(1.0, 0.0, 0.0),
558        );
559        items.push(origin);
560
561        if self.ir.model.products.is_empty() {
562            let product_def_shape = self.emit_product_structure();
563            self.default_product_definition_shape = Some(product_def_shape);
564            let representation_kind = if !has_standalone_geometry
565                && !self.ir.model.bodies.is_empty()
566                && self.ir.model.bodies.iter().all(|body| {
567                    body.transform
568                        .is_none_or(|transform| is_identity(&transform.rows))
569                })
570                && self
571                    .ir
572                    .model
573                    .bodies
574                    .iter()
575                    .all(|body| body.kind == BodyKind::Solid)
576            {
577                "ADVANCED_BREP_SHAPE_REPRESENTATION"
578            } else {
579                "SHAPE_REPRESENTATION"
580            };
581            let representation = self.emitter.emit(
582                representation_kind,
583                &format!("'',{},{context}", refs(&items)),
584            );
585            self.emitter.emit(
586                "SHAPE_DEFINITION_REPRESENTATION",
587                &format!("{product_def_shape},{representation}"),
588            );
589        } else {
590            self.emit_product_graph(context);
591            if has_standalone_geometry {
592                standalone_items.push(origin);
593                self.emitter.emit(
594                    "SHAPE_REPRESENTATION",
595                    &format!(
596                        "{},{},{context}",
597                        string("standalone geometry"),
598                        refs(&standalone_items)
599                    ),
600                );
601            }
602        }
603
604        self.emit_visibility();
605        self.emit_tessellations(context);
606        self.emit_presentation(context);
607        self.emit_layers();
608        self.emit_pmi(context);
609        self.note_unrepresented();
610    }
611
612    fn emit_presentation(&mut self, context: Ref) {
613        use cadmpeg_ir::appearance::AppearanceTarget;
614
615        let ir = self.ir;
616        let appearances: HashMap<&str, &Appearance> = ir
617            .model
618            .appearances
619            .iter()
620            .map(|appearance| (appearance.id.as_str(), appearance))
621            .collect();
622        let mut body_colors: HashMap<&str, ColorSpec<'_>> = HashMap::new();
623        let mut face_colors: HashMap<&str, ColorSpec<'_>> = HashMap::new();
624        for binding in &ir.model.appearance_bindings {
625            let Some(appearance) = appearances.get(binding.appearance.as_str()).copied() else {
626                continue;
627            };
628            let Some(color) = appearance.base_color else {
629                continue;
630            };
631            let spec = ColorSpec {
632                color,
633                appearance: Some(appearance),
634                binding_id: Some(&binding.id),
635            };
636            match &binding.target {
637                AppearanceTarget::Body(id) => {
638                    body_colors.entry(id.as_str()).or_insert(spec);
639                }
640                AppearanceTarget::Face(id) => {
641                    face_colors.entry(id.as_str()).or_insert(spec);
642                }
643                AppearanceTarget::Surface(_)
644                | AppearanceTarget::Curve(_)
645                | AppearanceTarget::Point(_)
646                | AppearanceTarget::Edge(_)
647                | AppearanceTarget::Vertex(_)
648                | AppearanceTarget::Tessellation(_)
649                | AppearanceTarget::Source { .. } => {}
650            }
651        }
652        for body in &ir.model.bodies {
653            if let Some(color) = body.color {
654                body_colors.entry(body.id.as_str()).or_insert(ColorSpec {
655                    color,
656                    appearance: None,
657                    binding_id: None,
658                });
659            }
660        }
661        for face in &ir.model.faces {
662            if let Some(color) = face.color {
663                face_colors.entry(face.id.as_str()).or_insert(ColorSpec {
664                    color,
665                    appearance: None,
666                    binding_id: None,
667                });
668            }
669        }
670
671        let mut face_body: HashMap<&str, &str> = HashMap::new();
672        for region in &ir.model.regions {
673            let body = region.body.0.as_str();
674            for shell_id in &region.shells {
675                let Some(shell) = self.shells.get(shell_id.as_str()).copied() else {
676                    continue;
677                };
678                for face in &shell.faces {
679                    face_body.insert(face.0.as_str(), body);
680                }
681            }
682        }
683
684        // Every colored face carries its own face-level STYLED_ITEM. A face's
685        // color is its own override when present, otherwise the color of the
686        // body that owns it. Whole-solid styling is intentionally not emitted:
687        // common OCCT/VTK-based viewers (f3d, CAD Assistant) read STEP surface
688        // colors only from ADVANCED_FACE and ignore MANIFOLD_SOLID_BREP, so a
689        // body color left at the solid level renders as the viewer default.
690        let mut style_refs: HashMap<String, Ref> = HashMap::new();
691        let mut styled = Vec::new();
692        let mut faces: Vec<(String, Ref)> = self
693            .face_step_refs
694            .iter()
695            .map(|(id, r)| (id.clone(), *r))
696            .collect();
697        faces.sort_by(|a, b| a.0.cmp(&b.0));
698        let mut styled_bodies: BTreeSet<&str> = BTreeSet::new();
699        for (face_id, face) in &faces {
700            let own = face_colors.get(face_id.as_str()).copied();
701            let body = face_body.get(face_id.as_str()).copied();
702            let inherited = body.and_then(|b| body_colors.get(b).copied());
703            let Some(spec) = own.or(inherited) else {
704                continue;
705            };
706            // The body color is only counted as represented when a face without
707            // its own override receives it.
708            if own.is_none() {
709                if let Some(b) = body {
710                    styled_bodies.insert(b);
711                }
712            }
713            if let Some(binding_id) = spec.binding_id {
714                self.written_appearance_bindings
715                    .insert(binding_id.to_string());
716            }
717            let name = spec
718                .appearance
719                .and_then(|appearance| appearance.name.as_deref())
720                .unwrap_or("");
721            let style = self.surface_style(spec.color, name, &mut style_refs);
722            styled.push(
723                self.emitter
724                    .emit("STYLED_ITEM", &format!("'color',({style}),{face}")),
725            );
726        }
727        let mut direct_unstyled = BTreeSet::new();
728        for binding in &ir.model.appearance_bindings {
729            if self.written_appearance_bindings.contains(&binding.id) {
730                continue;
731            }
732            let Some(appearance) = appearances.get(binding.appearance.as_str()).copied() else {
733                continue;
734            };
735            let Some(color) = appearance.base_color else {
736                continue;
737            };
738            let (target, style_kind) = match &binding.target {
739                AppearanceTarget::Face(id) => {
740                    (self.face_step_refs.get(id.as_str()).copied(), "surface")
741                }
742                AppearanceTarget::Surface(id) => {
743                    (self.surface_refs.get(id.as_str()).copied(), "surface")
744                }
745                AppearanceTarget::Curve(id) => (self.curve_refs.get(id.as_str()).copied(), "curve"),
746                AppearanceTarget::Edge(id) => (self.edge_refs.get(id.as_str()).copied(), "curve"),
747                AppearanceTarget::Point(id) => (self.point_refs.get(id.as_str()).copied(), "point"),
748                AppearanceTarget::Tessellation(id) => {
749                    (self.tessellation_step_refs.get(id).copied(), "surface")
750                }
751                AppearanceTarget::Body(_)
752                | AppearanceTarget::Vertex(_)
753                | AppearanceTarget::Source { .. } => continue,
754            };
755            let Some(target) = target else {
756                let target_id = match &binding.target {
757                    AppearanceTarget::Face(id) => id.0.clone(),
758                    AppearanceTarget::Surface(id) => id.0.clone(),
759                    AppearanceTarget::Curve(id) => id.0.clone(),
760                    AppearanceTarget::Edge(id) => id.0.clone(),
761                    AppearanceTarget::Point(id) => id.0.clone(),
762                    AppearanceTarget::Tessellation(id) => id.clone(),
763                    AppearanceTarget::Body(_)
764                    | AppearanceTarget::Vertex(_)
765                    | AppearanceTarget::Source { .. } => continue,
766                };
767                direct_unstyled.insert(target_id);
768                continue;
769            };
770            let name = appearance.name.as_deref().unwrap_or("");
771            let style = match style_kind {
772                "surface" => self.surface_style(color, name, &mut style_refs),
773                "curve" => self.curve_style(color, name, &mut style_refs),
774                "point" => self.point_style(color, name, &mut style_refs),
775                _ => unreachable!(),
776            };
777            self.written_appearance_bindings.insert(binding.id.clone());
778            styled.push(
779                self.emitter
780                    .emit("STYLED_ITEM", &format!("'color',({style}),{target}")),
781            );
782        }
783        // A color is unrepresented when no emitted ADVANCED_FACE could carry it:
784        // a face override whose face was skipped, or a body whose faces were all
785        // skipped (hidden bodies or faces without an explicit STEP surface).
786        let emitted: BTreeSet<&str> = self.face_step_refs.keys().map(String::as_str).collect();
787        let mut unstyled_targets = face_colors
788            .keys()
789            .filter(|id| !emitted.contains(**id as &str))
790            .map(|id| (*id).to_string())
791            .collect::<BTreeSet<_>>();
792        unstyled_targets.extend(
793            body_colors
794                .keys()
795                .filter(|id| !styled_bodies.contains(**id as &str))
796                .map(|id| (*id).to_string()),
797        );
798        unstyled_targets.extend(direct_unstyled);
799        self.unstyled_colors = unstyled_targets.len();
800        if styled.is_empty() {
801            return;
802        }
803        self.emitter.emit(
804            "MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION",
805            &format!("'',{},{context}", refs(&styled)),
806        );
807    }
808
809    fn surface_style(
810        &mut self,
811        color: cadmpeg_ir::topology::Color,
812        name: &str,
813        cache: &mut HashMap<String, Ref>,
814    ) -> Ref {
815        let rgb = format!(
816            "{},{},{}",
817            real(f64::from(color.r)),
818            real(f64::from(color.g)),
819            real(f64::from(color.b))
820        );
821        let key = format!("surface:{name}:{rgb}");
822        if let Some(style) = cache.get(&key) {
823            return *style;
824        }
825        let colour = self
826            .emitter
827            .emit("COLOUR_RGB", &format!("{},{rgb}", string(name)));
828        let fill_colour = self
829            .emitter
830            .emit("FILL_AREA_STYLE_COLOUR", &format!("'',{colour}"));
831        let fill = self
832            .emitter
833            .emit("FILL_AREA_STYLE", &format!("'',({fill_colour})"));
834        let style_fill = self
835            .emitter
836            .emit("SURFACE_STYLE_FILL_AREA", &fill.to_string());
837        let side = self
838            .emitter
839            .emit("SURFACE_SIDE_STYLE", &format!("'',({style_fill})"));
840        let usage = self
841            .emitter
842            .emit("SURFACE_STYLE_USAGE", &format!(".BOTH.,{side}"));
843        let assignment = self
844            .emitter
845            .emit("PRESENTATION_STYLE_ASSIGNMENT", &format!("({usage})"));
846        cache.insert(key, assignment);
847        assignment
848    }
849
850    fn curve_style(
851        &mut self,
852        color: cadmpeg_ir::topology::Color,
853        name: &str,
854        cache: &mut HashMap<String, Ref>,
855    ) -> Ref {
856        let rgb = format!(
857            "{},{},{}",
858            real(f64::from(color.r)),
859            real(f64::from(color.g)),
860            real(f64::from(color.b))
861        );
862        let key = format!("curve:{name}:{rgb}");
863        if let Some(style) = cache.get(&key) {
864            return *style;
865        }
866        let colour = self
867            .emitter
868            .emit("COLOUR_RGB", &format!("{},{rgb}", string(name)));
869        let font = self
870            .emitter
871            .emit("DRAUGHTING_PRE_DEFINED_CURVE_FONT", &string("continuous"));
872        let curve = self.emitter.emit(
873            "CURVE_STYLE",
874            &format!("'',{font},POSITIVE_LENGTH_MEASURE(0.1),{colour}"),
875        );
876        let assignment = self
877            .emitter
878            .emit("PRESENTATION_STYLE_ASSIGNMENT", &format!("({curve})"));
879        cache.insert(key, assignment);
880        assignment
881    }
882
883    fn point_style(
884        &mut self,
885        color: cadmpeg_ir::topology::Color,
886        name: &str,
887        cache: &mut HashMap<String, Ref>,
888    ) -> Ref {
889        let rgb = format!(
890            "{},{},{}",
891            real(f64::from(color.r)),
892            real(f64::from(color.g)),
893            real(f64::from(color.b))
894        );
895        let key = format!("point:{name}:{rgb}");
896        if let Some(style) = cache.get(&key) {
897            return *style;
898        }
899        let colour = self
900            .emitter
901            .emit("COLOUR_RGB", &format!("{},{rgb}", string(name)));
902        let point = self.emitter.emit(
903            "POINT_STYLE",
904            &format!("'',.DOT.,POSITIVE_LENGTH_MEASURE(1.),{colour}"),
905        );
906        let assignment = self
907            .emitter
908            .emit("PRESENTATION_STYLE_ASSIGNMENT", &format!("({point})"));
909        cache.insert(key, assignment);
910        assignment
911    }
912
913    fn emit_layers(&mut self) {
914        use cadmpeg_ir::presentation::PresentationItem;
915
916        for layer in self.ir.model.presentation_layers.clone() {
917            let mut assigned = Vec::new();
918            let mut unsupported = 0usize;
919            for item in layer.items {
920                let reference = match item {
921                    PresentationItem::Body { body } => {
922                        self.body_shape_refs.get(body.as_str()).copied()
923                    }
924                    PresentationItem::Face { face } => {
925                        self.face_step_refs.get(face.as_str()).copied()
926                    }
927                    PresentationItem::Edge { edge } => self.edge_refs.get(edge.as_str()).copied(),
928                    PresentationItem::Vertex { vertex } => {
929                        self.vertex_refs.get(vertex.as_str()).copied()
930                    }
931                    PresentationItem::Curve { curve } => {
932                        self.curve_refs.get(curve.as_str()).copied()
933                    }
934                    PresentationItem::Surface { surface } => {
935                        self.surface_refs.get(surface.as_str()).copied()
936                    }
937                    PresentationItem::Point { .. }
938                    | PresentationItem::Product { .. }
939                    | PresentationItem::Occurrence { .. }
940                    | PresentationItem::Pmi { .. }
941                    | PresentationItem::Tessellation { .. }
942                    | PresentationItem::Source { .. } => None,
943                };
944                if let Some(reference) = reference {
945                    assigned.push(reference);
946                } else {
947                    unsupported += 1;
948                }
949            }
950            if unsupported > 0 {
951                self.loss(
952                    LossCode::AttributesNotTransferred,
953                    LossCategory::Attribute,
954                    Severity::Warning,
955                    format!(
956                        "layer '{}' has {unsupported} item(s) without a writable STEP carrier",
957                        layer.name
958                    ),
959                );
960            }
961            if !assigned.is_empty() {
962                self.emitter.emit(
963                    "PRESENTATION_LAYER_ASSIGNMENT",
964                    &format!(
965                        "{},{},{}",
966                        string(&layer.name),
967                        string(layer.description.as_deref().unwrap_or("")),
968                        refs(&assigned)
969                    ),
970                );
971            }
972        }
973    }
974
975    fn emit_product_structure(&mut self) -> Ref {
976        let name = self
977            .ir
978            .model
979            .bodies
980            .first()
981            .and_then(|b| b.name.clone())
982            .unwrap_or_else(|| "cadmpeg_model".to_string());
983
984        let (application, protocol, year) = self.schema.application_protocol();
985        let app_ctx = self
986            .emitter
987            .emit("APPLICATION_CONTEXT", &string(application));
988        self.emitter.emit(
989            "APPLICATION_PROTOCOL_DEFINITION",
990            &format!(
991                "{},{},{year},{app_ctx}",
992                string("international standard"),
993                string(protocol)
994            ),
995        );
996        let prod_ctx = self.emitter.emit(
997            "PRODUCT_CONTEXT",
998            &format!("'',{app_ctx},{}", string("mechanical")),
999        );
1000        let product = self.emitter.emit(
1001            "PRODUCT",
1002            &format!("{},{},'',({prod_ctx})", string(&name), string(&name)),
1003        );
1004        let formation = self
1005            .emitter
1006            .emit("PRODUCT_DEFINITION_FORMATION", &format!("'','',{product}"));
1007        let pd_ctx = self.emitter.emit(
1008            "PRODUCT_DEFINITION_CONTEXT",
1009            &format!(
1010                "{},{app_ctx},{}",
1011                string("part definition"),
1012                string("design")
1013            ),
1014        );
1015        let product_def = self.emitter.emit(
1016            "PRODUCT_DEFINITION",
1017            &format!("{},'',{formation},{pd_ctx}", string("design")),
1018        );
1019        self.emitter
1020            .emit("PRODUCT_DEFINITION_SHAPE", &format!("'','',{product_def}"))
1021    }
1022
1023    fn emit_product_graph(&mut self, context: Ref) {
1024        let (application, protocol, year) = self.schema.application_protocol();
1025        let app_context = self
1026            .emitter
1027            .emit("APPLICATION_CONTEXT", &string(application));
1028        self.emitter.emit(
1029            "APPLICATION_PROTOCOL_DEFINITION",
1030            &format!(
1031                "{},{},{year},{app_context}",
1032                string("international standard"),
1033                string(protocol)
1034            ),
1035        );
1036        let product_context = self.emitter.emit(
1037            "PRODUCT_CONTEXT",
1038            &format!("'',{app_context},{}", string("mechanical")),
1039        );
1040        let definition_context = self.emitter.emit(
1041            "PRODUCT_DEFINITION_CONTEXT",
1042            &format!(
1043                "{},{app_context},{}",
1044                string("part definition"),
1045                string("design")
1046            ),
1047        );
1048
1049        let ir = self.ir;
1050        let products = &ir.model.products;
1051        let occurrences = &ir.model.product_occurrences;
1052        let occurrence_products = occurrences
1053            .iter()
1054            .map(|occurrence| (occurrence.id.clone(), occurrence.product.clone()))
1055            .collect::<HashMap<OccurrenceId, ProductId>>();
1056        let mut product_origins = HashMap::<ProductId, Ref>::new();
1057        for product in products {
1058            product_origins.insert(
1059                product.id.clone(),
1060                geometry::placement(
1061                    &mut self.emitter,
1062                    cadmpeg_ir::math::Point3::new(0.0, 0.0, 0.0),
1063                    cadmpeg_ir::math::Vector3::new(0.0, 0.0, 1.0),
1064                    cadmpeg_ir::math::Vector3::new(1.0, 0.0, 0.0),
1065                ),
1066            );
1067        }
1068        let mut representation_placements = HashMap::<ProductId, Vec<Ref>>::new();
1069        let mut occurrence_placements = HashMap::<OccurrenceId, (Ref, Ref)>::new();
1070        for occurrence in occurrences {
1071            let OccurrenceParent::Occurrence { occurrence: parent } = &occurrence.parent else {
1072                continue;
1073            };
1074            let Some(parent_product) = occurrence_products.get(parent) else {
1075                continue;
1076            };
1077            let Some(&from) = product_origins.get(&occurrence.product) else {
1078                continue;
1079            };
1080            if !is_rigid_transform(&occurrence.transform.rows) {
1081                continue;
1082            }
1083            let rows = occurrence.transform.rows;
1084            let to = geometry::placement(
1085                &mut self.emitter,
1086                cadmpeg_ir::math::Point3::new(rows[0][3], rows[1][3], rows[2][3]),
1087                cadmpeg_ir::math::Vector3::new(rows[0][2], rows[1][2], rows[2][2]),
1088                cadmpeg_ir::math::Vector3::new(rows[0][0], rows[1][0], rows[2][0]),
1089            );
1090            representation_placements
1091                .entry(parent_product.clone())
1092                .or_default()
1093                .push(to);
1094            occurrence_placements.insert(occurrence.id.clone(), (from, to));
1095        }
1096        let mut definitions = HashMap::<ProductId, Ref>::new();
1097        let mut representations = HashMap::<ProductId, Ref>::new();
1098        for product in products {
1099            let name = product.name.as_deref().unwrap_or(&product.product_id);
1100            let product_ref = self.emitter.emit(
1101                "PRODUCT",
1102                &format!(
1103                    "{},{},'',({product_context})",
1104                    string(&product.product_id),
1105                    string(name)
1106                ),
1107            );
1108            let formation = self.emitter.emit(
1109                "PRODUCT_DEFINITION_FORMATION",
1110                &format!("'','',{product_ref}"),
1111            );
1112            let definition = self.emitter.emit(
1113                "PRODUCT_DEFINITION",
1114                &format!(
1115                    "{},'',{formation},{definition_context}",
1116                    string(&product.product_id)
1117                ),
1118            );
1119            let shape = self
1120                .emitter
1121                .emit("PRODUCT_DEFINITION_SHAPE", &format!("'','',{definition}"));
1122            self.default_product_definition_shape.get_or_insert(shape);
1123            let mut body_items = product
1124                .bodies
1125                .iter()
1126                .flat_map(|body| {
1127                    self.body_item_refs
1128                        .get(body.as_str())
1129                        .into_iter()
1130                        .flatten()
1131                        .copied()
1132                })
1133                .collect::<Vec<_>>();
1134            if let Some(origin) = product_origins.get(&product.id) {
1135                body_items.push(*origin);
1136            }
1137            if let Some(placements) = representation_placements.get(&product.id) {
1138                body_items.extend(placements);
1139            }
1140            let representation = self.emitter.emit(
1141                "SHAPE_REPRESENTATION",
1142                &format!("{},{},{context}", string(name), refs(&body_items)),
1143            );
1144            self.emitter.emit(
1145                "SHAPE_DEFINITION_REPRESENTATION",
1146                &format!("{shape},{representation}"),
1147            );
1148            definitions.insert(product.id.clone(), definition);
1149            representations.insert(product.id.clone(), representation);
1150        }
1151
1152        for occurrence in occurrences {
1153            let OccurrenceParent::Occurrence { occurrence: parent } = &occurrence.parent else {
1154                if !is_identity(&occurrence.transform.rows) {
1155                    self.loss(
1156                        LossCode::BodyTransformNotApplied,
1157                        LossCategory::Topology,
1158                        Severity::Warning,
1159                        format!(
1160                            "root occurrence '{}' has a non-identity placement",
1161                            occurrence.id
1162                        ),
1163                    );
1164                }
1165                continue;
1166            };
1167            let Some(parent_product) = occurrence_products.get(parent) else {
1168                self.loss(
1169                    LossCode::TopologyNotTransferred,
1170                    LossCategory::Topology,
1171                    Severity::Warning,
1172                    format!("occurrence '{}' has an unresolved parent", occurrence.id),
1173                );
1174                continue;
1175            };
1176            let Some((
1177                &parent_definition,
1178                &child_definition,
1179                &parent_representation,
1180                &child_representation,
1181            )) = definitions
1182                .get(parent_product)
1183                .zip(definitions.get(&occurrence.product))
1184                .zip(representations.get(parent_product))
1185                .zip(representations.get(&occurrence.product))
1186                .map(|(((a, b), c), d)| (a, b, c, d))
1187            else {
1188                continue;
1189            };
1190            if !is_rigid_transform(&occurrence.transform.rows) {
1191                self.loss(
1192                    LossCode::BodyTransformNotApplied,
1193                    LossCategory::Topology,
1194                    Severity::Warning,
1195                    format!("occurrence '{}' placement is not rigid", occurrence.id),
1196                );
1197                continue;
1198            }
1199            let occurrence_name = occurrence.name.as_deref().unwrap_or(occurrence.id.as_str());
1200            let usage = self.emitter.emit(
1201                "NEXT_ASSEMBLY_USAGE_OCCURRENCE",
1202                &format!(
1203                    "{},{},'',{parent_definition},{child_definition},$",
1204                    string(occurrence.id.as_str()),
1205                    string(occurrence_name)
1206                ),
1207            );
1208            let usage_shape = self
1209                .emitter
1210                .emit("PRODUCT_DEFINITION_SHAPE", &format!("'','',{usage}"));
1211            let Some(&(from, to)) = occurrence_placements.get(&occurrence.id) else {
1212                continue;
1213            };
1214            let transform = self
1215                .emitter
1216                .emit("ITEM_DEFINED_TRANSFORMATION", &format!("'','',{from},{to}"));
1217            let relationship = self.emitter.emit_raw(
1218                "REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION",
1219                &format!(
1220                    "( REPRESENTATION_RELATIONSHIP('','',{child_representation},{parent_representation}) REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION({transform}) SHAPE_REPRESENTATION_RELATIONSHIP() )"
1221                ),
1222            );
1223            self.emitter.emit(
1224                "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION",
1225                &format!("{relationship},{usage_shape}"),
1226            );
1227        }
1228    }
1229
1230    fn emit_context(&mut self) -> Ref {
1231        let len = self.emit_length_unit();
1232        let angle = self.emit_angle_unit();
1233        let solid = self.emitter.emit_raw(
1234            "SOLID_ANGLE_UNIT",
1235            "( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() )",
1236        );
1237        let unc = self.emitter.emit(
1238            "UNCERTAINTY_MEASURE_WITH_UNIT",
1239            &format!(
1240                "LENGTH_MEASURE({}),{len},{},{}",
1241                real(self.ir.tolerances.linear),
1242                string("distance_accuracy_value"),
1243                string("maximum model space distance")
1244            ),
1245        );
1246        self.emitter.emit_raw(
1247            "GEOMETRIC_REPRESENTATION_CONTEXT",
1248            &format!(
1249                "( GEOMETRIC_REPRESENTATION_CONTEXT(3) \
1250                 GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT(({unc})) \
1251                 GLOBAL_UNIT_ASSIGNED_CONTEXT(({len},{angle},{solid})) \
1252                 REPRESENTATION_CONTEXT('Context','3D') )"
1253            ),
1254        )
1255    }
1256
1257    fn emit_length_unit(&mut self) -> Ref {
1258        if let Some(unit) = self.length_unit {
1259            return unit;
1260        }
1261        let unit = self.emitter.emit_raw(
1262            "LENGTH_UNIT",
1263            "( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) )",
1264        );
1265        self.length_unit = Some(unit);
1266        unit
1267    }
1268
1269    fn emit_angle_unit(&mut self) -> Ref {
1270        if let Some(unit) = self.angle_unit {
1271            return unit;
1272        }
1273        let unit = self.emitter.emit_raw(
1274            "PLANE_ANGLE_UNIT",
1275            "( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) )",
1276        );
1277        self.angle_unit = Some(unit);
1278        unit
1279    }
1280
1281    fn emit_ratio_unit(&mut self) -> Ref {
1282        if let Some(unit) = self.ratio_unit {
1283            return unit;
1284        }
1285        let unit = self
1286            .emitter
1287            .emit_raw("RATIO_UNIT", "( NAMED_UNIT(*) RATIO_UNIT() )");
1288        self.ratio_unit = Some(unit);
1289        unit
1290    }
1291
1292    /// Emit one shape item per region; visibility is represented separately
1293    /// when the target application protocol supports `INVISIBILITY`.
1294    fn emit_shape_items(&mut self, context: Ref) -> Vec<Ref> {
1295        let mut items = Vec::new();
1296        let ir = self.ir;
1297        for region in &ir.model.regions {
1298            let body_kind = self
1299                .bodies
1300                .get(region.body.as_str())
1301                .map_or(BodyKind::General, |body| body.kind);
1302            if body_kind == BodyKind::Wire {
1303                if let Some(item) = self.emit_wire_region(region) {
1304                    let shape_item = self.place_body_item(&region.body, item, context);
1305                    items.push(shape_item);
1306                    self.body_shape_refs
1307                        .entry(region.body.0.clone())
1308                        .or_insert(shape_item);
1309                    self.body_item_refs
1310                        .entry(region.body.0.clone())
1311                        .or_default()
1312                        .push(shape_item);
1313                    self.body_step_refs
1314                        .entry(region.body.0.clone())
1315                        .or_insert(item);
1316                }
1317                continue;
1318            }
1319            let closed = body_kind == BodyKind::Solid;
1320            let Some((outer_id, void_ids)) = region.shells.split_first() else {
1321                continue;
1322            };
1323            let Some(outer) = self.emit_shell(outer_id.as_str(), closed) else {
1324                self.loss(
1325                    LossCode::TopologyNotTransferred,
1326                    LossCategory::Topology,
1327                    Severity::Error,
1328                    format!("region {} has no writable outer shell", region.id),
1329                );
1330                continue;
1331            };
1332            let voids: Vec<Ref> = void_ids
1333                .iter()
1334                .filter_map(|sid| self.emit_shell(sid.as_str(), closed))
1335                .collect();
1336            let mut shell_refs = Vec::with_capacity(1 + voids.len());
1337            shell_refs.push(outer);
1338            shell_refs.extend_from_slice(&voids);
1339            let item = if !closed {
1340                self.emitter.emit(
1341                    "SHELL_BASED_SURFACE_MODEL",
1342                    &format!("'',{}", refs(&shell_refs)),
1343                )
1344            } else if voids.is_empty() {
1345                self.emitter
1346                    .emit("MANIFOLD_SOLID_BREP", &format!("'',{outer}"))
1347            } else {
1348                let void_refs: Vec<Ref> = voids
1349                    .iter()
1350                    .map(|s| {
1351                        self.emitter
1352                            .emit("ORIENTED_CLOSED_SHELL", &format!("'',*,{s},.F."))
1353                    })
1354                    .collect();
1355                self.emitter.emit(
1356                    "BREP_WITH_VOIDS",
1357                    &format!("'',{outer},{}", refs(&void_refs)),
1358                )
1359            };
1360            let shape_item = self.place_body_item(&region.body, item, context);
1361            items.push(shape_item);
1362            self.body_shape_refs
1363                .entry(region.body.0.clone())
1364                .or_insert(shape_item);
1365            self.body_item_refs
1366                .entry(region.body.0.clone())
1367                .or_default()
1368                .push(shape_item);
1369            self.body_step_refs
1370                .entry(region.body.0.clone())
1371                .or_insert(if closed { item } else { outer });
1372        }
1373        items
1374    }
1375
1376    fn place_body_item(
1377        &mut self,
1378        body_id: &cadmpeg_ir::ids::BodyId,
1379        item: Ref,
1380        context: Ref,
1381    ) -> Ref {
1382        let transform = self
1383            .bodies
1384            .get(body_id.as_str())
1385            .and_then(|body| body.transform);
1386        let Some(transform) = transform.filter(|transform| !is_identity(&transform.rows)) else {
1387            return item;
1388        };
1389        if !is_rigid_transform(&transform.rows) {
1390            self.loss(
1391                LossCode::BodyTransformNotApplied,
1392                LossCategory::Geometry,
1393                Severity::Warning,
1394                format!("body '{body_id}' carries a non-rigid transform"),
1395            );
1396            return item;
1397        }
1398        let origin = geometry::placement(
1399            &mut self.emitter,
1400            cadmpeg_ir::math::Point3::new(0.0, 0.0, 0.0),
1401            cadmpeg_ir::math::Vector3::new(0.0, 0.0, 1.0),
1402            cadmpeg_ir::math::Vector3::new(1.0, 0.0, 0.0),
1403        );
1404        let representation = self.emitter.emit(
1405            "SHAPE_REPRESENTATION",
1406            &format!("'body-local',({item}),{context}"),
1407        );
1408        let map = self
1409            .emitter
1410            .emit("REPRESENTATION_MAP", &format!("{origin},{representation}"));
1411        let rows = transform.rows;
1412        let target = geometry::placement(
1413            &mut self.emitter,
1414            cadmpeg_ir::math::Point3::new(rows[0][3], rows[1][3], rows[2][3]),
1415            cadmpeg_ir::math::Vector3::new(rows[0][2], rows[1][2], rows[2][2]),
1416            cadmpeg_ir::math::Vector3::new(rows[0][0], rows[1][0], rows[2][0]),
1417        );
1418        self.emitter.emit(
1419            "MAPPED_ITEM",
1420            &format!("'cadmpeg body placement',{map},{target}"),
1421        )
1422    }
1423
1424    fn emit_visibility(&mut self) {
1425        if !self.schema.supports_visibility() {
1426            let hidden = self
1427                .ir
1428                .model
1429                .bodies
1430                .iter()
1431                .filter(|body| body.visible == Some(false))
1432                .count();
1433            if hidden != 0 {
1434                self.loss(
1435                    LossCode::AttributesNotTransferred,
1436                    LossCategory::Metadata,
1437                    Severity::Warning,
1438                    format!(
1439                        "{hidden} hidden body visibility assignment(s) are unsupported by {}",
1440                        self.schema.file_schema()
1441                    ),
1442                );
1443            }
1444            return;
1445        }
1446        let hidden = self
1447            .ir
1448            .model
1449            .bodies
1450            .iter()
1451            .filter(|body| body.visible == Some(false))
1452            .filter_map(|body| self.body_step_refs.get(body.id.as_str()).copied())
1453            .collect::<Vec<_>>();
1454        if !hidden.is_empty() {
1455            self.emitter.emit("INVISIBILITY", &refs(&hidden));
1456        }
1457    }
1458
1459    fn emit_wire_region(&mut self, region: &cadmpeg_ir::topology::Region) -> Option<Ref> {
1460        let shells = region
1461            .shells
1462            .iter()
1463            .filter_map(|shell_id| self.shells.get(shell_id.as_str()).copied().cloned())
1464            .collect::<Vec<_>>();
1465        let mut connected_sets = Vec::new();
1466        for shell in shells {
1467            if !shell.free_vertices.is_empty() {
1468                self.loss(
1469                    LossCode::TopologyNotTransferred,
1470                    LossCategory::Topology,
1471                    Severity::Warning,
1472                    format!(
1473                        "wire shell '{}' has {} free vertex/vertices without an edge-based STEP carrier",
1474                        shell.id,
1475                        shell.free_vertices.len()
1476                    ),
1477                );
1478            }
1479            let edges = shell
1480                .wire_edges
1481                .iter()
1482                .filter_map(|edge| self.emit_edge(edge.as_str()))
1483                .collect::<Vec<_>>();
1484            if !edges.is_empty() {
1485                connected_sets.push(
1486                    self.emitter
1487                        .emit("CONNECTED_EDGE_SET", &format!("'',{}", refs(&edges))),
1488                );
1489            }
1490        }
1491        if connected_sets.is_empty() {
1492            return None;
1493        }
1494        Some(self.emitter.emit(
1495            "EDGE_BASED_WIREFRAME_MODEL",
1496            &format!("'',{}", refs(&connected_sets)),
1497        ))
1498    }
1499
1500    fn emit_standalone_geometry(&mut self) -> Vec<Ref> {
1501        let surface_ids = self
1502            .ir
1503            .model
1504            .surfaces
1505            .iter()
1506            .filter(|surface| !self.surface_refs.contains_key(surface.id.as_str()))
1507            .map(|surface| surface.id.0.clone())
1508            .collect::<Vec<_>>();
1509        let mut members = Vec::new();
1510        let mut has_surfaces = false;
1511        for surface_id in surface_ids {
1512            if let Some(reference) = self.emit_surface(&surface_id) {
1513                members.push(reference);
1514                has_surfaces = true;
1515            } else {
1516                self.unsupported_standalone_geometry += 1;
1517            }
1518        }
1519        let curve_ids = self
1520            .ir
1521            .model
1522            .curves
1523            .iter()
1524            .filter(|curve| !self.curve_refs.contains_key(curve.id.as_str()))
1525            .map(|curve| curve.id.0.clone())
1526            .collect::<Vec<_>>();
1527        for curve_id in curve_ids {
1528            if self
1529                .curves
1530                .get(curve_id.as_str())
1531                .is_some_and(|curve| matches!(curve.geometry, CurveGeometry::Unknown { .. }))
1532            {
1533                self.unsupported_standalone_geometry += 1;
1534            } else if let Some(reference) = self.emit_curve(&curve_id) {
1535                members.push(reference);
1536            }
1537        }
1538        let point_ids = self
1539            .ir
1540            .model
1541            .points
1542            .iter()
1543            .filter(|point| !self.point_refs.contains_key(point.id.as_str()))
1544            .map(|point| point.id.0.clone())
1545            .collect::<Vec<_>>();
1546        for point_id in point_ids {
1547            let Some(point) = self.points.get(point_id.as_str()).copied() else {
1548                continue;
1549            };
1550            let reference = geometry::point(&mut self.emitter, point.position);
1551            self.point_refs.insert(point_id, reference);
1552            members.push(reference);
1553        }
1554        if members.is_empty() {
1555            Vec::new()
1556        } else {
1557            vec![self.emitter.emit(
1558                if has_surfaces {
1559                    "GEOMETRIC_SET"
1560                } else {
1561                    "GEOMETRIC_CURVE_SET"
1562                },
1563                &format!("'',{}", refs(&members)),
1564            )]
1565        }
1566    }
1567
1568    fn emit_tessellations(&mut self, context: Ref) {
1569        if self.ir.model.tessellations.is_empty() {
1570            return;
1571        }
1572        if !self.schema.supports_tessellation() {
1573            self.loss(
1574                LossCode::TessellationOmitted,
1575                LossCategory::Geometry,
1576                Severity::Warning,
1577                format!(
1578                    "{} tessellation(s) require an AP242 target",
1579                    self.ir.model.tessellations.len()
1580                ),
1581            );
1582            return;
1583        }
1584
1585        let ir = self.ir;
1586        let mut representation_items = Vec::new();
1587        for mesh in &ir.model.tessellations {
1588            if mesh.vertices.is_empty()
1589                || mesh.triangles.is_empty()
1590                || mesh
1591                    .triangles
1592                    .iter()
1593                    .flatten()
1594                    .any(|index| *index as usize >= mesh.vertices.len())
1595                || (!mesh.normals.is_empty() && mesh.normals.len() != mesh.vertices.len())
1596            {
1597                self.loss(
1598                    LossCode::TessellationOmitted,
1599                    LossCategory::Geometry,
1600                    Severity::Warning,
1601                    format!(
1602                        "tessellation '{}' has invalid vertex/index/normal cardinality",
1603                        mesh.id
1604                    ),
1605                );
1606                continue;
1607            }
1608            let coordinates = mesh
1609                .vertices
1610                .iter()
1611                .map(|point| format!("({},{},{})", real(point.x), real(point.y), real(point.z)))
1612                .collect::<Vec<_>>()
1613                .join(",");
1614            let coordinates = self.emitter.emit(
1615                "COORDINATES_LIST",
1616                &format!(
1617                    "{}, {},({coordinates})",
1618                    string(&mesh.id),
1619                    mesh.vertices.len()
1620                ),
1621            );
1622            let normals = if mesh.normals.is_empty() {
1623                "$".to_string()
1624            } else {
1625                format!(
1626                    "({})",
1627                    mesh.normals
1628                        .iter()
1629                        .map(|normal| format!(
1630                            "({},{},{})",
1631                            real(normal.x),
1632                            real(normal.y),
1633                            real(normal.z)
1634                        ))
1635                        .collect::<Vec<_>>()
1636                        .join(",")
1637                )
1638            };
1639            let point_indices = (1..=mesh.vertices.len())
1640                .map(|index| index.to_string())
1641                .collect::<Vec<_>>()
1642                .join(",");
1643            let linked_body = mesh.body.as_ref().and_then(|body| {
1644                let link = self.body_step_refs.get(body.as_str()).copied()?;
1645                let kind = self.bodies.get(body.as_str())?.kind;
1646                matches!(kind, BodyKind::Solid | BodyKind::Sheet).then_some((kind, link))
1647            });
1648            let item = if let Some((kind, link)) = linked_body {
1649                let triangles = mesh
1650                    .triangles
1651                    .iter()
1652                    .map(|triangle| {
1653                        format!(
1654                            "({},{},{})",
1655                            triangle[0] + 1,
1656                            triangle[1] + 1,
1657                            triangle[2] + 1
1658                        )
1659                    })
1660                    .collect::<Vec<_>>()
1661                    .join(",");
1662                let face = self.emitter.emit(
1663                    "TRIANGULATED_FACE",
1664                    &format!(
1665                        "{},{coordinates},{},{normals},$,({point_indices}),({triangles})",
1666                        string(&mesh.id),
1667                        mesh.vertices.len()
1668                    ),
1669                );
1670                self.emitter.emit(
1671                    if kind == BodyKind::Solid {
1672                        "TESSELLATED_SOLID"
1673                    } else {
1674                        "TESSELLATED_SHELL"
1675                    },
1676                    &format!("{},({face}),{link}", string(&mesh.id)),
1677                )
1678            } else {
1679                let triangles = mesh
1680                    .triangles
1681                    .iter()
1682                    .map(|triangle| {
1683                        format!(
1684                            "({},{},{})",
1685                            triangle[0] + 1,
1686                            triangle[1] + 1,
1687                            triangle[2] + 1
1688                        )
1689                    })
1690                    .collect::<Vec<_>>()
1691                    .join(",");
1692                self.emitter.emit(
1693                    "TRIANGULATED_SURFACE_SET",
1694                    &format!(
1695                        "{},{coordinates},{},{normals},({point_indices}),({triangles})",
1696                        string(&mesh.id),
1697                        mesh.vertices.len()
1698                    ),
1699                )
1700            };
1701            self.tessellation_step_refs.insert(mesh.id.clone(), item);
1702            representation_items.push(item);
1703        }
1704        if !representation_items.is_empty() {
1705            self.emitter.emit(
1706                "TESSELLATED_SHAPE_REPRESENTATION",
1707                &format!("'',{},{context}", refs(&representation_items)),
1708            );
1709        }
1710    }
1711
1712    fn emit_shell(&mut self, shell_id: &str, closed: bool) -> Option<Ref> {
1713        let shell = self.shells.get(shell_id).copied()?;
1714        let face_ids: Vec<String> = shell.faces.iter().map(|f| f.0.clone()).collect();
1715        let mut face_refs = Vec::new();
1716        for fid in &face_ids {
1717            if let Some(r) = self.emit_face(fid) {
1718                face_refs.push(r);
1719            }
1720        }
1721        if face_refs.is_empty() {
1722            return None;
1723        }
1724        Some(self.emitter.emit(
1725            if closed { "CLOSED_SHELL" } else { "OPEN_SHELL" },
1726            &format!("'',{}", refs(&face_refs)),
1727        ))
1728    }
1729
1730    fn emit_face(&mut self, face_id: &str) -> Option<Ref> {
1731        let face = self.faces.get(face_id).copied()?;
1732        let surface_id = face.surface.0.clone();
1733        // A face resting on an unknown (opaque) surface cannot become an
1734        // ADVANCED_FACE: STEP requires a real surface. Skip it and aggregate the
1735        // loss rather than fabricate placeholder geometry.
1736        if let Some(surf) = self.surfaces.get(surface_id.as_str()) {
1737            if !geometry::surface_is_supported(&surf.geometry) {
1738                self.unknown_surface_faces.insert(face_id.to_string());
1739                return None;
1740            }
1741        }
1742        let loop_ids: Vec<String> = face.loops.iter().map(|l| l.0.clone()).collect();
1743        let same_sense = matches!(face.sense, Sense::Forward);
1744
1745        let Some(surf_ref) = self.emit_surface(&surface_id) else {
1746            self.unknown_surface_faces.insert(face_id.to_string());
1747            return None;
1748        };
1749
1750        let mut bound_refs = Vec::new();
1751        for (i, lid) in loop_ids.iter().enumerate() {
1752            if let Some(loop_ref) = self.emit_loop(lid) {
1753                let kind = if matches!(
1754                    self.loops
1755                        .get(lid.as_str())
1756                        .map(|loop_| loop_.boundary_role),
1757                    Some(LoopBoundaryRole::Outer)
1758                ) || (i == 0
1759                    && !loop_ids.iter().any(|id| {
1760                        self.loops
1761                            .get(id.as_str())
1762                            .is_some_and(|loop_| loop_.boundary_role == LoopBoundaryRole::Outer)
1763                    })) {
1764                    "FACE_OUTER_BOUND"
1765                } else {
1766                    "FACE_BOUND"
1767                };
1768                let b = self.emitter.emit(kind, &format!("'',{loop_ref},.T."));
1769                bound_refs.push(b);
1770            }
1771        }
1772        if bound_refs.is_empty() {
1773            return None;
1774        }
1775        let flag = if same_sense { ".T." } else { ".F." };
1776        let advanced_face = self.emitter.emit(
1777            "ADVANCED_FACE",
1778            &format!("'',{},{surf_ref},{flag}", refs(&bound_refs)),
1779        );
1780        self.face_step_refs
1781            .insert(face_id.to_string(), advanced_face);
1782        Some(advanced_face)
1783    }
1784
1785    fn emit_loop(&mut self, loop_id: &str) -> Option<Ref> {
1786        let lp = self.loops.get(loop_id).copied()?;
1787        if lp.coedges.is_empty() && lp.vertex_uses.len() == 1 {
1788            let vertex = self.emit_vertex(lp.vertex_uses[0].vertex.as_str())?;
1789            return Some(self.emitter.emit("VERTEX_LOOP", &format!("'',{vertex}")));
1790        }
1791        let coedge_ids: Vec<String> = lp.coedges.iter().map(|c| c.0.clone()).collect();
1792        let mut oe_refs = Vec::new();
1793        for cid in &coedge_ids {
1794            let Some(coedge) = self.coedges.get(cid.as_str()).copied() else {
1795                continue;
1796            };
1797            let orientation = matches!(coedge.sense, Sense::Forward);
1798            let Some(edge_ref) = self.emit_edge(coedge.edge.as_str()) else {
1799                continue;
1800            };
1801            let flag = if orientation { ".T." } else { ".F." };
1802            let oe = self
1803                .emitter
1804                .emit("ORIENTED_EDGE", &format!("'',*,*,{edge_ref},{flag}"));
1805            oe_refs.push(oe);
1806        }
1807        if oe_refs.is_empty() {
1808            return None;
1809        }
1810        Some(
1811            self.emitter
1812                .emit("EDGE_LOOP", &format!("'',{}", refs(&oe_refs))),
1813        )
1814    }
1815
1816    fn emit_edge(&mut self, edge_id: &str) -> Option<Ref> {
1817        if let Some(r) = self.edge_refs.get(edge_id) {
1818            return Some(*r);
1819        }
1820        let edge = self.edges.get(edge_id).copied()?;
1821        let v1 = self.emit_vertex(edge.start.as_str())?;
1822        let v2 = self.emit_vertex(edge.end.as_str())?;
1823        let Some(curve_id) = &edge.curve else {
1824            self.curveless_edges.insert(edge_id.to_string());
1825            return None;
1826        };
1827        if self
1828            .curves
1829            .get(curve_id.as_str())
1830            .is_some_and(|curve| !geometry::curve_is_supported(&curve.geometry))
1831        {
1832            self.curveless_edges.insert(edge_id.to_string());
1833            return None;
1834        }
1835        let basis_curve = self.emit_curve(curve_id.as_str())?;
1836        let associated = self.edge_coedges.get(edge_id).cloned().unwrap_or_default();
1837        let mut pcurve_refs = Vec::new();
1838        for (pcurve_id, surface_id) in associated {
1839            if let Some(pcurve) = self.emit_pcurve(pcurve_id, surface_id) {
1840                pcurve_refs.push(pcurve);
1841            }
1842        }
1843        let curve_ref = if pcurve_refs.is_empty() {
1844            basis_curve
1845        } else {
1846            self.emitter.emit(
1847                "SURFACE_CURVE",
1848                &format!("'',{basis_curve},{},.CURVE_3D.", refs(&pcurve_refs)),
1849            )
1850        };
1851        // same_sense = .T.: the edge runs start→end along the curve's own
1852        // parameterization, the convention IR curves follow.
1853        let r = self
1854            .emitter
1855            .emit("EDGE_CURVE", &format!("'',{v1},{v2},{curve_ref},.T."));
1856        self.edge_refs.insert(edge_id.to_string(), r);
1857        Some(r)
1858    }
1859
1860    fn emit_pcurve(&mut self, pcurve_id: &str, surface_id: &str) -> Option<Ref> {
1861        let pcurve = self.pcurves.get(pcurve_id).copied()?;
1862        let surface = self.emit_surface(surface_id)?;
1863        let curve = geometry::pcurve(&mut self.emitter, &pcurve.geometry)?;
1864        let context = if let Some(context) = self.pcurve_context {
1865            context
1866        } else {
1867            let context = self.emitter.emit_raw(
1868                "GEOMETRIC_REPRESENTATION_CONTEXT",
1869                "( GEOMETRIC_REPRESENTATION_CONTEXT(2) PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('uv','2D') )",
1870            );
1871            self.pcurve_context = Some(context);
1872            context
1873        };
1874        let representation = self.emitter.emit(
1875            "DEFINITIONAL_REPRESENTATION",
1876            &format!("'',({curve}),{context}"),
1877        );
1878        Some(
1879            self.emitter
1880                .emit("PCURVE", &format!("'',{surface},{representation}")),
1881        )
1882    }
1883
1884    fn emit_vertex(&mut self, vertex_id: &str) -> Option<Ref> {
1885        if let Some(r) = self.vertex_refs.get(vertex_id) {
1886            return Some(*r);
1887        }
1888        let vertex = self.vertices.get(vertex_id).copied()?;
1889        let pt = self.points.get(vertex.point.as_str()).copied()?;
1890        let cp = geometry::point(&mut self.emitter, pt.position);
1891        self.point_refs.insert(vertex.point.0.clone(), cp);
1892        let r = self.emitter.emit("VERTEX_POINT", &format!("'',{cp}"));
1893        self.vertex_refs.insert(vertex_id.to_string(), r);
1894        Some(r)
1895    }
1896
1897    fn emit_surface(&mut self, surface_id: &str) -> Option<Ref> {
1898        if let Some(r) = self.surface_refs.get(surface_id) {
1899            return Some(*r);
1900        }
1901        if self.geometry_emission_depth >= 256
1902            || !self.active_surfaces.insert(surface_id.to_string())
1903        {
1904            return None;
1905        }
1906        self.geometry_emission_depth += 1;
1907        let result = (|| {
1908            let surf = self.surfaces.get(surface_id).copied()?;
1909            let procedural = self
1910                .procedural_surfaces
1911                .get(surface_id)
1912                .map(|procedural| (procedural.id.0.clone(), procedural.definition.clone()));
1913            let emitted = procedural.and_then(|(id, definition)| {
1914                self.emit_procedural_surface(&surf.geometry, &definition)
1915                    .map(|reference| (id, reference))
1916            });
1917            let r = if let Some((id, reference)) = emitted {
1918                self.written_procedural_surfaces.insert(id);
1919                reference
1920            } else if !geometry::surface_is_supported(&surf.geometry) {
1921                return None;
1922            } else {
1923                geometry::surface(&mut self.emitter, &surf.geometry)
1924            };
1925            Some(r)
1926        })();
1927        self.active_surfaces.remove(surface_id);
1928        self.geometry_emission_depth -= 1;
1929        if let Some(r) = result {
1930            self.surface_refs.insert(surface_id.to_string(), r);
1931        }
1932        result
1933    }
1934
1935    fn emit_procedural_surface(
1936        &mut self,
1937        solved: &SurfaceGeometry,
1938        definition: &ProceduralSurfaceDefinition,
1939    ) -> Option<Ref> {
1940        let logical = |value: Option<bool>| match value {
1941            Some(true) => ".T.",
1942            Some(false) => ".F.",
1943            None => ".U.",
1944        };
1945        match definition {
1946            ProceduralSurfaceDefinition::LinearSweep {
1947                directrix,
1948                direction,
1949            } => {
1950                let directrix = self.emit_curve(directrix.as_str())?;
1951                let direction_ref = geometry::direction(&mut self.emitter, *direction);
1952                let vector = self.emitter.emit(
1953                    "VECTOR",
1954                    &format!("'',{direction_ref},{}", real(direction.norm())),
1955                );
1956                Some(self.emitter.emit(
1957                    "SURFACE_OF_LINEAR_EXTRUSION",
1958                    &format!("'',{directrix},{vector}"),
1959                ))
1960            }
1961            ProceduralSurfaceDefinition::AxisRevolution {
1962                directrix,
1963                axis_origin,
1964                axis_direction,
1965            } => {
1966                let directrix = self.emit_curve(directrix.as_str())?;
1967                let origin = geometry::point(&mut self.emitter, *axis_origin);
1968                let direction = geometry::direction(&mut self.emitter, *axis_direction);
1969                let axis = self
1970                    .emitter
1971                    .emit("AXIS1_PLACEMENT", &format!("'',{origin},{direction}"));
1972                Some(
1973                    self.emitter
1974                        .emit("SURFACE_OF_REVOLUTION", &format!("'',{directrix},{axis}")),
1975                )
1976            }
1977            ProceduralSurfaceDefinition::ParallelOffset {
1978                support,
1979                distance,
1980                self_intersect,
1981            } => {
1982                let support = self.emit_surface(support.as_str())?;
1983                Some(self.emitter.emit(
1984                    "OFFSET_SURFACE",
1985                    &format!(
1986                        "'',{support},{},{}",
1987                        real(*distance),
1988                        logical(*self_intersect)
1989                    ),
1990                ))
1991            }
1992            ProceduralSurfaceDefinition::DegenerateTorus { select_outer } => {
1993                let SurfaceGeometry::Torus {
1994                    center,
1995                    axis,
1996                    ref_direction,
1997                    major_radius,
1998                    minor_radius,
1999                } = solved
2000                else {
2001                    return None;
2002                };
2003                let placement =
2004                    geometry::placement(&mut self.emitter, *center, *axis, *ref_direction);
2005                Some(self.emitter.emit(
2006                    "DEGENERATE_TOROIDAL_SURFACE",
2007                    &format!(
2008                        "'',{placement},{},{},{}",
2009                        real(major_radius.abs()),
2010                        real(minor_radius.abs()),
2011                        if *select_outer { ".T." } else { ".F." }
2012                    ),
2013                ))
2014            }
2015            _ => None,
2016        }
2017    }
2018
2019    fn emit_curve(&mut self, curve_id: &str) -> Option<Ref> {
2020        if let Some(r) = self.curve_refs.get(curve_id) {
2021            return Some(*r);
2022        }
2023        if self.geometry_emission_depth >= 256 || !self.active_curves.insert(curve_id.to_string()) {
2024            return None;
2025        }
2026        self.geometry_emission_depth += 1;
2027        let result = (|| {
2028            let geometry = self.curves.get(curve_id)?.geometry.clone();
2029            let procedural = self
2030                .procedural_curves
2031                .get(curve_id)
2032                .map(|procedural| (procedural.id.0.clone(), procedural.definition.clone()));
2033            let emitted = procedural.and_then(|(id, definition)| {
2034                self.emit_procedural_curve(&definition)
2035                    .map(|reference| (id, reference))
2036            });
2037            let r = if let Some((id, reference)) = emitted {
2038                self.written_procedural_curves.insert(id);
2039                reference
2040            } else if let CurveGeometry::Composite {
2041                segments,
2042                self_intersect,
2043            } = &geometry
2044            {
2045                let mut segment_refs = Vec::with_capacity(segments.len());
2046                for segment in segments {
2047                    let curve = self.emit_curve(segment.curve.as_str())?;
2048                    let transition = match segment.transition {
2049                    cadmpeg_ir::geometry::CompositeCurveTransition::Discontinuous => {
2050                        ".DISCONTINUOUS."
2051                    }
2052                    cadmpeg_ir::geometry::CompositeCurveTransition::Continuous => ".CONTINUOUS.",
2053                    cadmpeg_ir::geometry::CompositeCurveTransition::ContSameGradient => {
2054                        ".CONTSAMEGRADIENT."
2055                    }
2056                    cadmpeg_ir::geometry::CompositeCurveTransition::ContSameGradientSameCurvature => {
2057                        ".CONTSAMEGRADIENTSAMECURVATURE."
2058                    }
2059                };
2060                    segment_refs.push(self.emitter.emit(
2061                        "COMPOSITE_CURVE_SEGMENT",
2062                        &format!(
2063                            "{transition},{},{curve}",
2064                            if segment.same_sense { ".T." } else { ".F." }
2065                        ),
2066                    ));
2067                }
2068                self.emitter.emit(
2069                    "COMPOSITE_CURVE",
2070                    &format!(
2071                        "'',{},{}",
2072                        refs(&segment_refs),
2073                        match self_intersect {
2074                            Some(true) => ".T.",
2075                            Some(false) => ".F.",
2076                            None => ".U.",
2077                        }
2078                    ),
2079                )
2080            } else if !geometry::curve_is_supported(&geometry) {
2081                return None;
2082            } else {
2083                geometry::curve(&mut self.emitter, &geometry)
2084            };
2085            Some(r)
2086        })();
2087        self.active_curves.remove(curve_id);
2088        self.geometry_emission_depth -= 1;
2089        if let Some(r) = result {
2090            self.curve_refs.insert(curve_id.to_string(), r);
2091        }
2092        result
2093    }
2094
2095    fn emit_procedural_curve(&mut self, definition: &ProceduralCurveDefinition) -> Option<Ref> {
2096        match definition {
2097            ProceduralCurveDefinition::Subset {
2098                source,
2099                parameter_range: [start, end],
2100            } => {
2101                let source = self.emit_curve(source.as_str())?;
2102                Some(self.emitter.emit(
2103                    "TRIMMED_CURVE",
2104                    &format!(
2105                        "'',{source},(PARAMETER_VALUE({})),(PARAMETER_VALUE({})),.T.,.PARAMETER.",
2106                        real(*start),
2107                        real(*end)
2108                    ),
2109                ))
2110            }
2111            ProceduralCurveDefinition::SpatialOffset {
2112                source,
2113                distance,
2114                reference_direction,
2115                self_intersect,
2116            } => {
2117                let source = self.emit_curve(source.as_str())?;
2118                let direction = geometry::direction(&mut self.emitter, *reference_direction);
2119                let self_intersect = match self_intersect {
2120                    Some(true) => ".T.",
2121                    Some(false) => ".F.",
2122                    None => ".U.",
2123                };
2124                Some(self.emitter.emit(
2125                    "OFFSET_CURVE_3D",
2126                    &format!(
2127                        "'',{source},{},{self_intersect},{direction}",
2128                        real(*distance)
2129                    ),
2130                ))
2131            }
2132            _ => None,
2133        }
2134    }
2135
2136    fn emit_pmi(&mut self, context: Ref) {
2137        use cadmpeg_ir::pmi::{DimensionKind, GeometricToleranceKind, PmiDefinition, PmiTarget};
2138
2139        if self.ir.model.pmi.is_empty() || !self.schema.supports_semantic_pmi() {
2140            return;
2141        }
2142        let annotations = self.ir.model.pmi.clone();
2143        let Some(pds) = self.default_product_definition_shape else {
2144            return;
2145        };
2146        let mut annotation_refs = HashMap::new();
2147        let mut aspects = HashMap::<String, Ref>::new();
2148        for annotation in &annotations {
2149            for target in &annotation.targets {
2150                let PmiTarget::ShapeAspect { source_id } = target else {
2151                    continue;
2152                };
2153                aspects.entry(source_id.clone()).or_insert_with(|| {
2154                    self.emitter.emit(
2155                        "SHAPE_ASPECT",
2156                        &format!("{},'',{pds},.T.", string(source_id)),
2157                    )
2158                });
2159            }
2160        }
2161        let fallback_aspect = self
2162            .emitter
2163            .emit("SHAPE_ASPECT", &format!("'PMI target','',{pds},.T."));
2164        let target_ref = |annotation: &cadmpeg_ir::PmiAnnotation| {
2165            annotation.targets.iter().find_map(|target| {
2166                if let PmiTarget::ShapeAspect { source_id } = target {
2167                    aspects.get(source_id).copied()
2168                } else {
2169                    None
2170                }
2171            })
2172        };
2173        let targets_exact = |annotation: &cadmpeg_ir::PmiAnnotation| {
2174            annotation
2175                .targets
2176                .iter()
2177                .all(|target| matches!(target, PmiTarget::ShapeAspect { .. }))
2178        };
2179
2180        for annotation in &annotations {
2181            if let PmiDefinition::Datum { identification } = &annotation.definition {
2182                let datum = self.emitter.emit(
2183                    "DATUM",
2184                    &format!(
2185                        "{},$,{pds},.F.,{}",
2186                        string(annotation.name.as_deref().unwrap_or("")),
2187                        string(identification)
2188                    ),
2189                );
2190                annotation_refs.insert(annotation.id.clone(), datum);
2191                self.written_pmi += usize::from(targets_exact(annotation));
2192            }
2193        }
2194        for annotation in &annotations {
2195            if let PmiDefinition::DatumSystem { references } = &annotation.definition {
2196                let mut groups = BTreeMap::<(u32, Option<u32>), Vec<_>>::new();
2197                for reference in references {
2198                    groups
2199                        .entry((reference.precedence, reference.common_group))
2200                        .or_default()
2201                        .push(reference);
2202                }
2203                let compartments = groups
2204                    .values()
2205                    .filter_map(|group| {
2206                        let datum_refs = group
2207                            .iter()
2208                            .map(|reference| annotation_refs.get(&reference.datum).copied())
2209                            .collect::<Option<Vec<_>>>()?;
2210                        if group[0].common_group.is_none() && group.len() != 1 {
2211                            return None;
2212                        }
2213                        let (datum, modifiers) = if group[0].common_group.is_some() {
2214                            let elements = group
2215                                .iter()
2216                                .zip(datum_refs)
2217                                .map(|(reference, datum)| {
2218                                    let modifiers =
2219                                        self.emit_datum_modifiers(&reference.modifiers)?;
2220                                    Some(self.emitter.emit(
2221                                        "DATUM_REFERENCE_ELEMENT",
2222                                        &format!("'',$,{pds},.F.,{datum},({modifiers})"),
2223                                    ))
2224                                })
2225                                .collect::<Option<Vec<_>>>()?;
2226                            (
2227                                format!("COMMON_DATUM_LIST({})", refs(&elements)),
2228                                String::new(),
2229                            )
2230                        } else {
2231                            (
2232                                datum_refs[0].to_string(),
2233                                self.emit_datum_modifiers(&group[0].modifiers)?,
2234                            )
2235                        };
2236                        Some(self.emitter.emit(
2237                            "DATUM_REFERENCE_COMPARTMENT",
2238                            &format!("'',$,{pds},.F.,{datum},({modifiers})"),
2239                        ))
2240                    })
2241                    .collect::<Vec<_>>();
2242                let complete = compartments.len() == groups.len();
2243                if compartments.is_empty() {
2244                    continue;
2245                }
2246                let system = self.emitter.emit(
2247                    "DATUM_SYSTEM",
2248                    &format!(
2249                        "{},'',{pds},.F.,{}",
2250                        string(annotation.name.as_deref().unwrap_or("")),
2251                        refs(&compartments)
2252                    ),
2253                );
2254                annotation_refs.insert(annotation.id.clone(), system);
2255                self.written_pmi += usize::from(targets_exact(annotation) && complete);
2256            }
2257        }
2258        for annotation in &annotations {
2259            match &annotation.definition {
2260                PmiDefinition::Dimension {
2261                    dimension,
2262                    nominal,
2263                    lower_deviation,
2264                    upper_deviation,
2265                    limits_and_fits,
2266                } => {
2267                    let aspect = target_ref(annotation).unwrap_or(fallback_aspect);
2268                    let name = annotation.name.as_deref().unwrap_or("");
2269                    let (entity, kind_exact) = match dimension {
2270                        DimensionKind::Size => ("DIMENSIONAL_SIZE", true),
2271                        DimensionKind::Location => ("DIMENSIONAL_LOCATION", true),
2272                        DimensionKind::Angular => ("ANGULAR_SIZE", true),
2273                        // AP242 represents diameter and radius as a
2274                        // DIMENSIONAL_SIZE whose name identifies the size
2275                        // category; DIAMETER_SIZE and RADIUS_SIZE are not
2276                        // entity types.
2277                        DimensionKind::Diameter | DimensionKind::Radius => {
2278                            ("DIMENSIONAL_SIZE", true)
2279                        }
2280                        DimensionKind::Other(_) => ("DIMENSIONAL_SIZE", false),
2281                    };
2282                    let characteristic_name = match dimension {
2283                        DimensionKind::Diameter => "diameter",
2284                        DimensionKind::Radius => "radius",
2285                        _ => name,
2286                    };
2287                    let parameters = match dimension {
2288                        DimensionKind::Location => {
2289                            format!("{},$,{aspect},{aspect}", string(characteristic_name))
2290                        }
2291                        DimensionKind::Angular => {
2292                            format!("{aspect},{},.SMALL.", string(characteristic_name))
2293                        }
2294                        _ => format!("{aspect},{}", string(characteristic_name)),
2295                    };
2296                    let characteristic = self.emitter.emit(entity, &parameters);
2297                    if let Some(value) = nominal {
2298                        let measure = self.emit_pmi_measure_representation_item(*value, name);
2299                        let representation = self.emitter.emit(
2300                            "SHAPE_DIMENSION_REPRESENTATION",
2301                            &format!("'',({measure}),{context}"),
2302                        );
2303                        self.emitter.emit(
2304                            "DIMENSIONAL_CHARACTERISTIC_REPRESENTATION",
2305                            &format!("{characteristic},{representation}"),
2306                        );
2307                    }
2308                    if let (Some(lower), Some(upper)) = (lower_deviation, upper_deviation) {
2309                        let lower = self.emit_pmi_measure(*lower);
2310                        let upper = self.emit_pmi_measure(*upper);
2311                        let tolerance = self
2312                            .emitter
2313                            .emit("TOLERANCE_VALUE", &format!("{lower},{upper}"));
2314                        self.emitter.emit(
2315                            "PLUS_MINUS_TOLERANCE",
2316                            &format!("{tolerance},{characteristic}"),
2317                        );
2318                    }
2319                    if let Some(fit) = limits_and_fits {
2320                        let fit = self.emitter.emit(
2321                            "LIMITS_AND_FITS",
2322                            &format!(
2323                                "{},{},{},{}",
2324                                string(&fit.form_variance),
2325                                string(&fit.zone_variance),
2326                                string(&fit.grade),
2327                                string(&fit.source)
2328                            ),
2329                        );
2330                        self.emitter
2331                            .emit("PLUS_MINUS_TOLERANCE", &format!("{fit},{characteristic}"));
2332                    }
2333                    annotation_refs.insert(annotation.id.clone(), characteristic);
2334                    let deviations_exact = lower_deviation.is_some() == upper_deviation.is_some();
2335                    self.written_pmi +=
2336                        usize::from(targets_exact(annotation) && deviations_exact && kind_exact);
2337                }
2338                PmiDefinition::GeometricTolerance {
2339                    tolerance,
2340                    magnitude,
2341                    datum_system,
2342                } => {
2343                    let kind_exact = !matches!(tolerance, GeometricToleranceKind::Other(value) if value != "geometric_tolerance");
2344                    let entity = match tolerance {
2345                        GeometricToleranceKind::Straightness => "STRAIGHTNESS_TOLERANCE",
2346                        GeometricToleranceKind::Flatness => "FLATNESS_TOLERANCE",
2347                        GeometricToleranceKind::Roundness => "ROUNDNESS_TOLERANCE",
2348                        GeometricToleranceKind::Cylindricity => "CYLINDRICITY_TOLERANCE",
2349                        GeometricToleranceKind::LineProfile => "LINE_PROFILE_TOLERANCE",
2350                        GeometricToleranceKind::SurfaceProfile => "SURFACE_PROFILE_TOLERANCE",
2351                        GeometricToleranceKind::Angularity => "ANGULARITY_TOLERANCE",
2352                        GeometricToleranceKind::Perpendicularity => "PERPENDICULARITY_TOLERANCE",
2353                        GeometricToleranceKind::Parallelism => "PARALLELISM_TOLERANCE",
2354                        GeometricToleranceKind::Position => "POSITION_TOLERANCE",
2355                        GeometricToleranceKind::Concentricity => "CONCENTRICITY_TOLERANCE",
2356                        GeometricToleranceKind::Symmetry => "SYMMETRY_TOLERANCE",
2357                        GeometricToleranceKind::CircularRunout => "CIRCULAR_RUNOUT_TOLERANCE",
2358                        GeometricToleranceKind::TotalRunout => "TOTAL_RUNOUT_TOLERANCE",
2359                        GeometricToleranceKind::Other(_) => continue,
2360                    };
2361                    let measure = self.emit_pmi_measure(*magnitude);
2362                    let aspect = target_ref(annotation).unwrap_or(fallback_aspect);
2363                    // Datum references are carried by the complex
2364                    // GEOMETRIC_TOLERANCE_WITH_DATUM_REFERENCE subtype. Until
2365                    // that complex entity is modeled, refuse it through the
2366                    // unwritten-PMI accounting instead of emitting an invalid
2367                    // fifth parameter on the simple tolerance entity.
2368                    if datum_system.is_some() {
2369                        continue;
2370                    }
2371                    let tolerance_ref = self.emitter.emit(
2372                        entity,
2373                        &format!(
2374                            "{},'',{measure},{aspect}",
2375                            string(annotation.name.as_deref().unwrap_or(""))
2376                        ),
2377                    );
2378                    annotation_refs.insert(annotation.id.clone(), tolerance_ref);
2379                    self.written_pmi += usize::from(targets_exact(annotation) && kind_exact);
2380                }
2381                PmiDefinition::Datum { .. }
2382                | PmiDefinition::DatumSystem { .. }
2383                | PmiDefinition::Presentation { .. } => {}
2384            }
2385        }
2386        let mut presentation_items = Vec::new();
2387        let mut presentation_semantics = Vec::new();
2388        for annotation in &annotations {
2389            let PmiDefinition::Presentation {
2390                text,
2391                placement,
2392                semantics,
2393            } = &annotation.definition
2394            else {
2395                continue;
2396            };
2397            let (Some(text), Some(placement)) = (text.as_deref(), placement.as_ref()) else {
2398                continue;
2399            };
2400            if !annotation.targets.is_empty() || !is_rigid_transform(&placement.rows) {
2401                continue;
2402            }
2403            let rows = placement.rows;
2404            let placement = geometry::placement(
2405                &mut self.emitter,
2406                cadmpeg_ir::math::Point3::new(rows[0][3], rows[1][3], rows[2][3]),
2407                cadmpeg_ir::math::Vector3::new(rows[0][2], rows[1][2], rows[2][2]),
2408                cadmpeg_ir::math::Vector3::new(rows[0][0], rows[1][0], rows[2][0]),
2409            );
2410            let font_source = self.emitter.emit("EXTERNAL_SOURCE", "'ISO 3098'");
2411            let font = self.emitter.emit(
2412                "EXTERNALLY_DEFINED_TEXT_FONT",
2413                &format!("IDENTIFIER('ISO 3098'),{font_source}"),
2414            );
2415            let literal = self.emitter.emit(
2416                "TEXT_LITERAL",
2417                &format!("{},{placement},'left',.RIGHT.,{font}", string(text)),
2418            );
2419            let semantic_refs = semantics
2420                .iter()
2421                .filter_map(|semantic| annotation_refs.get(semantic).copied())
2422                .collect::<Vec<_>>();
2423            if semantic_refs.len() != semantics.len() {
2424                continue;
2425            }
2426            let style = self
2427                .emitter
2428                .emit("PRESENTATION_STYLE_ASSIGNMENT", "(.NULL.)");
2429            let occurrence = self.emitter.emit(
2430                "ANNOTATION_TEXT_OCCURRENCE",
2431                &format!(
2432                    "{},{},{literal}",
2433                    string(annotation.name.as_deref().unwrap_or("")),
2434                    refs(&[style])
2435                ),
2436            );
2437            presentation_items.push(occurrence);
2438            presentation_semantics.push((occurrence, semantic_refs));
2439            annotation_refs.insert(annotation.id.clone(), occurrence);
2440            self.written_pmi += 1;
2441        }
2442        if !presentation_items.is_empty() {
2443            let model = self.emitter.emit(
2444                "DRAUGHTING_MODEL",
2445                &format!(
2446                    "'PMI presentation',{}, {context}",
2447                    refs(&presentation_items)
2448                ),
2449            );
2450            for (occurrence, semantics) in presentation_semantics {
2451                for semantic in semantics {
2452                    self.emitter.emit(
2453                        "DRAUGHTING_MODEL_ITEM_ASSOCIATION",
2454                        &format!("'','',{semantic},{model},{occurrence}"),
2455                    );
2456                }
2457            }
2458        }
2459    }
2460
2461    fn emit_datum_modifiers(&mut self, source: &[String]) -> Option<String> {
2462        let mut modifiers = Vec::with_capacity(source.len());
2463        for modifier in source {
2464            if let Some((kind, value)) = modifier.split_once(':') {
2465                let value = value.parse::<f64>().ok()?;
2466                let measure = self.emit_pmi_measure(cadmpeg_ir::PmiValue {
2467                    value,
2468                    quantity: cadmpeg_ir::PmiQuantity::Length,
2469                });
2470                modifiers.push(
2471                    self.emitter
2472                        .emit(
2473                            "DATUM_REFERENCE_MODIFIER_WITH_VALUE",
2474                            &format!(".{}.,{measure}", kind.to_ascii_uppercase()),
2475                        )
2476                        .to_string(),
2477                );
2478            } else {
2479                modifiers.push(format!(".{}.", modifier.to_ascii_uppercase()));
2480            }
2481        }
2482        Some(modifiers.join(","))
2483    }
2484
2485    fn emit_pmi_measure(&mut self, value: cadmpeg_ir::PmiValue) -> Ref {
2486        use cadmpeg_ir::pmi::PmiQuantity;
2487        let (entity, typed, unit) = match value.quantity {
2488            PmiQuantity::Length => (
2489                "LENGTH_MEASURE_WITH_UNIT",
2490                "LENGTH_MEASURE",
2491                self.emit_length_unit(),
2492            ),
2493            PmiQuantity::Angle => (
2494                "PLANE_ANGLE_MEASURE_WITH_UNIT",
2495                "PLANE_ANGLE_MEASURE",
2496                self.emit_angle_unit(),
2497            ),
2498            PmiQuantity::Ratio => ("MEASURE_WITH_UNIT", "RATIO_MEASURE", self.emit_ratio_unit()),
2499        };
2500        self.emitter
2501            .emit(entity, &format!("{typed}({}),{unit}", real(value.value)))
2502    }
2503
2504    fn emit_pmi_measure_representation_item(
2505        &mut self,
2506        value: cadmpeg_ir::PmiValue,
2507        name: &str,
2508    ) -> Ref {
2509        use cadmpeg_ir::pmi::PmiQuantity;
2510        let (typed, unit) = match value.quantity {
2511            PmiQuantity::Length => ("LENGTH_MEASURE", self.emit_length_unit()),
2512            PmiQuantity::Angle => ("PLANE_ANGLE_MEASURE", self.emit_angle_unit()),
2513            PmiQuantity::Ratio => ("RATIO_MEASURE", self.emit_ratio_unit()),
2514        };
2515        self.emitter.emit(
2516            "MEASURE_REPRESENTATION_ITEM",
2517            &format!("{},{typed}({}),{unit}", string(name), real(value.value)),
2518        )
2519    }
2520
2521    fn note_unrepresented(&mut self) {
2522        let nonstandard_analytic_surfaces = self
2523            .ir
2524            .model
2525            .surfaces
2526            .iter()
2527            .filter(|surface| match &surface.geometry {
2528                SurfaceGeometry::Sphere { radius, .. } => *radius < 0.0,
2529                SurfaceGeometry::Torus {
2530                    major_radius,
2531                    minor_radius,
2532                    ..
2533                } => {
2534                    *major_radius < 0.0
2535                        || *minor_radius < 0.0
2536                        || (minor_radius.abs() > major_radius.abs()
2537                            && !self.ir.model.procedural_surfaces.iter().any(|procedural| {
2538                                procedural.surface == surface.id
2539                                    && self.written_procedural_surfaces.contains(&procedural.id.0)
2540                                    && matches!(
2541                                        procedural.definition,
2542                                        ProceduralSurfaceDefinition::DegenerateTorus { .. }
2543                                    )
2544                            }))
2545                }
2546                _ => false,
2547            })
2548            .count();
2549        if nonstandard_analytic_surfaces > 0 {
2550            self.loss(
2551                LossCode::AnalyticSurfaceNormalized,
2552                LossCategory::Geometry,
2553                Severity::Warning,
2554                format!(
2555                    "{nonstandard_analytic_surfaces} signed or self-intersecting analytic \
2556                     surface(s) were normalized to positive STEP radii"
2557                ),
2558            );
2559        }
2560        let elliptical_cones = self
2561            .ir
2562            .model
2563            .surfaces
2564            .iter()
2565            .filter(|surface| {
2566                matches!(
2567                    surface.geometry,
2568                    SurfaceGeometry::Cone { ratio, .. } if ratio != 1.0
2569                )
2570            })
2571            .count();
2572        if elliptical_cones > 0 {
2573            self.loss(
2574                LossCode::EllipticalConeReduced,
2575                LossCategory::Geometry,
2576                Severity::Warning,
2577                format!(
2578                    "{elliptical_cones} elliptical cone surface(s) were reduced to circular STEP CONICAL_SURFACE carriers"
2579                ),
2580            );
2581        }
2582        if !self.curveless_edges.is_empty() {
2583            self.omit(
2584                LossCode::CurvelessEdgeOmitted,
2585                LossCategory::Geometry,
2586                Severity::Warning,
2587                format!(
2588                    "{} edge(s) have no typed 3D curve or carry a STEP-unsupported transform and were omitted from \
2589                     their edge loops (STEP EDGE_CURVE requires a 3D curve)",
2590                    self.curveless_edges.len()
2591                ),
2592            );
2593        }
2594        if !self.unknown_surface_faces.is_empty() {
2595            self.omit(
2596                LossCode::UnknownSurfaceFaceOmitted,
2597                LossCategory::Geometry,
2598                Severity::Warning,
2599                format!(
2600                    "{} face(s) rest on an unknown or STEP-unsupported surface and were omitted \
2601                     from the STEP shell (an ADVANCED_FACE requires a surface); their \
2602                     topology remains in the IR",
2603                    self.unknown_surface_faces.len()
2604                ),
2605            );
2606        }
2607        if self.unsupported_standalone_geometry > 0 {
2608            self.loss(
2609                LossCode::GeometryNotTransferred,
2610                LossCategory::Geometry,
2611                Severity::Warning,
2612                format!(
2613                    "{} standalone unknown geometry carrier(s) were not written",
2614                    self.unsupported_standalone_geometry
2615                ),
2616            );
2617        }
2618        let missing_pcurve_count = self
2619            .ir
2620            .model
2621            .coedges
2622            .iter()
2623            .flat_map(|coedge| &coedge.pcurves)
2624            .filter(|use_| !self.pcurves.contains_key(use_.pcurve.as_str()))
2625            .count();
2626        if missing_pcurve_count > 0 {
2627            self.omit(
2628                LossCode::PcurveOmitted,
2629                LossCategory::Geometry,
2630                Severity::Warning,
2631                format!(
2632                    "{missing_pcurve_count} coedge pcurve reference(s) have no geometry and were not written"
2633                ),
2634            );
2635        }
2636        let reduced_pcurve_count = self
2637            .ir
2638            .model
2639            .coedges
2640            .iter()
2641            .flat_map(|coedge| &coedge.pcurves)
2642            .filter_map(|use_| self.pcurves.get(use_.pcurve.as_str()))
2643            .filter(|pcurve| {
2644                pcurve.wrapper_reversed.is_some()
2645                    || pcurve.native_tail_flags.is_some()
2646                    || pcurve.parameter_range.is_some()
2647                    || pcurve.fit_tolerance.is_some()
2648            })
2649            .count();
2650        if reduced_pcurve_count > 0 {
2651            self.omit(
2652                LossCode::PcurveOmitted,
2653                LossCategory::Geometry,
2654                Severity::Info,
2655                format!(
2656                    "{reduced_pcurve_count} emitted coedge pcurve(s) carry native-only metadata not represented in STEP"
2657                ),
2658            );
2659        }
2660        if !self.ir.model.subds.is_empty() {
2661            self.omit(
2662                LossCode::SubdOmitted,
2663                LossCategory::Geometry,
2664                Severity::Warning,
2665                format!(
2666                    "{} subdivision surface(s) were omitted because this STEP writer \
2667                     does not encode SubD control cages",
2668                    self.ir.model.subds.len()
2669                ),
2670            );
2671        }
2672        let unwritten_pmi = self.ir.model.pmi.len().saturating_sub(self.written_pmi);
2673        if unwritten_pmi > 0 {
2674            self.omit(
2675                LossCode::PmiOmitted,
2676                LossCategory::Attribute,
2677                Severity::Warning,
2678                format!("{unwritten_pmi} PMI annotation(s) were not written to STEP"),
2679            );
2680        }
2681        let source_object_count = self
2682            .ir
2683            .model
2684            .surfaces
2685            .iter()
2686            .filter(|surface| surface.source_object.is_some())
2687            .count()
2688            + self
2689                .ir
2690                .model
2691                .curves
2692                .iter()
2693                .filter(|curve| curve.source_object.is_some())
2694                .count()
2695            + self
2696                .ir
2697                .model
2698                .subds
2699                .iter()
2700                .filter(|subd| subd.source_object.is_some())
2701                .count()
2702            + self
2703                .ir
2704                .model
2705                .tessellations
2706                .iter()
2707                .filter(|tessellation| tessellation.source_object.is_some())
2708                .count();
2709        if source_object_count > 0 {
2710            self.loss(
2711                LossCode::SourceAssociationOmitted,
2712                LossCategory::Metadata,
2713                Severity::Info,
2714                format!(
2715                    "{source_object_count} source-object association(s) were not represented in STEP"
2716                ),
2717            );
2718        }
2719        let unknown_count = self
2720            .ir
2721            .native
2722            .loss_counts()
2723            .into_iter()
2724            .filter(|count| count.kind == "unknowns")
2725            .map(|count| count.count)
2726            .sum::<usize>();
2727        if unknown_count > 0 {
2728            self.loss(
2729                LossCode::PassthroughRecordOmitted,
2730                LossCategory::Metadata,
2731                Severity::Info,
2732                format!("{unknown_count} uninterpreted passthrough record(s) were not represented in STEP"),
2733            );
2734        }
2735        if self.unstyled_colors > 0 {
2736            self.loss(
2737                LossCode::AttributesNotTransferred,
2738                LossCategory::Attribute,
2739                Severity::Info,
2740                format!(
2741                    "{} display color(s) had no emitted STEP item and were not written \
2742                     to STEP presentation",
2743                    self.unstyled_colors
2744                ),
2745            );
2746        }
2747        let lossy_appearances = self
2748            .ir
2749            .model
2750            .appearances
2751            .iter()
2752            .filter(|appearance| {
2753                let bindings = self
2754                    .ir
2755                    .model
2756                    .appearance_bindings
2757                    .iter()
2758                    .filter(|binding| binding.appearance == appearance.id)
2759                    .collect::<Vec<_>>();
2760                appearance.asset_guid.is_some()
2761                    || appearance.visual_guid.is_some()
2762                    || appearance.physical_token.is_some()
2763                    || appearance
2764                        .schema
2765                        .as_deref()
2766                        .is_some_and(|schema| schema != "step_surface_style")
2767                    || appearance.category.is_some()
2768                    || !appearance.properties.is_empty()
2769                    || appearance.base_color.is_none_or(|color| color.a != 1.0)
2770                    || bindings.is_empty()
2771                    || bindings
2772                        .iter()
2773                        .any(|binding| !self.written_appearance_bindings.contains(&binding.id))
2774            })
2775            .count();
2776        if lossy_appearances > 0 {
2777            self.loss(
2778                LossCode::AppearanceReduced,
2779                LossCategory::Material,
2780                Severity::Info,
2781                format!(
2782                    "{lossy_appearances} appearance asset(s) were reduced to STYLED_ITEM base colors; \
2783                     schemas, textures, and shader properties were not written to STEP"
2784                ),
2785            );
2786        }
2787        let lossy_binding_metadata = self
2788            .ir
2789            .model
2790            .appearance_bindings
2791            .iter()
2792            .filter(|binding| binding.object_type.is_some() || !binding.channels.is_empty())
2793            .count();
2794        if lossy_binding_metadata > 0 {
2795            self.loss(
2796                LossCode::AppearanceReduced,
2797                LossCategory::Metadata,
2798                Severity::Info,
2799                format!(
2800                    "{lossy_binding_metadata} appearance binding(s) carry source object or channel metadata not represented in STEP"
2801                ),
2802            );
2803        }
2804        if !self.ir.model.attributes.is_empty() {
2805            self.loss(
2806                LossCode::AttributesNotTransferred,
2807                LossCategory::Attribute,
2808                Severity::Info,
2809                format!(
2810                    "{} source attribute record(s) were not written to STEP",
2811                    self.ir.model.attributes.len()
2812                ),
2813            );
2814        }
2815        let procedural_surface_count = self
2816            .ir
2817            .model
2818            .procedural_surfaces
2819            .iter()
2820            .filter(|procedural| !self.written_procedural_surfaces.contains(&procedural.id.0))
2821            .count();
2822        let procedural_curve_count = self
2823            .ir
2824            .model
2825            .procedural_curves
2826            .iter()
2827            .filter(|procedural| !self.written_procedural_curves.contains(&procedural.id.0))
2828            .count();
2829        if procedural_surface_count > 0 || procedural_curve_count > 0 {
2830            self.loss(
2831                LossCode::ProceduralReduced,
2832                LossCategory::Geometry,
2833                Severity::Info,
2834                format!(
2835                    "{procedural_surface_count} procedural surface definition(s) and {procedural_curve_count} procedural curve definition(s) were reduced to their solved STEP carriers"
2836                ),
2837            );
2838        }
2839        let source_native_records: usize = self
2840            .ir
2841            .native
2842            .loss_counts()
2843            .iter()
2844            .filter(|loss| loss.kind != "unknowns")
2845            .map(|loss| loss.count)
2846            .sum();
2847        if source_native_records > 0 {
2848            self.loss(
2849                LossCode::ParametricRecordOmitted,
2850                LossCategory::Metadata,
2851                Severity::Info,
2852                format!(
2853                    "{source_native_records} source-native record(s) were not represented in STEP"
2854                ),
2855            );
2856        }
2857    }
2858
2859    fn finish_report(&self) -> ExportReport {
2860        ExportReport {
2861            format: "step".into(),
2862            entity_counts: self.emitter.counts(),
2863            total_entities: self.emitter.total(),
2864            losses: self.losses.clone(),
2865            notes: self.notes.clone(),
2866        }
2867    }
2868}
2869
2870/// STEP encoder with per-export header options.
2871#[derive(Debug, Clone, Default)]
2872pub struct StepCodec {
2873    /// Header metadata and deterministic writer options.
2874    pub options: StepWriteOptions,
2875}
2876
2877impl Encoder for StepCodec {
2878    fn id(&self) -> &'static str {
2879        "step"
2880    }
2881
2882    fn encode(&self, ir: &CadIr, writer: &mut dyn Write) -> Result<ExportReport, CodecError> {
2883        write_step(ir, writer, &self.options).map_err(CodecError::from)
2884    }
2885}
2886
2887impl Codec for StepCodec {
2888    fn id(&self) -> &'static str {
2889        "step"
2890    }
2891
2892    fn detect(&self, prefix: &[u8]) -> Confidence {
2893        if prefix.starts_with(b"ISO-10303-21;") {
2894            Confidence::High
2895        } else if is_part28_xml(prefix) {
2896            Confidence::Medium
2897        } else {
2898            Confidence::No
2899        }
2900    }
2901
2902    fn inspect_impl(
2903        &self,
2904        _ctx: &cadmpeg_ir::decode::DecodeContext<'_>,
2905        root: cadmpeg_ir::decode::View<'_>,
2906    ) -> Result<ContainerSummary, CodecError> {
2907        let bytes = root.window();
2908        refuse_alternate_encoding(bytes)?;
2909        if self.detect(bytes) == Confidence::No {
2910            return Err(CodecError::WrongFormat("missing ISO-10303-21 magic".into()));
2911        }
2912        let exchange =
2913            parse::parse(bytes).map_err(|error| CodecError::Malformed(error.to_string()))?;
2914        let (decoded, opaque_offsets) = reader::inspect_exchange(bytes, &exchange);
2915        let mut entries = vec![ContainerEntry {
2916            name: "HEADER".into(),
2917            role: "metadata".into(),
2918            compression: "none".into(),
2919            compressed_size: 0,
2920            uncompressed_size: 0,
2921            attributes: BTreeMap::default(),
2922        }];
2923        if !exchange.anchors.is_empty() {
2924            let mut attributes = std::collections::BTreeMap::new();
2925            attributes.insert("anchor_count".into(), exchange.anchors.len().to_string());
2926            entries.push(ContainerEntry {
2927                name: "ANCHOR".into(),
2928                role: "in_file_anchors".into(),
2929                compression: "none".into(),
2930                compressed_size: 0,
2931                uncompressed_size: 0,
2932                attributes,
2933            });
2934        }
2935        if !exchange.references.is_empty() {
2936            let mut attributes = std::collections::BTreeMap::new();
2937            attributes.insert(
2938                "external_count".into(),
2939                exchange.references.len().to_string(),
2940            );
2941            attributes.insert(
2942                "external_uris".into(),
2943                exchange
2944                    .references
2945                    .iter()
2946                    .map(|entry| entry.uri.as_str())
2947                    .collect::<Vec<_>>()
2948                    .join(","),
2949            );
2950            entries.push(ContainerEntry {
2951                name: "REFERENCE".into(),
2952                role: "external_references".into(),
2953                compression: "none".into(),
2954                compressed_size: 0,
2955                uncompressed_size: 0,
2956                attributes,
2957            });
2958        }
2959        for (index, section) in exchange.data.iter().enumerate() {
2960            let mut counts = std::collections::BTreeMap::<String, usize>::new();
2961            for id in &section.records {
2962                if !opaque_offsets.contains(&exchange.records[id].span.start) {
2963                    continue;
2964                }
2965                for partial in &exchange.records[id].partials {
2966                    *counts.entry(partial.name.clone()).or_default() += 1;
2967                }
2968            }
2969            let unknown = counts
2970                .iter()
2971                .map(|(name, count)| format!("{name}:{count}"))
2972                .collect::<Vec<_>>()
2973                .join(",");
2974            let mut attributes = std::collections::BTreeMap::new();
2975            attributes.insert("entity_count".into(), section.records.len().to_string());
2976            attributes.insert("unknown_entities".into(), unknown);
2977            entries.push(ContainerEntry {
2978                name: format!("DATA[{index}]"),
2979                role: "entity_records".into(),
2980                compression: "none".into(),
2981                compressed_size: 0,
2982                uncompressed_size: 0,
2983                attributes,
2984            });
2985        }
2986        let external_dependencies = decoded
2987            .report
2988            .notes
2989            .iter()
2990            .filter(|note| {
2991                note.starts_with("external document ") || note.starts_with("external source ")
2992            })
2993            .cloned()
2994            .collect::<Vec<_>>();
2995        if !external_dependencies.is_empty() {
2996            let mut attributes = std::collections::BTreeMap::new();
2997            attributes.insert(
2998                "dependency_count".into(),
2999                external_dependencies.len().to_string(),
3000            );
3001            attributes.insert("dependencies".into(), external_dependencies.join(","));
3002            entries.push(ContainerEntry {
3003                name: "EXTERNAL_DEPENDENCIES".into(),
3004                role: "external_references".into(),
3005                compression: "none".into(),
3006                compressed_size: 0,
3007                uncompressed_size: 0,
3008                attributes,
3009            });
3010        }
3011        if exchange.signature.is_some() {
3012            entries.push(ContainerEntry {
3013                name: "SIGNATURE".into(),
3014                role: "signature".into(),
3015                compression: "none".into(),
3016                compressed_size: 0,
3017                uncompressed_size: 0,
3018                attributes: BTreeMap::default(),
3019            });
3020        }
3021        let schema = exchange
3022            .header
3023            .iter()
3024            .find(|record| record.name == "FILE_SCHEMA")
3025            .map_or_else(
3026                || "unspecified".into(),
3027                |record| {
3028                    fn strings(value: &parse::Value, out: &mut Vec<String>) {
3029                        match value {
3030                            parse::Value::String(bytes) => {
3031                                if let Ok(value) = strings::decode(bytes) {
3032                                    out.push(value);
3033                                }
3034                            }
3035                            parse::Value::List(values) => {
3036                                for value in values {
3037                                    strings(value, out);
3038                                }
3039                            }
3040                            parse::Value::Typed(_, value) => strings(value, out),
3041                            _ => {}
3042                        }
3043                    }
3044                    let mut names = Vec::new();
3045                    record
3046                        .parameters
3047                        .iter()
3048                        .for_each(|value| strings(value, &mut names));
3049                    names.join(",")
3050                },
3051            );
3052        let edition = if schema.contains("442 4") {
3053            "edition 3"
3054        } else if schema.contains("442 3") {
3055            "edition 2"
3056        } else if schema.contains("442 1") {
3057            "edition 1"
3058        } else {
3059            "edition unspecified"
3060        };
3061        Ok(ContainerSummary {
3062            format: "step".into(),
3063            container_kind: "iso-10303-21-clear-text".into(),
3064            entries,
3065            notes: vec![format!("schema {schema}; {edition}")],
3066        })
3067    }
3068
3069    fn decode_impl(
3070        &self,
3071        ctx: &cadmpeg_ir::decode::DecodeContext<'_>,
3072        root: cadmpeg_ir::decode::View<'_>,
3073    ) -> Result<DecodeResult, CodecError> {
3074        let bytes = root.window();
3075        refuse_alternate_encoding(bytes)?;
3076        if self.detect(bytes) == Confidence::No {
3077            return Err(CodecError::WrongFormat("missing ISO-10303-21 magic".into()));
3078        }
3079        reader::decode(
3080            bytes,
3081            DecodeOptions {
3082                container_only: ctx.container_only(),
3083                policy: *ctx.policy(),
3084            },
3085        )
3086    }
3087}
3088
3089fn refuse_alternate_encoding(bytes: &[u8]) -> Result<(), CodecError> {
3090    if bytes.starts_with(b"PK\x03\x04") {
3091        return Err(CodecError::NotImplemented(
3092            "STEP Part 21 ZIP container".into(),
3093        ));
3094    }
3095    if bytes.starts_with(b"\x89HDF\r\n\x1a\n") {
3096        return Err(CodecError::NotImplemented(
3097            "STEP Part 26 binary/HDF5 encoding".into(),
3098        ));
3099    }
3100    if is_part28_xml(bytes) {
3101        return Err(CodecError::NotImplemented(
3102            "STEP Part 28 XML encoding".into(),
3103        ));
3104    }
3105    let lower = bytes
3106        .iter()
3107        .take(4096)
3108        .map(u8::to_ascii_lowercase)
3109        .collect::<Vec<_>>();
3110    if lower.starts_with(b"<?xml")
3111        && (lower
3112            .windows(21)
3113            .any(|window| window == b"business_object_model")
3114            || lower.windows(14).any(|window| window == b"ap242_bo_model"))
3115    {
3116        return Err(CodecError::NotImplemented(
3117            "AP242 BO-Model XML sidecar".into(),
3118        ));
3119    }
3120    Ok(())
3121}
3122
3123fn is_part28_xml(bytes: &[u8]) -> bool {
3124    let lower = bytes
3125        .iter()
3126        .take(4096)
3127        .map(u8::to_ascii_lowercase)
3128        .collect::<Vec<_>>();
3129    lower.starts_with(b"<?xml")
3130        && (lower.windows(12).any(|window| window == b"iso_10303_28")
3131            || lower
3132                .windows(21)
3133                .any(|window| window == b"iso:std:iso:10303:-28"))
3134}
3135
3136impl From<StepError> for CodecError {
3137    fn from(error: StepError) -> Self {
3138        match error {
3139            StepError::Unsupported(message) => Self::NotImplemented(message),
3140            StepError::Io(error) => Self::Io(error),
3141        }
3142    }
3143}
3144
3145fn is_identity(rows: &[[f64; 4]; 4]) -> bool {
3146    for (i, row) in rows.iter().enumerate() {
3147        for (j, &v) in row.iter().enumerate() {
3148            let expect = if i == j { 1.0 } else { 0.0 };
3149            if (v - expect).abs() > 1e-12 {
3150                return false;
3151            }
3152        }
3153    }
3154    true
3155}
3156
3157fn is_rigid_transform(rows: &[[f64; 4]; 4]) -> bool {
3158    const EPSILON: f64 = 1.0e-9;
3159    if rows.iter().flatten().any(|value| !value.is_finite())
3160        || rows[3]
3161            .iter()
3162            .zip([0.0, 0.0, 0.0, 1.0])
3163            .any(|(actual, expected)| (*actual - expected).abs() > EPSILON)
3164    {
3165        return false;
3166    }
3167    let columns = (0..3)
3168        .map(|column| [rows[0][column], rows[1][column], rows[2][column]])
3169        .collect::<Vec<_>>();
3170    for left in 0..3 {
3171        for right in 0..3 {
3172            let dot = (0..3)
3173                .map(|row| columns[left][row] * columns[right][row])
3174                .sum::<f64>();
3175            let expected = if left == right { 1.0 } else { 0.0 };
3176            if (dot - expected).abs() > EPSILON {
3177                return false;
3178            }
3179        }
3180    }
3181    let determinant = columns[0][0]
3182        * (columns[1][1] * columns[2][2] - columns[1][2] * columns[2][1])
3183        - columns[1][0] * (columns[0][1] * columns[2][2] - columns[0][2] * columns[2][1])
3184        + columns[2][0] * (columns[0][1] * columns[1][2] - columns[0][2] * columns[1][1]);
3185    (determinant - 1.0).abs() <= EPSILON
3186}
3187
3188#[cfg(test)]
3189mod tests;