Skip to main content

cadmpeg_codec_step/
lib.rs

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