1mod 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#[derive(Debug, Clone)]
41pub struct StepWriteOptions {
42 pub product_name: String,
44 pub author: String,
46 pub organization: String,
48 pub timestamp: String,
51 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#[derive(Debug, Clone, PartialEq)]
69pub struct StepReport {
70 pub entity_counts: BTreeMap<String, usize>,
72 pub total_entities: usize,
74 pub losses: Vec<LossNote>,
76}
77
78impl StepReport {
79 pub fn error_count(&self) -> usize {
81 self.losses
82 .iter()
83 .filter(|l| l.severity >= Severity::Error)
84 .count()
85 }
86}
87
88#[derive(Debug, thiserror::Error)]
92pub enum StepError {
93 #[error("failed to write STEP output: {0}")]
95 Io(#[from] std::io::Error),
96}
97
98pub 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
152struct Builder<'a> {
155 ir: &'a CadIr,
156 emitter: Emitter,
157 losses: Vec<LossNote>,
158
159 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 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 curveless_edges: BTreeSet<String>,
177
178 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 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 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 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 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 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 fn emit_solids(&mut self) -> Vec<Ref> {
362 let mut solids = Vec::new();
363 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 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 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 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 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 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 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
748fn 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;