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 [`StepReport::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, presentation data,
33//! source attributes, passthrough records, and parametric history are also
34//! reported rather than emitted. Body transforms remain unapplied, leaving
35//! affected coordinates in body-local space.
36//!
37//! Coordinates are emitted unchanged under a millimetre length-unit context.
38//! Callers must convert non-millimetre geometry before export. Analytic curves
39//! and surfaces map to their corresponding STEP carriers. Rational and
40//! non-rational NURBS use the `*_WITH_KNOTS` entities.
41//!
42//! [`StepError`] represents output-sink failures. Since the writer streams the
43//! header and DATA section, such a failure can leave partial output.
44
45mod geometry;
46mod writer;
47
48use std::collections::{BTreeMap, BTreeSet, HashMap};
49use std::io::Write;
50
51use cadmpeg_ir::geometry::{Curve, CurveGeometry, Surface, SurfaceGeometry};
52use cadmpeg_ir::report::{LossCategory, LossNote, Severity};
53use cadmpeg_ir::topology::{Coedge, Edge, Point, Sense, Vertex};
54use cadmpeg_ir::CadIr;
55
56use writer::{real, refs, string, Emitter, Ref};
57
58/// Metadata written to the STEP `FILE_NAME` header record.
59///
60/// Default values produce deterministic output. They identify the file as
61/// `cadmpeg_model`, leave the author and organization empty, use `cadmpeg` as
62/// the originating system, and substitute `1970-01-01T00:00:00` for the empty
63/// timestamp.
64#[derive(Debug, Clone)]
65pub struct StepWriteOptions {
66    /// The `FILE_NAME` name field.
67    ///
68    /// The STEP `PRODUCT` id and name come from the first IR body name, or
69    /// `cadmpeg_model` when that body has no name.
70    pub product_name: String,
71    /// The sole entry in the `FILE_NAME` author list.
72    pub author: String,
73    /// The sole entry in the `FILE_NAME` organization list.
74    pub organization: String,
75    /// The `FILE_NAME` timestamp.
76    ///
77    /// Supply an ISO 8601 value. An empty string is written as
78    /// `1970-01-01T00:00:00`.
79    pub timestamp: String,
80    /// The `FILE_NAME` originating-system field.
81    pub originating_system: String,
82}
83
84impl Default for StepWriteOptions {
85    fn default() -> Self {
86        StepWriteOptions {
87            product_name: "cadmpeg_model".to_string(),
88            author: String::new(),
89            organization: String::new(),
90            timestamp: String::new(),
91            originating_system: "cadmpeg".to_string(),
92        }
93    }
94}
95
96/// Summary of a completed STEP export.
97///
98/// A report is returned only after the entire file reaches the output sink.
99/// Loss notes describe omitted or reduced IR content and do not prevent export.
100#[derive(Debug, Clone, PartialEq)]
101pub struct StepReport {
102    /// DATA instance counts keyed by entity keyword.
103    ///
104    /// Keys are sorted. Complex rational B-spline instances are counted under
105    /// their `B_SPLINE_*_WITH_KNOTS` keyword.
106    pub entity_counts: BTreeMap<String, usize>,
107    /// Total DATA instances, including product, context, and geometry records.
108    pub total_entities: usize,
109    /// Omitted, normalized, or reduced IR content.
110    ///
111    /// Notes use [`cadmpeg_ir::LossNote`] categories and severities. Callers
112    /// should inspect the complete list rather than relying only on
113    /// [`Self::error_count`].
114    pub losses: Vec<LossNote>,
115}
116
117impl StepReport {
118    /// Counts loss notes whose severity is at least [`Severity::Error`].
119    pub fn error_count(&self) -> usize {
120        self.losses
121            .iter()
122            .filter(|l| l.severity >= Severity::Error)
123            .count()
124    }
125}
126
127/// Failure returned while streaming STEP output.
128///
129/// Unsupported or reduced IR content appears in [`StepReport::losses`] after a
130/// successful write.
131#[derive(Debug, thiserror::Error)]
132pub enum StepError {
133    /// The output sink rejected a write.
134    #[error("failed to write STEP output: {0}")]
135    Io(#[from] std::io::Error),
136}
137
138/// Serializes an IR document as an ISO 10303-21 STEP AP214 file.
139///
140/// The output declares the `AUTOMOTIVE_DESIGN` schema and a millimetre length
141/// unit. Coordinate values are not rescaled. The IR linear tolerance becomes
142/// the representation context's uncertainty value.
143///
144/// Geometry conversion completes before this function writes the header. It
145/// then streams the header, DATA instances, and closing records to `w`. An I/O
146/// error can therefore leave a partial file and returns no report.
147///
148/// On success, the report contains DATA entity counts and loss notes for
149/// omitted or reduced content.
150pub fn write_step(
151    ir: &CadIr,
152    w: &mut impl Write,
153    opts: &StepWriteOptions,
154) -> Result<StepReport, StepError> {
155    let mut b = Builder::new(ir);
156    b.build();
157    let report = b.finish_report();
158    let lines = b.emitter.into_lines();
159
160    write_header(w, opts)?;
161    writeln!(w, "DATA;")?;
162    for line in &lines {
163        writeln!(w, "{line}")?;
164    }
165    writeln!(w, "ENDSEC;")?;
166    writeln!(w, "END-ISO-10303-21;")?;
167    Ok(report)
168}
169
170fn write_header(w: &mut impl Write, opts: &StepWriteOptions) -> std::io::Result<()> {
171    let ts = if opts.timestamp.is_empty() {
172        "1970-01-01T00:00:00"
173    } else {
174        &opts.timestamp
175    };
176    writeln!(w, "ISO-10303-21;")?;
177    writeln!(w, "HEADER;")?;
178    writeln!(
179        w,
180        "FILE_DESCRIPTION(({}),'2;1');",
181        string("CAD model exported by cadmpeg")
182    )?;
183    writeln!(
184        w,
185        "FILE_NAME({},{},({}),({}),{},{},{});",
186        string(&opts.product_name),
187        string(ts),
188        string(&opts.author),
189        string(&opts.organization),
190        string("cadmpeg-step"),
191        string(&opts.originating_system),
192        string("")
193    )?;
194    writeln!(
195        w,
196        "FILE_SCHEMA(('AUTOMOTIVE_DESIGN {{ 1 0 10303 214 1 1 1 1 }}'));"
197    )?;
198    writeln!(w, "ENDSEC;")?;
199    Ok(())
200}
201
202/// Builds the DATA instance graph and accumulates export losses.
203struct Builder<'a> {
204    ir: &'a CadIr,
205    emitter: Emitter,
206    losses: Vec<LossNote>,
207
208    // Lookup indices from the flat IR arenas.
209    points: HashMap<&'a str, &'a Point>,
210    vertices: HashMap<&'a str, &'a Vertex>,
211    edges: HashMap<&'a str, &'a Edge>,
212    coedges: HashMap<&'a str, &'a Coedge>,
213    surfaces: HashMap<&'a str, &'a Surface>,
214    curves: HashMap<&'a str, &'a Curve>,
215
216    // Emitted-instance caches keyed by IR id, so shared carriers emit once.
217    surface_refs: HashMap<String, Ref>,
218    curve_refs: HashMap<String, Ref>,
219    edge_refs: HashMap<String, Ref>,
220    vertex_refs: HashMap<String, Ref>,
221
222    /// Edges skipped because they carry no attributed 3D curve, deduplicated
223    /// (a shared edge is reached once per coedge) and aggregated into a single
224    /// counted loss note.
225    curveless_edges: BTreeSet<String>,
226
227    /// Faces skipped because their surface geometry is unknown (opaque), so no
228    /// STEP surface exists to build an `ADVANCED_FACE` on. Deduplicated (a face
229    /// is reached once per shell) and aggregated into a single counted loss.
230    unknown_surface_faces: BTreeSet<String>,
231}
232
233impl<'a> Builder<'a> {
234    fn new(ir: &'a CadIr) -> Self {
235        Builder {
236            ir,
237            emitter: Emitter::new(),
238            losses: Vec::new(),
239            points: ir.model.points.iter().map(|p| (p.id.as_str(), p)).collect(),
240            vertices: ir
241                .model
242                .vertices
243                .iter()
244                .map(|v| (v.id.as_str(), v))
245                .collect(),
246            edges: ir.model.edges.iter().map(|e| (e.id.as_str(), e)).collect(),
247            coedges: ir
248                .model
249                .coedges
250                .iter()
251                .map(|c| (c.id.as_str(), c))
252                .collect(),
253            surfaces: ir
254                .model
255                .surfaces
256                .iter()
257                .map(|s| (s.id.as_str(), s))
258                .collect(),
259            curves: ir.model.curves.iter().map(|c| (c.id.as_str(), c)).collect(),
260            surface_refs: HashMap::new(),
261            curve_refs: HashMap::new(),
262            edge_refs: HashMap::new(),
263            vertex_refs: HashMap::new(),
264            curveless_edges: BTreeSet::new(),
265            unknown_surface_faces: BTreeSet::new(),
266        }
267    }
268
269    fn loss(&mut self, category: LossCategory, severity: Severity, message: String) {
270        self.losses.push(LossNote {
271            category,
272            severity,
273            message,
274            provenance: None,
275        });
276    }
277
278    fn build(&mut self) {
279        // Product structure and unit-bearing context first; the representation
280        // instance that ties them to the geometry is emitted last.
281        let product_def_shape = self.emit_product_structure();
282        let context = self.emit_context();
283
284        let solids = self.emit_solids();
285        if solids.is_empty() {
286            self.loss(
287                LossCategory::Topology,
288                Severity::Warning,
289                "no exportable solids: the IR document contains no body/region/shell \
290                 geometry, so the STEP representation is empty"
291                    .to_string(),
292            );
293        }
294
295        let mut items = solids;
296        // A representation-space origin placement is conventional and harmless.
297        let origin = geometry::placement(
298            &mut self.emitter,
299            cadmpeg_ir::math::Point3::new(0.0, 0.0, 0.0),
300            cadmpeg_ir::math::Vector3::new(0.0, 0.0, 1.0),
301            cadmpeg_ir::math::Vector3::new(1.0, 0.0, 0.0),
302        );
303        items.push(origin);
304
305        let absr = self.emitter.emit(
306            "ADVANCED_BREP_SHAPE_REPRESENTATION",
307            &format!("'',{},{context}", refs(&items)),
308        );
309        self.emitter.emit(
310            "SHAPE_DEFINITION_REPRESENTATION",
311            &format!("{product_def_shape},{absr}"),
312        );
313
314        self.note_unrepresented();
315    }
316
317    /// Emit the `PRODUCT` → `PRODUCT_DEFINITION_SHAPE` chain, returning the
318    /// `PRODUCT_DEFINITION_SHAPE` reference.
319    fn emit_product_structure(&mut self) -> Ref {
320        let name = self
321            .ir
322            .model
323            .bodies
324            .first()
325            .and_then(|b| b.name.clone())
326            .unwrap_or_else(|| "cadmpeg_model".to_string());
327
328        let app_ctx = self
329            .emitter
330            .emit("APPLICATION_CONTEXT", &string("automotive design"));
331        self.emitter.emit(
332            "APPLICATION_PROTOCOL_DEFINITION",
333            &format!(
334                "{},{},2000,{app_ctx}",
335                string("international standard"),
336                string("automotive_design")
337            ),
338        );
339        let prod_ctx = self.emitter.emit(
340            "PRODUCT_CONTEXT",
341            &format!("'',{app_ctx},{}", string("mechanical")),
342        );
343        let product = self.emitter.emit(
344            "PRODUCT",
345            &format!("{},{},'',({prod_ctx})", string(&name), string(&name)),
346        );
347        let formation = self
348            .emitter
349            .emit("PRODUCT_DEFINITION_FORMATION", &format!("'','',{product}"));
350        let pd_ctx = self.emitter.emit(
351            "PRODUCT_DEFINITION_CONTEXT",
352            &format!(
353                "{},{app_ctx},{}",
354                string("part definition"),
355                string("design")
356            ),
357        );
358        let product_def = self.emitter.emit(
359            "PRODUCT_DEFINITION",
360            &format!("{},'',{formation},{pd_ctx}", string("design")),
361        );
362        self.emitter
363            .emit("PRODUCT_DEFINITION_SHAPE", &format!("'','',{product_def}"))
364    }
365
366    /// Emit the units and the geometric representation context, returning the
367    /// context reference.
368    fn emit_context(&mut self) -> Ref {
369        let len = self.emit_length_unit();
370        let angle = self.emitter.emit_raw(
371            "PLANE_ANGLE_UNIT",
372            "( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) )",
373        );
374        let solid = self.emitter.emit_raw(
375            "SOLID_ANGLE_UNIT",
376            "( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() )",
377        );
378        let unc = self.emitter.emit(
379            "UNCERTAINTY_MEASURE_WITH_UNIT",
380            &format!(
381                "LENGTH_MEASURE({}),{len},{},{}",
382                real(self.ir.tolerances.linear),
383                string("distance_accuracy_value"),
384                string("maximum model space distance")
385            ),
386        );
387        self.emitter.emit_raw(
388            "GEOMETRIC_REPRESENTATION_CONTEXT",
389            &format!(
390                "( GEOMETRIC_REPRESENTATION_CONTEXT(3) \
391                 GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT(({unc})) \
392                 GLOBAL_UNIT_ASSIGNED_CONTEXT(({len},{angle},{solid})) \
393                 REPRESENTATION_CONTEXT('Context','3D') )"
394            ),
395        )
396    }
397
398    /// Emit the millimetre length unit used by the representation context.
399    ///
400    /// Coordinate values are written unchanged.
401    fn emit_length_unit(&mut self) -> Ref {
402        self.emitter.emit_raw(
403            "LENGTH_UNIT",
404            "( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) )",
405        )
406    }
407
408    /// Emit one solid per region across all displayed bodies; returns the
409    /// solid references. Bodies the source document hides are omitted.
410    fn emit_solids(&mut self) -> Vec<Ref> {
411        let mut solids = Vec::new();
412        // `ir` is a shared `&CadIr`; binding it locally lets us read the arenas
413        // while still calling `&mut self` helpers (loss/emit).
414        let ir = self.ir;
415        let hidden: BTreeSet<&str> = ir
416            .model
417            .bodies
418            .iter()
419            .filter(|body| body.visible == Some(false))
420            .map(|body| body.id.0.as_str())
421            .collect();
422        if !hidden.is_empty() {
423            self.loss(
424                LossCategory::Metadata,
425                Severity::Info,
426                format!(
427                    "{} hidden body(ies) were omitted from STEP output",
428                    hidden.len()
429                ),
430            );
431        }
432        for body in &ir.model.bodies {
433            if hidden.contains(body.id.0.as_str()) {
434                continue;
435            }
436            if let Some(t) = &body.transform {
437                if !is_identity(&t.rows) {
438                    self.loss(
439                        LossCategory::Geometry,
440                        Severity::Warning,
441                        format!(
442                            "body '{}' carries a non-identity transform that was not \
443                             applied to the exported geometry; coordinates are written \
444                             in body-local space",
445                            body.id
446                        ),
447                    );
448                }
449            }
450        }
451
452        for region in &ir.model.regions {
453            if hidden.contains(region.body.0.as_str()) {
454                continue;
455            }
456            let shell_refs: Vec<Ref> = region
457                .shells
458                .iter()
459                .filter_map(|sid| self.emit_shell(sid.as_str()))
460                .collect();
461            let Some((outer, voids)) = shell_refs.split_first() else {
462                continue;
463            };
464            let solid = if voids.is_empty() {
465                self.emitter
466                    .emit("MANIFOLD_SOLID_BREP", &format!("'',{outer}"))
467            } else {
468                let void_refs: Vec<Ref> = voids
469                    .iter()
470                    .map(|s| {
471                        self.emitter
472                            .emit("ORIENTED_CLOSED_SHELL", &format!("'',*,{s},.F."))
473                    })
474                    .collect();
475                self.emitter.emit(
476                    "BREP_WITH_VOIDS",
477                    &format!("'',{outer},{}", refs(&void_refs)),
478                )
479            };
480            solids.push(solid);
481        }
482        solids
483    }
484
485    fn emit_shell(&mut self, shell_id: &str) -> Option<Ref> {
486        let shell = self
487            .ir
488            .model
489            .shells
490            .iter()
491            .find(|s| s.id.as_str() == shell_id)?;
492        let face_ids: Vec<String> = shell.faces.iter().map(|f| f.0.clone()).collect();
493        let mut face_refs = Vec::new();
494        for fid in &face_ids {
495            if let Some(r) = self.emit_face(fid) {
496                face_refs.push(r);
497            }
498        }
499        if face_refs.is_empty() {
500            return None;
501        }
502        Some(
503            self.emitter
504                .emit("CLOSED_SHELL", &format!("'',{}", refs(&face_refs))),
505        )
506    }
507
508    fn emit_face(&mut self, face_id: &str) -> Option<Ref> {
509        let face = self
510            .ir
511            .model
512            .faces
513            .iter()
514            .find(|f| f.id.as_str() == face_id)?;
515        let surface_id = face.surface.0.clone();
516        // A face resting on an unknown (opaque) surface cannot become an
517        // ADVANCED_FACE: STEP requires a real surface. Skip it and aggregate the
518        // loss rather than fabricate placeholder geometry.
519        if let Some(surf) = self.surfaces.get(surface_id.as_str()) {
520            if matches!(surf.geometry, SurfaceGeometry::Unknown { .. }) {
521                self.unknown_surface_faces.insert(face_id.to_string());
522                return None;
523            }
524        }
525        let loop_ids: Vec<String> = face.loops.iter().map(|l| l.0.clone()).collect();
526        let same_sense = matches!(face.sense, Sense::Forward);
527
528        let surf_ref = self.emit_surface(&surface_id)?;
529
530        let mut bound_refs = Vec::new();
531        for (i, lid) in loop_ids.iter().enumerate() {
532            if let Some(loop_ref) = self.emit_loop(lid) {
533                // The first loop is the outer bound by IR convention.
534                let kind = if i == 0 {
535                    "FACE_OUTER_BOUND"
536                } else {
537                    "FACE_BOUND"
538                };
539                let b = self.emitter.emit(kind, &format!("'',{loop_ref},.T."));
540                bound_refs.push(b);
541            }
542        }
543        if bound_refs.is_empty() {
544            return None;
545        }
546        let flag = if same_sense { ".T." } else { ".F." };
547        Some(self.emitter.emit(
548            "ADVANCED_FACE",
549            &format!("'',{},{surf_ref},{flag}", refs(&bound_refs)),
550        ))
551    }
552
553    fn emit_loop(&mut self, loop_id: &str) -> Option<Ref> {
554        let lp = self
555            .ir
556            .model
557            .loops
558            .iter()
559            .find(|l| l.id.as_str() == loop_id)?;
560        let coedge_ids: Vec<String> = lp.coedges.iter().map(|c| c.0.clone()).collect();
561        let mut oe_refs = Vec::new();
562        for cid in &coedge_ids {
563            let Some(coedge) = self.coedges.get(cid.as_str()).copied() else {
564                continue;
565            };
566            let orientation = matches!(coedge.sense, Sense::Forward);
567            // Pcurves (coedge.pcurve) are intentionally dropped; the aggregate
568            // loss note is recorded in `note_unrepresented`.
569            let Some(edge_ref) = self.emit_edge(coedge.edge.as_str()) else {
570                continue;
571            };
572            let flag = if orientation { ".T." } else { ".F." };
573            let oe = self
574                .emitter
575                .emit("ORIENTED_EDGE", &format!("'',*,*,{edge_ref},{flag}"));
576            oe_refs.push(oe);
577        }
578        if oe_refs.is_empty() {
579            return None;
580        }
581        Some(
582            self.emitter
583                .emit("EDGE_LOOP", &format!("'',{}", refs(&oe_refs))),
584        )
585    }
586
587    fn emit_edge(&mut self, edge_id: &str) -> Option<Ref> {
588        if let Some(r) = self.edge_refs.get(edge_id) {
589            return Some(*r);
590        }
591        let edge = self.edges.get(edge_id).copied()?;
592        let v1 = self.emit_vertex(edge.start.as_str())?;
593        let v2 = self.emit_vertex(edge.end.as_str())?;
594        let Some(curve_id) = &edge.curve else {
595            self.curveless_edges.insert(edge_id.to_string());
596            return None;
597        };
598        if self
599            .curves
600            .get(curve_id.as_str())
601            .is_some_and(|curve| matches!(curve.geometry, CurveGeometry::Unknown { .. }))
602        {
603            self.curveless_edges.insert(edge_id.to_string());
604            return None;
605        }
606        let curve_ref = self.emit_curve(curve_id.as_str())?;
607        // same_sense = .T.: the edge runs start→end along the curve's own
608        // parameterization, the convention IR curves follow.
609        let r = self
610            .emitter
611            .emit("EDGE_CURVE", &format!("'',{v1},{v2},{curve_ref},.T."));
612        self.edge_refs.insert(edge_id.to_string(), r);
613        Some(r)
614    }
615
616    fn emit_vertex(&mut self, vertex_id: &str) -> Option<Ref> {
617        if let Some(r) = self.vertex_refs.get(vertex_id) {
618            return Some(*r);
619        }
620        let vertex = self.vertices.get(vertex_id).copied()?;
621        let pt = self.points.get(vertex.point.as_str()).copied()?;
622        let cp = geometry::point(&mut self.emitter, pt.position);
623        let r = self.emitter.emit("VERTEX_POINT", &format!("'',{cp}"));
624        self.vertex_refs.insert(vertex_id.to_string(), r);
625        Some(r)
626    }
627
628    fn emit_surface(&mut self, surface_id: &str) -> Option<Ref> {
629        if let Some(r) = self.surface_refs.get(surface_id) {
630            return Some(*r);
631        }
632        let surf = self.surfaces.get(surface_id).copied()?;
633        let r = geometry::surface(&mut self.emitter, &surf.geometry);
634        self.surface_refs.insert(surface_id.to_string(), r);
635        Some(r)
636    }
637
638    fn emit_curve(&mut self, curve_id: &str) -> Option<Ref> {
639        if let Some(r) = self.curve_refs.get(curve_id) {
640            return Some(*r);
641        }
642        let crv = self.curves.get(curve_id).copied()?;
643        let r = geometry::curve(&mut self.emitter, &crv.geometry);
644        self.curve_refs.insert(curve_id.to_string(), r);
645        Some(r)
646    }
647
648    /// Record aggregate loss notes for IR content the writer does not carry.
649    fn note_unrepresented(&mut self) {
650        let nonstandard_analytic_surfaces = self
651            .ir
652            .model
653            .surfaces
654            .iter()
655            .filter(|surface| match &surface.geometry {
656                SurfaceGeometry::Sphere { radius, .. } => *radius < 0.0,
657                SurfaceGeometry::Torus {
658                    major_radius,
659                    minor_radius,
660                    ..
661                } => *minor_radius < 0.0 || minor_radius.abs() > major_radius.abs(),
662                _ => false,
663            })
664            .count();
665        if nonstandard_analytic_surfaces > 0 {
666            self.loss(
667                LossCategory::Geometry,
668                Severity::Warning,
669                format!(
670                    "{nonstandard_analytic_surfaces} signed or self-intersecting analytic \
671                     surface(s) were normalized to positive STEP radii"
672                ),
673            );
674        }
675        if !self.curveless_edges.is_empty() {
676            self.loss(
677                LossCategory::Geometry,
678                Severity::Warning,
679                format!(
680                    "{} edge(s) have no typed 3D curve and were omitted from \
681                     their edge loops (STEP EDGE_CURVE requires a 3D curve)",
682                    self.curveless_edges.len()
683                ),
684            );
685        }
686        if !self.unknown_surface_faces.is_empty() {
687            self.loss(
688                LossCategory::Geometry,
689                Severity::Warning,
690                format!(
691                    "{} face(s) rest on an unknown (undecoded) surface and were omitted \
692                     from the STEP shell (an ADVANCED_FACE requires a surface); their \
693                     topology remains in the IR",
694                    self.unknown_surface_faces.len()
695                ),
696            );
697        }
698        let pcurve_count = self
699            .ir
700            .model
701            .coedges
702            .iter()
703            .filter(|c| c.pcurve.is_some())
704            .count();
705        if pcurve_count > 0 {
706            self.loss(
707                LossCategory::Geometry,
708                Severity::Info,
709                format!(
710                    "{pcurve_count} coedge pcurve(s) were not written; parameter-space \
711                     trims are omitted, and consumers recompute them from the 3D \
712                     edge/surface geometry"
713                ),
714            );
715        }
716        if !self.ir.model.pcurves.is_empty() {
717            self.loss(
718                LossCategory::Geometry,
719                Severity::Info,
720                format!(
721                    "{} pcurve carrier(s) in the IR were not emitted",
722                    self.ir.model.pcurves.len()
723                ),
724            );
725        }
726        if !self.ir.unknowns.is_empty() {
727            self.loss(
728                LossCategory::Metadata,
729                Severity::Info,
730                format!(
731                    "{} uninterpreted passthrough record(s) were not represented in STEP",
732                    self.ir.unknowns.len()
733                ),
734            );
735        }
736        // Colors carried on bodies/faces are not mapped to STEP presentation
737        // (styled_item/colour_rgb) in this writer.
738        let colored = self
739            .ir
740            .model
741            .bodies
742            .iter()
743            .filter(|b| b.color.is_some())
744            .count()
745            + self
746                .ir
747                .model
748                .faces
749                .iter()
750                .filter(|f| f.color.is_some())
751                .count();
752        if colored > 0 {
753            self.loss(
754                LossCategory::Attribute,
755                Severity::Info,
756                format!("{colored} display color(s) were not written to STEP presentation"),
757            );
758        }
759        if !self.ir.model.appearances.is_empty() || !self.ir.model.appearance_bindings.is_empty() {
760            self.loss(
761                LossCategory::Material,
762                Severity::Info,
763                format!(
764                    "{} appearance asset(s) and {} binding(s) were not written to STEP presentation",
765                    self.ir.model.appearances.len(),
766                    self.ir.model.appearance_bindings.len()
767                ),
768            );
769        }
770        if !self.ir.model.attributes.is_empty() {
771            self.loss(
772                LossCategory::Attribute,
773                Severity::Info,
774                format!(
775                    "{} source attribute record(s) were not written to STEP",
776                    self.ir.model.attributes.len()
777                ),
778            );
779        }
780        if !self.ir.model.procedural_surfaces.is_empty()
781            || !self.ir.model.procedural_curves.is_empty()
782        {
783            self.loss(
784                LossCategory::Geometry,
785                Severity::Info,
786                format!(
787                    "{} procedural surface definition(s) and {} procedural curve definition(s) were reduced to their solved STEP carriers",
788                    self.ir.model.procedural_surfaces.len(),
789                    self.ir.model.procedural_curves.len()
790                ),
791            );
792        }
793        let parametric_records: usize = self
794            .ir
795            .native
796            .loss_counts()
797            .iter()
798            .map(|loss| loss.count)
799            .sum();
800        if parametric_records > 0 {
801            self.loss(
802                LossCategory::Metadata,
803                Severity::Info,
804                format!(
805                    "{parametric_records} parametric design/history record(s) were not represented in STEP"
806                ),
807            );
808        }
809    }
810
811    fn finish_report(&self) -> StepReport {
812        StepReport {
813            entity_counts: self.emitter.counts().clone(),
814            total_entities: self.emitter.total(),
815            losses: self.losses.clone(),
816        }
817    }
818}
819
820/// Whether a 4×4 row-major matrix is (numerically) the identity.
821fn is_identity(rows: &[[f64; 4]; 4]) -> bool {
822    for (i, row) in rows.iter().enumerate() {
823        for (j, &v) in row.iter().enumerate() {
824            let expect = if i == j { 1.0 } else { 0.0 };
825            if (v - expect).abs() > 1e-12 {
826                return false;
827            }
828        }
829    }
830    true
831}
832
833#[cfg(test)]
834mod tests;