1mod 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#[derive(Debug, Clone)]
65pub struct StepWriteOptions {
66 pub product_name: String,
71 pub author: String,
73 pub organization: String,
75 pub timestamp: String,
80 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#[derive(Debug, Clone, PartialEq)]
101pub struct StepReport {
102 pub entity_counts: BTreeMap<String, usize>,
107 pub total_entities: usize,
109 pub losses: Vec<LossNote>,
115}
116
117impl StepReport {
118 pub fn error_count(&self) -> usize {
120 self.losses
121 .iter()
122 .filter(|l| l.severity >= Severity::Error)
123 .count()
124 }
125}
126
127#[derive(Debug, thiserror::Error)]
132pub enum StepError {
133 #[error("failed to write STEP output: {0}")]
135 Io(#[from] std::io::Error),
136}
137
138pub 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
202struct Builder<'a> {
204 ir: &'a CadIr,
205 emitter: Emitter,
206 losses: Vec<LossNote>,
207
208 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 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 curveless_edges: BTreeSet<String>,
226
227 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 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 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 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 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 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 fn emit_solids(&mut self) -> Vec<Ref> {
410 let mut solids = Vec::new();
411 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 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 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 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 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 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 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
796fn 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;