Skip to main content

cadmpeg_step/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Writes [`cadmpeg_ir::CadIr`] documents as ISO 10303-21 STEP AP214 exchange
3//! files.
4//!
5//! [`write_step`] emits a Part 21 `AUTOMOTIVE_DESIGN` file containing an
6//! `ADVANCED_BREP_SHAPE_REPRESENTATION`. It writes the product-definition and
7//! representation-context records needed to connect the file metadata to the
8//! boundary representation. Each reachable IR region becomes a
9//! `MANIFOLD_SOLID_BREP` or, when the region has inner shells, a
10//! `BREP_WITH_VOIDS`.
11//!
12//! # Export workflow
13//!
14//! Construct or decode a [`cadmpeg_ir::CadIr`], choose the header metadata in
15//! [`StepWriteOptions`], then write to any [`std::io::Write`] sink:
16//!
17//! ```
18//! use cadmpeg_ir::examples::unit_cube;
19//! use cadmpeg_step::{write_step, StepWriteOptions};
20//!
21//! let ir = unit_cube();
22//! let mut bytes = Vec::new();
23//! let report = write_step(&ir, &mut bytes, &StepWriteOptions::default())?;
24//!
25//! assert!(bytes.starts_with(b"ISO-10303-21;"));
26//! assert!(report.total_entities > 0);
27//! # Ok::<(), cadmpeg_step::StepError>(())
28//! ```
29//!
30//! Review [`cadmpeg_ir::ExportReport::losses`] before retaining the output. Export continues
31//! when an IR fact has no representation in this writer. Unknown-surface faces
32//! and edges without typed 3D curves are omitted; pcurves, source attributes,
33//! passthrough records, and parametric history are reported rather than
34//! emitted. Display colors on bodies and faces (direct or through appearance
35//! bindings) become `STYLED_ITEM` presentation; other appearance data is
36//! reduced to those base colors. Body transforms remain unapplied, leaving
37//! affected coordinates in body-local space.
38//!
39//! Coordinates are emitted unchanged under a millimetre length-unit context.
40//! Callers must convert non-millimetre geometry before export. Analytic curves
41//! and surfaces map to their corresponding STEP carriers. Rational and
42//! non-rational NURBS use the `*_WITH_KNOTS` entities.
43//!
44//! [`StepError`] represents output-sink failures. Since the writer streams the
45//! header and DATA section, such a failure can leave partial output.
46
47mod geometry;
48mod writer;
49
50use std::collections::{BTreeSet, HashMap};
51use std::io::Write;
52
53use cadmpeg_ir::codec::{CodecError, Encoder};
54use cadmpeg_ir::geometry::{
55    Curve, CurveGeometry, ProceduralSurfaceDefinition, Surface, SurfaceGeometry,
56};
57use cadmpeg_ir::report::{ExportReport, LossCategory, LossNote, Severity};
58use cadmpeg_ir::topology::{Coedge, Edge, Point, Sense, Vertex};
59use cadmpeg_ir::CadIr;
60
61use writer::{real, refs, string, Emitter, Ref};
62
63/// Metadata written to the STEP `FILE_NAME` header record.
64///
65/// Default values produce deterministic output. They identify the file as
66/// `cadmpeg_model`, leave the author and organization empty, use `cadmpeg` as
67/// the originating system, and substitute `1970-01-01T00:00:00` for the empty
68/// timestamp.
69#[derive(Debug, Clone)]
70pub struct StepWriteOptions {
71    /// The `FILE_NAME` name field.
72    ///
73    /// The STEP `PRODUCT` id and name come from the first IR body name, or
74    /// `cadmpeg_model` when that body has no name.
75    pub product_name: String,
76    /// The sole entry in the `FILE_NAME` author list.
77    pub author: String,
78    /// The sole entry in the `FILE_NAME` organization list.
79    pub organization: String,
80    /// The `FILE_NAME` timestamp.
81    ///
82    /// Supply an ISO 8601 value. An empty string is written as
83    /// `1970-01-01T00:00:00`.
84    pub timestamp: String,
85    /// The `FILE_NAME` originating-system field.
86    pub originating_system: String,
87}
88
89impl Default for StepWriteOptions {
90    fn default() -> Self {
91        StepWriteOptions {
92            product_name: "cadmpeg_model".to_string(),
93            author: String::new(),
94            organization: String::new(),
95            timestamp: String::new(),
96            originating_system: "cadmpeg".to_string(),
97        }
98    }
99}
100
101/// Failure returned while streaming STEP output.
102///
103/// Unsupported or reduced IR content appears in [`ExportReport::losses`] after a
104/// successful write.
105#[derive(Debug, thiserror::Error)]
106pub enum StepError {
107    /// The output sink rejected a write.
108    #[error("failed to write STEP output: {0}")]
109    Io(#[from] std::io::Error),
110}
111
112/// Serializes an IR document as an ISO 10303-21 STEP AP214 file.
113///
114/// The output declares the `AUTOMOTIVE_DESIGN` schema and a millimetre length
115/// unit. Coordinate values are not rescaled. The IR linear tolerance becomes
116/// the representation context's uncertainty value.
117///
118/// Geometry conversion completes before this function writes the header. It
119/// then streams the header, DATA instances, and closing records to `w`. An I/O
120/// error can therefore leave a partial file and returns no report.
121///
122/// On success, the report contains DATA entity counts and loss notes for
123/// omitted or reduced content.
124pub fn write_step(
125    ir: &CadIr,
126    w: &mut (impl Write + ?Sized),
127    opts: &StepWriteOptions,
128) -> Result<ExportReport, StepError> {
129    let mut b = Builder::new(ir);
130    b.build();
131    let report = b.finish_report();
132    let lines = b.emitter.into_lines();
133
134    write_header(w, opts)?;
135    writeln!(w, "DATA;")?;
136    for line in &lines {
137        writeln!(w, "{line}")?;
138    }
139    writeln!(w, "ENDSEC;")?;
140    writeln!(w, "END-ISO-10303-21;")?;
141    Ok(report)
142}
143
144fn write_header(w: &mut (impl Write + ?Sized), opts: &StepWriteOptions) -> std::io::Result<()> {
145    let ts = if opts.timestamp.is_empty() {
146        "1970-01-01T00:00:00"
147    } else {
148        &opts.timestamp
149    };
150    writeln!(w, "ISO-10303-21;")?;
151    writeln!(w, "HEADER;")?;
152    writeln!(
153        w,
154        "FILE_DESCRIPTION(({}),'2;1');",
155        string("CAD model exported by cadmpeg")
156    )?;
157    writeln!(
158        w,
159        "FILE_NAME({},{},({}),({}),{},{},{});",
160        string(&opts.product_name),
161        string(ts),
162        string(&opts.author),
163        string(&opts.organization),
164        string("cadmpeg-step"),
165        string(&opts.originating_system),
166        string("")
167    )?;
168    writeln!(
169        w,
170        "FILE_SCHEMA(('AUTOMOTIVE_DESIGN {{ 1 0 10303 214 1 1 1 1 }}'));"
171    )?;
172    writeln!(w, "ENDSEC;")?;
173    Ok(())
174}
175
176/// Builds the DATA instance graph and accumulates export losses.
177struct Builder<'a> {
178    ir: &'a CadIr,
179    emitter: Emitter,
180    losses: Vec<LossNote>,
181
182    // Lookup indices from the flat IR arenas.
183    points: HashMap<&'a str, &'a Point>,
184    vertices: HashMap<&'a str, &'a Vertex>,
185    edges: HashMap<&'a str, &'a Edge>,
186    coedges: HashMap<&'a str, &'a Coedge>,
187    surfaces: HashMap<&'a str, &'a Surface>,
188    curves: HashMap<&'a str, &'a Curve>,
189
190    // Emitted-instance caches keyed by IR id, so shared carriers emit once.
191    surface_refs: HashMap<String, Ref>,
192    curve_refs: HashMap<String, Ref>,
193    edge_refs: HashMap<String, Ref>,
194    vertex_refs: HashMap<String, Ref>,
195
196    /// Edges skipped because they carry no attributed 3D curve, deduplicated
197    /// (a shared edge is reached once per coedge) and aggregated into a single
198    /// counted loss note.
199    curveless_edges: BTreeSet<String>,
200
201    /// Faces skipped because their surface geometry is unknown (opaque), so no
202    /// STEP surface exists to build an `ADVANCED_FACE` on. Deduplicated (a face
203    /// is reached once per shell) and aggregated into a single counted loss.
204    unknown_surface_faces: BTreeSet<String>,
205
206    /// Emitted `ADVANCED_FACE` instances keyed by IR face id, for presentation
207    /// styling.
208    face_step_refs: HashMap<String, Ref>,
209    /// Emitted solid instances paired with their owning IR body id, for
210    /// presentation styling.
211    body_solid_refs: Vec<(String, Ref)>,
212    /// Display colors that could not be attached to an emitted STEP item.
213    unstyled_colors: usize,
214}
215
216impl<'a> Builder<'a> {
217    fn new(ir: &'a CadIr) -> Self {
218        Builder {
219            ir,
220            emitter: Emitter::new(),
221            losses: Vec::new(),
222            points: ir.model.points.iter().map(|p| (p.id.as_str(), p)).collect(),
223            vertices: ir
224                .model
225                .vertices
226                .iter()
227                .map(|v| (v.id.as_str(), v))
228                .collect(),
229            edges: ir.model.edges.iter().map(|e| (e.id.as_str(), e)).collect(),
230            coedges: ir
231                .model
232                .coedges
233                .iter()
234                .map(|c| (c.id.as_str(), c))
235                .collect(),
236            surfaces: ir
237                .model
238                .surfaces
239                .iter()
240                .map(|s| (s.id.as_str(), s))
241                .collect(),
242            curves: ir.model.curves.iter().map(|c| (c.id.as_str(), c)).collect(),
243            surface_refs: HashMap::new(),
244            curve_refs: HashMap::new(),
245            edge_refs: HashMap::new(),
246            vertex_refs: HashMap::new(),
247            curveless_edges: BTreeSet::new(),
248            unknown_surface_faces: BTreeSet::new(),
249            face_step_refs: HashMap::new(),
250            body_solid_refs: Vec::new(),
251            unstyled_colors: 0,
252        }
253    }
254
255    fn loss(&mut self, category: LossCategory, severity: Severity, message: String) {
256        self.losses.push(LossNote {
257            category,
258            severity,
259            message,
260            provenance: None,
261        });
262    }
263
264    fn build(&mut self) {
265        // Product structure and unit-bearing context first; the representation
266        // instance that ties them to the geometry is emitted last.
267        let product_def_shape = self.emit_product_structure();
268        let context = self.emit_context();
269
270        let solids = self.emit_solids();
271        if solids.is_empty() {
272            self.loss(
273                LossCategory::Topology,
274                Severity::Warning,
275                "no exportable solids: the IR document contains no body/region/shell \
276                 geometry, so the STEP representation is empty"
277                    .to_string(),
278            );
279        }
280
281        let mut items = solids;
282        // A representation-space origin placement is conventional and harmless.
283        let origin = geometry::placement(
284            &mut self.emitter,
285            cadmpeg_ir::math::Point3::new(0.0, 0.0, 0.0),
286            cadmpeg_ir::math::Vector3::new(0.0, 0.0, 1.0),
287            cadmpeg_ir::math::Vector3::new(1.0, 0.0, 0.0),
288        );
289        items.push(origin);
290
291        let absr = self.emitter.emit(
292            "ADVANCED_BREP_SHAPE_REPRESENTATION",
293            &format!("'',{},{context}", refs(&items)),
294        );
295        self.emitter.emit(
296            "SHAPE_DEFINITION_REPRESENTATION",
297            &format!("{product_def_shape},{absr}"),
298        );
299
300        self.emit_presentation(context);
301        self.note_unrepresented();
302    }
303
304    /// Emit `STYLED_ITEM` surface colors for every colored body and face.
305    ///
306    /// A body or face is colored by its own `color` field or, failing that,
307    /// by an appearance binding whose appearance carries a base color. Each
308    /// distinct color shares one `PRESENTATION_STYLE_ASSIGNMENT`; the styled
309    /// items are gathered into one
310    /// `MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION` in the
311    /// geometric context.
312    fn emit_presentation(&mut self, context: Ref) {
313        use cadmpeg_ir::appearance::AppearanceTarget;
314        use cadmpeg_ir::topology::Color;
315
316        let ir = self.ir;
317        let appearances: HashMap<&str, Option<Color>> = ir
318            .model
319            .appearances
320            .iter()
321            .map(|appearance| (appearance.id.as_str(), appearance.base_color))
322            .collect();
323        let mut body_colors: HashMap<&str, Color> = HashMap::new();
324        let mut face_colors: HashMap<&str, Color> = HashMap::new();
325        for binding in &ir.model.appearance_bindings {
326            let Some(color) = appearances
327                .get(binding.appearance.as_str())
328                .copied()
329                .flatten()
330            else {
331                continue;
332            };
333            match &binding.target {
334                AppearanceTarget::Body(id) => {
335                    body_colors.entry(id.as_str()).or_insert(color);
336                }
337                AppearanceTarget::Face(id) => {
338                    face_colors.entry(id.as_str()).or_insert(color);
339                }
340            }
341        }
342        for body in &ir.model.bodies {
343            if let Some(color) = body.color {
344                body_colors.insert(body.id.as_str(), color);
345            }
346        }
347        for face in &ir.model.faces {
348            if let Some(color) = face.color {
349                face_colors.insert(face.id.as_str(), color);
350            }
351        }
352
353        let mut style_refs: HashMap<String, Ref> = HashMap::new();
354        let mut styled = Vec::new();
355        let mut styled_bodies: BTreeSet<String> = BTreeSet::new();
356        let body_solids = self.body_solid_refs.clone();
357        for (body_id, solid) in &body_solids {
358            let Some(color) = body_colors.get(body_id.as_str()).copied() else {
359                continue;
360            };
361            let style = self.surface_style(color, &mut style_refs);
362            styled.push(
363                self.emitter
364                    .emit("STYLED_ITEM", &format!("'color',({style}),{solid}")),
365            );
366            styled_bodies.insert(body_id.clone());
367        }
368        let mut faces: Vec<(String, Ref)> = self
369            .face_step_refs
370            .iter()
371            .map(|(id, r)| (id.clone(), *r))
372            .collect();
373        faces.sort_by(|a, b| a.0.cmp(&b.0));
374        let mut styled_faces: BTreeSet<String> = BTreeSet::new();
375        for (face_id, face) in &faces {
376            let Some(color) = face_colors.get(face_id.as_str()).copied() else {
377                continue;
378            };
379            let style = self.surface_style(color, &mut style_refs);
380            styled.push(
381                self.emitter
382                    .emit("STYLED_ITEM", &format!("'color',({style}),{face}")),
383            );
384            styled_faces.insert(face_id.clone());
385        }
386        // Colors with no emitted STEP item to attach to (hidden or skipped
387        // geometry) remain unrepresented.
388        self.unstyled_colors = body_colors
389            .keys()
390            .filter(|id| !styled_bodies.contains(**id as &str))
391            .count()
392            + face_colors
393                .keys()
394                .filter(|id| !styled_faces.contains(**id as &str))
395                .count();
396        if styled.is_empty() {
397            return;
398        }
399        self.emitter.emit(
400            "MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION",
401            &format!("'',{},{context}", refs(&styled)),
402        );
403    }
404
405    /// Emit (or reuse) the `PRESENTATION_STYLE_ASSIGNMENT` chain for one
406    /// surface color.
407    fn surface_style(
408        &mut self,
409        color: cadmpeg_ir::topology::Color,
410        cache: &mut HashMap<String, Ref>,
411    ) -> Ref {
412        let rgb = format!(
413            "{},{},{}",
414            real(f64::from(color.r)),
415            real(f64::from(color.g)),
416            real(f64::from(color.b))
417        );
418        if let Some(style) = cache.get(&rgb) {
419            return *style;
420        }
421        let colour = self.emitter.emit("COLOUR_RGB", &format!("'',{rgb}"));
422        let fill_colour = self
423            .emitter
424            .emit("FILL_AREA_STYLE_COLOUR", &format!("'',{colour}"));
425        let fill = self
426            .emitter
427            .emit("FILL_AREA_STYLE", &format!("'',({fill_colour})"));
428        let style_fill = self
429            .emitter
430            .emit("SURFACE_STYLE_FILL_AREA", &fill.to_string());
431        let side = self
432            .emitter
433            .emit("SURFACE_SIDE_STYLE", &format!("'',({style_fill})"));
434        let usage = self
435            .emitter
436            .emit("SURFACE_STYLE_USAGE", &format!(".BOTH.,{side}"));
437        let assignment = self
438            .emitter
439            .emit("PRESENTATION_STYLE_ASSIGNMENT", &format!("({usage})"));
440        cache.insert(rgb, assignment);
441        assignment
442    }
443
444    /// Emit the `PRODUCT` → `PRODUCT_DEFINITION_SHAPE` chain, returning the
445    /// `PRODUCT_DEFINITION_SHAPE` reference.
446    fn emit_product_structure(&mut self) -> Ref {
447        let name = self
448            .ir
449            .model
450            .bodies
451            .first()
452            .and_then(|b| b.name.clone())
453            .unwrap_or_else(|| "cadmpeg_model".to_string());
454
455        let app_ctx = self
456            .emitter
457            .emit("APPLICATION_CONTEXT", &string("automotive design"));
458        self.emitter.emit(
459            "APPLICATION_PROTOCOL_DEFINITION",
460            &format!(
461                "{},{},2000,{app_ctx}",
462                string("international standard"),
463                string("automotive_design")
464            ),
465        );
466        let prod_ctx = self.emitter.emit(
467            "PRODUCT_CONTEXT",
468            &format!("'',{app_ctx},{}", string("mechanical")),
469        );
470        let product = self.emitter.emit(
471            "PRODUCT",
472            &format!("{},{},'',({prod_ctx})", string(&name), string(&name)),
473        );
474        let formation = self
475            .emitter
476            .emit("PRODUCT_DEFINITION_FORMATION", &format!("'','',{product}"));
477        let pd_ctx = self.emitter.emit(
478            "PRODUCT_DEFINITION_CONTEXT",
479            &format!(
480                "{},{app_ctx},{}",
481                string("part definition"),
482                string("design")
483            ),
484        );
485        let product_def = self.emitter.emit(
486            "PRODUCT_DEFINITION",
487            &format!("{},'',{formation},{pd_ctx}", string("design")),
488        );
489        self.emitter
490            .emit("PRODUCT_DEFINITION_SHAPE", &format!("'','',{product_def}"))
491    }
492
493    /// Emit the units and the geometric representation context, returning the
494    /// context reference.
495    fn emit_context(&mut self) -> Ref {
496        let len = self.emit_length_unit();
497        let angle = self.emitter.emit_raw(
498            "PLANE_ANGLE_UNIT",
499            "( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) )",
500        );
501        let solid = self.emitter.emit_raw(
502            "SOLID_ANGLE_UNIT",
503            "( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() )",
504        );
505        let unc = self.emitter.emit(
506            "UNCERTAINTY_MEASURE_WITH_UNIT",
507            &format!(
508                "LENGTH_MEASURE({}),{len},{},{}",
509                real(self.ir.tolerances.linear),
510                string("distance_accuracy_value"),
511                string("maximum model space distance")
512            ),
513        );
514        self.emitter.emit_raw(
515            "GEOMETRIC_REPRESENTATION_CONTEXT",
516            &format!(
517                "( GEOMETRIC_REPRESENTATION_CONTEXT(3) \
518                 GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT(({unc})) \
519                 GLOBAL_UNIT_ASSIGNED_CONTEXT(({len},{angle},{solid})) \
520                 REPRESENTATION_CONTEXT('Context','3D') )"
521            ),
522        )
523    }
524
525    /// Emit the millimetre length unit used by the representation context.
526    ///
527    /// Coordinate values are written unchanged.
528    fn emit_length_unit(&mut self) -> Ref {
529        self.emitter.emit_raw(
530            "LENGTH_UNIT",
531            "( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) )",
532        )
533    }
534
535    /// Emit one solid per region across all displayed bodies; returns the
536    /// solid references. Bodies the source document hides are omitted.
537    fn emit_solids(&mut self) -> Vec<Ref> {
538        let mut solids = Vec::new();
539        // `ir` is a shared `&CadIr`; binding it locally lets us read the arenas
540        // while still calling `&mut self` helpers (loss/emit).
541        let ir = self.ir;
542        let hidden: BTreeSet<&str> = ir
543            .model
544            .bodies
545            .iter()
546            .filter(|body| body.visible == Some(false))
547            .map(|body| body.id.0.as_str())
548            .collect();
549        if !hidden.is_empty() {
550            self.loss(
551                LossCategory::Metadata,
552                Severity::Info,
553                format!(
554                    "{} hidden body(ies) were omitted from STEP output",
555                    hidden.len()
556                ),
557            );
558        }
559        for body in &ir.model.bodies {
560            if hidden.contains(body.id.0.as_str()) {
561                continue;
562            }
563            if let Some(t) = &body.transform {
564                if !is_identity(&t.rows) {
565                    self.loss(
566                        LossCategory::Geometry,
567                        Severity::Warning,
568                        format!(
569                            "body '{}' carries a non-identity transform that was not \
570                             applied to the exported geometry; coordinates are written \
571                             in body-local space",
572                            body.id
573                        ),
574                    );
575                }
576            }
577        }
578
579        for region in &ir.model.regions {
580            if hidden.contains(region.body.0.as_str()) {
581                continue;
582            }
583            let shell_refs: Vec<Ref> = region
584                .shells
585                .iter()
586                .filter_map(|sid| self.emit_shell(sid.as_str()))
587                .collect();
588            let Some((outer, voids)) = shell_refs.split_first() else {
589                continue;
590            };
591            let solid = if voids.is_empty() {
592                self.emitter
593                    .emit("MANIFOLD_SOLID_BREP", &format!("'',{outer}"))
594            } else {
595                let void_refs: Vec<Ref> = voids
596                    .iter()
597                    .map(|s| {
598                        self.emitter
599                            .emit("ORIENTED_CLOSED_SHELL", &format!("'',*,{s},.F."))
600                    })
601                    .collect();
602                self.emitter.emit(
603                    "BREP_WITH_VOIDS",
604                    &format!("'',{outer},{}", refs(&void_refs)),
605                )
606            };
607            self.body_solid_refs.push((region.body.0.clone(), solid));
608            solids.push(solid);
609        }
610        solids
611    }
612
613    fn emit_shell(&mut self, shell_id: &str) -> Option<Ref> {
614        let shell = self
615            .ir
616            .model
617            .shells
618            .iter()
619            .find(|s| s.id.as_str() == shell_id)?;
620        let face_ids: Vec<String> = shell.faces.iter().map(|f| f.0.clone()).collect();
621        let mut face_refs = Vec::new();
622        for fid in &face_ids {
623            if let Some(r) = self.emit_face(fid) {
624                face_refs.push(r);
625            }
626        }
627        if face_refs.is_empty() {
628            return None;
629        }
630        Some(
631            self.emitter
632                .emit("CLOSED_SHELL", &format!("'',{}", refs(&face_refs))),
633        )
634    }
635
636    fn emit_face(&mut self, face_id: &str) -> Option<Ref> {
637        let face = self
638            .ir
639            .model
640            .faces
641            .iter()
642            .find(|f| f.id.as_str() == face_id)?;
643        let surface_id = face.surface.0.clone();
644        // A face resting on an unknown (opaque) surface cannot become an
645        // ADVANCED_FACE: STEP requires a real surface. Skip it and aggregate the
646        // loss rather than fabricate placeholder geometry.
647        if let Some(surf) = self.surfaces.get(surface_id.as_str()) {
648            if matches!(surf.geometry, SurfaceGeometry::Unknown { .. }) {
649                self.unknown_surface_faces.insert(face_id.to_string());
650                return None;
651            }
652        }
653        let loop_ids: Vec<String> = face.loops.iter().map(|l| l.0.clone()).collect();
654        let same_sense = matches!(face.sense, Sense::Forward);
655
656        let surf_ref = self.emit_surface(&surface_id)?;
657
658        let mut bound_refs = Vec::new();
659        for (i, lid) in loop_ids.iter().enumerate() {
660            if let Some(loop_ref) = self.emit_loop(lid) {
661                // The first loop is the outer bound by IR convention.
662                let kind = if i == 0 {
663                    "FACE_OUTER_BOUND"
664                } else {
665                    "FACE_BOUND"
666                };
667                let b = self.emitter.emit(kind, &format!("'',{loop_ref},.T."));
668                bound_refs.push(b);
669            }
670        }
671        if bound_refs.is_empty() {
672            return None;
673        }
674        let flag = if same_sense { ".T." } else { ".F." };
675        let advanced_face = self.emitter.emit(
676            "ADVANCED_FACE",
677            &format!("'',{},{surf_ref},{flag}", refs(&bound_refs)),
678        );
679        self.face_step_refs
680            .insert(face_id.to_string(), advanced_face);
681        Some(advanced_face)
682    }
683
684    fn emit_loop(&mut self, loop_id: &str) -> Option<Ref> {
685        let lp = self
686            .ir
687            .model
688            .loops
689            .iter()
690            .find(|l| l.id.as_str() == loop_id)?;
691        let coedge_ids: Vec<String> = lp.coedges.iter().map(|c| c.0.clone()).collect();
692        let mut oe_refs = Vec::new();
693        for cid in &coedge_ids {
694            let Some(coedge) = self.coedges.get(cid.as_str()).copied() else {
695                continue;
696            };
697            let orientation = matches!(coedge.sense, Sense::Forward);
698            // Pcurves (coedge.pcurve) are intentionally dropped; the aggregate
699            // loss note is recorded in `note_unrepresented`.
700            let Some(edge_ref) = self.emit_edge(coedge.edge.as_str()) else {
701                continue;
702            };
703            let flag = if orientation { ".T." } else { ".F." };
704            let oe = self
705                .emitter
706                .emit("ORIENTED_EDGE", &format!("'',*,*,{edge_ref},{flag}"));
707            oe_refs.push(oe);
708        }
709        if oe_refs.is_empty() {
710            return None;
711        }
712        Some(
713            self.emitter
714                .emit("EDGE_LOOP", &format!("'',{}", refs(&oe_refs))),
715        )
716    }
717
718    fn emit_edge(&mut self, edge_id: &str) -> Option<Ref> {
719        if let Some(r) = self.edge_refs.get(edge_id) {
720            return Some(*r);
721        }
722        let edge = self.edges.get(edge_id).copied()?;
723        let v1 = self.emit_vertex(edge.start.as_str())?;
724        let v2 = self.emit_vertex(edge.end.as_str())?;
725        let Some(curve_id) = &edge.curve else {
726            self.curveless_edges.insert(edge_id.to_string());
727            return None;
728        };
729        if self
730            .curves
731            .get(curve_id.as_str())
732            .is_some_and(|curve| matches!(curve.geometry, CurveGeometry::Unknown { .. }))
733        {
734            self.curveless_edges.insert(edge_id.to_string());
735            return None;
736        }
737        let curve_ref = self.emit_curve(curve_id.as_str())?;
738        // same_sense = .T.: the edge runs start→end along the curve's own
739        // parameterization, the convention IR curves follow.
740        let r = self
741            .emitter
742            .emit("EDGE_CURVE", &format!("'',{v1},{v2},{curve_ref},.T."));
743        self.edge_refs.insert(edge_id.to_string(), r);
744        Some(r)
745    }
746
747    fn emit_vertex(&mut self, vertex_id: &str) -> Option<Ref> {
748        if let Some(r) = self.vertex_refs.get(vertex_id) {
749            return Some(*r);
750        }
751        let vertex = self.vertices.get(vertex_id).copied()?;
752        let pt = self.points.get(vertex.point.as_str()).copied()?;
753        let cp = geometry::point(&mut self.emitter, pt.position);
754        let r = self.emitter.emit("VERTEX_POINT", &format!("'',{cp}"));
755        self.vertex_refs.insert(vertex_id.to_string(), r);
756        Some(r)
757    }
758
759    fn emit_surface(&mut self, surface_id: &str) -> Option<Ref> {
760        if let Some(r) = self.surface_refs.get(surface_id) {
761            return Some(*r);
762        }
763        let surf = self.surfaces.get(surface_id).copied()?;
764        let r = geometry::surface(&mut self.emitter, &surf.geometry);
765        self.surface_refs.insert(surface_id.to_string(), r);
766        Some(r)
767    }
768
769    fn emit_curve(&mut self, curve_id: &str) -> Option<Ref> {
770        if let Some(r) = self.curve_refs.get(curve_id) {
771            return Some(*r);
772        }
773        let crv = self.curves.get(curve_id).copied()?;
774        let r = geometry::curve(&mut self.emitter, &crv.geometry);
775        self.curve_refs.insert(curve_id.to_string(), r);
776        Some(r)
777    }
778
779    /// Record aggregate loss notes for IR content the writer does not carry.
780    fn note_unrepresented(&mut self) {
781        let nonstandard_analytic_surfaces = self
782            .ir
783            .model
784            .surfaces
785            .iter()
786            .filter(|surface| match &surface.geometry {
787                SurfaceGeometry::Sphere { radius, .. } => *radius < 0.0,
788                SurfaceGeometry::Torus {
789                    major_radius,
790                    minor_radius,
791                    ..
792                } => *minor_radius < 0.0 || minor_radius.abs() > major_radius.abs(),
793                _ => false,
794            })
795            .count();
796        if nonstandard_analytic_surfaces > 0 {
797            self.loss(
798                LossCategory::Geometry,
799                Severity::Warning,
800                format!(
801                    "{nonstandard_analytic_surfaces} signed or self-intersecting analytic \
802                     surface(s) were normalized to positive STEP radii"
803                ),
804            );
805        }
806        if !self.curveless_edges.is_empty() {
807            self.loss(
808                LossCategory::Geometry,
809                Severity::Warning,
810                format!(
811                    "{} edge(s) have no typed 3D curve and were omitted from \
812                     their edge loops (STEP EDGE_CURVE requires a 3D curve)",
813                    self.curveless_edges.len()
814                ),
815            );
816        }
817        if !self.unknown_surface_faces.is_empty() {
818            self.loss(
819                LossCategory::Geometry,
820                Severity::Warning,
821                format!(
822                    "{} face(s) rest on an unknown (undecoded) surface and were omitted \
823                     from the STEP shell (an ADVANCED_FACE requires a surface); their \
824                     topology remains in the IR",
825                    self.unknown_surface_faces.len()
826                ),
827            );
828        }
829        let pcurve_count = self
830            .ir
831            .model
832            .coedges
833            .iter()
834            .filter(|c| c.pcurve.is_some())
835            .count();
836        if pcurve_count > 0 {
837            self.loss(
838                LossCategory::Geometry,
839                Severity::Info,
840                format!(
841                    "{pcurve_count} coedge pcurve(s) were not written; parameter-space \
842                     trims are omitted, and consumers recompute them from the 3D \
843                     edge/surface geometry"
844                ),
845            );
846        }
847        if !self.ir.model.pcurves.is_empty() {
848            self.loss(
849                LossCategory::Geometry,
850                Severity::Info,
851                format!(
852                    "{} pcurve carrier(s) in the IR were not emitted",
853                    self.ir.model.pcurves.len()
854                ),
855            );
856        }
857        if !self.ir.model.subds.is_empty() {
858            self.loss(
859                LossCategory::Geometry,
860                Severity::Warning,
861                format!(
862                    "{} subdivision surface(s) were omitted because this STEP writer \
863                     does not encode SubD control cages",
864                    self.ir.model.subds.len()
865                ),
866            );
867        }
868        if !self.ir.model.tessellations.is_empty() {
869            self.loss(
870                LossCategory::Geometry,
871                Severity::Warning,
872                format!(
873                    "{} tessellation(s) were omitted because this STEP writer emits \
874                     exact B-rep geometry only",
875                    self.ir.model.tessellations.len()
876                ),
877            );
878        }
879        let source_object_count = self
880            .ir
881            .model
882            .surfaces
883            .iter()
884            .filter(|surface| surface.source_object.is_some())
885            .count()
886            + self
887                .ir
888                .model
889                .curves
890                .iter()
891                .filter(|curve| curve.source_object.is_some())
892                .count()
893            + self
894                .ir
895                .model
896                .subds
897                .iter()
898                .filter(|subd| subd.source_object.is_some())
899                .count()
900            + self
901                .ir
902                .model
903                .tessellations
904                .iter()
905                .filter(|tessellation| tessellation.source_object.is_some())
906                .count();
907        if source_object_count > 0 {
908            self.loss(
909                LossCategory::Metadata,
910                Severity::Info,
911                format!(
912                    "{source_object_count} source-object association(s) were not represented in STEP"
913                ),
914            );
915        }
916        let unknown_count = self
917            .ir
918            .native
919            .loss_counts()
920            .into_iter()
921            .filter(|count| count.kind == "unknowns")
922            .map(|count| count.count)
923            .sum::<usize>();
924        if unknown_count > 0 {
925            self.loss(
926                LossCategory::Metadata,
927                Severity::Info,
928                format!("{unknown_count} uninterpreted passthrough record(s) were not represented in STEP"),
929            );
930        }
931        if self.unstyled_colors > 0 {
932            self.loss(
933                LossCategory::Attribute,
934                Severity::Info,
935                format!(
936                    "{} display color(s) had no emitted STEP item and were not written \
937                     to STEP presentation",
938                    self.unstyled_colors
939                ),
940            );
941        }
942        if !self.ir.model.appearances.is_empty() {
943            self.loss(
944                LossCategory::Material,
945                Severity::Info,
946                format!(
947                    "{} appearance asset(s) were reduced to STYLED_ITEM base colors; \
948                     schemas, textures, and shader properties were not written to STEP",
949                    self.ir.model.appearances.len()
950                ),
951            );
952        }
953        if !self.ir.model.attributes.is_empty() {
954            self.loss(
955                LossCategory::Attribute,
956                Severity::Info,
957                format!(
958                    "{} source attribute record(s) were not written to STEP",
959                    self.ir.model.attributes.len()
960                ),
961            );
962        }
963        let procedural_surface_count = self
964            .ir
965            .model
966            .procedural_surfaces
967            .iter()
968            .filter(|procedural| match &procedural.definition {
969                ProceduralSurfaceDefinition::Exact { .. }
970                | ProceduralSurfaceDefinition::Compound { .. }
971                | ProceduralSurfaceDefinition::Taper { .. }
972                | ProceduralSurfaceDefinition::Loft { .. }
973                | ProceduralSurfaceDefinition::CompoundLoft { .. }
974                | ProceduralSurfaceDefinition::G2Blend { .. }
975                | ProceduralSurfaceDefinition::VariableBlend { .. }
976                | ProceduralSurfaceDefinition::VertexBlend { .. }
977                | ProceduralSurfaceDefinition::Extrusion { .. }
978                | ProceduralSurfaceDefinition::Revolution { .. }
979                | ProceduralSurfaceDefinition::Sum { .. }
980                | ProceduralSurfaceDefinition::Sweep { .. }
981                | ProceduralSurfaceDefinition::Offset { .. }
982                | ProceduralSurfaceDefinition::Ruled { .. }
983                | ProceduralSurfaceDefinition::Blend { .. }
984                | ProceduralSurfaceDefinition::Unknown { .. } => true,
985            })
986            .count();
987        if procedural_surface_count > 0 || !self.ir.model.procedural_curves.is_empty() {
988            self.loss(
989                LossCategory::Geometry,
990                Severity::Info,
991                format!(
992                    "{} procedural surface definition(s) and {} procedural curve definition(s) were reduced to their solved STEP carriers",
993                    procedural_surface_count,
994                    self.ir.model.procedural_curves.len()
995                ),
996            );
997        }
998        let parametric_records: usize = self
999            .ir
1000            .native
1001            .loss_counts()
1002            .iter()
1003            .map(|loss| loss.count)
1004            .sum();
1005        if parametric_records > 0 {
1006            self.loss(
1007                LossCategory::Metadata,
1008                Severity::Info,
1009                format!(
1010                    "{parametric_records} parametric design/history record(s) were not represented in STEP"
1011                ),
1012            );
1013        }
1014    }
1015
1016    fn finish_report(&self) -> ExportReport {
1017        ExportReport {
1018            format: "step".into(),
1019            entity_counts: self.emitter.counts(),
1020            total_entities: self.emitter.total(),
1021            losses: self.losses.clone(),
1022            notes: Vec::new(),
1023        }
1024    }
1025}
1026
1027/// STEP encoder with per-export header options.
1028#[derive(Debug, Clone, Default)]
1029pub struct StepCodec {
1030    /// Header metadata and deterministic writer options.
1031    pub options: StepWriteOptions,
1032}
1033
1034impl Encoder for StepCodec {
1035    fn id(&self) -> &'static str {
1036        "step"
1037    }
1038
1039    fn encode(&self, ir: &CadIr, writer: &mut dyn Write) -> Result<ExportReport, CodecError> {
1040        write_step(ir, writer, &self.options).map_err(CodecError::from)
1041    }
1042}
1043
1044impl From<StepError> for CodecError {
1045    fn from(error: StepError) -> Self {
1046        match error {
1047            StepError::Io(error) => Self::Io(error),
1048        }
1049    }
1050}
1051
1052/// Whether a 4×4 row-major matrix is (numerically) the identity.
1053fn is_identity(rows: &[[f64; 4]; 4]) -> bool {
1054    for (i, row) in rows.iter().enumerate() {
1055        for (j, &v) in row.iter().enumerate() {
1056            let expect = if i == j { 1.0 } else { 0.0 };
1057            if (v - expect).abs() > 1e-12 {
1058                return false;
1059            }
1060        }
1061    }
1062    true
1063}
1064
1065#[cfg(test)]
1066mod tests;