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 bodies; returns the solid references.
409    fn emit_solids(&mut self) -> Vec<Ref> {
410        let mut solids = Vec::new();
411        // `ir` is a shared `&CadIr`; binding it locally lets us read the arenas
412        // while still calling `&mut self` helpers (loss/emit).
413        let ir = self.ir;
414        for body in &ir.model.bodies {
415            if let Some(t) = &body.transform {
416                if !is_identity(&t.rows) {
417                    self.loss(
418                        LossCategory::Geometry,
419                        Severity::Warning,
420                        format!(
421                            "body '{}' carries a non-identity transform that was not \
422                             applied to the exported geometry; coordinates are written \
423                             in body-local space",
424                            body.id
425                        ),
426                    );
427                }
428            }
429        }
430
431        for region in &ir.model.regions {
432            let shell_refs: Vec<Ref> = region
433                .shells
434                .iter()
435                .filter_map(|sid| self.emit_shell(sid.as_str()))
436                .collect();
437            let Some((outer, voids)) = shell_refs.split_first() else {
438                continue;
439            };
440            let solid = if voids.is_empty() {
441                self.emitter
442                    .emit("MANIFOLD_SOLID_BREP", &format!("'',{outer}"))
443            } else {
444                let void_refs: Vec<Ref> = voids
445                    .iter()
446                    .map(|s| {
447                        self.emitter
448                            .emit("ORIENTED_CLOSED_SHELL", &format!("'',*,{s},.F."))
449                    })
450                    .collect();
451                self.emitter.emit(
452                    "BREP_WITH_VOIDS",
453                    &format!("'',{outer},{}", refs(&void_refs)),
454                )
455            };
456            solids.push(solid);
457        }
458        solids
459    }
460
461    fn emit_shell(&mut self, shell_id: &str) -> Option<Ref> {
462        let shell = self
463            .ir
464            .model
465            .shells
466            .iter()
467            .find(|s| s.id.as_str() == shell_id)?;
468        let face_ids: Vec<String> = shell.faces.iter().map(|f| f.0.clone()).collect();
469        let mut face_refs = Vec::new();
470        for fid in &face_ids {
471            if let Some(r) = self.emit_face(fid) {
472                face_refs.push(r);
473            }
474        }
475        if face_refs.is_empty() {
476            return None;
477        }
478        Some(
479            self.emitter
480                .emit("CLOSED_SHELL", &format!("'',{}", refs(&face_refs))),
481        )
482    }
483
484    fn emit_face(&mut self, face_id: &str) -> Option<Ref> {
485        let face = self
486            .ir
487            .model
488            .faces
489            .iter()
490            .find(|f| f.id.as_str() == face_id)?;
491        let surface_id = face.surface.0.clone();
492        // A face resting on an unknown (opaque) surface cannot become an
493        // ADVANCED_FACE: STEP requires a real surface. Skip it and aggregate the
494        // loss rather than fabricate placeholder geometry.
495        if let Some(surf) = self.surfaces.get(surface_id.as_str()) {
496            if matches!(surf.geometry, SurfaceGeometry::Unknown { .. }) {
497                self.unknown_surface_faces.insert(face_id.to_string());
498                return None;
499            }
500        }
501        let loop_ids: Vec<String> = face.loops.iter().map(|l| l.0.clone()).collect();
502        let same_sense = matches!(face.sense, Sense::Forward);
503
504        let surf_ref = self.emit_surface(&surface_id)?;
505
506        let mut bound_refs = Vec::new();
507        for (i, lid) in loop_ids.iter().enumerate() {
508            if let Some(loop_ref) = self.emit_loop(lid) {
509                // The first loop is the outer bound by IR convention.
510                let kind = if i == 0 {
511                    "FACE_OUTER_BOUND"
512                } else {
513                    "FACE_BOUND"
514                };
515                let b = self.emitter.emit(kind, &format!("'',{loop_ref},.T."));
516                bound_refs.push(b);
517            }
518        }
519        if bound_refs.is_empty() {
520            return None;
521        }
522        let flag = if same_sense { ".T." } else { ".F." };
523        Some(self.emitter.emit(
524            "ADVANCED_FACE",
525            &format!("'',{},{surf_ref},{flag}", refs(&bound_refs)),
526        ))
527    }
528
529    fn emit_loop(&mut self, loop_id: &str) -> Option<Ref> {
530        let lp = self
531            .ir
532            .model
533            .loops
534            .iter()
535            .find(|l| l.id.as_str() == loop_id)?;
536        let coedge_ids: Vec<String> = lp.coedges.iter().map(|c| c.0.clone()).collect();
537        let mut oe_refs = Vec::new();
538        for cid in &coedge_ids {
539            let Some(coedge) = self.coedges.get(cid.as_str()).copied() else {
540                continue;
541            };
542            let orientation = matches!(coedge.sense, Sense::Forward);
543            // Pcurves (coedge.pcurve) are intentionally dropped; the aggregate
544            // loss note is recorded in `note_unrepresented`.
545            let Some(edge_ref) = self.emit_edge(coedge.edge.as_str()) else {
546                continue;
547            };
548            let flag = if orientation { ".T." } else { ".F." };
549            let oe = self
550                .emitter
551                .emit("ORIENTED_EDGE", &format!("'',*,*,{edge_ref},{flag}"));
552            oe_refs.push(oe);
553        }
554        if oe_refs.is_empty() {
555            return None;
556        }
557        Some(
558            self.emitter
559                .emit("EDGE_LOOP", &format!("'',{}", refs(&oe_refs))),
560        )
561    }
562
563    fn emit_edge(&mut self, edge_id: &str) -> Option<Ref> {
564        if let Some(r) = self.edge_refs.get(edge_id) {
565            return Some(*r);
566        }
567        let edge = self.edges.get(edge_id).copied()?;
568        let v1 = self.emit_vertex(edge.start.as_str())?;
569        let v2 = self.emit_vertex(edge.end.as_str())?;
570        let Some(curve_id) = &edge.curve else {
571            self.curveless_edges.insert(edge_id.to_string());
572            return None;
573        };
574        if self
575            .curves
576            .get(curve_id.as_str())
577            .is_some_and(|curve| matches!(curve.geometry, CurveGeometry::Unknown { .. }))
578        {
579            self.curveless_edges.insert(edge_id.to_string());
580            return None;
581        }
582        let curve_ref = self.emit_curve(curve_id.as_str())?;
583        // same_sense = .T.: the edge runs start→end along the curve's own
584        // parameterization, the convention IR curves follow.
585        let r = self
586            .emitter
587            .emit("EDGE_CURVE", &format!("'',{v1},{v2},{curve_ref},.T."));
588        self.edge_refs.insert(edge_id.to_string(), r);
589        Some(r)
590    }
591
592    fn emit_vertex(&mut self, vertex_id: &str) -> Option<Ref> {
593        if let Some(r) = self.vertex_refs.get(vertex_id) {
594            return Some(*r);
595        }
596        let vertex = self.vertices.get(vertex_id).copied()?;
597        let pt = self.points.get(vertex.point.as_str()).copied()?;
598        let cp = geometry::point(&mut self.emitter, pt.position);
599        let r = self.emitter.emit("VERTEX_POINT", &format!("'',{cp}"));
600        self.vertex_refs.insert(vertex_id.to_string(), r);
601        Some(r)
602    }
603
604    fn emit_surface(&mut self, surface_id: &str) -> Option<Ref> {
605        if let Some(r) = self.surface_refs.get(surface_id) {
606            return Some(*r);
607        }
608        let surf = self.surfaces.get(surface_id).copied()?;
609        let r = geometry::surface(&mut self.emitter, &surf.geometry);
610        self.surface_refs.insert(surface_id.to_string(), r);
611        Some(r)
612    }
613
614    fn emit_curve(&mut self, curve_id: &str) -> Option<Ref> {
615        if let Some(r) = self.curve_refs.get(curve_id) {
616            return Some(*r);
617        }
618        let crv = self.curves.get(curve_id).copied()?;
619        let r = geometry::curve(&mut self.emitter, &crv.geometry);
620        self.curve_refs.insert(curve_id.to_string(), r);
621        Some(r)
622    }
623
624    /// Record aggregate loss notes for IR content the writer does not carry.
625    fn note_unrepresented(&mut self) {
626        let nonstandard_analytic_surfaces = self
627            .ir
628            .model
629            .surfaces
630            .iter()
631            .filter(|surface| match &surface.geometry {
632                SurfaceGeometry::Sphere { radius, .. } => *radius < 0.0,
633                SurfaceGeometry::Torus {
634                    major_radius,
635                    minor_radius,
636                    ..
637                } => *minor_radius < 0.0 || minor_radius.abs() > major_radius.abs(),
638                _ => false,
639            })
640            .count();
641        if nonstandard_analytic_surfaces > 0 {
642            self.loss(
643                LossCategory::Geometry,
644                Severity::Warning,
645                format!(
646                    "{nonstandard_analytic_surfaces} signed or self-intersecting analytic \
647                     surface(s) were normalized to positive STEP radii"
648                ),
649            );
650        }
651        if !self.curveless_edges.is_empty() {
652            self.loss(
653                LossCategory::Geometry,
654                Severity::Warning,
655                format!(
656                    "{} edge(s) have no typed 3D curve and were omitted from \
657                     their edge loops (STEP EDGE_CURVE requires a 3D curve)",
658                    self.curveless_edges.len()
659                ),
660            );
661        }
662        if !self.unknown_surface_faces.is_empty() {
663            self.loss(
664                LossCategory::Geometry,
665                Severity::Warning,
666                format!(
667                    "{} face(s) rest on an unknown (undecoded) surface and were omitted \
668                     from the STEP shell (an ADVANCED_FACE requires a surface); their \
669                     topology remains in the IR",
670                    self.unknown_surface_faces.len()
671                ),
672            );
673        }
674        let pcurve_count = self
675            .ir
676            .model
677            .coedges
678            .iter()
679            .filter(|c| c.pcurve.is_some())
680            .count();
681        if pcurve_count > 0 {
682            self.loss(
683                LossCategory::Geometry,
684                Severity::Info,
685                format!(
686                    "{pcurve_count} coedge pcurve(s) were not written; parameter-space \
687                     trims are omitted, and consumers recompute them from the 3D \
688                     edge/surface geometry"
689                ),
690            );
691        }
692        if !self.ir.model.pcurves.is_empty() {
693            self.loss(
694                LossCategory::Geometry,
695                Severity::Info,
696                format!(
697                    "{} pcurve carrier(s) in the IR were not emitted",
698                    self.ir.model.pcurves.len()
699                ),
700            );
701        }
702        if !self.ir.unknowns.is_empty() {
703            self.loss(
704                LossCategory::Metadata,
705                Severity::Info,
706                format!(
707                    "{} uninterpreted passthrough record(s) were not represented in STEP",
708                    self.ir.unknowns.len()
709                ),
710            );
711        }
712        // Colors carried on bodies/faces are not mapped to STEP presentation
713        // (styled_item/colour_rgb) in this writer.
714        let colored = self
715            .ir
716            .model
717            .bodies
718            .iter()
719            .filter(|b| b.color.is_some())
720            .count()
721            + self
722                .ir
723                .model
724                .faces
725                .iter()
726                .filter(|f| f.color.is_some())
727                .count();
728        if colored > 0 {
729            self.loss(
730                LossCategory::Attribute,
731                Severity::Info,
732                format!("{colored} display color(s) were not written to STEP presentation"),
733            );
734        }
735        if !self.ir.model.appearances.is_empty() || !self.ir.model.appearance_bindings.is_empty() {
736            self.loss(
737                LossCategory::Material,
738                Severity::Info,
739                format!(
740                    "{} appearance asset(s) and {} binding(s) were not written to STEP presentation",
741                    self.ir.model.appearances.len(),
742                    self.ir.model.appearance_bindings.len()
743                ),
744            );
745        }
746        if !self.ir.model.attributes.is_empty() {
747            self.loss(
748                LossCategory::Attribute,
749                Severity::Info,
750                format!(
751                    "{} source attribute record(s) were not written to STEP",
752                    self.ir.model.attributes.len()
753                ),
754            );
755        }
756        if !self.ir.model.procedural_surfaces.is_empty()
757            || !self.ir.model.procedural_curves.is_empty()
758        {
759            self.loss(
760                LossCategory::Geometry,
761                Severity::Info,
762                format!(
763                    "{} procedural surface definition(s) and {} procedural curve definition(s) were reduced to their solved STEP carriers",
764                    self.ir.model.procedural_surfaces.len(),
765                    self.ir.model.procedural_curves.len()
766                ),
767            );
768        }
769        let parametric_records: usize = self
770            .ir
771            .native
772            .loss_counts()
773            .iter()
774            .map(|loss| loss.count)
775            .sum();
776        if parametric_records > 0 {
777            self.loss(
778                LossCategory::Metadata,
779                Severity::Info,
780                format!(
781                    "{parametric_records} parametric design/history record(s) were not represented in STEP"
782                ),
783            );
784        }
785    }
786
787    fn finish_report(&self) -> StepReport {
788        StepReport {
789            entity_counts: self.emitter.counts().clone(),
790            total_entities: self.emitter.total(),
791            losses: self.losses.clone(),
792        }
793    }
794}
795
796/// Whether a 4×4 row-major matrix is (numerically) the identity.
797fn is_identity(rows: &[[f64; 4]; 4]) -> bool {
798    for (i, row) in rows.iter().enumerate() {
799        for (j, &v) in row.iter().enumerate() {
800            let expect = if i == j { 1.0 } else { 0.0 };
801            if (v - expect).abs() > 1e-12 {
802                return false;
803            }
804        }
805    }
806    true
807}
808
809#[cfg(test)]
810mod tests;