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