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