Skip to main content

cadmpeg_step/
lib.rs

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